수정사항

This commit is contained in:
2025-12-26 22:21:53 +09:00
parent 8f8ffb4937
commit d756fa6b72
4 changed files with 377 additions and 198 deletions

4
.flake8 Normal file
View File

@@ -0,0 +1,4 @@
[flake8]
max-line-length = 120
exclude = .git,__pycache__,node_modules,nest_api,yommi_api
ignore = E501, W503, E203

View File

@@ -7,17 +7,15 @@
# @Software: PyCharm # @Software: PyCharm
import json import json
import os import os
import random
import re import re
import sys import sys
import traceback
from datetime import datetime
import random
import time import time
import traceback
import urllib import urllib
from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
import PIL.Image
# third-party # third-party
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -52,7 +50,7 @@ for package in packages:
else: else:
os.system(f"pip3 install {package}") os.system(f"pip3 install {package}")
from anime_downloader.lib.ffmpeg_queue_v1 import FfmpegQueueEntity, FfmpegQueue from anime_downloader.lib.ffmpeg_queue_v1 import FfmpegQueue, FfmpegQueueEntity
from anime_downloader.lib.util import Util from anime_downloader.lib.util import Util
# 패키지 # 패키지
@@ -94,12 +92,14 @@ class LogicLinkkf(PluginModuleBase):
} }
def __init__(self, P): def __init__(self, P):
super(LogicLinkkf, self).__init__(P, "setting", scheduler_desc="linkkf 자동 다운로드") super(LogicLinkkf, self).__init__(
P, "setting", scheduler_desc="linkkf 자동 다운로드"
)
self.queue = None self.queue = None
self.name = name self.name = name
self.db_default = { self.db_default = {
"linkkf_db_version": "1", "linkkf_db_version": "1",
"linkkf_url": "https://linkkf.app", "linkkf_url": "https://linkkf.live",
f"{self.name}_recent_code": "", f"{self.name}_recent_code": "",
"linkkf_download_path": os.path.join(path_data, P.package_name, "linkkf"), "linkkf_download_path": os.path.join(path_data, P.package_name, "linkkf"),
"linkkf_save_path": os.path.join(path_data, P.package_name, "linkkf"), "linkkf_save_path": os.path.join(path_data, P.package_name, "linkkf"),
@@ -128,8 +128,8 @@ class LogicLinkkf(PluginModuleBase):
arg = P.ModelSetting.to_dict() arg = P.ModelSetting.to_dict()
arg["sub"] = self.name arg["sub"] = self.name
if sub in ["setting", "queue", "category", "list", "request", "search"]: if sub in ["setting", "queue", "category", "list", "request", "search"]:
if sub == "request" and req.args.get("content_code") is not None: if sub == "request" and req.args.get("code") is not None:
arg["linkkf_current_code"] = req.args.get("content_code") arg["linkkf_current_code"] = req.args.get("code")
if sub == "setting": if sub == "setting":
job_id = "%s_%s" % (self.P.package_name, self.name) job_id = "%s_%s" % (self.P.package_name, self.name)
arg["scheduler"] = str(scheduler.is_include(job_id)) arg["scheduler"] = str(scheduler.is_include(job_id))
@@ -164,7 +164,9 @@ class LogicLinkkf(PluginModuleBase):
data = self.get_anime_info(cate, page) data = self.get_anime_info(cate, page)
# self.current_data = data # self.current_data = data
return jsonify({"ret": "success", "cate": cate, "page": page, "data": data}) return jsonify(
{"ret": "success", "cate": cate, "page": page, "data": data}
)
elif sub == "screen_movie_list": elif sub == "screen_movie_list":
try: try:
logger.debug("request:::> %s", request.form["page"]) logger.debug("request:::> %s", request.form["page"])
@@ -197,7 +199,7 @@ class LogicLinkkf(PluginModuleBase):
} }
) )
elif sub == "add_queue": elif sub == "add_queue":
logger.debug(f"linkkf add_queue routine ===============") logger.debug("linkkf add_queue routine ===============")
ret = {} ret = {}
info = json.loads(request.form["data"]) info = json.loads(request.form["data"])
logger.info(f"info:: {info}") logger.info(f"info:: {info}")
@@ -222,7 +224,6 @@ class LogicLinkkf(PluginModuleBase):
@staticmethod @staticmethod
def get_html(url, cached=False): def get_html(url, cached=False):
try: try:
if LogicLinkkf.referer is None: if LogicLinkkf.referer is None:
LogicLinkkf.referer = f"{ModelSetting.get('linkkf_url')}" LogicLinkkf.referer = f"{ModelSetting.get('linkkf_url')}"
@@ -291,7 +292,6 @@ class LogicLinkkf(PluginModuleBase):
logger.debug(f"args: {args}") logger.debug(f"args: {args}")
try: try:
if len(args) == 0: if len(args) == 0:
code = str(LogicLinkkf.current_data["code"]) code = str(LogicLinkkf.current_data["code"])
else: else:
@@ -304,14 +304,21 @@ class LogicLinkkf(PluginModuleBase):
# str(x.strip().replace(" ", "")) # str(x.strip().replace(" ", ""))
# for x in whitelist_program.replace("\n", "|").split("|") # for x in whitelist_program.replace("\n", "|").split("|")
# ] # ]
whitelist_programs = [str(x.strip()) for x in whitelist_program.replace("\n", "|").split("|")] whitelist_programs = [
str(x.strip()) for x in whitelist_program.replace("\n", "|").split("|")
]
if code not in whitelist_programs: if code not in whitelist_programs:
whitelist_programs.append(code) whitelist_programs.append(code)
whitelist_programs = filter(lambda x: x != "", whitelist_programs) # remove blank code whitelist_programs = filter(
lambda x: x != "", whitelist_programs
) # remove blank code
whitelist_program = "|".join(whitelist_programs) whitelist_program = "|".join(whitelist_programs)
entity = ( entity = (
db.session.query(P.ModelSetting).filter_by(key="linkkf_auto_code_list").with_for_update().first() db.session.query(P.ModelSetting)
.filter_by(key="linkkf_auto_code_list")
.with_for_update()
.first()
) )
entity.value = whitelist_program entity.value = whitelist_program
db.session.commit() db.session.commit()
@@ -332,8 +339,12 @@ class LogicLinkkf(PluginModuleBase):
return ret return ret
def setting_save_after(self): def setting_save_after(self):
if self.queue.get_max_ffmpeg_count() != P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count"): if self.queue.get_max_ffmpeg_count() != P.ModelSetting.get_int(
self.queue.set_max_ffmpeg_count(P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count")) "linkkf_max_ffmpeg_process_count"
):
self.queue.set_max_ffmpeg_count(
P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count")
)
def get_video_url_from_url(url, url2): def get_video_url_from_url(url, url2):
video_url = None video_url = None
@@ -378,7 +389,9 @@ class LogicLinkkf(PluginModuleBase):
# print(vtt_elem) # print(vtt_elem)
match = re.compile(r"<track.+src=\"(?P<vtt_url>.*?.vtt)\"", re.MULTILINE).search(data) match = re.compile(
r"<track.+src=\"(?P<vtt_url>.*?.vtt)\"", re.MULTILINE
).search(data)
vtt_url = match.group("vtt_url") vtt_url = match.group("vtt_url")
@@ -407,10 +420,14 @@ class LogicLinkkf(PluginModuleBase):
# @k45734 # @k45734
vtt_url = None vtt_url = None
try: try:
_match1 = re.compile(r"<track.+src=\"(?P<vtt_url>.*?.vtt)", re.MULTILINE).search(data) _match1 = re.compile(
r"<track.+src=\"(?P<vtt_url>.*?.vtt)", re.MULTILINE
).search(data)
vtt_url = _match1.group("vtt_url") vtt_url = _match1.group("vtt_url")
except: except:
_match2 = re.compile(r"url: \'(?P<vtt_url>.*?.vtt)", re.MULTILINE).search(data) _match2 = re.compile(
r"url: \'(?P<vtt_url>.*?.vtt)", re.MULTILINE
).search(data)
vtt_url = _match2.group("vtt_url") vtt_url = _match2.group("vtt_url")
logger.info("vtt_url: %s", vtt_url) logger.info("vtt_url: %s", vtt_url)
@@ -486,13 +503,19 @@ class LogicLinkkf(PluginModuleBase):
elif "kakao" in url2: elif "kakao" in url2:
# kakao 계열 처리, 외부 API 이용 # kakao 계열 처리, 외부 API 이용
payload = {"inputUrl": url2} payload = {"inputUrl": url2}
kakao_url = "http://webtool.cusis.net/wp-pages/download-kakaotv-video/video.php" kakao_url = (
"http://webtool.cusis.net/wp-pages/download-kakaotv-video/video.php"
)
data2 = requests.post( data2 = requests.post(
kakao_url, kakao_url,
json=payload, json=payload,
headers={"referer": "http://webtool.cusis.net/download-kakaotv-video/"}, headers={
"referer": "http://webtool.cusis.net/download-kakaotv-video/"
},
).content ).content
time.sleep(3) # 서버 부하 방지를 위해 단시간에 너무 많은 URL전송을 하면 IP를 차단합니다. time.sleep(
3
) # 서버 부하 방지를 위해 단시간에 너무 많은 URL전송을 하면 IP를 차단합니다.
url3 = json.loads(data2) url3 = json.loads(data2)
# logger.info("download url2 : %s , url3 : %s" % (url2, url3)) # logger.info("download url2 : %s , url3 : %s" % (url2, url3))
video_url = url3 video_url = url3
@@ -619,12 +642,13 @@ class LogicLinkkf(PluginModuleBase):
index = 0 index = 0
for js_script in js_scripts: for js_script in js_scripts:
# print(f"{index}.. {js_script.text_content()}") # print(f"{index}.. {js_script.text_content()}")
if pattern.match(js_script.text_content()): if pattern.match(js_script.text_content()):
# logger.debug("match::::") # logger.debug("match::::")
match_data = pattern.match(js_script.text_content()) match_data = pattern.match(js_script.text_content())
iframe_info = json.loads(match_data.groups()[0].replace("path:", '"path":')) iframe_info = json.loads(
match_data.groups()[0].replace("path:", '"path":')
)
# logger.debug(f"iframe_info:: {iframe_info}") # logger.debug(f"iframe_info:: {iframe_info}")
index += 1 index += 1
@@ -633,7 +657,7 @@ class LogicLinkkf(PluginModuleBase):
# iframe url:: https://s2.ani1c12.top/player/index.php?data='+player_data.url+' # iframe url:: https://s2.ani1c12.top/player/index.php?data='+player_data.url+'
#################################################### ####################################################
url = f'https://s2.ani1c12.top/player/index.php?data={iframe_info["url"]}' url = f"https://s2.ani1c12.top/player/index.php?data={iframe_info['url']}"
html_data = LogicLinkkf.get_html(url) html_data = LogicLinkkf.get_html(url)
return html_data return html_data
@@ -708,7 +732,9 @@ class LogicLinkkf(PluginModuleBase):
entity["title"] = item.xpath(title_xpath)[0].strip() entity["title"] = item.xpath(title_xpath)[0].strip()
entity["image_link"] = item.xpath("./a/@data-original")[0] entity["image_link"] = item.xpath("./a/@data-original")[0]
entity["chapter"] = ( entity["chapter"] = (
item.xpath("./a/span//text()")[0].strip() if len(item.xpath("./a/span//text()")) > 0 else "" item.xpath("./a/span//text()")[0].strip()
if len(item.xpath("./a/span//text()")) > 0
else ""
) )
# logger.info('entity:::', entity['title']) # logger.info('entity:::', entity['title'])
data["episode"].append(entity) data["episode"].append(entity)
@@ -746,10 +772,14 @@ class LogicLinkkf(PluginModuleBase):
entity = {} entity = {}
entity["link"] = item.xpath(".//a/@href")[0] entity["link"] = item.xpath(".//a/@href")[0]
entity["code"] = re.search(r"[0-9]+", entity["link"]).group() entity["code"] = re.search(r"[0-9]+", entity["link"]).group()
entity["title"] = item.xpath('.//a[@class="text-fff"]//text()')[0].strip() entity["title"] = item.xpath('.//a[@class="text-fff"]//text()')[
0
].strip()
entity["image_link"] = item.xpath("./a/@data-original")[0] entity["image_link"] = item.xpath("./a/@data-original")[0]
entity["chapter"] = ( entity["chapter"] = (
item.xpath("./a/span//text()")[0].strip() if len(item.xpath("./a/span//text()")) > 0 else "" item.xpath("./a/span//text()")[0].strip()
if len(item.xpath("./a/span//text()")) > 0
else ""
) )
data["episode"].append(entity) data["episode"].append(entity)
@@ -769,159 +799,170 @@ class LogicLinkkf(PluginModuleBase):
and LogicLinkkf.current_data["ret"] and LogicLinkkf.current_data["ret"]
): ):
return LogicLinkkf.current_data return LogicLinkkf.current_data
url = "%s/%s" % (P.ModelSetting.get("linkkf_url"), code)
logger.info(url)
logger.debug(LogicLinkkf.headers) url = "%s/%s/" % (P.ModelSetting.get("linkkf_url"), code)
logger.info(f"get_series_info URL: {url}")
html_content = LogicLinkkf.get_html(url, cached=False) html_content = LogicLinkkf.get_html(url, cached=False)
# html_content = LogicLinkkf.get_html_playwright(url)
# html_content = LogicLinkkf.get_html_cloudflare(url, cached=False)
sys.setrecursionlimit(10**7) if not html_content:
# logger.info(html_content) data["log"] = "Failed to fetch page content"
tree = html.fromstring(html_content) data["ret"] = "error"
# tree = etree.fromstring( return data
# html_content, parser=etree.XMLParser(huge_tree=True)
# )
# tree1 = BeautifulSoup(html_content, "lxml")
soup = BeautifulSoup(html_content, "html.parser") soup = BeautifulSoup(html_content, "html.parser")
# tree = etree.HTML(str(soup))
# logger.info(tree)
tmp2 = soup.select("ul > a") # === 제목 추출 ===
if len(tmp2) == 0: # 방법 1: #anime-details > h3 (가장 정확)
tmp = soup.select("u > a") title_elem = soup.select_one("#anime-details > h3")
if not title_elem:
# 방법 2: .anime-tab-content > h3
title_elem = soup.select_one(".anime-tab-content > h3")
title_text = ""
if title_elem:
title_text = title_elem.get_text(strip=True)
# "11/12 - 너와 넘어 사랑이 된다" 형식에서 제목만 추출
if " - " in title_text:
data["title"] = title_text.split(" - ", 1)[1]
else:
data["title"] = title_text
else: else:
tmp = soup.select("ul > a") # 방법 3: gemini-dark-card__link의 title 속성
card_link = soup.select_one("a.gemini-dark-card__link")
if card_link and card_link.get("title"):
data["title"] = card_link.get("title")
else:
# 방법 4: 포스터 이미지의 alt 속성
poster_img = soup.select_one("img.gemini-dark-card__image")
if poster_img and poster_img.get("alt"):
data["title"] = poster_img.get("alt")
else:
# 방법 5: 페이지 title에서 추출
page_title = soup.select_one("title")
if page_title:
title_text = page_title.get_text(strip=True)
# "제목 자막 / 더빙 / Linkkf" 형식 처리
data["title"] = title_text.split(" 자막")[0].split(" /")[0].strip()
else:
data["title"] = f"Unknown-{code}"
# logger.debug(f"tmp1 size:=> {str(len(tmp))}") # 제목 정리
data["title"] = Util.change_text_for_use_filename(data["title"]).strip()
data["_id"] = str(code)
try: # === 시즌 추출 ===
tmp = tree.xpath('//div[@class="hrecipe"]/article/center/strong')[0].text_content().strip() match = re.compile(r"(?P<season>\d+)기").search(data.get("title", ""))
except IndexError:
tmp = tree.xpath("//article/center/strong")[0].text_content().strip()
# logger.info(tmp)
match = re.compile(r"(?P<season>\d+)기").search(tmp)
if match: if match:
data["season"] = match.group("season") data["season"] = match.group("season")
data["title"] = data["title"].replace(data["season"] + "", "").strip()
else: else:
data["season"] = "1" data["season"] = "1"
data["_id"] = str(code) # === 포스터 이미지 ===
data["title"] = tmp.replace(data["season"] + "", "").strip() poster_elem = soup.select_one("img.gemini-dark-card__image")
data["title"] = data["title"].replace("()", "").strip() if poster_elem:
data["title"] = Util.change_text_for_use_filename(data["title"]).replace("OVA", "").strip() # lazy loading 대응: data-lazy-src (사이트에서 사용하는 속성), data-src, src 순서로 확인
data["poster_url"] = (
try: poster_elem.get("data-lazy-src") or
data["poster_url"] = tree.xpath('//div[@class="myui-content__thumb"]/a/@data-original') poster_elem.get("data-src") or
# print(tree.xpath('//div[@class="myui-content__detail"]/text()')) poster_elem.get("src") or ""
if len(tree.xpath('//div[@class="myui-content__detail"]/text()')) > 3: )
data["detail"] = [{"info": str(tree.xpath("//div[@class='myui-content__detail']/text()")[3])}] # placeholder SVG 제외
if data["poster_url"].startswith("data:image/svg"):
data["poster_url"] = poster_elem.get("data-lazy-src") or poster_elem.get("data-src") or ""
else:
# 대안 선택자
poster_alt = soup.select_one("a.gemini-dark-card__link img")
if poster_alt:
data["poster_url"] = (
poster_alt.get("data-lazy-src") or
poster_alt.get("data-src") or
poster_alt.get("src") or ""
)
else: else:
data["detail"] = [{"정보없음": ""}] data["poster_url"] = None
except Exception as e:
logger.error(e) # === 상세 정보 ===
data["detail"] = []
info_items = soup.select("li")
for item in info_items:
text = item.get_text(strip=True)
if any(keyword in text for keyword in ["방영일", "제작사", "장르", "분류", ""]):
data["detail"].append({"info": text})
if not data["detail"]:
data["detail"] = [{"정보없음": ""}] data["detail"] = [{"정보없음": ""}]
data["poster_url"] = None
data["rate"] = tree.xpath('span[@class="tag-score"]')
tag_score = tree.xpath('//span[@class="taq-score"]')[0].text_content()
# logger.debug(tag_score)
tag_count = tree.xpath('//span[contains(@class, "taq-count")]')[0].text_content().strip()
data_rate = tree.xpath('//div[@class="rating"]/div/@data-rate')
tmp2 = soup.select("ul > a")
if len(tmp) == 0:
tmp = soup.select("u > a")
else:
tmp = soup.select("ul > a")
if tmp is not None:
data["episode_count"] = str(len(tmp))
else:
data["episode_count"] = "0"
# === 에피소드 목록 - API에서 가져오기 ===
data["episode"] = [] data["episode"] = []
# tags = tree.xpath(
# '//*[@id="syno-nsc-ext-gen3"]/article/div[1]/article/a')
# tags = tree.xpath("//ul/a")
tags = soup.select("ul > u > a")
if len(tags) > 0:
pass
else:
tags = soup.select("ul > a")
logger.debug(len(tags))
# logger.info("tags", tags)
# re1 = re.compile(r'\/(?P<code>\d+)')
re1 = re.compile(r"\-([^-])+\.")
data["save_folder"] = data["title"] data["save_folder"] = data["title"]
# logger.debug(f"save_folder::> {data['save_folder']}")
# program = ( # 에피소드 API 호출
# db.session.query(ModelLinkkfProgram).filter_by(programcode=code).first() episode_api_url = f"https://linkkfep.5imgdarr.top/api2.php?epid={code}"
# ) try:
episode_response = requests.get(episode_api_url, timeout=10)
episode_data = episode_response.json()
idx = 1 logger.debug(f"Episode API response: {len(episode_data)} servers found")
for t in tags:
entity = {
"_id": data["code"],
"program_code": data["code"],
"program_title": data["title"],
"save_folder": Util.change_text_for_use_filename(data["save_folder"]),
"title": t.text.strip(),
# "title": t.text_content().strip(),
}
# entity['code'] = re1.search(t.attrib['href']).group('code')
# logger.debug(f"title ::>{entity['title']}") # 첫 번째 서버 (보통 자막-S)의 에피소드 목록 사용
if episode_data and len(episode_data) > 0:
server_data = episode_data[0].get("server_data", [])
# 역순 정렬 (최신 에피소드가 위로)
server_data = list(reversed(server_data))
# 고유id임을 알수 없는 말도 안됨.. for idx, ep_info in enumerate(server_data):
# 에피소드 코드가 고유해야 상태값 갱신이 제대로 된 값에 넣어짐 ep_name = ep_info.get("name", str(idx + 1))
p = re.compile(r"([0-9]+)화?") ep_slug = ep_info.get("slug", str(idx + 1))
m_obj = p.match(entity["title"]) ep_link = ep_info.get("link", "")
# logger.info(m_obj.group())
# entity['code'] = data['code'] + '_' +str(idx)
episode_code = None # 화면 표시용 title은 "01화" 형태
# logger.debug(f"m_obj::> {m_obj}") ep_title = f"{ep_name}"
if m_obj is not None:
episode_code = m_obj.group(1)
entity["code"] = data["code"] + episode_code.zfill(4)
else:
entity["code"] = data["code"]
aa = t["href"] entity = {
if "/player" in aa: "_id": data["code"],
entity["url"] = "https://linkkf.app" + t["href"] "program_code": data["code"],
else: "program_title": data["title"],
entity["url"] = t["href"] "save_folder": Util.change_text_for_use_filename(data["save_folder"]),
entity["season"] = data["season"] "title": ep_title,
"season": data["season"],
}
# 저장 경로 저장 # 에피소드 코드 생성
# Todo: db entity["code"] = data["code"] + ep_name.zfill(4)
tmp_save_path = P.ModelSetting.get(f"linkkf_download_path")
if P.ModelSetting.get("linkkf_auto_make_folder") == "True":
program_path = os.path.join(tmp_save_path, entity["save_folder"])
entity["save_path"] = program_path
if P.ModelSetting.get("linkkf_auto_make_season_folder"):
entity["save_path"] = os.path.join(entity["save_path"], "Season %s" % int(entity["season"]))
entity["image"] = data["poster_url"] # URL 생성: playid/{code}/?server=12&slug={slug} 형태
entity["url"] = f"https://linkkf.live/playid/{code}/?server=12&slug={ep_slug}"
entity["filename"] = LogicLinkkf.get_filename(data["save_folder"], data["season"], entity["title"]) # 저장 경로 설정
data["episode"].append(entity) tmp_save_path = P.ModelSetting.get("linkkf_download_path")
idx = idx + 1 if P.ModelSetting.get("linkkf_auto_make_folder") == "True":
program_path = os.path.join(tmp_save_path, entity["save_folder"])
entity["save_path"] = program_path
if P.ModelSetting.get("linkkf_auto_make_season_folder"):
entity["save_path"] = os.path.join(
entity["save_path"], "Season %s" % int(entity["season"])
)
entity["image"] = data["poster_url"]
# filename 생성 시 숫자만 전달 ("01화" 아님)
entity["filename"] = LogicLinkkf.get_filename(
data["save_folder"], data["season"], ep_name
)
data["episode"].append(entity)
except Exception as ep_error:
logger.error(f"Episode API error: {ep_error}")
logger.error(traceback.format_exc())
data["episode_count"] = str(len(data["episode"]))
data["ret"] = True data["ret"] = True
# logger.info('data', data)
self.current_data = data self.current_data = data
logger.info(f"Parsed series: {data['title']}, Episodes: {data['episode_count']}")
return data return data
except Exception as e: except Exception as e:
@@ -930,12 +971,6 @@ class LogicLinkkf(PluginModuleBase):
data["log"] = str(e) data["log"] = str(e)
data["ret"] = "error" data["ret"] = "error"
return data return data
except IndexError as e:
logger.error("Exception:%s", e)
logger.error(traceback.format_exc())
data["log"] = str(e)
data["ret"] = "error"
return data
def get_screen_movie_info(self, page): def get_screen_movie_info(self, page):
try: try:
@@ -973,7 +1008,11 @@ class LogicLinkkf(PluginModuleBase):
else: else:
entity["image_link"] = "" entity["image_link"] = ""
# entity["image_link"] = item.xpath("./a/@data-original")[0] # entity["image_link"] = item.xpath("./a/@data-original")[0]
entity["chapter"] = item.xpath("./a/span//text()")[0] if len(item.xpath("./a/span//text()")) > 0 else "" entity["chapter"] = (
item.xpath("./a/span//text()")[0]
if len(item.xpath("./a/span//text()")) > 0
else ""
)
# logger.info('entity:::', entity['title']) # logger.info('entity:::', entity['title'])
data["episode"].append(entity) data["episode"].append(entity)
@@ -999,7 +1038,7 @@ class LogicLinkkf(PluginModuleBase):
): ):
data = "" data = ""
headers = { headers = {
"referer": f"https://linkkf.app", "referer": "https://linkkf.live",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36" "Chrome/96.0.4664.110 Whale/3.12.129.46 Safari/537.36"
"Mozilla/5.0 (Macintosh; Intel " "Mozilla/5.0 (Macintosh; Intel "
@@ -1008,13 +1047,14 @@ class LogicLinkkf(PluginModuleBase):
"X-Requested-With": "XMLHttpRequest", "X-Requested-With": "XMLHttpRequest",
} }
try: try:
if LogicOhli24.session is None: if LogicOhli24.session is None:
LogicOhli24.session = requests.session() LogicOhli24.session = requests.session()
# logger.debug('get_html :%s', url) # logger.debug('get_html :%s', url)
headers["Referer"] = "" if referer is None else referer headers["Referer"] = "" if referer is None else referer
page_content = LogicOhli24.session.get(url, headers=headers, timeout=timeout) page_content = LogicOhli24.session.get(
url, headers=headers, timeout=timeout
)
data = page_content.text data = page_content.text
except Exception as e: except Exception as e:
logger.error("Exception:%s", e) logger.error("Exception:%s", e)
@@ -1037,7 +1077,7 @@ class LogicLinkkf(PluginModuleBase):
else: else:
LogicLinkkf.session = requests.Session() LogicLinkkf.session = requests.Session()
LogicLinkkf.referer = "https://linkkf.app" LogicLinkkf.referer = "https://linkkf.live"
LogicLinkkf.headers["Referer"] = LogicLinkkf.referer LogicLinkkf.headers["Referer"] = LogicLinkkf.referer
@@ -1055,7 +1095,9 @@ class LogicLinkkf(PluginModuleBase):
# logger.debug("get_filename()===") # logger.debug("get_filename()===")
# logger.info("title:: %s", title) # logger.info("title:: %s", title)
# logger.info("maintitle:: %s", maintitle) # logger.info("maintitle:: %s", maintitle)
match = re.compile(r"(?P<title>.*?)\s?((?P<season>\d+)기)?\s?((?P<epi_no>\d+)화?)").search(title) match = re.compile(
r"(?P<title>.*?)\s?((?P<season>\d+)기)?\s?((?P<epi_no>\d+)화?)"
).search(title)
if match: if match:
epi_no = int(match.group("epi_no")) epi_no = int(match.group("epi_no"))
if epi_no < 10: if epi_no < 10:
@@ -1084,7 +1126,6 @@ class LogicLinkkf(PluginModuleBase):
if self.is_exist(episode_info): if self.is_exist(episode_info):
return "queue_exist" return "queue_exist"
else: else:
db_entity = ModelLinkkfItem.get_by_linkkf_id(episode_info["_id"]) db_entity = ModelLinkkfItem.get_by_linkkf_id(episode_info["_id"])
logger.debug("db_entity:::> %s", db_entity) logger.debug("db_entity:::> %s", db_entity)
@@ -1151,7 +1192,9 @@ class LogicLinkkf(PluginModuleBase):
try: try:
logger.debug("%s plugin_load", P.package_name) logger.debug("%s plugin_load", P.package_name)
# old version # old version
self.queue = FfmpegQueue(P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count")) self.queue = FfmpegQueue(
P, P.ModelSetting.get_int("linkkf_max_ffmpeg_process_count")
)
self.current_data = None self.current_data = None
self.queue.queue_start() self.queue.queue_start()
@@ -1176,7 +1219,9 @@ class LogicLinkkf(PluginModuleBase):
try: try:
while True: while True:
logger.debug(self.current_download_count) logger.debug(self.current_download_count)
if self.current_download_count < P.ModelSetting.get_int(f"{self.name}_max_download_count"): if self.current_download_count < P.ModelSetting.get_int(
f"{self.name}_max_download_count"
):
break break
time.sleep(5) time.sleep(5)
@@ -1197,19 +1242,23 @@ class LinkkfQueueEntity(FfmpegQueueEntity):
def __init__(self, P, module_logic, info): def __init__(self, P, module_logic, info):
super(LinkkfQueueEntity, self).__init__(P, module_logic, info) super(LinkkfQueueEntity, self).__init__(P, module_logic, info)
self._vi = None self._vi = None
self.url = None
self.epi_queue = None self.epi_queue = None
self.filepath = None
self.savepath = None
self.quality = None
self.filename = None
self.vtt = None self.vtt = None
self.season = 1
self.content_title = None
self.srt_url = None self.srt_url = None
self.headers = None self.headers = None
# Todo::: 임시 주석 처리
self.make_episode_info() # info에서 필요한 정보 설정
self.url = info.get("url", "")
self.filename = info.get("filename", "")
self.filepath = info.get("filename", "")
self.savepath = info.get("save_path", "")
self.quality = info.get("quality", "720p")
self.season = info.get("season", "1")
self.content_title = info.get("program_title", "")
# make_episode_info는 비디오 URL 추출이 필요할 때만 호출
# 현재는 바로 다운로드 큐에 추가하므로 주석 처리
# 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())
@@ -1338,7 +1387,9 @@ class ModelLinkkfItem(db.Model):
ret = {x.name: getattr(self, x.name) for x in self.__table__.columns} ret = {x.name: getattr(self, x.name) for x in self.__table__.columns}
ret["created_time"] = self.created_time.strftime("%Y-%m-%d %H:%M:%S") ret["created_time"] = self.created_time.strftime("%Y-%m-%d %H:%M:%S")
ret["completed_time"] = ( ret["completed_time"] = (
self.completed_time.strftime("%Y-%m-%d %H:%M:%S") if self.completed_time is not None else None self.completed_time.strftime("%Y-%m-%d %H:%M:%S")
if self.completed_time is not None
else None
) )
return ret return ret

