Files
anime_downloader/lib/zendriver_daemon.py

385 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Zendriver 데몬 서버
- 브라우저를 상시 유지하여 빠른 HTML 페칭
- HTTP API로 요청 받아 처리
- 4~6초 → 2~3초 속도 향상 기대
"""
import sys
import json
import asyncio
import signal
import time
import os
import traceback
from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread, Lock
from typing import Any, Optional, Dict, List, Type, cast
import zendriver as zd
# 터미널 및 파일로 로그 출력 설정
LOG_FILE: str = "/tmp/zendriver_daemon.log"
def log_debug(msg: str) -> None:
"""타임스탬프와 함께 로그 출력 및 파일 저장"""
timestamp: str = time.strftime("%Y-%m-%d %H:%M:%S")
formatted_msg: str = f"[{timestamp}] {msg}"
print(formatted_msg, file=sys.stderr)
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(formatted_msg + "\n")
except Exception:
pass
DAEMON_PORT: int = 19876
browser: Optional[Any] = None
browser_lock: Lock = Lock()
loop: Optional[asyncio.AbstractEventLoop] = None
manual_browser_path: Optional[str] = None
def find_browser_executable() -> List[str]:
"""시스템에서 브라우저 실행 파일 찾기 (OS별 대응)"""
import platform
import shutil
# 수동 설정된 경로 최우선
if manual_browser_path and os.path.exists(manual_browser_path):
return [manual_browser_path]
system = platform.system()
app_dirs = ["/Applications", "/Volumes/WD/Users/Applications"]
common_paths = []
if system == "Darwin": # Mac
for base in app_dirs:
common_paths.extend([
f"{base}/Google Chrome.app/Contents/MacOS/Google Chrome",
f"{base}/Chromium.app/Contents/MacOS/Chromium",
f"{base}/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
])
elif system == "Windows":
common_paths = [
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%LocalAppData%\Google\Chrome\Application\chrome.exe"),
]
else: # Linux/Other
common_paths = [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/lib/chromium-browser/chromium-browser",
]
# 존재하는 모든 후보들 반환
candidates = [p for p in common_paths if os.path.exists(p)]
# PATH에서 찾기 추가
for cmd in ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "chrome", "microsoft-edge"]:
found = shutil.which(cmd)
if found and found not in candidates:
candidates.append(found)
return candidates
class ZendriverHandler(BaseHTTPRequestHandler):
"""HTTP 요청 핸들러"""
def log_message(self, format: str, *args: Any) -> None:
"""로그 출력 억제"""
pass
def do_POST(self) -> None:
"""POST 요청 처리 (/fetch, /health, /shutdown)"""
global browser, loop
if self.path == "/fetch":
try:
content_length: int = int(self.headers.get('Content-Length', 0))
if content_length == 0:
self._send_json(400, {"success": False, "error": "Empty body"})
return
body_bytes: bytes = self.rfile.read(content_length)
body: str = body_bytes.decode('utf-8')
data: Dict[str, Any] = json.loads(body)
url: Optional[str] = data.get("url")
timeout: int = cast(int, data.get("timeout", 30))
if not url:
self._send_json(400, {"success": False, "error": "Missing 'url' parameter"})
return
# 비동기 fetch 실행
if loop:
future = asyncio.run_coroutine_threadsafe(
fetch_with_browser(url, timeout), loop
)
result: Dict[str, Any] = future.result(timeout=timeout + 15)
self._send_json(200, result)
else:
self._send_json(500, {"success": False, "error": "Event loop not ready"})
except Exception as e:
log_debug(f"[Handler] Error: {e}\n{traceback.format_exc()}")
self._send_json(500, {
"success": False,
"error": str(e) or e.__class__.__name__,
"traceback": traceback.format_exc()
})
elif self.path == "/health":
self._send_json(200, {"status": "ok", "browser_ready": browser is not None})
elif self.path == "/shutdown":
self._send_json(200, {"status": "shutting_down"})
Thread(target=lambda: (time.sleep(0.5), os._exit(0))).start()
else:
self._send_json(404, {"error": "Not found"})
def do_GET(self) -> None:
"""GET 요청 처리 (/health)"""
if self.path == "/health":
self._send_json(200, {"status": "ok", "browser_ready": browser is not None})
else:
self._send_json(404, {"error": "Not found"})
def _send_json(self, status_code: int, data: Dict[str, Any]) -> None:
"""JSON 응답 전송"""
try:
self.send_response(status_code)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
except Exception as e:
log_debug(f"[Handler] Failed to send response: {e}")
async def ensure_browser() -> Any:
"""브라우저 인스턴스 확인/생성"""
global browser
with browser_lock:
if browser is None:
try:
# 존재하는 후보군 가져오기
candidates = find_browser_executable()
if not candidates:
log_debug("[ZendriverDaemon] No browser candidates found!")
return None
# 사용자 데이터 디렉토리 설정 (Mac/Root 권한 이슈 대응)
import tempfile
uid = os.getuid() if hasattr(os, 'getuid') else 'win'
browser_args = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--no-first-run",
"--no-service-autorun",
"--password-store=basic",
"--mute-audio",
"--disable-notifications",
"--disable-background-networking",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-default-apps",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
"--no-default-browser-check",
"--safebrowsing-disable-auto-update",
"--remote-allow-origins=*",
"--blink-settings=imagesEnabled=false",
]
for exec_path in candidates:
user_data_dir = os.path.join(tempfile.gettempdir(), f"zd_daemon_{uid}_{os.path.basename(exec_path).replace(' ', '_')}")
os.makedirs(user_data_dir, exist_ok=True)
try:
log_debug(f"[ZendriverDaemon] Trying browser at: {exec_path}")
browser = await zd.start(
headless=True,
browser_executable_path=exec_path,
no_sandbox=True,
user_data_dir=user_data_dir,
browser_args=browser_args
)
log_debug(f"[ZendriverDaemon] Browser started successfully with: {exec_path}")
return browser
except Exception as e:
log_debug(f"[ZendriverDaemon] Failed to start {exec_path}: {e}")
browser = None
raise Exception("All browser candidates failed to start")
except Exception as e:
log_debug(f"[ZendriverDaemon] Failed to start browser: {e}")
browser = None
raise
return browser
async def fetch_with_browser(url: str, timeout: int = 30) -> Dict[str, Any]:
"""상시 대기 브라우저로 HTML 페칭 (탭 유지 방식)"""
global browser
result: Dict[str, Any] = {"success": False, "html": "", "elapsed": 0.0}
start_time: float = time.time()
try:
await ensure_browser()
if browser is None:
result["error"] = "Browser not available"
return result
# zendriver의 browser.get(url)은 이미 열린 탭이 있으면 거기서 열려고 시도함.
# 하지만 모든 탭이 닫히면 StopIteration이 발생할 수 있음.
log_debug(f"[ZendriverDaemon] Fetching URL: {url}")
# StopIteration 방지를 위해 페이지 이동 시도
try:
# browser.get(url)은 새 탭을 열거나 기존 탭을 사용함
page: Any = await browser.get(url)
# 페이지 로드 대기 - 지능형 폴링 (최대 10초)
# 1. 리스트 페이지는 바로 반환, 2. 에피소드 페이지는 플레이어 로딩 대기
max_wait = 10
poll_interval = 0.2 # 1.0s -> 0.2s로 단축하여 반응속도 향상
waited = 0
html_content = ""
while waited < max_wait:
await asyncio.sleep(poll_interval)
waited += poll_interval
html_content = await page.get_content()
# 리스트 페이지 마커 확인 (발견 즉시 탈출)
if "post-list" in html_content or "list-box" in html_content or "post-row" in html_content:
log_debug(f"[ZendriverDaemon] List page detected in {waited:.1f}s")
break
# cdndania/fireplayer iframe이 로드되었는지 확인 (에피소드 페이지)
if "cdndania" in html_content or "fireplayer" in html_content:
log_debug(f"[ZendriverDaemon] Player detected in {waited:.1f}s")
break
elapsed: float = time.time() - start_time
if html_content and len(html_content) > 100:
result.update({
"success": True,
"html": html_content,
"elapsed": round(elapsed, 2)
})
log_debug(f"[ZendriverDaemon] Fetch success in {elapsed:.2f}s (Length: {len(html_content)})")
else:
result["error"] = f"Short response: {len(html_content) if html_content else 0} bytes"
result["elapsed"] = round(elapsed, 2)
log_debug(f"[ZendriverDaemon] Fetch failure: Short response ({len(html_content) if html_content else 0} bytes)")
# 여기서 page.close()를 하지 않음! (탭을 하나라도 남겨두어야 StopIteration 방지 가능)
# 대신 나중에 탭이 너무 많아지면 정리하는 로직 필요할 수 있음
except StopIteration:
log_debug("[ZendriverDaemon] StopIteration caught during browser.get, resetting browser")
browser = None
raise
except BaseException as e:
# StopIteration 등 모든 예외 캐치
err_msg: str = str(e) or e.__class__.__name__
result["error"] = err_msg
result["elapsed"] = round(time.time() - start_time, 2)
log_debug(f"[ZendriverDaemon] Exception during fetch: {err_msg}")
if not isinstance(e, asyncio.CancelledError):
log_debug(traceback.format_exc())
# 브라우저 오류 시 재시작 플래그
if "browser" in err_msg.lower() or "closed" in err_msg.lower() or "stopiteration" in err_msg.lower():
log_debug("[ZendriverDaemon] Resetting browser due to critical error")
browser = None
return result
async def run_async_loop() -> None:
"""비동기 이벤트 루프 실행"""
global loop
loop = asyncio.get_event_loop()
log_debug("[ZendriverDaemon] Async loop started")
# 브라우저 미리 시작
try:
await ensure_browser()
except Exception as e:
log_debug(f"[ZendriverDaemon] Initial browser start failed: {e}")
# 루프 유지
while True:
await asyncio.sleep(1)
def run_server() -> None:
"""HTTP 서버 실행"""
try:
server: HTTPServer = HTTPServer(('127.0.0.1', DAEMON_PORT), ZendriverHandler)
log_debug(f"[ZendriverDaemon] HTTP server starting on port {DAEMON_PORT}")
server.serve_forever()
except Exception as e:
log_debug(f"[ZendriverDaemon] HTTP server error: {e}")
def signal_handler(sig: int, frame: Any) -> None:
"""종료 시그널 처리"""
global browser
log_debug("\n[ZendriverDaemon] Shutdown signal received")
if browser:
try:
if loop and loop.is_running():
future = asyncio.run_coroutine_threadsafe(browser.stop(), loop)
future.result(timeout=5)
except Exception as e:
log_debug(f"[ZendriverDaemon] Error during browser stop: {e}")
sys.exit(0)
if __name__ == "__main__":
# 인자 처리
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--browser_path", type=str, default=None)
args = parser.parse_args()
if args.browser_path:
manual_browser_path = args.browser_path
log_debug(f"[ZendriverDaemon] Manual browser path set: {manual_browser_path}")
# 시그널 핸들러 등록
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# 비동기 루프를 별도 스레드에서 실행
async_thread: Thread = Thread(target=lambda: asyncio.run(run_async_loop()), daemon=True)
async_thread.start()
# HTTP 서버 실행 (메인 스레드)
time.sleep(2) # 초기화 대기
run_server()