feat: Enhance Anilife extraction with aggressive resource blocking, headless mode, and robust JSON output handling.
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Camoufox 기반 Anilife 비디오 URL 추출 스크립트 (최적화 비동기 버전)
|
Camoufox 기반 Anilife 비디오 URL 추출 스크립트 (Ultra-Speed 버전)
|
||||||
|
- Stealth-Headless 모드 사용 (Xvfb 오버헤드 제거)
|
||||||
|
- 엄격한 Stdout/Stderr 분리 (JSON 파싱 안정성)
|
||||||
|
- 공격적 리소스 및 도메인 차단
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -9,7 +12,7 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
|
||||||
async def _wait_for_aldata(page, timeout=10):
|
async def _wait_for_aldata(page, timeout=8):
|
||||||
"""_aldata 변수가 나타날 때까지 폴링 (최대 timeout초)"""
|
"""_aldata 변수가 나타날 때까지 폴링 (최대 timeout초)"""
|
||||||
start_time = asyncio.get_event_loop().time()
|
start_time = asyncio.get_event_loop().time()
|
||||||
while asyncio.get_event_loop().time() - start_time < timeout:
|
while asyncio.get_event_loop().time() - start_time < timeout:
|
||||||
@@ -26,17 +29,24 @@ async def _wait_for_aldata(page, timeout=10):
|
|||||||
return match.group(1), "HTML"
|
return match.group(1), "HTML"
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.2)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def _run_browser(browser, detail_url, episode_num, result):
|
async def _run_browser(browser, detail_url, episode_num, result):
|
||||||
"""최적화된 브라우저 작업 수행"""
|
"""최적화된 브라우저 작업 수행"""
|
||||||
# 1. 컨텍스트 및 페이지 생성 (이미지/CSS 차단 옵션 적용 가능 시 적용)
|
start_time_all = asyncio.get_event_loop().time()
|
||||||
page = await browser.new_page()
|
page = await browser.new_page()
|
||||||
|
|
||||||
# 리소스 차단 (속도 향상의 핵심)
|
# 공격적 리소스 및 트래킹 차단
|
||||||
async def intercept(route):
|
async def intercept(route):
|
||||||
if route.request.resource_type in ["image", "media", "font", "stylesheet"]:
|
req_url = route.request.url.lower()
|
||||||
|
resource_type = route.request.resource_type
|
||||||
|
|
||||||
|
# 차단 목록: 이미지, 미디어, 폰트, 스타일시트, 분석/광고 스크립트
|
||||||
|
block_types = ["image", "media", "font", "stylesheet"]
|
||||||
|
block_patterns = ["google-analytics", "googletagmanager", "facebook.net", "ads"]
|
||||||
|
|
||||||
|
if resource_type in block_types or any(p in req_url for p in block_patterns):
|
||||||
await route.abort()
|
await route.abort()
|
||||||
else:
|
else:
|
||||||
await route.continue_()
|
await route.continue_()
|
||||||
@@ -44,24 +54,24 @@ async def _run_browser(browser, detail_url, episode_num, result):
|
|||||||
await page.route("**/*", intercept)
|
await page.route("**/*", intercept)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Detail 페이지 이동
|
# 1. Detail 페이지 이동 (commit까지만 대기하여 즉시 처리)
|
||||||
print(f"1. Navigating to detail page: {detail_url}", file=sys.stderr)
|
print(f"1. Navigating: {detail_url}", file=sys.stderr)
|
||||||
await page.goto(detail_url, wait_until="commit", timeout=20000) # domcontentloaded보다 빠른 commit 대기
|
await page.goto(detail_url, wait_until="commit", timeout=15000)
|
||||||
|
|
||||||
# 2. 에피소드 링크 찾기 (폴링 대기)
|
# 2. 에피소드 링크 찾기 및 클릭
|
||||||
print(f"2. Searching for episode {episode_num}...", file=sys.stderr)
|
print(f"2. Searching episode {episode_num}...", file=sys.stderr)
|
||||||
episode_link = None
|
episode_link = None
|
||||||
for _ in range(25): # 약 5초간 대기
|
for _ in range(20): # 약 4초
|
||||||
try:
|
try:
|
||||||
|
# epl-num 텍스트 매칭
|
||||||
episode_link = page.locator(f'a:has(.epl-num:text("{episode_num}"))').first
|
episode_link = page.locator(f'a:has(.epl-num:text("{episode_num}"))').first
|
||||||
if await episode_link.is_visible():
|
if await episode_link.is_visible():
|
||||||
break
|
break
|
||||||
|
|
||||||
# 대체 수단: provider 링크 검색
|
# 대체: provider 링크
|
||||||
links = await page.locator('a[href*="/ani/provider/"]').all()
|
links = await page.locator('a[href*="/ani/provider/"]').all()
|
||||||
for link in links:
|
for link in links:
|
||||||
text = await link.inner_text()
|
if episode_num in await link.inner_text():
|
||||||
if episode_num in text:
|
|
||||||
episode_link = link
|
episode_link = link
|
||||||
break
|
break
|
||||||
if episode_link: break
|
if episode_link: break
|
||||||
@@ -69,41 +79,38 @@ async def _run_browser(browser, detail_url, episode_num, result):
|
|||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
if not episode_link:
|
if not episode_link:
|
||||||
result["error"] = f"Episode {episode_num} not found"
|
result["error"] = "Episode not found"
|
||||||
result["html"] = await page.content()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 3. 에피소드 클릭 및 이동
|
# 3. 에피소드 클릭
|
||||||
print(f"3. Clicking episode {episode_num}", file=sys.stderr)
|
|
||||||
await episode_link.click()
|
await episode_link.click()
|
||||||
|
|
||||||
# 4. _aldata 추출 (폴링)
|
# 4. _aldata 추출 (최대 6초 폴링)
|
||||||
print("4. Waiting for _aldata...", file=sys.stderr)
|
aldata, source = await _wait_for_aldata(page, timeout=6)
|
||||||
aldata, source = await _wait_for_aldata(page, timeout=8)
|
|
||||||
|
|
||||||
if aldata:
|
if aldata:
|
||||||
result["aldata"] = aldata
|
elapsed = asyncio.get_event_loop().time() - start_time_all
|
||||||
result["success"] = True
|
result.update({
|
||||||
result["current_url"] = page.url
|
"aldata": aldata, "success": True,
|
||||||
print(f" SUCCESS! Got _aldata from {source}", file=sys.stderr)
|
"elapsed": round(elapsed, 2), "source": source
|
||||||
|
})
|
||||||
|
print(f" SUCCESS! Extracted via {source} in {result['elapsed']}s", file=sys.stderr)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 5. 추출 실패 시 CloudVideo 버튼 강제 클릭 시도
|
# 5. 최후의 수단: 플레이어 버튼 클릭 시도
|
||||||
print("5. Aldata not found yet. Trying player button...", file=sys.stderr)
|
|
||||||
await page.mouse.wheel(0, 500)
|
|
||||||
btn = page.locator('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]').first
|
btn = page.locator('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]').first
|
||||||
if await btn.is_visible(timeout=2000):
|
if await btn.is_visible(timeout=1500):
|
||||||
await btn.click()
|
await btn.click()
|
||||||
aldata, source = await _wait_for_aldata(page, timeout=5)
|
aldata, source = await _wait_for_aldata(page, timeout=4)
|
||||||
if aldata:
|
if aldata:
|
||||||
result["aldata"] = aldata
|
elapsed = asyncio.get_event_loop().time() - start_time_all
|
||||||
result["success"] = True
|
result.update({
|
||||||
result["current_url"] = page.url
|
"aldata": aldata, "success": True,
|
||||||
|
"elapsed": round(elapsed, 2), "source": f"{source}-player"
|
||||||
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result["error"] = "Could not extract aldata"
|
result["error"] = "Aldata extraction failed"
|
||||||
result["html"] = await page.content()
|
|
||||||
result["current_url"] = page.url
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await page.close()
|
await page.close()
|
||||||
@@ -111,28 +118,19 @@ async def _run_browser(browser, detail_url, episode_num, result):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async def extract_aldata(detail_url: str, episode_num: str) -> dict:
|
async def extract_aldata(detail_url: str, episode_num: str) -> dict:
|
||||||
"""AsyncCamoufox로 최적화된 추출 수행"""
|
"""AsyncCamoufox Stealth-Headless mode"""
|
||||||
try:
|
try:
|
||||||
from camoufox.async_api import AsyncCamoufox
|
from camoufox.async_api import AsyncCamoufox
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
return {"error": f"Camoufox not installed: {e}"}
|
return {"error": f"Camoufox not installed: {e}"}
|
||||||
|
|
||||||
result = {"success": False, "aldata": None, "current_url": None, "error": None}
|
result = {"success": False, "aldata": None, "elapsed": 0}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
has_display = os.environ.get('DISPLAY') is not None
|
# Camoufox는 headless=True에서도 강력한 스텔스를 제공함 (Xvfb 오버헤드 불필요)
|
||||||
camou_args = {"headless": False}
|
# MacOS/Linux 공통으로 headless=True 권장 (속도 향상)
|
||||||
if not has_display:
|
async with AsyncCamoufox(headless=True) as browser:
|
||||||
camou_args["xvfb"] = True
|
return await _run_browser(browser, detail_url, episode_num, result)
|
||||||
|
|
||||||
# 속도 최 최적화를 위한 추가 인자 (필요 시)
|
|
||||||
try:
|
|
||||||
async with AsyncCamoufox(**camou_args) as browser:
|
|
||||||
return await _run_browser(browser, detail_url, episode_num, result)
|
|
||||||
except TypeError:
|
|
||||||
# xvfb 미지원 버전 대비
|
|
||||||
async with AsyncCamoufox(headless=True) as browser:
|
|
||||||
return await _run_browser(browser, detail_url, episode_num, result)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["error"] = str(e)
|
result["error"] = str(e)
|
||||||
@@ -143,8 +141,10 @@ if __name__ == "__main__":
|
|||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
detail_url = sys.argv[1]
|
# stdout에는 오직 JSON만 출력하도록 보장
|
||||||
episode_num = sys.argv[2]
|
try:
|
||||||
|
res = asyncio.run(extract_aldata(sys.argv[1], sys.argv[2]))
|
||||||
res = asyncio.run(extract_aldata(detail_url, episode_num))
|
# 최종 JSON 결과 출력
|
||||||
print(json.dumps(res, ensure_ascii=False))
|
print(json.dumps(res, ensure_ascii=False))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e), "success": False, "elapsed": 0}))
|
||||||
|
|||||||
@@ -1314,22 +1314,29 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
|||||||
logger.error(f"Camoufox subprocess failed: {result.stderr}")
|
logger.error(f"Camoufox subprocess failed: {result.stderr}")
|
||||||
raise Exception(f"Subprocess error: {result.stderr}")
|
raise Exception(f"Subprocess error: {result.stderr}")
|
||||||
|
|
||||||
# JSON 결과 파싱
|
# JSON 결과 파싱 (엄격한 분리를 통해 stdout에는 JSON만 남음)
|
||||||
cf_result = json_module.loads(result.stdout)
|
try:
|
||||||
logger.debug(f"Camoufox result: success={cf_result.get('success')}, current_url={cf_result.get('current_url')}")
|
cf_result = json_module.loads(result.stdout)
|
||||||
|
except json_module.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse Camoufox result: {e}")
|
||||||
|
logger.error(f"Raw stdout: {result.stdout}")
|
||||||
|
return
|
||||||
|
|
||||||
if cf_result.get("error"):
|
elapsed = cf_result.get("elapsed", "?")
|
||||||
logger.error(f"Camoufox error: {cf_result['error']}")
|
logger.info(f"Camoufox extraction finished in {elapsed}s (success={cf_result.get('success')})")
|
||||||
|
|
||||||
# _aldata 추출
|
if not cf_result.get("success"):
|
||||||
if cf_result.get("success") and cf_result.get("aldata"):
|
logger.error(f"Camoufox failed: {cf_result.get('error')}")
|
||||||
|
if cf_result.get("html"):
|
||||||
|
logger.debug(f"Failed page HTML length: {len(cf_result['html'])}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# _aldata 추출 성공
|
||||||
|
if cf_result.get("aldata"):
|
||||||
aldata_value = cf_result["aldata"]
|
aldata_value = cf_result["aldata"]
|
||||||
logger.debug(f"Got _aldata from Camoufox: {aldata_value[:50]}...")
|
logger.debug(f"Got _aldata ({cf_result.get('source', 'unknown')})")
|
||||||
elif cf_result.get("html"):
|
|
||||||
provider_html = cf_result["html"]
|
|
||||||
logger.debug(f"Provider page loaded via Camoufox, length: {len(provider_html)}")
|
|
||||||
else:
|
else:
|
||||||
logger.error("No aldata or HTML returned from Camoufox")
|
logger.error("Success reported but no aldata returned")
|
||||||
return
|
return
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|||||||
Reference in New Issue
Block a user