124
outline.md Normal file
View File

@@ -0,0 +1,124 @@
# anime_downloader 플러그인 구조 분석
## 📌 개요
FlaskFarm용 **애니메이션 다운로드 플러그인**으로, 여러 애니메이션 스트리밍 사이트에서 콘텐츠를 검색하고 다운로드할 수 있는 기능을 제공합니다.
---
## 🏗️ 전체 구조
```
anime_downloader/
├── __init__.py # 플러그인 초기화 (현재 비활성화됨)
├── setup.py # 플러그인 설정 및 메뉴 구조, 모듈 로드
├── info.yaml # 플러그인 메타데이터 (이름, 버전, 개발자)
├── mod_ohli24.py # 애니24 사이트 모듈 (1,542줄)
├── mod_anilife.py # 애니라이프 사이트 모듈 (1,322줄)
├── mod_linkkf.py # 링크애니 사이트 모듈 (1,449줄)
├── lib/ # 공용 라이브러리
│ ├── crawler.py # 웹 크롤링 엔진 (Playwright, Selenium, Cloudscraper)
│ ├── ffmpeg_queue_v1.py# FFmpeg 다운로드 큐 관리
│ ├── util.py # 유틸리티 함수 (파일명 정리, 타이밍 등)
│ └── misc.py # 비동기 실행 헬퍼 함수
├── templates/ # HTML 템플릿 (18개 파일)
├── static/ # CSS, JS, 이미지 리소스
├── bin/ # 플랫폼별 바이너리 (Darwin, Linux)
├── nest_api/ # 애니 API 관련 (서브디렉토리)
└── yommi_api/ # 커스텀 API 관련
```
---
## 🔧 핵심 컴포넌트
### 1. setup.py - 플러그인 엔트리포인트
| 항목 | 설명 |
|------|------|
| `__menu` | 3개 사이트별 서브메뉴 (설정, 요청, 큐, 검색, 목록) |
| `setting` | DB 사용, 기본 설정, 홈 모듈(`ohli24`) 지정 |
| `P` | FlaskFarm 플러그인 인스턴스 생성 |
| 모듈 로드 | `LogicOhli24`, `LogicAniLife`, `LogicLinkkf` |
### 2. 사이트 모듈 (mod_*.py)
각 모듈은 동일한 구조를 따릅니다:
| 클래스 | 역할 |
|--------|------|
| `LogicXxx` | 사이트별 비즈니스 로직 (검색, 시리즈 정보, 다운로드 추가) |
| `XxxQueueEntity` | 다운로드 큐 항목 (에피소드 정보, 상태 관리) |
| `ModelXxxItem` | SQLAlchemy DB 모델 (다운로드 기록 저장) |
**LogicXxx 주요 메서드:**
- `process_menu()` / `process_ajax()` - 웹 요청 처리
- `get_series_info()` - 시리즈/에피소드 정보 파싱
- `get_anime_info()` / `get_search_result()` - 목록/검색
- `add()` - 다운로드 큐에 추가
- `scheduler_function()` - 자동 다운로드 스케줄러
- `plugin_load()` / `plugin_unload()` - 생명주기 관리
### 3. lib/crawler.py - 웹 크롤링 엔진
| 메서드 | 기술 |
|--------|------|
| `get_html_requests()` | 기본 requests 요청 |
| `get_html_playwright()` | Playwright 비동기 (헤드리스 브라우저) |
| `get_html_playwright_sync()` | Playwright 동기 |
| `get_html_selenium()` | Selenium WebDriver |
| `get_html_cloudflare()` | Cloudscraper (CF 우회) |
### 4. lib/ffmpeg_queue_v1.py - 다운로드 큐
| 클래스 | 역할 |
|--------|------|
| `FfmpegQueueEntity` | 개별 다운로드 항목 (URL, 파일경로, 상태) |
| `FfmpegQueue` | 큐 관리자 (스레드 기반 다운로드, 동시 다운로드 수 제어) |
---
## 🖥️ 지원 사이트 (3개)
| 모듈 | 사이트 | URI |
|------|--------|-----|
| `mod_ohli24.py` | 애니24 (ohli24) | `/ohli24` |
| `mod_anilife.py` | 애니라이프 | `/anilife` |
| `mod_linkkf.py` | 링크애니 | `/linkkf` |
---
## 📄 템플릿 구조
각 사이트별로 6개 템플릿 제공:
- `*_setting.html` - 사이트 설정
- `*_request.html` - 다운로드 요청 페이지
- `*_queue.html` - 다운로드 큐 현황
- `*_search.html` - 검색 인터페이스
- `*_list.html` - 다운로드 목록
- `*_category.html` - 카테고리 탐색
---
## 🔄 동작 흐름
```mermaid
graph LR
A[사용자] --> B[검색/탐색]
B --> C[시리즈 선택]
C --> D[에피소드 선택]
D --> E[다운로드 큐 추가]
E --> F[FfmpegQueue]
F --> G[FFmpeg 다운로드]
G --> H[DB 기록 저장]
```
---
## ⚠️ 주의사항
1. **개발 모드**: `setup.py`에서 `DEFINE_DEV = True`로 설정되어 직접 모듈 import
2. **`__init__.py` 비활성화**: 현재 주석 처리되어 `setup.py`가 실제 엔트리포인트 역할
3. **크롤링 기술 혼용**: Cloudflare 우회를 위해 Playwright, Selenium, cloudscraper 등 다양한 기술 사용

