anilife.live 사이트 구현
다른 버그도 고침
This commit is contained in:
177
lib/camoufox_anilife.py
Normal file
177
lib/camoufox_anilife.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Camoufox 기반 Anilife 비디오 URL 추출 스크립트
|
||||||
|
강력한 봇 감지 우회 기능이 있는 스텔스 Firefox
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python camoufox_anilife.py <detail_url> <episode_num>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
def extract_aldata(detail_url: str, episode_num: str) -> dict:
|
||||||
|
"""Camoufox로 Detail 페이지에서 _aldata 추출"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from camoufox.sync_api import Camoufox
|
||||||
|
except ImportError as e:
|
||||||
|
return {"error": f"Camoufox not installed: {e}"}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"aldata": None,
|
||||||
|
"html": None,
|
||||||
|
"current_url": None,
|
||||||
|
"error": None,
|
||||||
|
"vod_url": None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Camoufox 시작 (자동 fingerprint 생성)
|
||||||
|
with Camoufox(headless=False) as browser:
|
||||||
|
page = browser.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Detail 페이지로 이동
|
||||||
|
print(f"1. Navigating to detail page: {detail_url}", file=sys.stderr)
|
||||||
|
page.goto(detail_url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
print(f" Current URL: {page.url}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 2. 에피소드 목록으로 스크롤
|
||||||
|
page.mouse.wheel(0, 800)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 3. 해당 에피소드 찾아서 클릭
|
||||||
|
print(f"2. Looking for episode {episode_num}", file=sys.stderr)
|
||||||
|
|
||||||
|
episode_clicked = False
|
||||||
|
try:
|
||||||
|
# epl-num 클래스의 div에서 에피소드 번호 찾기
|
||||||
|
episode_link = page.locator(f'a:has(.epl-num:text("{episode_num}"))').first
|
||||||
|
if episode_link.is_visible(timeout=5000):
|
||||||
|
href = episode_link.get_attribute("href")
|
||||||
|
print(f" Found episode link: {href}", file=sys.stderr)
|
||||||
|
episode_link.click()
|
||||||
|
episode_clicked = True
|
||||||
|
time.sleep(3)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Method 1 failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if not episode_clicked:
|
||||||
|
try:
|
||||||
|
# provider 링크들 중에서 에피소드 번호가 포함된 것 클릭
|
||||||
|
links = page.locator('a[href*="/ani/provider/"]').all()
|
||||||
|
for link in links:
|
||||||
|
text = link.inner_text()
|
||||||
|
if episode_num in text:
|
||||||
|
print(f" Found: {text}", file=sys.stderr)
|
||||||
|
link.click()
|
||||||
|
episode_clicked = True
|
||||||
|
time.sleep(3)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Method 2 failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if not episode_clicked:
|
||||||
|
result["error"] = f"Episode {episode_num} not found"
|
||||||
|
result["html"] = page.content()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 4. Provider 페이지에서 _aldata 추출
|
||||||
|
print(f"3. Provider page URL: {page.url}", file=sys.stderr)
|
||||||
|
result["current_url"] = page.url
|
||||||
|
|
||||||
|
# 리다이렉트 확인
|
||||||
|
if "/ani/provider/" not in page.url:
|
||||||
|
result["error"] = f"Redirected to {page.url}"
|
||||||
|
result["html"] = page.content()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# _aldata 추출 시도
|
||||||
|
try:
|
||||||
|
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
|
||||||
|
if aldata_value:
|
||||||
|
result["aldata"] = aldata_value
|
||||||
|
result["success"] = True
|
||||||
|
print(f" SUCCESS! _aldata found: {aldata_value[:60]}...", file=sys.stderr)
|
||||||
|
return result
|
||||||
|
except Exception as js_err:
|
||||||
|
print(f" JS error: {js_err}", file=sys.stderr)
|
||||||
|
|
||||||
|
# HTML에서 _aldata 패턴 추출 시도
|
||||||
|
html = page.content()
|
||||||
|
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
|
||||||
|
if aldata_match:
|
||||||
|
result["aldata"] = aldata_match.group(1)
|
||||||
|
result["success"] = True
|
||||||
|
print(f" SUCCESS! _aldata from HTML: {result['aldata'][:60]}...", file=sys.stderr)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 5. CloudVideo 버튼 클릭 시도
|
||||||
|
print("4. Trying CloudVideo button click...", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
page.mouse.wheel(0, 500)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
cloudvideo_btn = page.locator('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]').first
|
||||||
|
if cloudvideo_btn.is_visible(timeout=3000):
|
||||||
|
cloudvideo_btn.click()
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
result["current_url"] = page.url
|
||||||
|
print(f" After click URL: {page.url}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 리다이렉트 확인 (구글로 갔는지)
|
||||||
|
if "google.com" in page.url:
|
||||||
|
result["error"] = "Redirected to Google - bot detected"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 플레이어 페이지에서 _aldata 추출
|
||||||
|
try:
|
||||||
|
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
|
||||||
|
if aldata_value:
|
||||||
|
result["aldata"] = aldata_value
|
||||||
|
result["success"] = True
|
||||||
|
print(f" SUCCESS! _aldata: {aldata_value[:60]}...", file=sys.stderr)
|
||||||
|
return result
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# HTML에서 추출
|
||||||
|
html = page.content()
|
||||||
|
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
|
||||||
|
if aldata_match:
|
||||||
|
result["aldata"] = aldata_match.group(1)
|
||||||
|
result["success"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["html"] = html
|
||||||
|
except Exception as click_err:
|
||||||
|
print(f" Click error: {click_err}", file=sys.stderr)
|
||||||
|
result["html"] = page.content()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
import traceback
|
||||||
|
print(traceback.format_exc(), file=sys.stderr)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(json.dumps({"error": "Usage: python camoufox_anilife.py <detail_url> <episode_num>"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
detail_url = sys.argv[1]
|
||||||
|
episode_num = sys.argv[2]
|
||||||
|
result = extract_aldata(detail_url, episode_num)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
@@ -43,6 +43,7 @@ class FfmpegQueueEntity(abc.ABCMeta("ABC", (object,), {"__slots__": ()})):
|
|||||||
self.filepath = None
|
self.filepath = None
|
||||||
self.quality = None
|
self.quality = None
|
||||||
self.headers = None
|
self.headers = None
|
||||||
|
self.proxy = None
|
||||||
self.current_speed = "" # 다운로드 속도
|
self.current_speed = "" # 다운로드 속도
|
||||||
self.download_time = "" # 경과 시간
|
self.download_time = "" # 경과 시간
|
||||||
# FfmpegQueueEntity.static_index += 1
|
# FfmpegQueueEntity.static_index += 1
|
||||||
@@ -79,7 +80,27 @@ class FfmpegQueueEntity(abc.ABCMeta("ABC", (object,), {"__slots__": ()})):
|
|||||||
tmp["filename"] = self.filename
|
tmp["filename"] = self.filename
|
||||||
tmp["filepath"] = self.filepath
|
tmp["filepath"] = self.filepath
|
||||||
tmp["quality"] = self.quality
|
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)
|
tmp = self.info_dict(tmp)
|
||||||
return tmp
|
return tmp
|
||||||
|
|
||||||
@@ -194,13 +215,19 @@ class FfmpegQueue(object):
|
|||||||
P.logger.debug(filename)
|
P.logger.debug(filename)
|
||||||
# P.logger.debug(filepath)
|
# P.logger.debug(filepath)
|
||||||
|
|
||||||
# SupportFfmpeg 초기화
|
|
||||||
self.support_init()
|
|
||||||
# entity.headers가 있으면 우선 사용, 없으면 caller.headers 사용
|
# entity.headers가 있으면 우선 사용, 없으면 caller.headers 사용
|
||||||
_headers = entity.headers
|
_headers = entity.headers
|
||||||
if _headers is None and self.caller is not None:
|
if _headers is None and self.caller is not None:
|
||||||
_headers = self.caller.headers
|
_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"Starting ffmpeg download - video_url: {video_url}")
|
||||||
logger.info(f"save_path: {dirname}, filename: {filename}")
|
logger.info(f"save_path: {dirname}, filename: {filename}")
|
||||||
logger.info(f"headers: {_headers}")
|
logger.info(f"headers: {_headers}")
|
||||||
@@ -219,10 +246,17 @@ class FfmpegQueue(object):
|
|||||||
logger.info(f"=== END COMMAND ===")
|
logger.info(f"=== END COMMAND ===")
|
||||||
|
|
||||||
# m3u8 URL인 경우 다운로드 방법 설정에 따라 분기
|
# 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")
|
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}")
|
logger.info(f"Download method: {download_method}")
|
||||||
|
|
||||||
|
|
||||||
# 다운로드 시작 전 카운트 증가
|
# 다운로드 시작 전 카운트 증가
|
||||||
self.current_ffmpeg_count += 1
|
self.current_ffmpeg_count += 1
|
||||||
@@ -245,12 +279,17 @@ class FfmpegQueue(object):
|
|||||||
# yt-dlp 사용
|
# yt-dlp 사용
|
||||||
from .ytdlp_downloader import YtdlpDownloader
|
from .ytdlp_downloader import YtdlpDownloader
|
||||||
logger.info("Using yt-dlp downloader...")
|
logger.info("Using yt-dlp downloader...")
|
||||||
|
# 엔티티에서 쿠키 파일 가져오기 (있는 경우)
|
||||||
|
_cookies_file = getattr(entity_ref, 'cookies_file', None)
|
||||||
downloader = YtdlpDownloader(
|
downloader = YtdlpDownloader(
|
||||||
url=video_url,
|
url=video_url,
|
||||||
output_path=output_file_ref,
|
output_path=output_file_ref,
|
||||||
headers=headers_ref,
|
headers=headers_ref,
|
||||||
callback=progress_callback
|
callback=progress_callback,
|
||||||
|
proxy=_proxy,
|
||||||
|
cookies_file=_cookies_file
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 기본: HLS 다운로더 사용
|
# 기본: HLS 다운로더 사용
|
||||||
from .hls_downloader import HlsDownloader
|
from .hls_downloader import HlsDownloader
|
||||||
@@ -259,7 +298,8 @@ class FfmpegQueue(object):
|
|||||||
m3u8_url=video_url,
|
m3u8_url=video_url,
|
||||||
output_path=output_file_ref,
|
output_path=output_file_ref,
|
||||||
headers=headers_ref,
|
headers=headers_ref,
|
||||||
callback=progress_callback
|
callback=progress_callback,
|
||||||
|
proxy=_proxy
|
||||||
)
|
)
|
||||||
|
|
||||||
success, message = downloader.download()
|
success, message = downloader.download()
|
||||||
@@ -360,6 +400,7 @@ class FfmpegQueue(object):
|
|||||||
max_pf_count=0,
|
max_pf_count=0,
|
||||||
save_path=ToolUtil.make_path(dirname),
|
save_path=ToolUtil.make_path(dirname),
|
||||||
timeout_minute=60,
|
timeout_minute=60,
|
||||||
|
proxy=_proxy,
|
||||||
)
|
)
|
||||||
#
|
#
|
||||||
# todo: 임시로 start() 중지
|
# todo: 임시로 start() 중지
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import requests
|
|||||||
import tempfile
|
import tempfile
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HlsDownloader:
|
class HlsDownloader:
|
||||||
"""HLS 다운로더 - .jpg 확장자 세그먼트 지원"""
|
"""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.m3u8_url = m3u8_url
|
||||||
self.output_path = output_path
|
self.output_path = output_path
|
||||||
self.headers = headers or {}
|
self.headers = headers or {}
|
||||||
self.callback = callback # 진행 상황 콜백
|
self.callback = callback # 진행 상황 콜백
|
||||||
|
self.proxy = proxy
|
||||||
self.segments = []
|
self.segments = []
|
||||||
self.total_segments = 0
|
self.total_segments = 0
|
||||||
self.downloaded_segments = 0
|
self.downloaded_segments = 0
|
||||||
@@ -31,12 +35,35 @@ class HlsDownloader:
|
|||||||
self.last_bytes = 0
|
self.last_bytes = 0
|
||||||
self.current_speed = 0 # bytes per second
|
self.current_speed = 0 # bytes per second
|
||||||
|
|
||||||
def parse_m3u8(self):
|
def parse_m3u8(self, url=None):
|
||||||
"""m3u8 파일 파싱"""
|
"""m3u8 파일 파싱 (Master Playlist 대응)"""
|
||||||
response = requests.get(self.m3u8_url, headers=self.headers, timeout=30)
|
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
|
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 = []
|
self.segments = []
|
||||||
for line in content.strip().split('\n'):
|
for line in content.strip().split('\n'):
|
||||||
@@ -96,17 +123,22 @@ class HlsDownloader:
|
|||||||
return False, "Cancelled"
|
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:
|
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()
|
response.raise_for_status()
|
||||||
|
|
||||||
segment_data = response.content
|
segment_data = response.content
|
||||||
with open(segment_path, 'wb') as f:
|
with open(segment_path, 'wb') as f:
|
||||||
f.write(segment_data)
|
f.write(segment_data)
|
||||||
|
|
||||||
segment_files.append(segment_path)
|
segment_files.append(segment_filename) # 상대 경로 저장
|
||||||
self.downloaded_segments = i + 1
|
self.downloaded_segments = i + 1
|
||||||
self.total_bytes += len(segment_data)
|
self.total_bytes += len(segment_data)
|
||||||
|
|
||||||
@@ -139,27 +171,28 @@ class HlsDownloader:
|
|||||||
# 세그먼트 합치기 (concat 파일 생성)
|
# 세그먼트 합치기 (concat 파일 생성)
|
||||||
concat_file = os.path.join(temp_dir, "concat.txt")
|
concat_file = os.path.join(temp_dir, "concat.txt")
|
||||||
with open(concat_file, 'w') as f:
|
with open(concat_file, 'w') as f:
|
||||||
for seg_file in segment_files:
|
for seg_filename in segment_files:
|
||||||
f.write(f"file '{seg_file}'\n")
|
f.write(f"file '{seg_filename}'\n")
|
||||||
|
|
||||||
# 출력 디렉토리 생성
|
# 출력 디렉토리 생성
|
||||||
output_dir = os.path.dirname(self.output_path)
|
output_dir = os.path.dirname(self.output_path)
|
||||||
if output_dir and not os.path.exists(output_dir):
|
if output_dir and not os.path.exists(output_dir):
|
||||||
os.makedirs(output_dir)
|
os.makedirs(output_dir)
|
||||||
|
|
||||||
# ffmpeg로 합치기
|
# ffmpeg로 합치기 (temp_dir에서 실행)
|
||||||
cmd = [
|
cmd = [
|
||||||
'ffmpeg', '-y',
|
'ffmpeg', '-y',
|
||||||
'-f', 'concat',
|
'-f', 'concat',
|
||||||
'-safe', '0',
|
'-safe', '0',
|
||||||
'-i', concat_file,
|
'-i', 'concat.txt',
|
||||||
'-c', 'copy',
|
'-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:
|
if result.returncode != 0:
|
||||||
|
logger.error(f"FFmpeg stderr: {result.stderr}")
|
||||||
return False, f"FFmpeg concat failed: {result.stderr}"
|
return False, f"FFmpeg concat failed: {result.stderr}"
|
||||||
|
|
||||||
return True, "Download completed"
|
return True, "Download completed"
|
||||||
|
|||||||
221
lib/playwright_anilife.py
Normal file
221
lib/playwright_anilife.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Playwright 기반 Anilife 비디오 URL 추출 스크립트
|
||||||
|
FlaskFarm의 gevent와 충돌을 피하기 위해 별도의 subprocess로 실행됩니다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python playwright_anilife.py <detail_url> <episode_num>
|
||||||
|
|
||||||
|
출력:
|
||||||
|
JSON 형식으로 _aldata 또는 에러 메시지 출력
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
def extract_aldata(detail_url: str, episode_num: str) -> dict:
|
||||||
|
"""Detail 페이지에서 에피소드를 클릭하고 _aldata를 추출합니다."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError as e:
|
||||||
|
return {"error": f"Playwright not installed: {e}"}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"aldata": None,
|
||||||
|
"html": None,
|
||||||
|
"current_url": None,
|
||||||
|
"error": None,
|
||||||
|
"player_url": None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# 시스템에 설치된 Chrome 사용
|
||||||
|
browser = p.chromium.launch(
|
||||||
|
headless=False, # visible 모드
|
||||||
|
channel="chrome", # 시스템 Chrome 사용
|
||||||
|
args=[
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
"--disable-automation",
|
||||||
|
"--no-sandbox",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 브라우저 컨텍스트 생성 (스텔스 설정)
|
||||||
|
context = browser.new_context(
|
||||||
|
viewport={"width": 1920, "height": 1080},
|
||||||
|
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
locale="ko-KR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# navigator.webdriver 숨기기
|
||||||
|
context.add_init_script("""
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {
|
||||||
|
get: () => undefined
|
||||||
|
});
|
||||||
|
""")
|
||||||
|
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Detail 페이지 방문
|
||||||
|
page.goto(detail_url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# 2. 에피소드 찾아서 클릭 (episode_num을 포함하는 provider 링크)
|
||||||
|
episode_clicked = False
|
||||||
|
|
||||||
|
# 스크롤하여 에피소드 목록 로드
|
||||||
|
page.mouse.wheel(0, 800)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# JavaScript로 에피소드 링크 찾아 클릭
|
||||||
|
try:
|
||||||
|
episode_href = page.evaluate(f"""
|
||||||
|
(() => {{
|
||||||
|
const links = Array.from(document.querySelectorAll('a[href*="/ani/provider/"]'));
|
||||||
|
const ep = links.find(a => a.innerText.includes('{episode_num}'));
|
||||||
|
if (ep) {{
|
||||||
|
ep.click();
|
||||||
|
return ep.href;
|
||||||
|
}}
|
||||||
|
return null;
|
||||||
|
}})()
|
||||||
|
""")
|
||||||
|
if episode_href:
|
||||||
|
episode_clicked = True
|
||||||
|
time.sleep(2)
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Episode click failed: {e}"
|
||||||
|
|
||||||
|
if not episode_clicked:
|
||||||
|
result["error"] = f"Episode {episode_num} not found"
|
||||||
|
result["html"] = page.content()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 3. Provider 페이지에서 player_guid 추출 (버튼 클릭 대신)
|
||||||
|
# moveCloudvideo() 또는 moveJawcloud() 함수에서 GUID 추출
|
||||||
|
try:
|
||||||
|
player_info = page.evaluate("""
|
||||||
|
(() => {
|
||||||
|
// 함수 소스에서 GUID 추출 시도
|
||||||
|
let playerUrl = null;
|
||||||
|
|
||||||
|
// moveCloudvideo 함수 확인
|
||||||
|
if (typeof moveCloudvideo === 'function') {
|
||||||
|
const funcStr = moveCloudvideo.toString();
|
||||||
|
// URL 패턴 찾기
|
||||||
|
const match = funcStr.match(/['"]([^'"]+\\/h\\/live[^'"]+)['"]/);
|
||||||
|
if (match) {
|
||||||
|
playerUrl = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveJawcloud 함수 확인
|
||||||
|
if (!playerUrl && typeof moveJawcloud === 'function') {
|
||||||
|
const funcStr = moveJawcloud.toString();
|
||||||
|
const match = funcStr.match(/['"]([^'"]+\\/h\\/live[^'"]+)['"]/);
|
||||||
|
if (match) {
|
||||||
|
playerUrl = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 변수 확인
|
||||||
|
if (!playerUrl && typeof _player_guid !== 'undefined') {
|
||||||
|
playerUrl = '/h/live?p=' + _player_guid + '&player=jawcloud';
|
||||||
|
}
|
||||||
|
|
||||||
|
// onclick 속성에서 추출
|
||||||
|
if (!playerUrl) {
|
||||||
|
const btn = document.querySelector('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]');
|
||||||
|
if (btn) {
|
||||||
|
const onclick = btn.getAttribute('onclick');
|
||||||
|
// 함수 이름 확인 후 페이지 소스에서 URL 추출
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 변수 검색
|
||||||
|
if (!playerUrl) {
|
||||||
|
for (const key of Object.keys(window)) {
|
||||||
|
if (key.includes('player') || key.includes('guid')) {
|
||||||
|
const val = window[key];
|
||||||
|
if (typeof val === 'string' && val.match(/^[a-f0-9-]{36}$/)) {
|
||||||
|
playerUrl = '/h/live?p=' + val + '&player=jawcloud';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _aldata 직접 확인 (provider 페이지에 있을 수 있음)
|
||||||
|
if (typeof _aldata !== 'undefined') {
|
||||||
|
return { aldata: _aldata, playerUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { aldata: null, playerUrl: playerUrl };
|
||||||
|
})()
|
||||||
|
""")
|
||||||
|
|
||||||
|
if player_info.get("aldata"):
|
||||||
|
result["aldata"] = player_info["aldata"]
|
||||||
|
result["success"] = True
|
||||||
|
result["current_url"] = page.url
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["player_url"] = player_info.get("playerUrl")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Player info extraction failed: {e}"
|
||||||
|
|
||||||
|
# 4. Player URL이 있으면 해당 페이지로 이동하여 _aldata 추출
|
||||||
|
if result.get("player_url"):
|
||||||
|
player_full_url = "https://anilife.live" + result["player_url"] if result["player_url"].startswith("/") else result["player_url"]
|
||||||
|
page.goto(player_full_url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# _aldata 추출
|
||||||
|
try:
|
||||||
|
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
|
||||||
|
if aldata_value:
|
||||||
|
result["aldata"] = aldata_value
|
||||||
|
result["success"] = True
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 현재 URL 기록
|
||||||
|
result["current_url"] = page.url
|
||||||
|
|
||||||
|
# HTML에서 _aldata 패턴 추출 시도
|
||||||
|
if not result["aldata"]:
|
||||||
|
html = page.content()
|
||||||
|
# _aldata = "..." 패턴 찾기
|
||||||
|
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
|
||||||
|
if aldata_match:
|
||||||
|
result["aldata"] = aldata_match.group(1)
|
||||||
|
result["success"] = True
|
||||||
|
else:
|
||||||
|
result["html"] = html
|
||||||
|
|
||||||
|
finally:
|
||||||
|
context.close()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(json.dumps({"error": "Usage: python playwright_anilife.py <detail_url> <episode_num>"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
detail_url = sys.argv[1]
|
||||||
|
episode_num = sys.argv[2]
|
||||||
|
result = extract_aldata(detail_url, episode_num)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
181
lib/playwright_cdp.py
Normal file
181
lib/playwright_cdp.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Chrome 디버그 모드에 연결하여 Anilife 비디오 URL 추출
|
||||||
|
Detail 페이지 → 에피소드 클릭 → _aldata 추출 플로우
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
1. Chrome 디버그 모드 실행:
|
||||||
|
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome_debug
|
||||||
|
|
||||||
|
2. 스크립트 실행:
|
||||||
|
python playwright_cdp.py <detail_url> <episode_num>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
def extract_aldata_via_cdp(detail_url: str, episode_num: str) -> dict:
|
||||||
|
"""Chrome DevTools Protocol로 연결하여 _aldata 추출"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError as e:
|
||||||
|
return {"error": f"Playwright not installed: {e}"}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": False,
|
||||||
|
"aldata": None,
|
||||||
|
"html": None,
|
||||||
|
"current_url": None,
|
||||||
|
"error": None,
|
||||||
|
"vod_url": None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# Chrome 디버그 포트에 연결
|
||||||
|
browser = p.chromium.connect_over_cdp("http://localhost:9222")
|
||||||
|
|
||||||
|
# 기존 컨텍스트 사용
|
||||||
|
contexts = browser.contexts
|
||||||
|
if not contexts:
|
||||||
|
context = browser.new_context()
|
||||||
|
else:
|
||||||
|
context = contexts[0]
|
||||||
|
|
||||||
|
# 새 페이지 열기
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Detail 페이지로 이동
|
||||||
|
print(f"1. Navigating to detail page: {detail_url}", file=sys.stderr)
|
||||||
|
page.goto(detail_url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
print(f" Current URL: {page.url}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 2. 에피소드 목록으로 스크롤
|
||||||
|
page.mouse.wheel(0, 800)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 3. 해당 에피소드 찾아서 클릭
|
||||||
|
print(f"2. Looking for episode {episode_num}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 에피소드 링크 찾기 (provider 링크 중에서)
|
||||||
|
episode_clicked = False
|
||||||
|
try:
|
||||||
|
# 방법 1: epl-num 클래스의 div에서 에피소드 번호 찾기
|
||||||
|
episode_link = page.locator(f'a:has(.epl-num:text("{episode_num}"))').first
|
||||||
|
if episode_link.is_visible(timeout=5000):
|
||||||
|
href = episode_link.get_attribute("href")
|
||||||
|
print(f" Found episode link: {href}", file=sys.stderr)
|
||||||
|
episode_link.click()
|
||||||
|
episode_clicked = True
|
||||||
|
time.sleep(3)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Method 1 failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if not episode_clicked:
|
||||||
|
try:
|
||||||
|
# 방법 2: provider 링크들 중에서 에피소드 번호가 포함된 것 클릭
|
||||||
|
links = page.locator('a[href*="/ani/provider/"]').all()
|
||||||
|
for link in links:
|
||||||
|
text = link.inner_text()
|
||||||
|
if episode_num in text:
|
||||||
|
print(f" Found: {text}", file=sys.stderr)
|
||||||
|
link.click()
|
||||||
|
episode_clicked = True
|
||||||
|
time.sleep(3)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Method 2 failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if not episode_clicked:
|
||||||
|
result["error"] = f"Episode {episode_num} not found"
|
||||||
|
result["html"] = page.content()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 4. Provider 페이지에서 _aldata 추출
|
||||||
|
print(f"3. Provider page URL: {page.url}", file=sys.stderr)
|
||||||
|
result["current_url"] = page.url
|
||||||
|
|
||||||
|
# _aldata 추출 시도
|
||||||
|
try:
|
||||||
|
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
|
||||||
|
if aldata_value:
|
||||||
|
result["aldata"] = aldata_value
|
||||||
|
result["success"] = True
|
||||||
|
print(f" SUCCESS! _aldata found: {aldata_value[:60]}...", file=sys.stderr)
|
||||||
|
return result
|
||||||
|
except Exception as js_err:
|
||||||
|
print(f" JS error: {js_err}", file=sys.stderr)
|
||||||
|
|
||||||
|
# HTML에서 _aldata 패턴 추출 시도
|
||||||
|
html = page.content()
|
||||||
|
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
|
||||||
|
if aldata_match:
|
||||||
|
result["aldata"] = aldata_match.group(1)
|
||||||
|
result["success"] = True
|
||||||
|
print(f" SUCCESS! _aldata from HTML: {result['aldata'][:60]}...", file=sys.stderr)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 5. CloudVideo 버튼 클릭 시도
|
||||||
|
print("4. Trying CloudVideo button click...", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
page.mouse.wheel(0, 500)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
cloudvideo_btn = page.locator('a[onclick*="moveCloudvideo"], a[onclick*="moveJawcloud"]').first
|
||||||
|
if cloudvideo_btn.is_visible(timeout=3000):
|
||||||
|
cloudvideo_btn.click()
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
result["current_url"] = page.url
|
||||||
|
print(f" After click URL: {page.url}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 플레이어 페이지에서 _aldata 추출
|
||||||
|
try:
|
||||||
|
aldata_value = page.evaluate("typeof _aldata !== 'undefined' ? _aldata : null")
|
||||||
|
if aldata_value:
|
||||||
|
result["aldata"] = aldata_value
|
||||||
|
result["success"] = True
|
||||||
|
print(f" SUCCESS! _aldata: {aldata_value[:60]}...", file=sys.stderr)
|
||||||
|
return result
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# HTML에서 추출
|
||||||
|
html = page.content()
|
||||||
|
aldata_match = re.search(r'_aldata\s*=\s*["\']([A-Za-z0-9+/=]+)["\']', html)
|
||||||
|
if aldata_match:
|
||||||
|
result["aldata"] = aldata_match.group(1)
|
||||||
|
result["success"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["html"] = html
|
||||||
|
except Exception as click_err:
|
||||||
|
print(f" Click error: {click_err}", file=sys.stderr)
|
||||||
|
result["html"] = page.content()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
if "connect" in str(e).lower():
|
||||||
|
result["error"] = "Chrome 디버그 모드가 실행 중이 아닙니다."
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(json.dumps({"error": "Usage: python playwright_cdp.py <detail_url> <episode_num>"}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
detail_url = sys.argv[1]
|
||||||
|
episode_num = sys.argv[2]
|
||||||
|
result = extract_aldata_via_cdp(detail_url, episode_num)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
@@ -16,11 +16,13 @@ logger = logging.getLogger(__name__)
|
|||||||
class YtdlpDownloader:
|
class YtdlpDownloader:
|
||||||
"""yt-dlp 기반 다운로더"""
|
"""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.url = url
|
||||||
self.output_path = output_path
|
self.output_path = output_path
|
||||||
self.headers = headers or {}
|
self.headers = headers or {}
|
||||||
self.callback = callback # 진행 상황 콜백
|
self.callback = callback # 진행 상황 콜백
|
||||||
|
self.proxy = proxy
|
||||||
|
self.cookies_file = cookies_file # CDN 세션 쿠키 파일 경로
|
||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
self.process = None
|
self.process = None
|
||||||
self.error_output = [] # 에러 메시지 저장
|
self.error_output = [] # 에러 메시지 저장
|
||||||
@@ -30,6 +32,7 @@ class YtdlpDownloader:
|
|||||||
self.current_speed = ""
|
self.current_speed = ""
|
||||||
self.elapsed_time = ""
|
self.elapsed_time = ""
|
||||||
self.percent = 0
|
self.percent = 0
|
||||||
|
|
||||||
|
|
||||||
def format_time(self, seconds):
|
def format_time(self, seconds):
|
||||||
"""시간을 읽기 좋은 형식으로 변환"""
|
"""시간을 읽기 좋은 형식으로 변환"""
|
||||||
@@ -57,12 +60,7 @@ class YtdlpDownloader:
|
|||||||
return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
|
return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
"""yt-dlp Python 모듈로 다운로드 수행"""
|
"""yt-dlp CLI를 통한 브라우저 흉내(Impersonate) 방식 다운로드 수행"""
|
||||||
try:
|
|
||||||
import yt_dlp
|
|
||||||
except ImportError:
|
|
||||||
return False, "yt-dlp를 찾을 수 없습니다. pip install yt-dlp 로 설치해주세요."
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.start_time = time.time()
|
self.start_time = time.time()
|
||||||
|
|
||||||
@@ -71,86 +69,118 @@ class YtdlpDownloader:
|
|||||||
if output_dir and not os.path.exists(output_dir):
|
if output_dir and not os.path.exists(output_dir):
|
||||||
os.makedirs(output_dir)
|
os.makedirs(output_dir)
|
||||||
|
|
||||||
# 진행률 콜백
|
# URL 전처리: 확장자 힌트(?dummy=.m3u8) 사용
|
||||||
def progress_hook(d):
|
# (m3u8: 접두사나 #.m3u8보다 호환성이 높음. HLS 인식 강제용)
|
||||||
|
current_url = self.url
|
||||||
|
if 'master.txt' in current_url:
|
||||||
|
concat_char = '&' if '?' in current_url else '?'
|
||||||
|
current_url = f"{current_url}{concat_char}dummy=.m3u8"
|
||||||
|
|
||||||
|
# 1. 기본 명령어 구성 (Impersonate & HLS 강제)
|
||||||
|
cmd = [
|
||||||
|
'yt-dlp',
|
||||||
|
'--newline',
|
||||||
|
'--no-playlist',
|
||||||
|
'--no-part',
|
||||||
|
'--hls-prefer-ffmpeg',
|
||||||
|
'--hls-use-mpegts',
|
||||||
|
'--no-check-certificate',
|
||||||
|
'--progress',
|
||||||
|
'--verbose', # 디버깅용 상세 로그
|
||||||
|
'--impersonate', 'chrome-120', # 정밀한 크롬-120 지문 사용
|
||||||
|
'--extractor-args', 'generic:force_hls', # HLS 강제 추출
|
||||||
|
'-o', self.output_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2. 프록시 설정
|
||||||
|
if self.proxy:
|
||||||
|
cmd += ['--proxy', self.proxy]
|
||||||
|
|
||||||
|
# 2.5 쿠키 파일 설정 (CDN 세션 인증용)
|
||||||
|
if self.cookies_file and os.path.exists(self.cookies_file):
|
||||||
|
cmd += ['--cookies', self.cookies_file]
|
||||||
|
logger.info(f"Using cookies file: {self.cookies_file}")
|
||||||
|
|
||||||
|
# 3. 필수 헤더 구성
|
||||||
|
# --impersonate가 기본적인 Sec-Fetch를 처리하지만,
|
||||||
|
# X-Requested-With와 정확한 Referer/Origin은 명시적으로 주는 것이 안전합니다.
|
||||||
|
has_referer = False
|
||||||
|
for k, v in self.headers.items():
|
||||||
|
if k.lower() == 'referer':
|
||||||
|
cmd += ['--referer', v]
|
||||||
|
has_referer = True
|
||||||
|
elif k.lower() == 'user-agent':
|
||||||
|
# impersonate가 설정한 UA를 명시적 UA로 덮어씀 (필요시)
|
||||||
|
cmd += ['--user-agent', v]
|
||||||
|
else:
|
||||||
|
cmd += ['--add-header', f"{k}:{v}"]
|
||||||
|
|
||||||
|
# cdndania 전용 헤더 보강
|
||||||
|
if 'cdndania.com' in current_url:
|
||||||
|
if not has_referer:
|
||||||
|
cmd += ['--referer', 'https://cdndania.com/']
|
||||||
|
cmd += ['--add-header', 'Origin:https://cdndania.com']
|
||||||
|
cmd += ['--add-header', 'X-Requested-With:XMLHttpRequest']
|
||||||
|
|
||||||
|
cmd.append(current_url)
|
||||||
|
|
||||||
|
logger.info(f"Executing refined browser-impersonated yt-dlp CLI (v16): {' '.join(cmd)}")
|
||||||
|
|
||||||
|
# 4. subprocess 실행 및 파싱
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
universal_newlines=True,
|
||||||
|
bufsize=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# [download] 10.5% of ~100.00MiB at 2.45MiB/s
|
||||||
|
prog_re = re.compile(r'\[download\]\s+(?P<percent>[\d\.]+)%\s+of\s+.*?\s+at\s+(?P<speed>.*?)(\s+ETA|$)')
|
||||||
|
|
||||||
|
for line in self.process.stdout:
|
||||||
if self.cancelled:
|
if self.cancelled:
|
||||||
raise Exception("Cancelled")
|
self.process.terminate()
|
||||||
|
return False, "Cancelled"
|
||||||
|
|
||||||
if d['status'] == 'downloading':
|
line = line.strip()
|
||||||
# 진행률 추출
|
if not line: continue
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
elif d['status'] == 'finished':
|
match = prog_re.search(line)
|
||||||
logger.info(f"yt-dlp download finished: {d.get('filename', '')}")
|
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 옵션 설정
|
if self.process.returncode == 0 and os.path.exists(self.output_path):
|
||||||
ydl_opts = {
|
# 가짜 파일(보안 에러 텍스트) 체크
|
||||||
'outtmpl': self.output_path,
|
file_size = os.path.getsize(self.output_path)
|
||||||
'progress_hooks': [progress_hook],
|
if file_size < 2000:
|
||||||
'quiet': False,
|
try:
|
||||||
'no_warnings': False,
|
with open(self.output_path, 'r') as f:
|
||||||
'noprogress': False,
|
text = f.read().lower()
|
||||||
}
|
if "security error" in text or not text:
|
||||||
|
os.remove(self.output_path)
|
||||||
# 헤더 추가
|
return False, f"CDN 보안 차단(가짜 파일 다운로드됨: {file_size}B)"
|
||||||
http_headers = {}
|
except: pass
|
||||||
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):
|
|
||||||
return True, "Download completed"
|
return True, "Download completed"
|
||||||
else:
|
|
||||||
# yt-dlp가 확장자를 변경했을 수 있음
|
error_msg = "\n".join(self.error_output[-3:]) if self.error_output else f"Exit code {self.process.returncode}"
|
||||||
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}")
|
|
||||||
return False, f"yt-dlp 실패: {error_msg}"
|
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):
|
def cancel(self):
|
||||||
"""다운로드 취소"""
|
"""다운로드 취소"""
|
||||||
|
|||||||
585
mod_anilife.py
585
mod_anilife.py
@@ -77,6 +77,7 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
"anilife_auto_make_season_folder": "True",
|
"anilife_auto_make_season_folder": "True",
|
||||||
"anilife_finished_insert": "[완결]",
|
"anilife_finished_insert": "[완결]",
|
||||||
"anilife_max_ffmpeg_process_count": "1",
|
"anilife_max_ffmpeg_process_count": "1",
|
||||||
|
"anilife_download_method": "ffmpeg", # ffmpeg or ytdlp
|
||||||
"anilife_order_desc": "False",
|
"anilife_order_desc": "False",
|
||||||
"anilife_auto_start": "False",
|
"anilife_auto_start": "False",
|
||||||
"anilife_interval": "* 5 * * *",
|
"anilife_interval": "* 5 * * *",
|
||||||
@@ -525,6 +526,9 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
info = json.loads(request.form["data"])
|
info = json.loads(request.form["data"])
|
||||||
logger.info(f"info:: {info}")
|
logger.info(f"info:: {info}")
|
||||||
ret["ret"] = self.add(info)
|
ret["ret"] = self.add(info)
|
||||||
|
# 성공적으로 큐에 추가되면 UI 새로고침 트리거
|
||||||
|
if ret["ret"].startswith("enqueue"):
|
||||||
|
self.socketio_callback("list_refresh", "")
|
||||||
return jsonify(ret)
|
return jsonify(ret)
|
||||||
elif sub == "entity_list":
|
elif sub == "entity_list":
|
||||||
return jsonify(self.queue.get_entity_list())
|
return jsonify(self.queue.get_entity_list())
|
||||||
@@ -559,6 +563,36 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
return jsonify(ModelAniLifeItem.web_list(request))
|
return jsonify(ModelAniLifeItem.web_list(request))
|
||||||
elif sub == "db_remove":
|
elif sub == "db_remove":
|
||||||
return jsonify(ModelAniLifeItem.delete_by_id(req.form["id"]))
|
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:
|
except Exception as e:
|
||||||
P.logger.error("Exception:%s", e)
|
P.logger.error("Exception:%s", e)
|
||||||
P.logger.error(traceback.format_exc())
|
P.logger.error(traceback.format_exc())
|
||||||
@@ -595,9 +629,10 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
ret["msg"] = "다운로드를 추가 하였습니다."
|
ret["msg"] = "다운로드를 추가 하였습니다."
|
||||||
|
|
||||||
elif command == "list":
|
elif command == "list":
|
||||||
|
# Anilife 큐의 entity_list 반환 (이전: SupportFfmpeg.get_list() - 잘못된 소스)
|
||||||
ret = []
|
ret = []
|
||||||
for ins in SupportFfmpeg.get_list():
|
for entity in self.queue.entity_list:
|
||||||
ret.append(ins.get_data())
|
ret.append(entity.as_dict())
|
||||||
|
|
||||||
return jsonify(ret)
|
return jsonify(ret)
|
||||||
|
|
||||||
@@ -672,7 +707,7 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
|
|
||||||
def plugin_load(self):
|
def plugin_load(self):
|
||||||
self.queue = FfmpegQueue(
|
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.current_data = None
|
||||||
self.queue.queue_start()
|
self.queue.queue_start()
|
||||||
@@ -690,7 +725,7 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 시리즈 정보를 가져오는 함수
|
# 시리즈 정보를 가져오는 함수 (cloudscraper 버전)
|
||||||
def get_series_info(self, code):
|
def get_series_info(self, code):
|
||||||
try:
|
try:
|
||||||
if code.isdigit():
|
if code.isdigit():
|
||||||
@@ -698,24 +733,28 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
else:
|
else:
|
||||||
url = P.ModelSetting.get("anilife_url") + "/g/l?id=" + code
|
url = P.ModelSetting.get("anilife_url") + "/g/l?id=" + code
|
||||||
|
|
||||||
logger.debug("url::: > %s", url)
|
logger.debug("get_series_info()::url > %s", url)
|
||||||
# response_data = LogicAniLife.get_html(self, url=url, timeout=10)
|
|
||||||
|
|
||||||
import json
|
# cloudscraper를 사용하여 Cloudflare 우회
|
||||||
|
scraper = cloudscraper.create_scraper(
|
||||||
post_data = {"url": url, "headless": True, "engine": "webkit"}
|
browser={
|
||||||
payload = json.dumps(post_data)
|
"browser": "chrome",
|
||||||
logger.debug(payload)
|
"platform": "windows",
|
||||||
response_data = None
|
"desktop": True
|
||||||
|
}
|
||||||
response_data = requests.post(
|
|
||||||
url="http://localhost:7070/get_html_by_playwright", data=payload
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 리다이렉트 자동 처리 (숫자 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"])
|
tree = html.fromstring(response.text)
|
||||||
soup_text = BeautifulSoup(response_data.json()["html"], "lxml")
|
|
||||||
|
|
||||||
tree = html.fromstring(response_data.json()["html"])
|
|
||||||
|
|
||||||
# tree = html.fromstring(response_data)
|
# tree = html.fromstring(response_data)
|
||||||
# logger.debug(response_data)
|
# logger.debug(response_data)
|
||||||
@@ -788,6 +827,9 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
date = ""
|
date = ""
|
||||||
m = hashlib.md5(title.encode("utf-8"))
|
m = hashlib.md5(title.encode("utf-8"))
|
||||||
_vi = m.hexdigest()
|
_vi = m.hexdigest()
|
||||||
|
# 고유한 _id 생성: content_code + ep_num + link의 조합
|
||||||
|
# 같은 시리즈 내에서도 에피소드마다 고유하게 식별
|
||||||
|
unique_id = f"{code}_{ep_num}_{link}"
|
||||||
episodes.append(
|
episodes.append(
|
||||||
{
|
{
|
||||||
"ep_num": ep_num,
|
"ep_num": ep_num,
|
||||||
@@ -796,7 +838,7 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
"thumbnail": image,
|
"thumbnail": image,
|
||||||
"date": date,
|
"date": date,
|
||||||
"day": date,
|
"day": date,
|
||||||
"_id": title,
|
"_id": unique_id,
|
||||||
"va": link,
|
"va": link,
|
||||||
"_vi": _vi,
|
"_vi": _vi,
|
||||||
"content_code": code,
|
"content_code": code,
|
||||||
@@ -880,44 +922,29 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
logger.info("url:::> %s", url)
|
logger.info("url:::> %s", url)
|
||||||
data = {}
|
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 = {
|
LogicAniLife.episode_url = response.url
|
||||||
"url": url,
|
logger.info(response.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"])
|
|
||||||
logger.debug(LogicAniLife.episode_url)
|
logger.debug(LogicAniLife.episode_url)
|
||||||
|
|
||||||
# logger.debug(response_data.json())
|
soup_text = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# logger.debug(f"wrapper_xath:: {wrapper_xpath}")
|
tree = html.fromstring(response.text)
|
||||||
# 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)
|
|
||||||
tmp_items = tree.xpath(wrapper_xpath)
|
tmp_items = tree.xpath(wrapper_xpath)
|
||||||
# tmp_items = tree.xpath('//div[@class="bsx"]')
|
|
||||||
|
|
||||||
logger.debug(tmp_items)
|
logger.debug(tmp_items)
|
||||||
data["anime_count"] = len(tmp_items)
|
data["anime_count"] = len(tmp_items)
|
||||||
@@ -925,21 +952,34 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
|
|
||||||
for item in tmp_items:
|
for item in tmp_items:
|
||||||
entity = {}
|
entity = {}
|
||||||
entity["link"] = item.xpath(".//a/@href")[0]
|
link_elem = item.xpath(".//a/@href")
|
||||||
# logger.debug(entity["link"])
|
if not link_elem:
|
||||||
|
continue
|
||||||
|
entity["link"] = link_elem[0]
|
||||||
p = re.compile(r"^[http?s://]+[a-zA-Z0-9-]+/[a-zA-Z0-9-_.?=]+$")
|
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:
|
if p.match(entity["link"]) is None:
|
||||||
entity["link"] = P.ModelSetting.get("anilife_url") + entity["link"]
|
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["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")[
|
epx_elem = item.xpath(".//span[@class='epx']/text()")
|
||||||
0
|
entity["epx"] = epx_elem[0].strip() if epx_elem else ""
|
||||||
].replace("..", P.ModelSetting.get("anilife_url"))
|
|
||||||
|
# 제목
|
||||||
|
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["ret"] = "success"
|
||||||
data["anime_list"].append(entity)
|
data["anime_list"].append(entity)
|
||||||
|
|
||||||
@@ -949,6 +989,120 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
P.logger.error(traceback.format_exc())
|
P.logger.error(traceback.format_exc())
|
||||||
return {"ret": "exception", "log": str(e)}
|
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):
|
def add(self, episode_info):
|
||||||
if self.is_exist(episode_info):
|
if self.is_exist(episode_info):
|
||||||
@@ -1022,114 +1176,181 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
|||||||
db_entity.save()
|
db_entity.save()
|
||||||
|
|
||||||
def make_episode_info(self):
|
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:
|
try:
|
||||||
# 다운로드 추가
|
import base64
|
||||||
|
import json as json_module
|
||||||
|
|
||||||
base_url = "https://anilife.live"
|
base_url = "https://anilife.live"
|
||||||
iframe_url = ""
|
LogicAniLife.episode_url = self.info.get("ep_url", base_url)
|
||||||
LogicAniLife.episode_url = self.info["ep_url"]
|
|
||||||
|
# 에피소드 provider 페이지 URL
|
||||||
logger.debug(LogicAniLife.episode_url)
|
provider_url = self.info["va"]
|
||||||
|
if provider_url.startswith("/"):
|
||||||
url = self.info["va"]
|
provider_url = base_url + provider_url
|
||||||
# LogicAniLife.episode_url = url
|
|
||||||
logger.debug(f"url:: {url}")
|
logger.debug(f"Provider URL: {provider_url}")
|
||||||
|
logger.info(f"Episode info: {self.info}")
|
||||||
ourls = parse.urlparse(url)
|
|
||||||
|
provider_html = None
|
||||||
self.headers = {
|
aldata_value = None
|
||||||
"Referer": LogicAniLife.episode_url,
|
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, "
|
# Camoufox를 subprocess로 실행 (스텔스 Firefox - 봇 감지 우회)
|
||||||
"like Gecko) Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36",
|
try:
|
||||||
}
|
import subprocess
|
||||||
|
import json as json_module
|
||||||
logger.debug("make_episode_info()::url==> %s", url)
|
|
||||||
logger.info(f"self.info:::> {self.info}")
|
# camoufox_anilife.py 스크립트 경로
|
||||||
|
script_path = os.path.join(os.path.dirname(__file__), "lib", "camoufox_anilife.py")
|
||||||
referer = "https://anilife.live/g/l?id=13fd4d28-ff18-4764-9968-7e7ea7347c51"
|
|
||||||
|
# detail_url과 episode_num 추출
|
||||||
# text = requests.get(url, headers=headers).text
|
detail_url = self.info.get("ep_url", f"https://anilife.live/detail/id/{self.info.get('content_code', '')}")
|
||||||
# text = LogicAniLife.get_html_seleniumwire(url, referer=referer, wired=True)
|
episode_num = str(self.info.get("ep_num", "1"))
|
||||||
# https://anilife.live/ani/provider/10f60832-20d1-4918-be62-0f508bf5460c
|
|
||||||
referer_url = (
|
logger.debug(f"Running Camoufox subprocess: {script_path}")
|
||||||
"https://anilife.live/g/l?id=b012a355-a997-449a-ae2b-408a81a9b464"
|
logger.debug(f"Detail URL: {detail_url}, Episode: {episode_num}")
|
||||||
)
|
|
||||||
|
# subprocess로 Camoufox 스크립트 실행
|
||||||
referer_url = LogicAniLife.episode_url
|
result = subprocess.run(
|
||||||
|
[sys.executable, script_path, detail_url, episode_num],
|
||||||
logger.debug(f"LogicAniLife.episode_url:: {LogicAniLife.episode_url}")
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
# gevent 에서 asyncio.run
|
timeout=120 # 120초 타임아웃
|
||||||
# text = asyncio.run(
|
)
|
||||||
# LogicAniLife.get_html_playwright(
|
|
||||||
# url,
|
if result.returncode != 0:
|
||||||
# headless=False,
|
logger.error(f"Camoufox subprocess failed: {result.stderr}")
|
||||||
# referer=referer_url,
|
raise Exception(f"Subprocess error: {result.stderr}")
|
||||||
# engine="chrome",
|
|
||||||
# stealth=True,
|
# 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')}")
|
||||||
# task1 = asyncio.create_task(LogicAniLife.get_html_playwright(
|
|
||||||
# url,
|
if cf_result.get("error"):
|
||||||
# headless=True,
|
logger.error(f"Camoufox error: {cf_result['error']}")
|
||||||
# referer=referer_url,
|
|
||||||
# engine="chrome",
|
# _aldata 추출
|
||||||
# stealth=True
|
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]}...")
|
||||||
# loop = asyncio.new_event_loop()
|
elif cf_result.get("html"):
|
||||||
logger.debug(url, referer_url)
|
provider_html = cf_result["html"]
|
||||||
import json
|
logger.debug(f"Provider page loaded via Camoufox, length: {len(provider_html)}")
|
||||||
|
else:
|
||||||
post_data = {
|
logger.error("No aldata or HTML returned from Camoufox")
|
||||||
"url": url,
|
return
|
||||||
"headless": False,
|
|
||||||
# "engine": "chromium",
|
except subprocess.TimeoutExpired:
|
||||||
"engine": "webkit",
|
logger.error("Camoufox subprocess timed out")
|
||||||
"referer": referer_url,
|
return
|
||||||
"stealth": "False",
|
except FileNotFoundError:
|
||||||
"reload": True,
|
logger.error(f"Camoufox script not found: {script_path}")
|
||||||
}
|
return
|
||||||
payload = json.dumps(post_data)
|
except Exception as cf_err:
|
||||||
logger.debug(payload)
|
logger.error(f"Camoufox subprocess error: {cf_err}")
|
||||||
response_data = requests.post(
|
logger.error(traceback.format_exc())
|
||||||
url="http://localhost:7070/get_html_by_playwright", data=payload
|
return
|
||||||
)
|
|
||||||
|
# _aldata 처리
|
||||||
# logger.debug(response_data.json()["html"])
|
if aldata_value:
|
||||||
# soup_text = BeautifulSoup(response_data.json()["html"], 'lxml')
|
# JavaScript에서 직접 가져온 경우
|
||||||
#
|
aldata_b64 = aldata_value
|
||||||
# tree = html.fromstring(response_data.json()["html"])
|
elif provider_html:
|
||||||
text = response_data.json()["html"]
|
# HTML에서 추출
|
||||||
|
aldata_patterns = [
|
||||||
# vod_1080p_url = text
|
r"var\s+_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
|
||||||
# logger.debug(text)
|
r"let\s+_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
|
||||||
soup = BeautifulSoup(text, "lxml")
|
r"const\s+_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
|
||||||
|
r"_aldata\s*=\s*['\"]([A-Za-z0-9+/=]+)['\"]",
|
||||||
all_scripts = soup.find_all("script")
|
r"_aldata\s*=\s*'([^']+)'",
|
||||||
print(f"all_scripts:: {all_scripts}")
|
r'_aldata\s*=\s*"([^"]+)"',
|
||||||
|
]
|
||||||
regex = r"(?P<jawcloud_url>http?s:\/\/.*=jawcloud)"
|
|
||||||
match = re.compile(regex).search(text)
|
aldata_match = None
|
||||||
|
for pattern in aldata_patterns:
|
||||||
jawcloud_url = None
|
aldata_match = re.search(pattern, provider_html)
|
||||||
# print(match)
|
if aldata_match:
|
||||||
if match:
|
logger.debug(f"Found _aldata with pattern: {pattern}")
|
||||||
jawcloud_url = match.group("jawcloud_url")
|
break
|
||||||
|
|
||||||
logger.debug(f"jawcloud_url:: {jawcloud_url}")
|
if not aldata_match:
|
||||||
|
if "_aldata" in provider_html:
|
||||||
# loop = asyncio.new_event_loop()
|
idx = provider_html.find("_aldata")
|
||||||
# asyncio.set_event_loop(loop)
|
snippet = provider_html[idx:idx+200]
|
||||||
#
|
logger.error(f"_aldata found but pattern didn't match. Snippet: {snippet}")
|
||||||
logger.info(self.info)
|
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(
|
match = re.compile(
|
||||||
r"(?P<title>.*?)\s*((?P<season>\d+)%s)?\s*((?P<epi_no>\d+)%s)"
|
r"(?P<title>.*?)\s*((?P<season>\d+)%s)?\s*((?P<epi_no>\d+)%s)"
|
||||||
% ("기", "화")
|
% ("기", "화")
|
||||||
).search(self.info["title"])
|
).search(self.info["title"])
|
||||||
|
|
||||||
# epi_no 초기값
|
|
||||||
epi_no = 1
|
epi_no = 1
|
||||||
self.quality = "1080P"
|
self.quality = "1080P"
|
||||||
|
|
||||||
@@ -1138,7 +1359,6 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
|||||||
if "season" in match.groupdict() and match.group("season") is not None:
|
if "season" in match.groupdict() and match.group("season") is not None:
|
||||||
self.season = int(match.group("season"))
|
self.season = int(match.group("season"))
|
||||||
|
|
||||||
# epi_no = 1
|
|
||||||
epi_no = int(match.group("epi_no"))
|
epi_no = int(match.group("epi_no"))
|
||||||
ret = "%s.S%sE%s.%s-AL.mp4" % (
|
ret = "%s.S%sE%s.%s-AL.mp4" % (
|
||||||
self.content_title,
|
self.content_title,
|
||||||
@@ -1151,16 +1371,19 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
|||||||
P.logger.debug("NOT MATCH")
|
P.logger.debug("NOT MATCH")
|
||||||
ret = "%s.720p-AL.mp4" % self.info["title"]
|
ret = "%s.720p-AL.mp4" % self.info["title"]
|
||||||
|
|
||||||
# logger.info('self.content_title:: %s', self.content_title)
|
|
||||||
self.epi_queue = epi_no
|
self.epi_queue = epi_no
|
||||||
|
|
||||||
self.filename = Util.change_text_for_use_filename(ret)
|
self.filename = Util.change_text_for_use_filename(ret)
|
||||||
logger.info(f"self.filename::> {self.filename}")
|
logger.info(f"Filename: {self.filename}")
|
||||||
self.savepath = P.ModelSetting.get("ohli24_download_path")
|
|
||||||
logger.info(f"self.savepath::> {self.savepath}")
|
# 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 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" % (
|
folder_name = "%s %s" % (
|
||||||
P.ModelSetting.get("ohli24_finished_insert"),
|
P.ModelSetting.get("ohli24_finished_insert"),
|
||||||
self.content_title,
|
self.content_title,
|
||||||
@@ -1173,21 +1396,25 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
|||||||
self.savepath = os.path.join(
|
self.savepath = os.path.join(
|
||||||
self.savepath, "Season %s" % int(self.season)
|
self.savepath, "Season %s" % int(self.season)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.filepath = os.path.join(self.savepath, self.filename)
|
self.filepath = os.path.join(self.savepath, self.filename)
|
||||||
if not os.path.exists(self.savepath):
|
if not os.path.exists(self.savepath):
|
||||||
os.makedirs(self.savepath)
|
os.makedirs(self.savepath)
|
||||||
|
|
||||||
# vod_1080p_url = asyncio.run(
|
# 최종 비디오 URL 설정
|
||||||
# LogicAniLife.get_vod_url(jawcloud_url, headless=True)
|
self.url = vod_url
|
||||||
# )
|
logger.info(f"Final video URL: {self.url}")
|
||||||
vod_1080p_url = LogicAniLife.get_vod_url_v2(jawcloud_url, headless=False)
|
|
||||||
|
# 헤더 설정 (gcdn.app CDN 접근용)
|
||||||
print(f"vod_1080p_url:: {vod_1080p_url}")
|
self.headers = {
|
||||||
self.url = vod_1080p_url
|
"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",
|
||||||
logger.info(self.url)
|
"Origin": "https://anilife.live"
|
||||||
|
}
|
||||||
|
logger.info(f"Headers: {self.headers}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
P.logger.error(f"Exception: str(e)")
|
P.logger.error(f"Exception: {str(e)}")
|
||||||
P.logger.error(traceback.format_exc())
|
P.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
114
mod_ohli24.py
114
mod_ohli24.py
@@ -66,6 +66,11 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
origin_url = None
|
origin_url = None
|
||||||
episode_url = None
|
episode_url = None
|
||||||
cookies = None
|
cookies = None
|
||||||
|
proxy = "http://192.168.0.2:3138"
|
||||||
|
proxies = {
|
||||||
|
"http": proxy,
|
||||||
|
"https": proxy,
|
||||||
|
}
|
||||||
|
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
|
|
||||||
@@ -458,8 +463,9 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
code = urllib.parse.quote(code)
|
code = urllib.parse.quote(code)
|
||||||
|
|
||||||
try:
|
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 code.startswith("http"):
|
||||||
if "/c/" in code:
|
if "/c/" in code:
|
||||||
@@ -628,6 +634,9 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Found {len(episodes)} episodes")
|
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()')
|
ser_description_result = tree.xpath('//div[@class="view-stocon"]/div[@class="c"]/text()')
|
||||||
@@ -646,10 +655,24 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
"code": code,
|
"code": code,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not P.ModelSetting.get_bool("ohli24_order_desc"):
|
# 정렬 적용: 사이트 원본은 최신화가 가장 위임 (13, 12, ... 1)
|
||||||
data["episode"] = list(reversed(data["episode"]))
|
# 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"
|
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
|
self.current_data = data
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -845,10 +868,7 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
delay=10
|
delay=10
|
||||||
)
|
)
|
||||||
# 프록시 설정 (필요시 사용)
|
# 프록시 설정 (필요시 사용)
|
||||||
proxies = {
|
proxies = LogicOhli24.proxies
|
||||||
"http": "http://192.168.0.2:3138",
|
|
||||||
"https": "http://192.168.0.2:3138",
|
|
||||||
}
|
|
||||||
if method.upper() == 'POST':
|
if method.upper() == 'POST':
|
||||||
response = scraper.post(url, headers=headers, data=data, timeout=timeout, proxies=proxies)
|
response = scraper.post(url, headers=headers, data=data, timeout=timeout, proxies=proxies)
|
||||||
else:
|
else:
|
||||||
@@ -916,6 +936,7 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
# logger.debug("db_entity.status ::: %s", db_entity.status)
|
# logger.debug("db_entity.status ::: %s", db_entity.status)
|
||||||
if db_entity is None:
|
if db_entity is None:
|
||||||
entity = Ohli24QueueEntity(P, self, episode_info)
|
entity = Ohli24QueueEntity(P, self, episode_info)
|
||||||
|
entity.proxy = self.proxy
|
||||||
logger.debug("entity:::> %s", entity.as_dict())
|
logger.debug("entity:::> %s", entity.as_dict())
|
||||||
ModelOhli24Item.append(entity.as_dict())
|
ModelOhli24Item.append(entity.as_dict())
|
||||||
# # logger.debug("entity:: type >> %s", type(entity))
|
# # logger.debug("entity:: type >> %s", type(entity))
|
||||||
@@ -934,7 +955,7 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
return "enqueue_db_append"
|
return "enqueue_db_append"
|
||||||
elif db_entity.status != "completed":
|
elif db_entity.status != "completed":
|
||||||
entity = Ohli24QueueEntity(P, self, episode_info)
|
entity = Ohli24QueueEntity(P, self, episode_info)
|
||||||
|
entity.proxy = self.proxy
|
||||||
logger.debug("entity:::> %s", entity.as_dict())
|
logger.debug("entity:::> %s", entity.as_dict())
|
||||||
|
|
||||||
# P.logger.debug(F.config['path_data'])
|
# P.logger.debug(F.config['path_data'])
|
||||||
@@ -1080,11 +1101,20 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
self.content_title = None
|
self.content_title = None
|
||||||
self.srt_url = None
|
self.srt_url = None
|
||||||
self.headers = None
|
self.headers = None
|
||||||
|
self.cookies_file = None # yt-dlp용 CDN 세션 쿠키 파일 경로
|
||||||
# Todo::: 임시 주석 처리
|
# Todo::: 임시 주석 처리
|
||||||
self.make_episode_info()
|
self.make_episode_info()
|
||||||
|
|
||||||
|
|
||||||
def refresh_status(self):
|
def refresh_status(self):
|
||||||
self.module_logic.socketio_callback("status", self.as_dict())
|
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):
|
def info_dict(self, tmp):
|
||||||
# logger.debug('self.info::> %s', self.info)
|
# logger.debug('self.info::> %s', self.info)
|
||||||
@@ -1168,7 +1198,7 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
logger.info(f"Found cdndania iframe: {iframe_src}")
|
logger.info(f"Found cdndania iframe: {iframe_src}")
|
||||||
|
|
||||||
# Step 2: cdndania.com 페이지에서 m3u8 URL 추출
|
# 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:
|
if not video_url:
|
||||||
logger.error("Failed to extract video URL from cdndania")
|
logger.error("Failed to extract video URL from cdndania")
|
||||||
@@ -1176,15 +1206,19 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
|
|
||||||
self.url = video_url
|
self.url = video_url
|
||||||
self.srt_url = vtt_url
|
self.srt_url = vtt_url
|
||||||
|
self.cookies_file = cookies_file # yt-dlp용 세션 쿠키 파일
|
||||||
logger.info(f"Video URL: {self.url}")
|
logger.info(f"Video URL: {self.url}")
|
||||||
if self.srt_url:
|
if self.srt_url:
|
||||||
logger.info(f"Subtitle URL: {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 = {
|
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",
|
"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,
|
"Referer": iframe_src,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# 파일명 생성
|
# 파일명 생성
|
||||||
match = re.compile(r"(?P<title>.*?)\s*((?P<season>\d+)%s)?\s*((?P<epi_no>\d+)%s)" % ("기", "화")).search(
|
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())
|
P.logger.error(traceback.format_exc())
|
||||||
|
|
||||||
def extract_video_from_cdndania(self, iframe_src, referer_url):
|
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
|
video_url = None
|
||||||
vtt_url = None
|
vtt_url = None
|
||||||
|
cookies_file = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
import cloudscraper
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
|
||||||
logger.debug(f"Extracting from cdndania: {iframe_src}")
|
logger.debug(f"Extracting from cdndania: {iframe_src}")
|
||||||
|
|
||||||
# iframe URL에서 비디오 ID(hash) 추출
|
# iframe URL에서 비디오 ID(hash) 추출
|
||||||
@@ -1266,27 +1309,35 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
|
|
||||||
if not video_id:
|
if not video_id:
|
||||||
logger.error(f"Could not find video ID in iframe URL: {iframe_src}")
|
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 호출
|
# getVideo API 호출
|
||||||
api_url = f"https://cdndania.com/player/index.php?data={video_id}&do=getVideo"
|
api_url = f"https://cdndania.com/player/index.php?data={video_id}&do=getVideo"
|
||||||
headers = {
|
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",
|
"x-requested-with": "XMLHttpRequest",
|
||||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
"referer": iframe_src
|
"referer": iframe_src,
|
||||||
|
"origin": "https://cdndania.com"
|
||||||
}
|
}
|
||||||
# Referer는 메인 사이트 도메인만 보내는 것이 더 안정적일 수 있음
|
|
||||||
post_data = {
|
post_data = {
|
||||||
"hash": video_id,
|
"hash": video_id,
|
||||||
"r": "https://ani.ohli24.com/"
|
"r": "https://ani.ohli24.com/"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"Calling video API: {api_url}")
|
logger.debug(f"Calling video API with session: {api_url}")
|
||||||
json_text = LogicOhli24.get_html(api_url, headers=headers, data=post_data, method='POST', timeout=30)
|
response = scraper.post(api_url, headers=headers, data=post_data, timeout=30, proxies=proxies)
|
||||||
|
json_text = response.text
|
||||||
|
|
||||||
if json_text:
|
if json_text:
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
data = json.loads(json_text)
|
data = json.loads(json_text)
|
||||||
video_url = data.get("videoSource")
|
video_url = data.get("videoSource")
|
||||||
if not video_url:
|
if not video_url:
|
||||||
@@ -1299,13 +1350,35 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
vtt_url = data.get("videoSubtitle")
|
vtt_url = data.get("videoSubtitle")
|
||||||
if vtt_url:
|
if vtt_url:
|
||||||
logger.info(f"Found subtitle URL via API: {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:
|
except Exception as json_err:
|
||||||
logger.warning(f"Failed to parse API JSON: {json_err}")
|
logger.warning(f"Failed to parse API JSON: {json_err}")
|
||||||
|
|
||||||
# API 실패 시 기존 방식(정규식)으로 폴백
|
# API 실패 시 기존 방식(정규식)으로 폴백
|
||||||
if not video_url:
|
if not video_url:
|
||||||
logger.info("API extraction failed, falling back to regex")
|
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:
|
if html_content:
|
||||||
# m3u8 URL 패턴 찾기
|
# m3u8 URL 패턴 찾기
|
||||||
m3u8_patterns = [
|
m3u8_patterns = [
|
||||||
@@ -1337,7 +1410,8 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
logger.error(f"Error in extract_video_from_cdndania: {e}")
|
logger.error(f"Error in extract_video_from_cdndania: {e}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
return video_url, vtt_url
|
return video_url, vtt_url, cookies_file
|
||||||
|
|
||||||
|
|
||||||
# def callback_function(self, **args):
|
# def callback_function(self, **args):
|
||||||
# refresh_type = None
|
# refresh_type = None
|
||||||
|
|||||||
@@ -140,46 +140,65 @@
|
|||||||
str += m_hr_black();
|
str += m_hr_black();
|
||||||
str += m_row_start(0);
|
str += m_row_start(0);
|
||||||
tmp = ''
|
tmp = ''
|
||||||
if (data.image != null)
|
if (data.image != null) {
|
||||||
tmp = '<img src="' + data.image + '" class="img-fluid">';
|
// 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)
|
str += m_col(3, tmp)
|
||||||
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, '<strong style="font-size:1.3em;">' + data.title + '</strong>') + 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();
|
// des1 데이터를 각 항목별로 파싱하여 표시
|
||||||
//
|
if (data.des1) {
|
||||||
// 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();
|
const fields = ['상태:', '제작사:', '감독:', '각본:', '원작:', '시즌:', '공식 방영일:', '유형:', '에피소드:', '등급:', '방영 시작일:', '최근 방영일:'];
|
||||||
// tmp += m_row_start(2) + m_col(3, '분류', 'right') + m_col(9, data.des._classifi) + m_row_end();
|
let formattedDes = data.des1;
|
||||||
// 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();
|
fields.forEach(field => {
|
||||||
// tmp += m_row_start(2) + m_col(3, '최근 방영일', 'right') + m_col(9, data.des._recent_date ? data.des._recent_date : '') + m_row_end();
|
formattedDes = formattedDes.replace(new RegExp(field, 'g'), '<br><strong>' + field + '</strong> ');
|
||||||
// tmp += m_row_start(2) + m_col(3, '줄거리', 'right') + m_col(9, data.ser_description) + m_row_end();
|
});
|
||||||
|
|
||||||
tmp += "<div>" + data.des1 + "</div>"
|
// 첫 번째 br 태그 제거 (첫 줄에는 필요없음)
|
||||||
|
formattedDes = formattedDes.replace(/^<br>/, '');
|
||||||
|
|
||||||
|
tmp += '<div class="series-info-box">' + formattedDes + '</div>';
|
||||||
|
}
|
||||||
str += m_col(9, tmp)
|
str += m_col(9, tmp)
|
||||||
str += m_row_end();
|
str += m_row_end();
|
||||||
|
|
||||||
str += m_hr_black();
|
str += '<div class="episode-list-container">';
|
||||||
for (i in data.episode) {
|
for (i in data.episode) {
|
||||||
str += m_row_start();
|
// CDN 이미지 프록시 적용
|
||||||
tmp = '';
|
let epThumbSrc = data.episode[i].thumbnail || '';
|
||||||
if (data.episode[i].thumbnail)
|
if (epThumbSrc && epThumbSrc.includes('cdn.anilife.live')) {
|
||||||
tmp = '<img src="' + data.episode[i].thumbnail + '" class="img-fluid">'
|
epThumbSrc = '/' + package_name + '/ajax/' + sub + '/proxy_image?image_url=' + encodeURIComponent(epThumbSrc);
|
||||||
str += m_col(3, tmp)
|
}
|
||||||
tmp = '<strong>' + data.episode[i].ep_num + '화. ' + data.episode[i].title + '</strong>';
|
|
||||||
tmp += '<br>';
|
str += '<div class="episode-card">';
|
||||||
tmp += data.episode[i].date + '<br>';
|
str += '<div class="episode-thumb">';
|
||||||
|
if (epThumbSrc) {
|
||||||
tmp += '<div class="form-inline">'
|
str += '<img src="' + epThumbSrc + '" onerror="this.src=\'../static/img_loader_x200.svg\'">';
|
||||||
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}])
|
str += '<span class="episode-num">' + data.episode[i].ep_num + '화</span>';
|
||||||
tmp += '</div>'
|
str += '</div>';
|
||||||
str += m_col(9, tmp)
|
str += '<div class="episode-info">';
|
||||||
str += m_row_end();
|
str += '<div class="episode-title">' + data.episode[i].title + '</div>';
|
||||||
if (i != data.length - 1) str += m_hr(0);
|
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;
|
document.getElementById("episode_list").innerHTML = str;
|
||||||
$('input[id^="checkbox_"]').bootstrapToggle()
|
$('input[id^="checkbox_"]').bootstrapToggle()
|
||||||
}
|
}
|
||||||
@@ -329,6 +348,139 @@
|
|||||||
min-width: 82px !important;
|
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 {
|
.tooltip {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -182,8 +182,12 @@
|
|||||||
|
|
||||||
tmp = '<div class="col-6 col-sm-4 col-md-3">';
|
tmp = '<div class="col-6 col-sm-4 col-md-3">';
|
||||||
tmp += '<div class="card">';
|
tmp += '<div class="card">';
|
||||||
// tmp += '<img class="lozad" data-src="' + data.anime_list[i].image_link + '" />';
|
// 이미지 프록시를 통해 CDN 이미지 로드 (hotlink 보호 우회)
|
||||||
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 + '\'"/>';
|
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 += '<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">' +#}
|
// {#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>';#}
|
// {# '<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="col-sm-4">';
|
||||||
tmp += '<div class="card">';
|
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 += '<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">' +#}
|
// {#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>';#}
|
// {# '<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="col-sm-4">';
|
||||||
tmp += '<div class="card">';
|
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 += '<div class="card-body">'
|
||||||
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
|
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
|
||||||
tmp += '<p class="card-text">' + data.anime_list[i].code + '</p>';
|
tmp += '<p class="card-text">' + data.anime_list[i].code + '</p>';
|
||||||
@@ -719,12 +733,76 @@
|
|||||||
margin-top: 10px;
|
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 {
|
.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 {
|
.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 {
|
button#add_whitelist {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
{{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }}
|
{{ 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_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_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_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='제목으로 폴더를 생성하고 폴더 안에 다운로드합니다.') }}
|
{{ macros.setting_checkbox('anilife_auto_make_folder', '제목 폴더 생성', value=arg['anilife_auto_make_folder'], desc='제목으로 폴더를 생성하고 폴더 안에 다운로드합니다.') }}
|
||||||
<div id="anilife_auto_make_folder_div" class="collapse">
|
<div id="anilife_auto_make_folder_div" class="collapse">
|
||||||
|
|||||||
@@ -112,12 +112,19 @@
|
|||||||
|
|
||||||
|
|
||||||
function on_status(data) {
|
function on_status(data) {
|
||||||
console.log(data)
|
// console.log(data)
|
||||||
console.log(data.percent)
|
var entity_id = data.entity_id;
|
||||||
tmp = document.getElementById("progress_" + data.idx)
|
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) {
|
if (tmp != null) {
|
||||||
document.getElementById("progress_" + data.idx).style.width = data.percent + '%';
|
document.getElementById("progress_" + entity_id).style.width = percent + '%';
|
||||||
document.getElementById("progress_" + data.idx + "_label").innerHTML = data.status_kor + "(" + data.percent + "%)" + ' ' + ((data.current_speed != null) ? data.current_speed : '')
|
var label = status_kor;
|
||||||
|
if (percent != 0) label += "(" + percent + "%)";
|
||||||
|
if (speed) label += " " + speed;
|
||||||
|
document.getElementById("progress_" + entity_id + "_label").innerHTML = label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,12 @@
|
|||||||
if (ret.ret === 'success' && ret.data != null) {
|
if (ret.ret === 'success' && ret.data != null) {
|
||||||
// {#console.log(ret.code)#}
|
// {#console.log(ret.code)#}
|
||||||
console.log(ret.data)
|
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)
|
make_program(ret.data)
|
||||||
} else {
|
} else {
|
||||||
$.notify('<strong>분석 실패</strong><br>' + ret.log, {type: 'warning'});
|
$.notify('<strong>분석 실패</strong><br>' + ret.log, {type: 'warning'});
|
||||||
|
|||||||
@@ -425,9 +425,10 @@ async def get_vod_url(p_param: PlParam):
|
|||||||
|
|
||||||
async with async_playwright() as p:
|
async with async_playwright() as p:
|
||||||
try:
|
try:
|
||||||
# browser = await p.chromium.launch(headless=headless, args=browser_args)
|
# WebKit 사용 (Safari 엔진)
|
||||||
browser = await p.chromium.launch(
|
browser = await p.webkit.launch(
|
||||||
headless=pl_dict["headless"], args=browser_args
|
headless=pl_dict["headless"],
|
||||||
|
args=browser_args
|
||||||
)
|
)
|
||||||
|
|
||||||
# browser = await p.webkit.launch(headless=headless)
|
# browser = await p.webkit.launch(headless=headless)
|
||||||
|
|||||||
Reference in New Issue
Block a user