anilife.live 사이트 구현

다른 버그도 고침
This commit is contained in:
2025-12-28 19:38:18 +09:00
parent e6e8c45f5a
commit 6dbeff13d3
14 changed files with 1576 additions and 347 deletions

177
lib/camoufox_anilife.py Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Camoufox 기반 Anilife 비디오 URL 추출 스크립트
강력한 봇 감지 우회 기능이 있는 스텔스 Firefox
사용법:
python camoufox_anilife.py <detail_url> <episode_num>
"""
import sys
import json
import time
import re
def extract_aldata(detail_url: str, episode_num: str) -> dict:
"""Camoufox로 Detail 페이지에서 _aldata 추출"""
try:
from camoufox.sync_api import Camoufox
except ImportError as e:
return {"error": f"Camoufox not installed: {e}"}
result = {
"success": False,
"aldata": None,
"html": None,
"current_url": None,
"error": None,
"vod_url": None
}
try:
# Camoufox 시작 (자동 fingerprint 생성)
with Camoufox(headless=False) as browser:
page = browser.new_page()
try:
# 1. Detail 페이지로 이동
print(f"1. Navigating to detail page: {detail_url}", file=sys.stderr)
page.goto(detail_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
print(f" Current URL: {page.url}", file=sys.stderr)
# 2. 에피소드 목록으로 스크롤
page.mouse.wheel(0, 800)
time.sleep(1)
# 3. 해당 에피소드 찾아서 클릭
print(f"2. Looking for episode {episode_num}", file=sys.stderr)
episode_clicked = False
try:
# epl-num 클래스의 div에서 에피소드 번호 찾기
episode_link = page.locator(f'a:has(.epl-num:text("{episode_num}"))').first
if episode_link.is_visible(timeout=5000):
href = episode_link.get_attribute("href")
print(f" Found episode link: {href}", file=sys.stderr)
episode_link.click()
episode_clicked = True
time.sleep(3)
except Exception as e:
print(f" Method 1 failed: {e}", file=sys.stderr)
if not episode_clicked:
try:
# provider 링크들 중에서 에피소드 번호가 포함된 것 클릭
links = page.locator('a[href*="/ani/provider/"]').all()
for link in links:
text = link.inner_text()
if episode_num in text:
print(f" Found: {text}", file=sys.stderr)
link.click()
episode_clicked = True
time.sleep(3)
break
except Exception as e:
print(f" Method 2 failed: {e}", file=sys.stderr)
if not episode_clicked:
result["error"] = f"Episode {episode_num} not found"
result["html"] = page.content()
return result
# 4. Provider 페이지에서 _aldata 추출
print(f"3. Provider page URL: {page.url}", file=sys.stderr)
result["current_url"] = page.url
# 리다이렉트 확인
if "/ani/provider/" not in page.url:
result["error"] = f"Redirected to {page.url}"
result["html"] = page.content()
return result
# _aldata 추출 시도
try:
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
if aldata_value:
result["aldata"] = aldata_value
result["success"] = True
print(f" SUCCESS! _aldata found: {aldata_value[:60]}...", file=sys.stderr)
return result
except Exception as js_err:
print(f" JS error: {js_err}", file=sys.stderr)
# HTML에서 _aldata 패턴 추출 시도
html = page.content()
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
if aldata_match:
result["aldata"] = aldata_match.group(1)
result["success"] = True
print(f" SUCCESS! _aldata from HTML: {result['aldata'][:60]}...", file=sys.stderr)
return result
# 5. CloudVideo 버튼 클릭 시도
print("4. Trying CloudVideo button click...", file=sys.stderr)
try:
page.mouse.wheel(0, 500)
time.sleep(1)
cloudvideo_btn = page.locator('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]').first
if cloudvideo_btn.is_visible(timeout=3000):
cloudvideo_btn.click()
time.sleep(3)
result["current_url"] = page.url
print(f" After click URL: {page.url}", file=sys.stderr)
# 리다이렉트 확인 (구글로 갔는지)
if "google.com" in page.url:
result["error"] = "Redirected to Google - bot detected"
return result
# 플레이어 페이지에서 _aldata 추출
try:
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
if aldata_value:
result["aldata"] = aldata_value
result["success"] = True
print(f" SUCCESS! _aldata: {aldata_value[:60]}...", file=sys.stderr)
return result
except:
pass
# HTML에서 추출
html = page.content()
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
if aldata_match:
result["aldata"] = aldata_match.group(1)
result["success"] = True
return result
result["html"] = html
except Exception as click_err:
print(f" Click error: {click_err}", file=sys.stderr)
result["html"] = page.content()
finally:
page.close()
except Exception as e:
result["error"] = str(e)
import traceback
print(traceback.format_exc(), file=sys.stderr)
return result
if __name__ == "__main__":
if len(sys.argv) < 3:
print(json.dumps({"error": "Usage: python camoufox_anilife.py <detail_url> <episode_num>"}))
sys.exit(1)
detail_url = sys.argv[1]
episode_num = sys.argv[2]
result = extract_aldata(detail_url, episode_num)
print(json.dumps(result, ensure_ascii=False))

View File

@@ -43,6 +43,7 @@ class FfmpegQueueEntity(abc.ABCMeta("ABC", (object,), {"__slots__": ()})):
self.filepath = None
self.quality = None
self.headers = None
self.proxy = None
self.current_speed = "" # 다운로드 속도
self.download_time = "" # 경과 시간
# FfmpegQueueEntity.static_index += 1
@@ -79,7 +80,27 @@ class FfmpegQueueEntity(abc.ABCMeta("ABC", (object,), {"__slots__": ()})):
tmp["filename"] = self.filename
tmp["filepath"] = self.filepath
tmp["quality"] = self.quality
# tmp['current_speed'] = self.ffmpeg_arg['current_speed'] if self.ffmpeg_arg is not None else ''
tmp["current_speed"] = self.current_speed
tmp["download_time"] = self.download_time
# 템플릿 호환 필드 추가 (queue.html에서 사용하는 필드명)
tmp["idx"] = self.entity_id
tmp["callback_id"] = getattr(self, 'name', 'anilife') if hasattr(self, 'name') else 'anilife'
tmp["start_time"] = self.created_time
tmp["status_kor"] = self.ffmpeg_status_kor
tmp["status_str"] = str(self.ffmpeg_status) if self.ffmpeg_status != -1 else "WAITING"
tmp["percent"] = self.ffmpeg_percent
tmp["duration_str"] = ""
tmp["duration"] = ""
tmp["current_duration"] = ""
tmp["current_pf_count"] = 0
tmp["max_pf_count"] = 0
tmp["current_bitrate"] = ""
tmp["end_time"] = ""
tmp["exist"] = False
tmp["temp_fullpath"] = self.filepath or ""
tmp["save_fullpath"] = self.filepath or ""
tmp = self.info_dict(tmp)
return tmp
@@ -194,13 +215,19 @@ class FfmpegQueue(object):
P.logger.debug(filename)
# P.logger.debug(filepath)
# SupportFfmpeg 초기화
self.support_init()
# entity.headers가 있으면 우선 사용, 없으면 caller.headers 사용
_headers = entity.headers
if _headers is None and self.caller is not None:
_headers = self.caller.headers
# SupportFfmpeg 초기화
self.support_init()
# proxy 가져오기
_proxy = getattr(entity, 'proxy', None)
if _proxy is None and self.caller is not None:
_proxy = getattr(self.caller, 'proxy', None)
logger.info(f"Starting ffmpeg download - video_url: {video_url}")
logger.info(f"save_path: {dirname}, filename: {filename}")
logger.info(f"headers: {_headers}")
@@ -219,10 +246,17 @@ class FfmpegQueue(object):
logger.info(f"=== END COMMAND ===")
# m3u8 URL인 경우 다운로드 방법 설정에 따라 분기
if video_url.endswith('.m3u8'):
if video_url.endswith('.m3u8') or 'master.txt' in video_url:
# 다운로드 방법 설정 확인
download_method = P.ModelSetting.get(f"{self.name}_download_method")
# cdndania.com 감지 시 YtdlpDownloader 사용 (CDN 세션 쿠키 + Impersonate로 보안 우회)
if 'cdndania.com' in video_url:
logger.info("Detected cdndania.com URL - forcing YtdlpDownloader with cookies (CDN security bypass)")
download_method = "ytdlp"
logger.info(f"Download method: {download_method}")
# 다운로드 시작 전 카운트 증가
self.current_ffmpeg_count += 1
@@ -245,12 +279,17 @@ class FfmpegQueue(object):
# yt-dlp 사용
from .ytdlp_downloader import YtdlpDownloader
logger.info("Using yt-dlp downloader...")
# 엔티티에서 쿠키 파일 가져오기 (있는 경우)
_cookies_file = getattr(entity_ref, 'cookies_file', None)
downloader = YtdlpDownloader(
url=video_url,
output_path=output_file_ref,
headers=headers_ref,
callback=progress_callback
callback=progress_callback,
proxy=_proxy,
cookies_file=_cookies_file
)
else:
# 기본: HLS 다운로더 사용
from .hls_downloader import HlsDownloader
@@ -259,7 +298,8 @@ class FfmpegQueue(object):
m3u8_url=video_url,
output_path=output_file_ref,
headers=headers_ref,
callback=progress_callback
callback=progress_callback,
proxy=_proxy
)
success, message = downloader.download()
@@ -360,6 +400,7 @@ class FfmpegQueue(object):
max_pf_count=0,
save_path=ToolUtil.make_path(dirname),
timeout_minute=60,
proxy=_proxy,
)
#
# todo: 임시로 start() 중지

View File

@@ -8,17 +8,21 @@ import requests
import tempfile
import subprocess
import time
import logging
from urllib.parse import urljoin
logger = logging.getLogger(__name__)
class HlsDownloader:
"""HLS 다운로더 - .jpg 확장자 세그먼트 지원"""
def __init__(self, m3u8_url, output_path, headers=None, callback=None):
def __init__(self, m3u8_url, output_path, headers=None, callback=None, proxy=None):
self.m3u8_url = m3u8_url
self.output_path = output_path
self.headers = headers or {}
self.callback = callback # 진행 상황 콜백
self.proxy = proxy
self.segments = []
self.total_segments = 0
self.downloaded_segments = 0
@@ -31,12 +35,35 @@ class HlsDownloader:
self.last_bytes = 0
self.current_speed = 0 # bytes per second
def parse_m3u8(self):
"""m3u8 파일 파싱"""
response = requests.get(self.m3u8_url, headers=self.headers, timeout=30)
def parse_m3u8(self, url=None):
"""m3u8 파일 파싱 (Master Playlist 대응)"""
if url is None:
url = self.m3u8_url
proxies = None
if self.proxy:
proxies = {"http": self.proxy, "https": self.proxy}
logger.debug(f"Parsing m3u8: {url}")
response = requests.get(url, headers=self.headers, timeout=30, proxies=proxies)
content = response.text
base_url = self.m3u8_url.rsplit('/', 1)[0] + '/'
# Master Playlist 체크
if "#EXT-X-STREAM-INF" in content:
last_media_url = None
for line in content.strip().split('\n'):
line = line.strip()
if line and not line.startswith('#'):
if not line.startswith('http'):
last_media_url = urljoin(url, line)
else:
last_media_url = line
if last_media_url:
logger.info(f"Master playlist detected, following media playlist: {last_media_url}")
return self.parse_m3u8(last_media_url)
base_url = url.rsplit('/', 1)[0] + '/'
self.segments = []
for line in content.strip().split('\n'):
@@ -96,17 +123,22 @@ class HlsDownloader:
return False, "Cancelled"
# 세그먼트 다운로드
segment_path = os.path.join(temp_dir, f"segment_{i:05d}.ts")
segment_filename = f"segment_{i:05d}.ts"
segment_path = os.path.join(temp_dir, segment_filename)
try:
response = requests.get(segment_url, headers=self.headers, timeout=60)
proxies = None
if self.proxy:
proxies = {"http": self.proxy, "https": self.proxy}
response = requests.get(segment_url, headers=self.headers, timeout=60, proxies=proxies)
response.raise_for_status()
segment_data = response.content
with open(segment_path, 'wb') as f:
f.write(segment_data)
segment_files.append(segment_path)
segment_files.append(segment_filename) # 상대 경로 저장
self.downloaded_segments = i + 1
self.total_bytes += len(segment_data)
@@ -139,27 +171,28 @@ class HlsDownloader:
# 세그먼트 합치기 (concat 파일 생성)
concat_file = os.path.join(temp_dir, "concat.txt")
with open(concat_file, 'w') as f:
for seg_file in segment_files:
f.write(f"file '{seg_file}'\n")
for seg_filename in segment_files:
f.write(f"file '{seg_filename}'\n")
# 출력 디렉토리 생성
output_dir = os.path.dirname(self.output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir)
# ffmpeg로 합치기
# ffmpeg로 합치기 (temp_dir에서 실행)
cmd = [
'ffmpeg', '-y',
'-f', 'concat',
'-safe', '0',
'-i', concat_file,
'-i', 'concat.txt',
'-c', 'copy',
self.output_path
os.path.abspath(self.output_path)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, cwd=temp_dir)
if result.returncode != 0:
logger.error(f"FFmpeg stderr: {result.stderr}")
return False, f"FFmpeg concat failed: {result.stderr}"
return True, "Download completed"

221
lib/playwright_anilife.py Normal file
View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Playwright 기반 Anilife 비디오 URL 추출 스크립트
FlaskFarm의 gevent와 충돌을 피하기 위해 별도의 subprocess로 실행됩니다.
사용법:
python playwright_anilife.py <detail_url> <episode_num>
출력:
JSON 형식으로 _aldata 또는 에러 메시지 출력
"""
import sys
import json
import time
import re
def extract_aldata(detail_url: str, episode_num: str) -> dict:
"""Detail 페이지에서 에피소드를 클릭하고 _aldata를 추출합니다."""
try:
from playwright.sync_api import sync_playwright
except ImportError as e:
return {"error": f"Playwright not installed: {e}"}
result = {
"success": False,
"aldata": None,
"html": None,
"current_url": None,
"error": None,
"player_url": None
}
try:
with sync_playwright() as p:
# 시스템에 설치된 Chrome 사용
browser = p.chromium.launch(
headless=False, # visible 모드
channel="chrome", # 시스템 Chrome 사용
args=[
"--disable-blink-features=AutomationControlled",
"--disable-automation",
"--no-sandbox",
]
)
# 브라우저 컨텍스트 생성 (스텔스 설정)
context = browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
locale="ko-KR",
)
# navigator.webdriver 숨기기
context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
""")
page = context.new_page()
try:
# 1. Detail 페이지 방문
page.goto(detail_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
# 2. 에피소드 찾아서 클릭 (episode_num을 포함하는 provider 링크)
episode_clicked = False
# 스크롤하여 에피소드 목록 로드
page.mouse.wheel(0, 800)
time.sleep(1)
# JavaScript로 에피소드 링크 찾아 클릭
try:
episode_href = page.evaluate(f"""
(() => {{
const links = Array.from(document.querySelectorAll('a[href*="/ani/provider/"]'));
const ep = links.find(a => a.innerText.includes('{episode_num}'));
if (ep) {{
ep.click();
return ep.href;
}}
return null;
}})()
""")
if episode_href:
episode_clicked = True
time.sleep(2)
except Exception as e:
result["error"] = f"Episode click failed: {e}"
if not episode_clicked:
result["error"] = f"Episode {episode_num} not found"
result["html"] = page.content()
return result
# 3. Provider 페이지에서 player_guid 추출 (버튼 클릭 대신)
# moveCloudvideo() 또는 moveJawcloud() 함수에서 GUID 추출
try:
player_info = page.evaluate("""
(() => {
// 함수 소스에서 GUID 추출 시도
let playerUrl = null;
// moveCloudvideo 함수 확인
if (typeof moveCloudvideo === 'function') {
const funcStr = moveCloudvideo.toString();
// URL 패턴 찾기
const match = funcStr.match(/['"]([^'"]+\\/h\\/live[^'"]+)['"]/);
if (match) {
playerUrl = match[1];
}
}
// moveJawcloud 함수 확인
if (!playerUrl && typeof moveJawcloud === 'function') {
const funcStr = moveJawcloud.toString();
const match = funcStr.match(/['"]([^'"]+\\/h\\/live[^'"]+)['"]/);
if (match) {
playerUrl = match[1];
}
}
// 페이지 변수 확인
if (!playerUrl && typeof _player_guid !== 'undefined') {
playerUrl = '/h/live?p=' + _player_guid + '&player=jawcloud';
}
// onclick 속성에서 추출
if (!playerUrl) {
const btn = document.querySelector('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]');
if (btn) {
const onclick = btn.getAttribute('onclick');
// 함수 이름 확인 후 페이지 소스에서 URL 추출
}
}
// 전역 변수 검색
if (!playerUrl) {
for (const key of Object.keys(window)) {
if (key.includes('player') || key.includes('guid')) {
const val = window[key];
if (typeof val === 'string' && val.match(/^[a-f0-9-]{36}$/)) {
playerUrl = '/h/live?p=' + val + '&player=jawcloud';
break;
}
}
}
}
// _aldata 직접 확인 (provider 페이지에 있을 수 있음)
if (typeof _aldata !== 'undefined') {
return { aldata: _aldata, playerUrl: null };
}
return { aldata: null, playerUrl: playerUrl };
})()
""")
if player_info.get("aldata"):
result["aldata"] = player_info["aldata"]
result["success"] = True
result["current_url"] = page.url
return result
result["player_url"] = player_info.get("playerUrl")
except Exception as e:
result["error"] = f"Player info extraction failed: {e}"
# 4. Player URL이 있으면 해당 페이지로 이동하여 _aldata 추출
if result.get("player_url"):
player_full_url = "https://anilife.live" + result["player_url"] if result["player_url"].startswith("/") else result["player_url"]
page.goto(player_full_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
# _aldata 추출
try:
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
if aldata_value:
result["aldata"] = aldata_value
result["success"] = True
except Exception as e:
pass
# 현재 URL 기록
result["current_url"] = page.url
# HTML에서 _aldata 패턴 추출 시도
if not result["aldata"]:
html = page.content()
# _aldata = "..." 패턴 찾기
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
if aldata_match:
result["aldata"] = aldata_match.group(1)
result["success"] = True
else:
result["html"] = html
finally:
context.close()
browser.close()
except Exception as e:
result["error"] = str(e)
return result
if __name__ == "__main__":
if len(sys.argv) < 3:
print(json.dumps({"error": "Usage: python playwright_anilife.py <detail_url> <episode_num>"}))
sys.exit(1)
detail_url = sys.argv[1]
episode_num = sys.argv[2]
result = extract_aldata(detail_url, episode_num)
print(json.dumps(result, ensure_ascii=False))

181
lib/playwright_cdp.py Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Chrome 디버그 모드에 연결하여 Anilife 비디오 URL 추출
Detail 페이지 → 에피소드 클릭 → _aldata 추출 플로우
사용법:
1. Chrome 디버그 모드 실행:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome_debug
2. 스크립트 실행:
python playwright_cdp.py <detail_url> <episode_num>
"""
import sys
import json
import time
import re
def extract_aldata_via_cdp(detail_url: str, episode_num: str) -> dict:
"""Chrome DevTools Protocol로 연결하여 _aldata 추출"""
try:
from playwright.sync_api import sync_playwright
except ImportError as e:
return {"error": f"Playwright not installed: {e}"}
result = {
"success": False,
"aldata": None,
"html": None,
"current_url": None,
"error": None,
"vod_url": None
}
try:
with sync_playwright() as p:
# Chrome 디버그 포트에 연결
browser = p.chromium.connect_over_cdp("http://localhost:9222")
# 기존 컨텍스트 사용
contexts = browser.contexts
if not contexts:
context = browser.new_context()
else:
context = contexts[0]
# 새 페이지 열기
page = context.new_page()
try:
# 1. Detail 페이지로 이동
print(f"1. Navigating to detail page: {detail_url}", file=sys.stderr)
page.goto(detail_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
print(f" Current URL: {page.url}", file=sys.stderr)
# 2. 에피소드 목록으로 스크롤
page.mouse.wheel(0, 800)
time.sleep(1)
# 3. 해당 에피소드 찾아서 클릭
print(f"2. Looking for episode {episode_num}", file=sys.stderr)
# 에피소드 링크 찾기 (provider 링크 중에서)
episode_clicked = False
try:
# 방법 1: epl-num 클래스의 div에서 에피소드 번호 찾기
episode_link = page.locator(f'a:has(.epl-num:text("{episode_num}"))').first
if episode_link.is_visible(timeout=5000):
href = episode_link.get_attribute("href")
print(f" Found episode link: {href}", file=sys.stderr)
episode_link.click()
episode_clicked = True
time.sleep(3)
except Exception as e:
print(f" Method 1 failed: {e}", file=sys.stderr)
if not episode_clicked:
try:
# 방법 2: provider 링크들 중에서 에피소드 번호가 포함된 것 클릭
links = page.locator('a[href*="/ani/provider/"]').all()
for link in links:
text = link.inner_text()
if episode_num in text:
print(f" Found: {text}", file=sys.stderr)
link.click()
episode_clicked = True
time.sleep(3)
break
except Exception as e:
print(f" Method 2 failed: {e}", file=sys.stderr)
if not episode_clicked:
result["error"] = f"Episode {episode_num} not found"
result["html"] = page.content()
return result
# 4. Provider 페이지에서 _aldata 추출
print(f"3. Provider page URL: {page.url}", file=sys.stderr)
result["current_url"] = page.url
# _aldata 추출 시도
try:
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
if aldata_value:
result["aldata"] = aldata_value
result["success"] = True
print(f" SUCCESS! _aldata found: {aldata_value[:60]}...", file=sys.stderr)
return result
except Exception as js_err:
print(f" JS error: {js_err}", file=sys.stderr)
# HTML에서 _aldata 패턴 추출 시도
html = page.content()
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
if aldata_match:
result["aldata"] = aldata_match.group(1)
result["success"] = True
print(f" SUCCESS! _aldata from HTML: {result['aldata'][:60]}...", file=sys.stderr)
return result
# 5. CloudVideo 버튼 클릭 시도
print("4. Trying CloudVideo button click...", file=sys.stderr)
try:
page.mouse.wheel(0, 500)
time.sleep(1)
cloudvideo_btn = page.locator('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]').first
if cloudvideo_btn.is_visible(timeout=3000):
cloudvideo_btn.click()
time.sleep(3)
result["current_url"] = page.url
print(f" After click URL: {page.url}", file=sys.stderr)
# 플레이어 페이지에서 _aldata 추출
try:
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
if aldata_value:
result["aldata"] = aldata_value
result["success"] = True
print(f" SUCCESS! _aldata: {aldata_value[:60]}...", file=sys.stderr)
return result
except:
pass
# HTML에서 추출
html = page.content()
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
if aldata_match:
result["aldata"] = aldata_match.group(1)
result["success"] = True
return result
result["html"] = html
except Exception as click_err:
print(f" Click error: {click_err}", file=sys.stderr)
result["html"] = page.content()
finally:
page.close()
except Exception as e:
result["error"] = str(e)
if "connect" in str(e).lower():
result["error"] = "Chrome 디버그 모드가 실행 중이 아닙니다."
return result
if __name__ == "__main__":
if len(sys.argv) < 3:
print(json.dumps({"error": "Usage: python playwright_cdp.py <detail_url> <episode_num>"}))
sys.exit(1)
detail_url = sys.argv[1]
episode_num = sys.argv[2]
result = extract_aldata_via_cdp(detail_url, episode_num)
print(json.dumps(result, ensure_ascii=False))

View File

@@ -16,11 +16,13 @@ logger = logging.getLogger(__name__)
class YtdlpDownloader:
"""yt-dlp 기반 다운로더"""
def __init__(self, url, output_path, headers=None, callback=None):
def __init__(self, url, output_path, headers=None, callback=None, proxy=None, cookies_file=None):
self.url = url
self.output_path = output_path
self.headers = headers or {}
self.callback = callback # 진행 상황 콜백
self.proxy = proxy
self.cookies_file = cookies_file # CDN 세션 쿠키 파일 경로
self.cancelled = False
self.process = None
self.error_output = [] # 에러 메시지 저장
@@ -30,6 +32,7 @@ class YtdlpDownloader:
self.current_speed = ""
self.elapsed_time = ""
self.percent = 0
def format_time(self, seconds):
"""시간을 읽기 좋은 형식으로 변환"""
@@ -57,12 +60,7 @@ class YtdlpDownloader:
return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
def download(self):
"""yt-dlp Python 모듈로 다운로드 수행"""
try:
import yt_dlp
except ImportError:
return False, "yt-dlp를 찾을 수 없습니다. pip install yt-dlp 로 설치해주세요."
"""yt-dlp CLI를 통한 브라우저 흉내(Impersonate) 방식 다운로드 수행"""
try:
self.start_time = time.time()
@@ -71,86 +69,118 @@ class YtdlpDownloader:
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir)
# 진행률 콜백
def progress_hook(d):
# URL 전처리: 확장자 힌트(?dummy=.m3u8) 사용
# (m3u8: 접두사나 #.m3u8보다 호환성이 높음. HLS 인식 강제용)
current_url = self.url
if 'master.txt' in current_url:
concat_char = '&' if '?' in current_url else '?'
current_url = f"{current_url}{concat_char}dummy=.m3u8"
# 1. 기본 명령어 구성 (Impersonate & HLS 강제)
cmd = [
'yt-dlp',
'--newline',
'--no-playlist',
'--no-part',
'--hls-prefer-ffmpeg',
'--hls-use-mpegts',
'--no-check-certificate',
'--progress',
'--verbose', # 디버깅용 상세 로그
'--impersonate', 'chrome-120', # 정밀한 크롬-120 지문 사용
'--extractor-args', 'generic:force_hls', # HLS 강제 추출
'-o', self.output_path,
]
# 2. 프록시 설정
if self.proxy:
cmd += ['--proxy', self.proxy]
# 2.5 쿠키 파일 설정 (CDN 세션 인증용)
if self.cookies_file and os.path.exists(self.cookies_file):
cmd += ['--cookies', self.cookies_file]
logger.info(f"Using cookies file: {self.cookies_file}")
# 3. 필수 헤더 구성
# --impersonate가 기본적인 Sec-Fetch를 처리하지만,
# X-Requested-With와 정확한 Referer/Origin은 명시적으로 주는 것이 안전합니다.
has_referer = False
for k, v in self.headers.items():
if k.lower() == 'referer':
cmd += ['--referer', v]
has_referer = True
elif k.lower() == 'user-agent':
# impersonate가 설정한 UA를 명시적 UA로 덮어씀 (필요시)
cmd += ['--user-agent', v]
else:
cmd += ['--add-header', f"{k}:{v}"]
# cdndania 전용 헤더 보강
if 'cdndania.com' in current_url:
if not has_referer:
cmd += ['--referer', 'https://cdndania.com/']
cmd += ['--add-header', 'Origin:https://cdndania.com']
cmd += ['--add-header', 'X-Requested-With:XMLHttpRequest']
cmd.append(current_url)
logger.info(f"Executing refined browser-impersonated yt-dlp CLI (v16): {' '.join(cmd)}")
# 4. subprocess 실행 및 파싱
self.process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1
)
# [download] 10.5% of ~100.00MiB at 2.45MiB/s
prog_re = re.compile(r'\[download\]\s+(?P<percent>[\d\.]+)%\s+of\s+.*?\s+at\s+(?P<speed>.*?)(\s+ETA|$)')
for line in self.process.stdout:
if self.cancelled:
raise Exception("Cancelled")
self.process.terminate()
return False, "Cancelled"
if d['status'] == 'downloading':
# 진행률 추출
total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0
downloaded = d.get('downloaded_bytes', 0)
speed = d.get('speed', 0)
if total > 0:
self.percent = (downloaded / total) * 100
self.current_speed = self.format_speed(speed) if speed else ""
if self.start_time:
elapsed = time.time() - self.start_time
self.elapsed_time = self.format_time(elapsed)
# 콜백 호출
if self.callback:
self.callback(
percent=int(self.percent),
current=int(self.percent),
total=100,
speed=self.current_speed,
elapsed=self.elapsed_time
)
line = line.strip()
if not line: continue
elif d['status'] == 'finished':
logger.info(f"yt-dlp download finished: {d.get('filename', '')}")
match = prog_re.search(line)
if match:
try:
self.percent = float(match.group('percent'))
self.current_speed = match.group('speed').strip()
if self.start_time:
elapsed = time.time() - self.start_time
self.elapsed_time = self.format_time(elapsed)
if self.callback:
self.callback(percent=int(self.percent), current=int(self.percent), total=100, speed=self.current_speed, elapsed=self.elapsed_time)
except: pass
elif 'error' in line.lower() or 'security' in line.lower() or 'unable' in line.lower():
logger.warning(f"yt-dlp output notice: {line}")
self.error_output.append(line)
self.process.wait()
# yt-dlp 옵션 설정
ydl_opts = {
'outtmpl': self.output_path,
'progress_hooks': [progress_hook],
'quiet': False,
'no_warnings': False,
'noprogress': False,
}
# 헤더 추가
http_headers = {}
if self.headers:
if self.headers.get('Referer'):
http_headers['Referer'] = self.headers['Referer']
if self.headers.get('User-Agent'):
http_headers['User-Agent'] = self.headers['User-Agent']
if http_headers:
ydl_opts['http_headers'] = http_headers
logger.info(f"yt-dlp downloading: {self.url}")
logger.info(f"Output path: {self.output_path}")
logger.info(f"Headers: {http_headers}")
# 다운로드 실행
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.url])
# 파일 존재 확인
if os.path.exists(self.output_path):
if self.process.returncode == 0 and os.path.exists(self.output_path):
# 가짜 파일(보안 에러 텍스트) 체크
file_size = os.path.getsize(self.output_path)
if file_size < 2000:
try:
with open(self.output_path, 'r') as f:
text = f.read().lower()
if "security error" in text or not text:
os.remove(self.output_path)
return False, f"CDN 보안 차단(가짜 파일 다운로드됨: {file_size}B)"
except: pass
return True, "Download completed"
else:
# yt-dlp가 확장자를 변경했을 수 있음
base_name = os.path.splitext(self.output_path)[0]
for ext in ['.mp4', '.mkv', '.webm', '.ts']:
possible_path = base_name + ext
if os.path.exists(possible_path):
if possible_path != self.output_path:
os.rename(possible_path, self.output_path)
return True, "Download completed"
return False, "Output file not found"
except Exception as e:
error_msg = str(e)
logger.error(f"yt-dlp download error: {error_msg}")
error_msg = "\n".join(self.error_output[-3:]) if self.error_output else f"Exit code {self.process.returncode}"
return False, f"yt-dlp 실패: {error_msg}"
except Exception as e:
logger.error(f"yt-dlp download exception: {e}")
return False, f"yt-dlp download exception: {str(e)}"
def cancel(self):
"""다운로드 취소"""