View File

@@ -158,7 +158,7 @@
str += m_row_start(); str += m_row_start();
// tmp = '<img src="' + data.episode[i].image + '" class="img-fluid">' // tmp = '<img src="' + data.episode[i].image + '" class="img-fluid">'
// str += m_col(3, tmp) // str += m_col(3, tmp)
tmp = "<strong>" + data.episode[i].title + "</strong><span>. </span>"; tmp = "<strong>" + data.episode[i].title + "</strong><span>. </span>";
{#tmp += "<br>";#} {#tmp += "<br>";#}
tmp += data.episode[i].filename + "<br><p></p>"; tmp += data.episode[i].filename + "<br><p></p>";
@@ -202,13 +202,13 @@
// {#document.getElementById("analysis_btn").click();#} // {#document.getElementById("analysis_btn").click();#}
} }
if ("{{arg['ohli24_current_code']}}" !== "") { if ("{{arg['linkkf_current_code']}}" !== "") {
if (params.code === null) { if (params.code === null) {
console.log('params.code === null') console.log('params.code === null')
document.getElementById("code").value = "{{arg['ohli24_current_code']}}"; document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else if (params.code === '') { } else if (params.code === '') {
document.getElementById("code").value = "{{arg['ohli24_current_code']}}"; document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else { } else {
console.log('params code exist') console.log('params code exist')