diff --git a/lib/camoufox_anilife.py b/lib/camoufox_anilife.py new file mode 100644 index 0000000..7634031 --- /dev/null +++ b/lib/camoufox_anilife.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Camoufox 기반 Anilife 비디오 URL 추출 스크립트 +강력한 봇 감지 우회 기능이 있는 스텔스 Firefox + +사용법: + python camoufox_anilife.py +""" + +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 "})) + 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)) diff --git a/lib/ffmpeg_queue_v1.py b/lib/ffmpeg_queue_v1.py index 69b3942..37ac90a 100644 --- a/lib/ffmpeg_queue_v1.py +++ b/lib/ffmpeg_queue_v1.py @@ -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() 중지 diff --git a/lib/hls_downloader.py b/lib/hls_downloader.py index 8e1b081..5e12e89 100644 --- a/lib/hls_downloader.py +++ b/lib/hls_downloader.py @@ -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" diff --git a/lib/playwright_anilife.py b/lib/playwright_anilife.py new file mode 100644 index 0000000..c8ad6ec --- /dev/null +++ b/lib/playwright_anilife.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +Playwright 기반 Anilife 비디오 URL 추출 스크립트 +FlaskFarm의 gevent와 충돌을 피하기 위해 별도의 subprocess로 실행됩니다. + +사용법: + python playwright_anilife.py + +출력: + 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 "})) + 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)) diff --git a/lib/playwright_cdp.py b/lib/playwright_cdp.py new file mode 100644 index 0000000..0e48d95 --- /dev/null +++ b/lib/playwright_cdp.py @@ -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 +""" + +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 "})) + 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)) diff --git a/lib/ytdlp_downloader.py b/lib/ytdlp_downloader.py index d1b76f0..43b0cc5 100644 --- a/lib/ytdlp_downloader.py +++ b/lib/ytdlp_downloader.py @@ -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[\d\.]+)%\s+of\s+.*?\s+at\s+(?P.*?)(\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): """다운로드 취소""" diff --git a/mod_anilife.py b/mod_anilife.py index e13bbbe..f805f64 100644 --- a/mod_anilife.py +++ b/mod_anilife.py @@ -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"(?Phttp?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.*?)\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()) diff --git a/mod_ohli24.py b/mod_ohli24.py index 9d3bfc1..84f9c94 100644 --- a/mod_ohli24.py +++ b/mod_ohli24.py @@ -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 diff --git a/templates/anime_downloader_anilife_request.html b/templates/anime_downloader_anilife_request.html index d64fb54..7d5867f 100644 --- a/templates/anime_downloader_anilife_request.html +++ b/templates/anime_downloader_anilife_request.html @@ -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">    ' - 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; diff --git a/templates/anime_downloader_anilife_search.html b/templates/anime_downloader_anilife_search.html index aaaaa0a..ccea3e8 100644 --- a/templates/anime_downloader_anilife_search.html +++ b/templates/anime_downloader_anilife_search.html @@ -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 { diff --git a/templates/anime_downloader_anilife_setting.html b/templates/anime_downloader_anilife_setting.html index 1488b63..0ac9a46 100644 --- a/templates/anime_downloader_anilife_setting.html +++ b/templates/anime_downloader_anilife_setting.html @@ -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"> diff --git a/templates/anime_downloader_ohli24_queue.html b/templates/anime_downloader_ohli24_queue.html index 824d9ca..65cd457 100644 --- a/templates/anime_downloader_ohli24_queue.html +++ b/templates/anime_downloader_ohli24_queue.html @@ -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; } } diff --git a/templates/anime_downloader_ohli24_request.html b/templates/anime_downloader_ohli24_request.html index 21146af..fa64f12 100644 --- a/templates/anime_downloader_ohli24_request.html +++ b/templates/anime_downloader_ohli24_request.html @@ -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'}); diff --git a/yommi_api/main.py b/yommi_api/main.py index 9caac04..879d6c3 100644 --- a/yommi_api/main.py +++ b/yommi_api/main.py @@ -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)