View File

@@ -77,6 +77,7 @@ class LogicAniLife(PluginModuleBase):
"anilife_auto_make_season_folder": "True",
"anilife_finished_insert": "[완결]",
"anilife_max_ffmpeg_process_count": "1",
"anilife_download_method": "ffmpeg", # ffmpeg or ytdlp
"anilife_order_desc": "False",
"anilife_auto_start": "False",
"anilife_interval": "* 5 * * *",
@@ -525,6 +526,9 @@ class LogicAniLife(PluginModuleBase):
info = json.loads(request.form["data"])
logger.info(f"info:: {info}")
ret["ret"] = self.add(info)
# 성공적으로 큐에 추가되면 UI 새로고침 트리거
if ret["ret"].startswith("enqueue"):
self.socketio_callback("list_refresh", "")
return jsonify(ret)
elif sub == "entity_list":
return jsonify(self.queue.get_entity_list())
@@ -559,6 +563,36 @@ class LogicAniLife(PluginModuleBase):
return jsonify(ModelAniLifeItem.web_list(request))
elif sub == "db_remove":
return jsonify(ModelAniLifeItem.delete_by_id(req.form["id"]))
elif sub == "proxy_image":
# 이미지 프록시: CDN hotlink 보호 우회
from flask import Response
# 'image_url' 또는 'url' 파라미터 둘 다 지원
image_url = request.args.get("image_url") or request.args.get("url", "")
if not image_url or not image_url.startswith("http"):
return Response("Invalid URL", status=400)
try:
# cloudscraper 사용하여 Cloudflare 우회
scraper = cloudscraper.create_scraper(
browser={
"browser": "chrome",
"platform": "windows",
"desktop": True
}
)
headers = {
"Referer": "https://anilife.live/",
}
img_response = scraper.get(image_url, headers=headers, timeout=10)
logger.debug(f"Image proxy: {image_url} -> status {img_response.status_code}")
if img_response.status_code == 200:
content_type = img_response.headers.get("Content-Type", "image/jpeg")
return Response(img_response.content, mimetype=content_type)
else:
logger.warning(f"Image proxy failed: {image_url} -> {img_response.status_code}")
return Response("Image not found", status=404)
except Exception as img_err:
logger.error(f"Image proxy error for {image_url}: {img_err}")
return Response("Proxy error", status=500)
except Exception as e:
P.logger.error("Exception:%s", e)
P.logger.error(traceback.format_exc())
@@ -595,9 +629,10 @@ class LogicAniLife(PluginModuleBase):
ret["msg"] = "다운로드를 추가 하였습니다."
elif command == "list":
# Anilife 큐의 entity_list 반환 (이전: SupportFfmpeg.get_list() - 잘못된 소스)
ret = []
for ins in SupportFfmpeg.get_list():
ret.append(ins.get_data())
for entity in self.queue.entity_list:
ret.append(entity.as_dict())
return jsonify(ret)
@@ -672,7 +707,7 @@ class LogicAniLife(PluginModuleBase):
def plugin_load(self):
self.queue = FfmpegQueue(
P, P.ModelSetting.get_int("anilife_max_ffmpeg_process_count"), name
P, P.ModelSetting.get_int("anilife_max_ffmpeg_process_count"), name, self
)
self.current_data = None
self.queue.queue_start()
@@ -690,7 +725,7 @@ class LogicAniLife(PluginModuleBase):
db.session.commit()
return True
# 시리즈 정보를 가져오는 함수
# 시리즈 정보를 가져오는 함수 (cloudscraper 버전)
def get_series_info(self, code):
try:
if code.isdigit():
@@ -698,24 +733,28 @@ class LogicAniLife(PluginModuleBase):
else:
url = P.ModelSetting.get("anilife_url") + "/g/l?id=" + code
logger.debug("url::: > %s", url)
# response_data = LogicAniLife.get_html(self, url=url, timeout=10)
logger.debug("get_series_info()::url > %s", url)
import json
post_data = {"url": url, "headless": True, "engine": "webkit"}
payload = json.dumps(post_data)
logger.debug(payload)
response_data = None
response_data = requests.post(
url="http://localhost:7070/get_html_by_playwright", data=payload
# cloudscraper를 사용하여 Cloudflare 우회
scraper = cloudscraper.create_scraper(
browser={
"browser": "chrome",
"platform": "windows",
"desktop": True
}
)
# 리다이렉트 자동 처리 (숫자 ID → UUID 페이지로 리다이렉트됨)
response = scraper.get(url, timeout=15, allow_redirects=True)
if response.status_code != 200:
logger.error(f"Failed to fetch series info: HTTP {response.status_code}")
return {"ret": "error", "log": f"HTTP {response.status_code}"}
# 최종 URL 로깅 (리다이렉트된 경우)
logger.debug(f"Final URL after redirect: {response.url}")
# logger.debug(response_data.json()["html"])
soup_text = BeautifulSoup(response_data.json()["html"], "lxml")
tree = html.fromstring(response_data.json()["html"])
tree = html.fromstring(response.text)
# tree = html.fromstring(response_data)
# logger.debug(response_data)
@@ -788,6 +827,9 @@ class LogicAniLife(PluginModuleBase):
date = ""
m = hashlib.md5(title.encode("utf-8"))
_vi = m.hexdigest()
# 고유한 _id 생성: content_code + ep_num + link의 조합
# 같은 시리즈 내에서도 에피소드마다 고유하게 식별
unique_id = f"{code}_{ep_num}_{link}"
episodes.append(
{
"ep_num": ep_num,
@@ -796,7 +838,7 @@ class LogicAniLife(PluginModuleBase):
"thumbnail": image,
"date": date,
"day": date,
"_id": title,
"_id": unique_id,
"va": link,
"_vi": _vi,
"content_code": code,
@@ -880,44 +922,29 @@ class LogicAniLife(PluginModuleBase):
logger.info("url:::> %s", url)
data = {}
import json
# cloudscraper를 사용하여 Cloudflare 우회
scraper = cloudscraper.create_scraper(
browser={
"browser": "chrome",
"platform": "windows",
"desktop": True
}
)
response = scraper.get(url, timeout=15)
if response.status_code != 200:
logger.error(f"Failed to fetch anime info: HTTP {response.status_code}")
return {"ret": "error", "log": f"HTTP {response.status_code}"}
post_data = {
"url": url,
"headless": True,
"engine": "chrome",
"reload": True,
}
payload = json.dumps(post_data)
logger.debug(payload)
try:
API_BASE_URL = "http://localhost:7070"
response_data = requests.post(
url=("%s/get_html_by_playwright" % API_BASE_URL), data=payload
)
except Exception as e:
logger.error(f"Exception: {str(e)}")
return
LogicAniLife.episode_url = response_data.json()["url"]
logger.info(response_data.json()["url"])
LogicAniLife.episode_url = response.url
logger.info(response.url)
logger.debug(LogicAniLife.episode_url)
# logger.debug(response_data.json())
soup_text = BeautifulSoup(response.text, "lxml")
# logger.debug(f"wrapper_xath:: {wrapper_xpath}")
# logger.debug(LogicAniLife.response_data)
# print(type(response_data))
# logger.debug(response_data.json()["html"])
soup_text = BeautifulSoup(response_data.json()["html"], "lxml")
# print(len(soup_text.select("div.bsx")))
tree = html.fromstring(response_data.json()["html"])
# tree = lxml.etree.HTML(str(soup_text))
# logger.debug(tree)
# print(wrapper_xpath)
tree = html.fromstring(response.text)
tmp_items = tree.xpath(wrapper_xpath)
# tmp_items = tree.xpath('//div[@class="bsx"]')
logger.debug(tmp_items)
data["anime_count"] = len(tmp_items)
@@ -925,21 +952,34 @@ class LogicAniLife(PluginModuleBase):
for item in tmp_items:
entity = {}
entity["link"] = item.xpath(".//a/@href")[0]
# logger.debug(entity["link"])
link_elem = item.xpath(".//a/@href")
if not link_elem:
continue
entity["link"] = link_elem[0]
p = re.compile(r"^[http?s://]+[a-zA-Z0-9-]+/[a-zA-Z0-9-_.?=]+$")
# print(p.match(entity["link"]) != None)
if p.match(entity["link"]) is None:
entity["link"] = P.ModelSetting.get("anilife_url") + entity["link"]
# real_url = LogicAniLife.get_real_link(url=entity["link"])
entity["code"] = entity["link"].split("/")[-1]
entity["epx"] = item.xpath(".//span[@class='epx']/text()")[0].strip()
entity["title"] = item.xpath(".//div[@class='tt']/text()")[0].strip()
entity["image_link"] = item.xpath(".//div[@class='limit']/img/@src")[
0
].replace("..", P.ModelSetting.get("anilife_url"))
# 에피소드 수
epx_elem = item.xpath(".//span[@class='epx']/text()")
entity["epx"] = epx_elem[0].strip() if epx_elem else ""
# 제목
title_elem = item.xpath(".//div[@class='tt']/text()")
entity["title"] = title_elem[0].strip() if title_elem else ""
# 이미지 URL (img 태그에서 직접 추출)
img_elem = item.xpath(".//img/@src")
if not img_elem:
img_elem = item.xpath(".//img/@data-src")
if img_elem:
entity["image_link"] = img_elem[0].replace("..", P.ModelSetting.get("anilife_url"))
else:
entity["image_link"] = ""
data["ret"] = "success"
data["anime_list"].append(entity)
@@ -949,6 +989,120 @@ class LogicAniLife(PluginModuleBase):
P.logger.error(traceback.format_exc())
return {"ret": "exception", "log": str(e)}
def get_search_result(self, query, page, cate):
"""
anilife.live 검색 결과를 가져오는 함수
cloudscraper 버전(v2)을 직접 사용
Args:
query: 검색어
page: 페이지 번호 (현재 미사용)
cate: 카테고리 (현재 미사용)
Returns:
dict: 검색 결과 데이터 (anime_count, anime_list)
"""
# cloudscraper 버전 직접 사용 (외부 playwright API 서버 불필요)
return self.get_search_result_v2(query, page, cate)
def get_search_result_v2(self, query, page, cate):
"""
anilife.live 검색 결과를 가져오는 함수 (cloudscraper 버전)
외부 playwright API 서버 없이 직접 cloudscraper를 사용
Args:
query: 검색어
page: 페이지 번호 (현재 미사용, 향후 페이지네이션 지원용)
cate: 카테고리 (현재 미사용)
Returns:
dict: 검색 결과 데이터 (anime_count, anime_list)
"""
try:
_query = urllib.parse.quote(query)
url = P.ModelSetting.get("anilife_url") + "/search?keyword=" + _query
logger.info("get_search_result_v2()::url> %s", url)
data = {}
# cloudscraper를 사용하여 Cloudflare 우회
scraper = cloudscraper.create_scraper(
browser={
"browser": "chrome",
"platform": "windows",
"desktop": True
}
)
response = scraper.get(url, timeout=15)
if response.status_code != 200:
logger.error(f"Failed to fetch search results: HTTP {response.status_code}")
return {"ret": "error", "log": f"HTTP {response.status_code}"}
tree = html.fromstring(response.text)
# 검색 결과 항목들 (div.bsx)
tmp_items = tree.xpath('//div[@class="bsx"]')
data["anime_count"] = len(tmp_items)
data["anime_list"] = []
for item in tmp_items:
entity = {}
# 링크 추출
link_elem = item.xpath(".//a/@href")
if link_elem:
entity["link"] = link_elem[0]
# 상대 경로인 경우 절대 경로로 변환
if entity["link"].startswith("/"):
entity["link"] = P.ModelSetting.get("anilife_url") + entity["link"]
else:
continue
# 코드 추출 (링크에서 ID 추출)
# /detail/id/832 -> 832
code_match = re.search(r'/detail/id/(\d+)', entity["link"])
if code_match:
entity["code"] = code_match.group(1)
else:
entity["code"] = entity["link"].split("/")[-1]
# 에피소드 수
epx_elem = item.xpath(".//span[@class='epx']/text()")
entity["epx"] = epx_elem[0].strip() if epx_elem else ""
# 제목 (h2 또는 div.tt에서 추출)
title_elem = item.xpath(".//h2[@itemprop='headline']/text()")
if not title_elem:
title_elem = item.xpath(".//div[@class='tt']/text()")
entity["title"] = title_elem[0].strip() if title_elem else ""
# 이미지 URL (img 태그에서 직접 추출)
img_elem = item.xpath(".//img/@src")
if not img_elem:
# data-src 속성 체크 (lazy loading 대응)
img_elem = item.xpath(".//img/@data-src")
if img_elem:
entity["image_link"] = img_elem[0]
else:
entity["image_link"] = ""
# wr_id는 anilife에서는 사용하지 않음
entity["wr_id"] = ""
data["ret"] = "success"
data["anime_list"].append(entity)
logger.info("Found %d search results (v2) for query: %s", len(data["anime_list"]), query)
return data
except Exception as e:
P.logger.error(f"Exception: {str(e)}")
P.logger.error(traceback.format_exc())
return {"ret": "exception", "log": str(e)}
#########################################################
def add(self, episode_info):
if self.is_exist(episode_info):
@@ -1022,114 +1176,181 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
db_entity.save()
def make_episode_info(self):
logger.debug("make_episode_info() routine ==========")
"""
에피소드 정보를 추출하고 비디오 URL을 가져옵니다.
Selenium + stealth 기반 구현 (JavaScript 실행 필요)
플로우:
1. Selenium으로 provider 페이지 접속
2. _aldata JavaScript 변수에서 Base64 데이터 추출
3. vid_url_1080 값으로 최종 m3u8 URL 구성
"""
logger.debug("make_episode_info() routine (Selenium version) ==========")
try:
# 다운로드 추가
import base64
import json as json_module
base_url = "https://anilife.live"
iframe_url = ""
LogicAniLife.episode_url = self.info["ep_url"]
logger.debug(LogicAniLife.episode_url)
url = self.info["va"]
# LogicAniLife.episode_url = url
logger.debug(f"url:: {url}")
ourls = parse.urlparse(url)
self.headers = {
"Referer": LogicAniLife.episode_url,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, "
"like Gecko) Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36",
}
logger.debug("make_episode_info()::url==> %s", url)
logger.info(f"self.info:::> {self.info}")
referer = "https://anilife.live/g/l?id=13fd4d28-ff18-4764-9968-7e7ea7347c51"
# text = requests.get(url, headers=headers).text
# text = LogicAniLife.get_html_seleniumwire(url, referer=referer, wired=True)
# https://anilife.live/ani/provider/10f60832-20d1-4918-be62-0f508bf5460c
referer_url = (
"https://anilife.live/g/l?id=b012a355-a997-449a-ae2b-408a81a9b464"
)
referer_url = LogicAniLife.episode_url
logger.debug(f"LogicAniLife.episode_url:: {LogicAniLife.episode_url}")
# gevent 에서 asyncio.run
# text = asyncio.run(
# LogicAniLife.get_html_playwright(
# url,
# headless=False,
# referer=referer_url,
# engine="chrome",
# stealth=True,
# )
# )
# task1 = asyncio.create_task(LogicAniLife.get_html_playwright(
# url,
# headless=True,
# referer=referer_url,
# engine="chrome",
# stealth=True
# ))
# loop = asyncio.new_event_loop()
logger.debug(url, referer_url)
import json
post_data = {
"url": url,
"headless": False,
# "engine": "chromium",
"engine": "webkit",
"referer": referer_url,
"stealth": "False",
"reload": True,
}
payload = json.dumps(post_data)
logger.debug(payload)
response_data = requests.post(
url="http://localhost:7070/get_html_by_playwright", data=payload
)
# logger.debug(response_data.json()["html"])
# soup_text = BeautifulSoup(response_data.json()["html"], 'lxml')
#
# tree = html.fromstring(response_data.json()["html"])
text = response_data.json()["html"]
# vod_1080p_url = text
# logger.debug(text)
soup = BeautifulSoup(text, "lxml")
all_scripts = soup.find_all("script")
print(f"all_scripts:: {all_scripts}")
regex = r"(?P<jawcloud_url>http?s:\/\/.*=jawcloud)"
match = re.compile(regex).search(text)
jawcloud_url = None
# print(match)
if match:
jawcloud_url = match.group("jawcloud_url")
logger.debug(f"jawcloud_url:: {jawcloud_url}")
# loop = asyncio.new_event_loop()
# asyncio.set_event_loop(loop)
#
logger.info(self.info)
LogicAniLife.episode_url = self.info.get("ep_url", base_url)
# 에피소드 provider 페이지 URL
provider_url = self.info["va"]
if provider_url.startswith("/"):
provider_url = base_url + provider_url
logger.debug(f"Provider URL: {provider_url}")
logger.info(f"Episode info: {self.info}")
provider_html = None
aldata_value = None
# Camoufox를 subprocess로 실행 (스텔스 Firefox - 봇 감지 우회)
try:
import subprocess
import json as json_module
# camoufox_anilife.py 스크립트 경로
script_path = os.path.join(os.path.dirname(__file__), "lib", "camoufox_anilife.py")
# detail_url과 episode_num 추출
detail_url = self.info.get("ep_url", f"https://anilife.live/detail/id/{self.info.get('content_code', '')}")
episode_num = str(self.info.get("ep_num", "1"))
logger.debug(f"Running Camoufox subprocess: {script_path}")
logger.debug(f"Detail URL: {detail_url}, Episode: {episode_num}")
# subprocess로 Camoufox 스크립트 실행
result = subprocess.run(
[sys.executable, script_path, detail_url, episode_num],
capture_output=True,
text=True,
timeout=120 # 120초 타임아웃
)
if result.returncode != 0:
logger.error(f"Camoufox subprocess failed: {result.stderr}")
raise Exception(f"Subprocess error: {result.stderr}")
# JSON 결과 파싱
cf_result = json_module.loads(result.stdout)
logger.debug(f"Camoufox result: success={cf_result.get('success')}, current_url={cf_result.get('current_url')}")
if cf_result.get("error"):
logger.error(f"Camoufox error: {cf_result['error']}")
# _aldata 추출
if cf_result.get("success") and cf_result.get("aldata"):
aldata_value = cf_result["aldata"]
logger.debug(f"Got _aldata from Camoufox: {aldata_value[:50]}...")
elif cf_result.get("html"):
provider_html = cf_result["html"]
logger.debug(f"Provider page loaded via Camoufox, length: {len(provider_html)}")
else:
logger.error("No aldata or HTML returned from Camoufox")
return
except subprocess.TimeoutExpired:
logger.error("Camoufox subprocess timed out")
return
except FileNotFoundError:
logger.error(f"Camoufox script not found: {script_path}")
return
except Exception as cf_err:
logger.error(f"Camoufox subprocess error: {cf_err}")
logger.error(traceback.format_exc())
return
# _aldata 처리
if aldata_value:
# JavaScript에서 직접 가져온 경우
aldata_b64 = aldata_value
elif provider_html:
# HTML에서 추출
aldata_patterns = [
r"var\s+_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
r"let\s+_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
r"const\s+_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
r"_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
r"_aldata\s*=\s*'([^']+)'",
r'_aldata\s*=\s*"([^"]+)"',
]
aldata_match = None
for pattern in aldata_patterns:
aldata_match = re.search(pattern, provider_html)
if aldata_match:
logger.debug(f"Found _aldata with pattern: {pattern}")
break
if not aldata_match:
if "_aldata" in provider_html:
idx = provider_html.find("_aldata")
snippet = provider_html[idx:idx+200]
logger.error(f"_aldata found but pattern didn't match. Snippet: {snippet}")
else:
logger.error("_aldata not found in provider page at all")
logger.debug(f"HTML snippet (first 1000 chars): {provider_html[:1000]}")
return
aldata_b64 = aldata_match.group(1)
else:
logger.error("No provider HTML or _aldata value available")
return
logger.debug(f"Found _aldata: {aldata_b64[:50]}...")
# Base64 디코딩
try:
aldata_json = base64.b64decode(aldata_b64).decode('utf-8')
aldata = json_module.loads(aldata_json)
logger.debug(f"Decoded _aldata: {aldata}")
except Exception as decode_err:
logger.error(f"Failed to decode _aldata: {decode_err}")
return
# vid_url_1080 추출
vid_url_path = aldata.get("vid_url_1080")
if not vid_url_path or vid_url_path == "none":
# 720p 폴백
vid_url_path = aldata.get("vid_url_720")
if not vid_url_path or vid_url_path == "none":
logger.error("No video URL found in _aldata")
return
# API URL 구성 (이 URL은 JSON을 반환함)
api_url = f"https://{vid_url_path}"
logger.info(f"API URL: {api_url}")
# API에서 실제 m3u8 URL 가져오기
try:
api_headers = {
"Referer": "https://anilife.live/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
api_response = requests.get(api_url, headers=api_headers, timeout=30)
api_data = api_response.json()
# JSON 배열에서 URL 추출
if isinstance(api_data, list) and len(api_data) > 0:
vod_url = api_data[0].get("url")
logger.info(f"Extracted m3u8 URL from API: {vod_url}")
else:
logger.error(f"Unexpected API response format: {api_data}")
return
except Exception as api_err:
logger.error(f"Failed to get m3u8 URL from API: {api_err}")
# 폴백: 원래 URL 사용
vod_url = api_url
logger.info(f"Video URL: {vod_url}")
# 파일명 및 저장 경로 설정
match = re.compile(
r"(?P<title>.*?)\s*((?P<season>\d+)%s)?\s*((?P<epi_no>\d+)%s)"
% ("", "")
).search(self.info["title"])
# epi_no 초기값
epi_no = 1
self.quality = "1080P"
@@ -1138,7 +1359,6 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
if "season" in match.groupdict() and match.group("season") is not None:
self.season = int(match.group("season"))
# epi_no = 1
epi_no = int(match.group("epi_no"))
ret = "%s.S%sE%s.%s-AL.mp4" % (
self.content_title,
@@ -1151,16 +1371,19 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
P.logger.debug("NOT MATCH")
ret = "%s.720p-AL.mp4" % self.info["title"]
# logger.info('self.content_title:: %s', self.content_title)
self.epi_queue = epi_no
self.filename = Util.change_text_for_use_filename(ret)
logger.info(f"self.filename::> {self.filename}")
self.savepath = P.ModelSetting.get("ohli24_download_path")
logger.info(f"self.savepath::> {self.savepath}")
logger.info(f"Filename: {self.filename}")
# anilife 전용 다운로드 경로 설정 (ohli24_download_path 대신 anilife_download_path 사용)
self.savepath = P.ModelSetting.get("anilife_download_path")
if not self.savepath:
self.savepath = P.ModelSetting.get("ohli24_download_path")
logger.info(f"Savepath: {self.savepath}")
if P.ModelSetting.get_bool("ohli24_auto_make_folder"):
if self.info["day"].find("완결") != -1:
if self.info.get("day", "").find("완결") != -1:
folder_name = "%s %s" % (
P.ModelSetting.get("ohli24_finished_insert"),
self.content_title,
@@ -1173,21 +1396,25 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
self.savepath = os.path.join(
self.savepath, "Season %s" % int(self.season)
)
self.filepath = os.path.join(self.savepath, self.filename)
if not os.path.exists(self.savepath):
os.makedirs(self.savepath)
# vod_1080p_url = asyncio.run(
# LogicAniLife.get_vod_url(jawcloud_url, headless=True)
# )
vod_1080p_url = LogicAniLife.get_vod_url_v2(jawcloud_url, headless=False)
print(f"vod_1080p_url:: {vod_1080p_url}")
self.url = vod_1080p_url
logger.info(self.url)
# 최종 비디오 URL 설정
self.url = vod_url
logger.info(f"Final video URL: {self.url}")
# 헤더 설정 (gcdn.app CDN 접근용)
self.headers = {
"Referer": "https://anilife.live/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Origin": "https://anilife.live"
}
logger.info(f"Headers: {self.headers}")
except Exception as e:
P.logger.error(f"Exception: str(e)")
P.logger.error(f"Exception: {str(e)}")
P.logger.error(traceback.format_exc())

View File

@@ -66,6 +66,11 @@ class LogicOhli24(PluginModuleBase):
origin_url = None
episode_url = None
cookies = None
proxy = "http://192.168.0.2:3138"
proxies = {
"http": proxy,
"https": proxy,
}
session = requests.Session()
@@ -458,8 +463,9 @@ class LogicOhli24(PluginModuleBase):
code = urllib.parse.quote(code)
try:
if self.current_data is not None and "code" in self.current_data and self.current_data["code"] == code:
return self.current_data
# 캐시 기능을 제거하여 분석 버튼 클릭 시 항상 최신 설정으로 다시 분석하도록 함
# if self.current_data is not None and "code" in self.current_data and self.current_data["code"] == code:
# return self.current_data
if code.startswith("http"):
if "/c/" in code:
@@ -628,6 +634,9 @@ class LogicOhli24(PluginModuleBase):
continue
logger.info(f"Found {len(episodes)} episodes")
# 디버깅: 원본 순서 확인 (첫번째 에피소드 제목)
if episodes:
logger.info(f"First parsed episode: {episodes[0]['title']}")
# 줄거리 추출
ser_description_result = tree.xpath('//div[@class="view-stocon"]/div[@class="c"]/text()')
@@ -646,10 +655,24 @@ class LogicOhli24(PluginModuleBase):
"code": code,
}
if not P.ModelSetting.get_bool("ohli24_order_desc"):
data["episode"] = list(reversed(data["episode"]))
# 정렬 적용: 사이트 원본은 최신화가 가장 위임 (13, 12, ... 1)
# ohli24_order_desc가 Off(False)이면 1화부터 나오게 뒤집기
raw_order_desc = P.ModelSetting.get("ohli24_order_desc")
order_desc = True if str(raw_order_desc).lower() == 'true' else False
logger.info(f"Sorting - Raw: {raw_order_desc}, Parsed: {order_desc}")
if not order_desc:
logger.info("Order is set to Ascending (Off), reversing list to show episode 1 first.")
data["episode"] = list(reversed(data['episode']))
data["list_order"] = "asc"
else:
logger.info("Order is set to Descending (On), keeping site order (Newest first).")
data["list_order"] = "desc"
if data["episode"]:
logger.info(f"Final episode list range: {data['episode'][0]['title']} ~ {data['episode'][-1]['title']}")
self.current_data = data
return data
@@ -845,10 +868,7 @@ class LogicOhli24(PluginModuleBase):
delay=10
)
# 프록시 설정 (필요시 사용)
proxies = {
"http": "http://192.168.0.2:3138",
"https": "http://192.168.0.2:3138",
}
proxies = LogicOhli24.proxies
if method.upper() == 'POST':
response = scraper.post(url, headers=headers, data=data, timeout=timeout, proxies=proxies)
else:
@@ -916,6 +936,7 @@ class LogicOhli24(PluginModuleBase):
# logger.debug("db_entity.status ::: %s", db_entity.status)
if db_entity is None:
entity = Ohli24QueueEntity(P, self, episode_info)
entity.proxy = self.proxy
logger.debug("entity:::> %s", entity.as_dict())
ModelOhli24Item.append(entity.as_dict())
# # logger.debug("entity:: type >> %s", type(entity))
@@ -934,7 +955,7 @@ class LogicOhli24(PluginModuleBase):
return "enqueue_db_append"
elif db_entity.status != "completed":
entity = Ohli24QueueEntity(P, self, episode_info)
entity.proxy = self.proxy
logger.debug("entity:::> %s", entity.as_dict())
# P.logger.debug(F.config['path_data'])
@@ -1080,11 +1101,20 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
self.content_title = None
self.srt_url = None
self.headers = None
self.cookies_file = None # yt-dlp용 CDN 세션 쿠키 파일 경로
# Todo::: 임시 주석 처리
self.make_episode_info()
def refresh_status(self):
self.module_logic.socketio_callback("status", self.as_dict())
# 추가: /queue 네임스페이스로도 명시적으로 전송
try:
from framework import socketio
namespace = f"/{self.P.package_name}/{self.module_logic.name}/queue"
socketio.emit("status", self.as_dict(), namespace=namespace)
except:
pass
def info_dict(self, tmp):
# logger.debug('self.info::> %s', self.info)
@@ -1168,7 +1198,7 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
logger.info(f"Found cdndania iframe: {iframe_src}")
# Step 2: cdndania.com 페이지에서 m3u8 URL 추출
video_url, vtt_url = self.extract_video_from_cdndania(iframe_src, url)
video_url, vtt_url, cookies_file = self.extract_video_from_cdndania(iframe_src, url)
if not video_url:
logger.error("Failed to extract video URL from cdndania")
@@ -1176,15 +1206,19 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
self.url = video_url
self.srt_url = vtt_url
self.cookies_file = cookies_file # yt-dlp용 세션 쿠키 파일
logger.info(f"Video URL: {self.url}")
if self.srt_url:
logger.info(f"Subtitle URL: {self.srt_url}")
if self.cookies_file:
logger.info(f"Cookies file: {self.cookies_file}")
# 헤더 설정
self.headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": iframe_src,
}
# 파일명 생성
match = re.compile(r"(?P<title>.*?)\s*((?P<season>\d+)%s)?\s*((?P<epi_no>\d+)%s)" % ("", "")).search(
@@ -1250,11 +1284,20 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
P.logger.error(traceback.format_exc())
def extract_video_from_cdndania(self, iframe_src, referer_url):
"""cdndania.com 플레이어에서 API 호출을 통해 비디오(m3u8) 및 자막(vtt) URL 추출"""
"""cdndania.com 플레이어에서 API 호출을 통해 비디오(m3u8) 및 자막(vtt) URL 추출
Returns:
tuple: (video_url, vtt_url, cookies_file) - cookies_file은 yt-dlp용 쿠키 파일 경로
"""
video_url = None
vtt_url = None
cookies_file = None
try:
import cloudscraper
import tempfile
import json
logger.debug(f"Extracting from cdndania: {iframe_src}")
# iframe URL에서 비디오 ID(hash) 추출
@@ -1266,27 +1309,35 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
if not video_id:
logger.error(f"Could not find video ID in iframe URL: {iframe_src}")
return video_url, vtt_url
return video_url, vtt_url, cookies_file
# cloudscraper 세션 생성 (쿠키 유지용)
scraper = cloudscraper.create_scraper(
browser={'browser': 'chrome', 'platform': 'darwin', 'mobile': False},
delay=10
)
proxies = LogicOhli24.proxies
# getVideo API 호출
api_url = f"https://cdndania.com/player/index.php?data={video_id}&do=getVideo"
headers = {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"x-requested-with": "XMLHttpRequest",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"referer": iframe_src
"referer": iframe_src,
"origin": "https://cdndania.com"
}
# Referer는 메인 사이트 도메인만 보내는 것이 더 안정적일 수 있음
post_data = {
"hash": video_id,
"r": "https://ani.ohli24.com/"
}
logger.debug(f"Calling video API: {api_url}")
json_text = LogicOhli24.get_html(api_url, headers=headers, data=post_data, method='POST', timeout=30)
logger.debug(f"Calling video API with session: {api_url}")
response = scraper.post(api_url, headers=headers, data=post_data, timeout=30, proxies=proxies)
json_text = response.text
if json_text:
try:
import json
data = json.loads(json_text)
video_url = data.get("videoSource")
if not video_url:
@@ -1299,13 +1350,35 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
vtt_url = data.get("videoSubtitle")
if vtt_url:
logger.info(f"Found subtitle URL via API: {vtt_url}")
# 세션 쿠키를 파일로 저장 (yt-dlp용)
try:
# Netscape 형식 쿠키 파일 생성
fd, cookies_file = tempfile.mkstemp(suffix='.txt', prefix='cdndania_cookies_')
with os.fdopen(fd, 'w') as f:
f.write("# Netscape HTTP Cookie File\n")
f.write("# https://curl.haxx.se/docs/http-cookies.html\n\n")
for cookie in scraper.cookies:
# 형식: domain, flag, path, secure, expiry, name, value
domain = cookie.domain
flag = "TRUE" if domain.startswith('.') else "FALSE"
path = cookie.path or "/"
secure = "TRUE" if cookie.secure else "FALSE"
expiry = str(int(cookie.expires)) if cookie.expires else "0"
f.write(f"{domain}\t{flag}\t{path}\t{secure}\t{expiry}\t{cookie.name}\t{cookie.value}\n")
logger.info(f"Saved {len(scraper.cookies)} cookies to: {cookies_file}")
except Exception as cookie_err:
logger.warning(f"Failed to save cookies: {cookie_err}")
cookies_file = None
except Exception as json_err:
logger.warning(f"Failed to parse API JSON: {json_err}")
# API 실패 시 기존 방식(정규식)으로 폴백
if not video_url:
logger.info("API extraction failed, falling back to regex")
html_content = LogicOhli24.get_html(iframe_src, referer=referer_url, timeout=30)
html_response = scraper.get(iframe_src, headers={"referer": referer_url}, timeout=30, proxies=proxies)
html_content = html_response.text
if html_content:
# m3u8 URL 패턴 찾기
m3u8_patterns = [
@@ -1337,7 +1410,8 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
logger.error(f"Error in extract_video_from_cdndania: {e}")
logger.error(traceback.format_exc())
return video_url, vtt_url
return video_url, vtt_url, cookies_file
# def callback_function(self, **args):
# refresh_type = None

View File

@@ -140,46 +140,65 @@
str += m_hr_black();
str += m_row_start(0);
tmp = ''
if (data.image != null)
tmp = '<img src="' + data.image + '" class="img-fluid">';
if (data.image != null) {
// CDN 이미지 프록시 적용
let proxyImgSrc = data.image;
if (data.image && data.image.includes('cdn.anilife.live')) {
proxyImgSrc = '/' + package_name + '/ajax/' + sub + '/proxy_image?image_url=' + encodeURIComponent(data.image);
}
tmp = '<img src="' + proxyImgSrc + '" class="img-fluid" onerror="this.src=\'../static/img_loader_x200.svg\'">';
}
str += m_col(3, tmp)
tmp = ''
tmp += m_row_start(2) + m_col(3, '제목', 'right') + m_col(9, data.title) + m_row_end();
// tmp += m_row_start(2) + m_col(3, '제작사', 'right') + m_col(9, data.des._pub) + m_row_end();
// tmp += m_row_start(2) + m_col(3, '감독', 'right') + m_col(9, data.des._dir) + m_row_end();
//
// tmp += m_row_start(2) + m_col(3, '원작', 'right') + m_col(9, data.des._otit) + m_row_end();
// tmp += m_row_start(2) + m_col(3, '장르', 'right') + m_col(9, data.des._tag) + m_row_end();
// tmp += m_row_start(2) + m_col(3, '분류', 'right') + m_col(9, data.des._classifi) + m_row_end();
// tmp += m_row_start(2) + m_col(3, '공식 방영일', 'right') + m_col(9, data.date+'('+data.day+')') + m_row_end();
// tmp += m_row_start(2) + m_col(3, '에피소드', 'right') + m_col(9, data.des._total_chapter ? data.des._total_chapter : '') + m_row_end();
// tmp += m_row_start(2) + m_col(3, '등급', 'right') + m_col(9, data.des._grade) + m_row_end();
// tmp += m_row_start(2) + m_col(3, '최근 방영일', 'right') + m_col(9, data.des._recent_date ? data.des._recent_date : '') + m_row_end();
// tmp += m_row_start(2) + m_col(3, '줄거리', 'right') + m_col(9, data.ser_description) + m_row_end();
tmp += "<div>" + data.des1 + "</div>"
tmp += m_row_start(2) + m_col(3, '제목', 'right') + m_col(9, '<strong style="font-size:1.3em;">' + data.title + '</strong>') + m_row_end();
// des1 데이터를 각 항목별로 파싱하여 표시
if (data.des1) {
// 항목 키워드들로 분리
const fields = ['상태:', '제작사:', '감독:', '각본:', '원작:', '시즌:', '공식 방영일:', '유형:', '에피소드:', '등급:', '방영 시작일:', '최근 방영일:'];
let formattedDes = data.des1;
// 각 필드 앞에 줄바꿈 추가
fields.forEach(field => {
formattedDes = formattedDes.replace(new RegExp(field, 'g'), '<br><strong>' + field + '</strong> ');
});
// 첫 번째 br 태그 제거 (첫 줄에는 필요없음)
formattedDes = formattedDes.replace(/^<br>/, '');
tmp += '<div class="series-info-box">' + formattedDes + '</div>';
}
str += m_col(9, tmp)
str += m_row_end();
str += m_hr_black();
str += '<div class="episode-list-container">';
for (i in data.episode) {
str += m_row_start();
tmp = '';
if (data.episode[i].thumbnail)
tmp = '<img src="' + data.episode[i].thumbnail + '" class="img-fluid">'
str += m_col(3, tmp)
tmp = '<strong>' + data.episode[i].ep_num + '화. ' + data.episode[i].title + '</strong>';
tmp += '<br>';
tmp += data.episode[i].date + '<br>';
tmp += '<div class="form-inline">'
tmp += '<input id="checkbox_' + i + '" name="checkbox_' + i + '" type="checkbox" checked data-toggle="toggle" data-on="선 택" data-off="-" data-onstyle="success" data-offstyle="danger" data-size="small">&nbsp;&nbsp;&nbsp;&nbsp;'
tmp += m_button('add_queue_btn', '다운로드 추가', [{'key': 'idx', 'value': i}])
tmp += '</div>'
str += m_col(9, tmp)
str += m_row_end();
if (i != data.length - 1) str += m_hr(0);
// CDN 이미지 프록시 적용
let epThumbSrc = data.episode[i].thumbnail || '';
if (epThumbSrc && epThumbSrc.includes('cdn.anilife.live')) {
epThumbSrc = '/' + package_name + '/ajax/' + sub + '/proxy_image?image_url=' + encodeURIComponent(epThumbSrc);
}
str += '<div class="episode-card">';
str += '<div class="episode-thumb">';
if (epThumbSrc) {
str += '<img src="' + epThumbSrc + '" onerror="this.src=\'../static/img_loader_x200.svg\'">';
}
str += '<span class="episode-num">' + data.episode[i].ep_num + '화</span>';
str += '</div>';
str += '<div class="episode-info">';
str += '<div class="episode-title">' + data.episode[i].title + '</div>';
if (data.episode[i].date) {
str += '<div class="episode-date">' + data.episode[i].date + '</div>';
}
str += '<div class="episode-actions">';
str += '<input id="checkbox_' + i + '" name="checkbox_' + i + '" type="checkbox" checked data-toggle="toggle" data-on="선택" data-off="-" data-onstyle="success" data-offstyle="secondary" data-size="small">';
str += m_button('add_queue_btn', '다운로드', [{'key': 'idx', 'value': i}]);
str += '</div>';
str += '</div>';
str += '</div>';
}
str += '</div>';
document.getElementById("episode_list").innerHTML = str;
$('input[id^="checkbox_"]').bootstrapToggle()
}
@@ -329,6 +348,139 @@
min-width: 82px !important;
}
/* 시리즈 정보 박스 스타일 */
.series-info-box {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.95) 100%);
border-radius: 12px;
padding: 20px 25px;
margin-top: 15px;
line-height: 2.2;
color: #e2e8f0;
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.series-info-box strong {
color: #60a5fa;
font-weight: 600;
min-width: 100px;
display: inline-block;
}
/* 에피소드 목록 컨테이너 */
.episode-list-container {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 10px;
}
/* 에피소드 카드 */
.episode-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%);
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.12);
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.episode-card:hover {
background: linear-gradient(135deg, rgba(51, 65, 85, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%);
border-color: rgba(96, 165, 250, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(96, 165, 250, 0.2);
}
/* 에피소드 썸네일 */
.episode-thumb {
position: relative;
width: 56px;
min-width: 56px;
height: 42px;
border-radius: 5px;
overflow: hidden;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.3) 0%, rgba(30, 41, 59, 0.5) 100%);
flex-shrink: 0;
}
.episode-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
.episode-card:hover .episode-thumb img {
transform: scale(1.05);
}
/* 에피소드 번호 배지 */
.episode-num {
position: absolute;
bottom: 2px;
left: 2px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* 에피소드 정보 */
.episode-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.episode-title {
color: #e2e8f0;
font-weight: 500;
font-size: 13px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episode-date {
color: #64748b;
font-size: 11px;
}
/* 에피소드 액션 버튼 */
.episode-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.episode-actions .btn {
font-size: 11px;
padding: 3px 10px;
border-radius: 4px;
}
.episode-actions .toggle {
transform: scale(0.85);
}
/* 반응형: 작은 화면에서는 1열 */
@media (max-width: 768px) {
.episode-list-container {
grid-template-columns: 1fr;
}
}
.tooltip {
position: relative;
display: block;

View File

@@ -182,8 +182,12 @@
tmp = '<div class="col-6 col-sm-4 col-md-3">';
tmp += '<div class="card">';
// tmp += '<img class="lozad" data-src="' + data.anime_list[i].image_link + '" />';
tmp += '<img class="lazyload" src="../static/img_loader_x200.svg" data-original="' + data.anime_list[i].image_link + '" style="cursor: pointer" onclick="location.href=\'./request?code=' + data.anime_list[i].code + '\'"/>';
// 이미지 프록시를 통해 CDN 이미지 로드 (hotlink 보호 우회)
let airingImgUrl = data.anime_list[i].image_link;
if (airingImgUrl && airingImgUrl.includes('cdn.anilife.live')) {
airingImgUrl = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(airingImgUrl);
}
tmp += '<img class="lazyload" src="../static/img_loader_x200.svg" data-original="' + airingImgUrl + '" style="cursor: pointer" onerror="this.src=\'../static/img_loader_x200.svg\'" onclick="location.href=\'./request?code=' + data.anime_list[i].code + '\'"/>';
tmp += '<div class="card-body">'
// {#tmp += '<button id="code_button" data-code="' + data.episode[i].code + '" type="button" class="btn btn-primary code-button bootstrap-tooltip" data-toggle="button" data-tooltip="true" aria-pressed="true" autocomplete="off" data-placement="top">' +#}
// {# '<span data-tooltip-text="'+data.episode[i].title+'">' + data.episode[i].code + '</span></button></div>';#}
@@ -244,7 +248,12 @@
tmp = '<div class="col-sm-4">';
tmp += '<div class="card">';
tmp += '<img class="card-img-top" src="' + data.anime_list[i].image_link + '" />';
// 이미지 프록시를 통해 CDN 이미지 로드 (hotlink 보호 우회)
let imgUrl = data.anime_list[i].image_link;
if (imgUrl && imgUrl.includes('cdn.anilife.live')) {
imgUrl = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(imgUrl);
}
tmp += '<img class="card-img-top" src="' + imgUrl + '" onerror="this.src=\'../static/img_loader_x200.svg\'" />';
tmp += '<div class="card-body">'
// {#tmp += '<button id="code_button" data-code="' + data.episode[i].code + '" type="button" class="btn btn-primary code-button bootstrap-tooltip" data-toggle="button" data-tooltip="true" aria-pressed="true" autocomplete="off" data-placement="top">' +#}
// {# '<span data-tooltip-text="'+data.episode[i].title+'">' + data.episode[i].code + '</span></button></div>';#}
@@ -290,7 +299,12 @@
tmp = '<div class="col-sm-4">';
tmp += '<div class="card">';
tmp += '<img class="card-img-top" src="' + data.anime_list[i].image_link + '" />';
// 이미지 프록시를 통해 CDN 이미지 로드 (hotlink 보호 우회)
let screenImgUrl = data.anime_list[i].image_link;
if (screenImgUrl && screenImgUrl.includes('cdn.anilife.live')) {
screenImgUrl = '/' + package_name + '/ajax/' + sub + '/proxy_image?url=' + encodeURIComponent(screenImgUrl);
}
tmp += '<img class="card-img-top" src="' + screenImgUrl + '" onerror="this.src=\'../static/img_loader_x200.svg\'" />';
tmp += '<div class="card-body">'
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
tmp += '<p class="card-text">' + data.anime_list[i].code + '</p>';
@@ -719,12 +733,76 @@
margin-top: 10px;
}
/* 카드 레이아웃 개선 */
.card {
height: 100%;
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease, box-shadow 0.3s ease;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
margin-bottom: 20px;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
/* 이미지 고정 비율 (3:4 포스터 비율) */
.card img,
.card .card-img-top,
.card .lazyload {
width: 100%;
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 12px 12px 0 0;
}
.card-body {
padding: 0 !important;
padding: 12px !important;
flex-grow: 1;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.6);
}
.card-title {
padding: 1rem !important;
padding: 0 !important;
margin-bottom: 8px;
font-size: 0.95rem;
font-weight: 600;
color: #fff;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-text {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 4px;
}
.card .btn-primary {
margin-top: auto;
border-radius: 8px;
font-size: 0.85rem;
padding: 8px 12px;
}
/* 그리드 간격 조정 */
.row.infinite-scroll {
gap: 0;
}
.row.infinite-scroll > [class*="col-"] {
padding: 10px;
}
button#add_whitelist {

View File

@@ -17,6 +17,7 @@
{{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }}
{{ macros.setting_input_text('anilife_download_path', '저장 폴더', value=arg['anilife_download_path'], desc='정상적으로 다운 완료 된 파일이 이동할 폴더 입니다. ') }}
{{ macros.setting_input_int('anilife_max_ffmpeg_process_count', '동시 다운로드 수', value=arg['anilife_max_ffmpeg_process_count'], desc='동시에 다운로드 할 에피소드 갯수입니다.') }}
{{ macros.setting_select('anilife_download_method', '다운로드 방법', [['ffmpeg', 'ffmpeg (기본)'], ['ytdlp', 'yt-dlp']], value=arg.get('anilife_download_method', 'ffmpeg'), desc='m3u8 다운로드에 사용할 도구를 선택합니다.') }}
{{ macros.setting_checkbox('anilife_order_desc', '요청 화면 최신순 정렬', value=arg['anilife_order_desc'], desc='On : 최신화부터, Off : 1화부터') }}
{{ macros.setting_checkbox('anilife_auto_make_folder', '제목 폴더 생성', value=arg['anilife_auto_make_folder'], desc='제목으로 폴더를 생성하고 폴더 안에 다운로드합니다.') }}
<div id="anilife_auto_make_folder_div" class="collapse">

View File

@@ -112,12 +112,19 @@
function on_status(data) {
console.log(data)
console.log(data.percent)
tmp = document.getElementById("progress_" + data.idx)
// console.log(data)
var entity_id = data.entity_id;
var percent = data.ffmpeg_percent;
var status_kor = data.ffmpeg_status_kor;
var speed = data.current_speed;
var tmp = document.getElementById("progress_" + entity_id)
if (tmp != null) {
document.getElementById("progress_" + data.idx).style.width = data.percent + '%';
document.getElementById("progress_" + data.idx + "_label").innerHTML = data.status_kor + "(" + data.percent + "%)" + ' ' + ((data.current_speed != null) ? data.current_speed : '')
document.getElementById("progress_" + entity_id).style.width = percent + '%';
var label = status_kor;
if (percent != 0) label += "(" + percent + "%)";
if (speed) label += " " + speed;
document.getElementById("progress_" + entity_id + "_label").innerHTML = label;
}
}

View File

@@ -86,6 +86,12 @@
if (ret.ret === 'success' && ret.data != null) {
// {#console.log(ret.code)#}
console.log(ret.data)
var order_text = (ret.data.list_order === 'desc') ? '최신화부터 (역순)' : '1화부터 (정순)';
if (ret.data.list_order === undefined) {
// 로직상 list_order가 없을 수 있으므로 체크
order_text = '';
}
$.notify('<strong>분석 성공</strong><br>' + order_text, {type: 'success'});
make_program(ret.data)
} else {
$.notify('<strong>분석 실패</strong><br>' + ret.log, {type: 'warning'});

View File

@@ -425,9 +425,10 @@ async def get_vod_url(p_param: PlParam):
async with async_playwright() as p:
try:
# browser = await p.chromium.launch(headless=headless, args=browser_args)
browser = await p.chromium.launch(
headless=pl_dict["headless"], args=browser_args
# WebKit 사용 (Safari 엔진)
browser = await p.webkit.launch(
headless=pl_dict["headless"],
args=browser_args
)
# browser = await p.webkit.launch(headless=headless)