Files
MoviePilot/app/modules/indexer/__init__.py
WenGuoyin e19076f482 feat(indexer): 适配 Milkie 站点搜索与连通测试
- 新增 MilkieSpider:基于 milkie.cc JSON API(x-milkie-auth 认证)的
  种子搜索与浏览,支持关键字分类收敛、服务端翻页、免费因子与
  媒体类型映射,下载链接按 API Key URL 编码拼接
- 注册 SPIDER_PARSER_CLASSES 与专用搜索参数投影
- SiteChain 新增 milkie.cc 连通性测试,401/403 判定 API Key 失效
- 补充 MilkieSpider 单元测试与同步异步参数一致性用例,同步依赖基线
2026-10-08 17:18:10 +08:00

915 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from dataclasses import dataclass
from datetime import datetime
from types import MappingProxyType
from typing import Any, Callable, List, Mapping, Optional, Tuple, Union, cast
from urllib.parse import urljoin
from app.application.site.health import get_configured_site_health_service
from app.application.site.observation import report_site_search_outcome, report_site_search_page
from app.application.site.query import get_configured_site_query_service
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.domain import site as site_rules
from app.domain import torrent as torrent_rules
from app.domain.context import Context, SubtitleInfo, TorrentInfo
from app.foundation import text as text_tools
from app.foundation.reflection import ModuleHelper
from app.modules import _ModuleBase
from app.modules.indexer.parser import SiteParserBase, SiteSchema
from app.modules.indexer.spider import SiteSpider
from app.modules.indexer.spider.haidan import HaiDanSpider
from app.modules.indexer.spider.hddolby import HddolbySpider
from app.modules.indexer.spider.milkie import MilkieSpider
from app.modules.indexer.spider.mtorrent import MTorrentSpider
from app.modules.indexer.spider.rousi import RousiSpider
from app.modules.indexer.spider.sunnypt import SunnyPTSpider
from app.modules.indexer.spider.tnode import TNodeSpider
from app.modules.indexer.spider.torrentleech import TorrentLeech
from app.modules.indexer.spider.yema import YemaSpider
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.media import resolve_media_identity
from app.schemas.site import SiteUserData
from app.schemas.types import MediaSource, MediaType, ModuleType, OtherModulesType
SPIDER_PARSER_CLASSES = {
"TNodeSpider": TNodeSpider,
"TorrentLeech": TorrentLeech,
"mTorrent": MTorrentSpider,
"Yema": YemaSpider,
"Haidan": HaiDanSpider,
"HDDolby": HddolbySpider,
"RousiPro": RousiSpider,
"SunnyPT": SunnyPTSpider,
"Milkie": MilkieSpider,
}
_SPECIALIZED_SEARCH_ARGUMENTS = {
"TNodeSpider": ("keyword", "page"),
"TorrentLeech": ("keyword", "mtype", "page"),
"mTorrent": ("keyword", "mtype", "page"),
"SunnyPT": ("keyword", "mtype", "cat", "page"),
"Yema": ("keyword", "mtype", "page"),
"Haidan": ("keyword", "mtype"),
"HDDolby": ("keyword", "mtype", "page"),
"RousiPro": ("keyword", "mtype", "cat", "page"),
"Milkie": ("keyword", "mtype", "page"),
}
_SPECIALIZED_SUBTITLE_SEARCH_ARGUMENTS = {
"mTorrent": ("keyword", "page", "search_type"),
}
_UNKNOWN_SEARCH_FAILURE_MESSAGE = "站点请求或页面解析失败"
# NexusPHP、Gazelle 和 Unit3d 覆盖大多数站点,其余模型多为专站适配,
# 不参与自动探测,避免对同一站点重复请求专用接口。
_COMMON_SITE_USERDATA_FALLBACK_SCHEMAS = frozenset({
SiteSchema.Gazelle.value,
SiteSchema.NexusPhp.value,
SiteSchema.Unit3d.value,
})
@dataclass(frozen=True)
class _IndexerSearchRequest:
"""一次站点搜索的解析器选择与不可变调用参数"""
parser_class: Optional[Callable[[dict[str, Any]], Any]]
arguments: Mapping[str, Any]
@dataclass(frozen=True)
class _IndexerSearchOutcome:
"""一次站点搜索完成后的错误状态、原始结果与耗时"""
error_flag: bool
result: List[dict[str, Any]]
seconds: int
error: Optional[Exception] = None
# 爬虫判定失败时给出的原因(状态码、页面标题等),只用于展示,不参与冷却分类
failure_detail: Optional[str] = None
def _classify_search_failure(error: Optional[Exception]) -> str:
"""把索引器异常归一为站点预算可执行的冷却类别。"""
if error is None:
return "error"
status_code = getattr(error, "status_code", None)
text = f"{type(error).__name__}: {error}".lower()
if status_code == 429 or "429" in text or "rate limit" in text:
return "rate_limited"
if status_code == 403 or "403" in text or "forbidden" in text:
return "forbidden"
if any(token in text for token in ("未登录", "登录失效", "login", "cookie")):
return "login_invalid"
if isinstance(error, TimeoutError) or "timeout" in text or "timed out" in text or "超时" in text:
return "timeout"
return "error"
def _search_failure_message(outcome: _IndexerSearchOutcome) -> Optional[str]:
"""返回供站点预算持久化和任务聚合展示的失败原因。"""
if outcome.error is not None:
return str(outcome.error)
if outcome.error_flag and outcome.failure_detail:
return f"{_UNKNOWN_SEARCH_FAILURE_MESSAGE}({outcome.failure_detail})"
if outcome.error_flag:
return _UNKNOWN_SEARCH_FAILURE_MESSAGE
return None
class IndexerModule(_ModuleBase):
"""
索引模块
"""
_site_schemas = []
def init_module(self) -> None:
"""加载站点用户数据解析器"""
# 加载模块
self._site_schemas = ModuleHelper.load(
'app.modules.indexer.parser',
filter_func=lambda _, obj: hasattr(obj, 'schema') and getattr(obj, 'schema') is not None)
pass
@staticmethod
def get_name() -> str:
"""获取模块名称"""
return "站点索引"
@staticmethod
def get_type() -> ModuleType:
"""
获取模块类型
"""
return ModuleType.Indexer
@staticmethod
def get_subtype() -> OtherModulesType:
"""
获取模块子类型
"""
return OtherModulesType.Indexer
@staticmethod
def get_priority() -> int:
"""
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
"""
return 0
def stop(self):
"""停止索引模块"""
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
sites = SitesHelper().get_indexers()
if not sites:
return False, "未配置站点或未通过用户认证"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""索引模块无需独立开关配置"""
pass
def site_subtitle_links(self, context: Context) -> Optional[List[str]]:
"""
解析采用API访问的站点的字幕下载链接,非API站点返回None交由页面解析模块处理
:param context: 上下文,包括识别信息、媒体信息、种子信息
:return: 字幕下载链接列表,不适用时返回None
"""
torrent = context.torrent_info
if torrent.site is None:
return None
site = get_configured_site_query_service().get_sync(torrent.site)
if not site:
return None
indexer = SitesHelper().get_indexer(site.domain)
if not indexer:
return None
if indexer.get("parser") == "mTorrent":
return MTorrentSpider(indexer).get_subtitle_links(
torrent.page_url
)
# TODO 其它采用API访问的站点
return None
@staticmethod
def __search_check(site: dict, search_word: Optional[str] = None) -> bool:
"""
检查是否可以执行搜索
"""
# 可能为关键字或ttxxxx
if search_word \
and site.get('language') == "en" \
and text_tools.contains_chinese(search_word):
# 不支持中文
logger.warn(f"{site.get('name')} 不支持中文搜索")
return False
# 站点流控
state, msg = SitesHelper().check(site_rules.extract_domain(site.get("domain")))
if state:
logger.warn(msg)
report_site_search_outcome(attempted=False, outcome="deferred", error=msg)
return False
return True
@staticmethod
def __clear_search_text(text: Optional[str]) -> Optional[str]:
"""
清理搜索文本
:param text: 需要清理的文本
:return: 清理后的文本
"""
if not text:
return text
# 去除特殊字符和多余空格
return text_tools.remove_punctuation(text, replacement=" ", allow_space=True)
@staticmethod
def __indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None:
"""
索引器统计
"""
domain = site_rules.extract_domain(site.get("domain"))
if error_flag:
get_configured_site_health_service().fail(domain)
else:
get_configured_site_health_service().success(
domain=domain,
seconds=seconds,
)
@staticmethod
async def __async_indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None:
"""
异步索引器统计
"""
domain = site_rules.extract_domain(site.get("domain"))
if error_flag:
await get_configured_site_health_service().async_fail(domain)
else:
await get_configured_site_health_service().async_success(
domain=domain,
seconds=seconds,
)
@staticmethod
def __parse_result(site: dict, result_array: list, seconds: int) -> TorrentInfo:
"""
解析搜索结果为 TorrentInfo 对象
"""
report_site_search_page(raw_count=len(result_array or []))
if not result_array or len(result_array) == 0:
logger.warn(f"{site.get('name')} 未搜索到数据,耗时 {seconds} 秒")
return []
logger.info(
f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}")
torrents = []
for result in result_array:
result = dict(result)
legacy_imdb_id = result.pop("imdbid", None)
media_source, media_id = resolve_media_identity(media=result)
if not media_source and legacy_imdb_id:
media_source, media_id = MediaSource.IMDb, str(legacy_imdb_id)
result["media_source"] = media_source
result["media_id"] = media_id
torrents.append(TorrentInfo(
site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua"),
site_proxy=site.get("proxy"),
site_order=site.get("pri"),
site_downloader=site.get("downloader"),
**result,
))
return torrents
@staticmethod
def __parse_subtitle_result(site: dict, result_array: list, seconds: int) -> List[SubtitleInfo]:
"""
解析字幕搜索结果为 SubtitleInfo 对象。
"""
if not result_array or len(result_array) == 0:
logger.warn(f"{site.get('name')} 未搜索到字幕,耗时 {seconds} 秒")
return []
logger.info(
f"{site.get('name')} 字幕搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}")
return [SubtitleInfo(site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua"),
site_proxy=site.get("proxy"),
site_order=site.get("pri"),
**result) for result in result_array if
result.get("language") and result.get("enclosure") and result.get("title") and result.get("size")]
@staticmethod
def get_search_page_size(site: dict, keyword: Optional[str] = None) -> Optional[int]:
"""
获取站点搜索单页容量;None 表示当前搜索入口不支持可靠翻页。
"""
site = site or {}
site_parser = site.get("parser")
if site_parser in SPIDER_PARSER_CLASSES:
return SPIDER_PARSER_CLASSES[site_parser].get_search_page_size(keyword=keyword)
try:
page_size = int(site.get("result_num") or SiteSpider.default_result_num())
except (TypeError, ValueError):
page_size = SiteSpider.default_result_num()
return page_size if page_size > 0 else SiteSpider.default_result_num()
@staticmethod
def restore_search_torrent(site: dict[str, Any], record: dict[str, Any], allow_missing: bool = False) -> Optional[TorrentInfo]:
"""用当前站点凭据恢复候选;无法安全重建的临时下载票据由调用方重取原页。"""
torrent = TorrentInfo()
torrent.from_dict(record)
# M-Team 下载链接含 API Key 不入检查点,按详情页链接中的站内 ID 用当前凭据重建。
identity = torrent_rules.resource_identity(torrent)
if site.get("parser") == "mTorrent" and identity and identity[1].startswith("id="):
torrent.enclosure = MTorrentSpider(site).get_download_url(identity[1][3:])
if not torrent.enclosure and not allow_missing:
return None
torrent.site_cookie = site.get("cookie") or ""
torrent.site_ua = site.get("ua") or ""
torrent.site_proxy = bool(site.get("proxy"))
torrent.site_downloader = site.get("downloader") or ""
return torrent
@staticmethod
def __create_search_request(
site: dict[str, Any],
keyword: Optional[str],
mtype: Optional[MediaType] = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
search_type: str = "torrents",
) -> Optional[_IndexerSearchRequest]:
"""校验搜索条件并生成同步、异步共用的解析器调用请求"""
if search_type == "subtitles" and not site.get("subtitles"):
return None
if not IndexerModule.__search_check(site, keyword):
return None
search_word = IndexerModule.__clear_search_text(keyword)
parser_name = str(site.get("parser") or "")
parser_class = SPIDER_PARSER_CLASSES.get(parser_name)
if search_type == "subtitles":
subtitle_argument_names = _SPECIALIZED_SUBTITLE_SEARCH_ARGUMENTS.get(parser_name)
if parser_class and subtitle_argument_names:
subtitle_available_arguments: dict[str, Any] = {
"keyword": search_word,
"page": page,
"search_type": search_type,
}
return _IndexerSearchRequest(
parser_class=parser_class,
arguments=MappingProxyType({
name: subtitle_available_arguments[name]
for name in subtitle_argument_names
}),
)
return _IndexerSearchRequest(
parser_class=None,
arguments=MappingProxyType({
"search_word": search_word,
"indexer": site,
"page": page,
"search_type": search_type,
}),
)
torrent_argument_names = _SPECIALIZED_SEARCH_ARGUMENTS.get(parser_name)
if parser_class and torrent_argument_names:
torrent_available_arguments: dict[str, Any] = {
"keyword": search_word,
"mtype": mtype,
"cat": cat,
"page": page,
}
return _IndexerSearchRequest(
parser_class=parser_class,
arguments=MappingProxyType({
name: torrent_available_arguments[name]
for name in torrent_argument_names
}),
)
return _IndexerSearchRequest(
parser_class=None,
arguments=MappingProxyType({
"search_word": search_word,
"indexer": site,
"mtype": mtype,
"cat": cat,
"page": page,
}),
)
@staticmethod
def __execute_search(
site: dict[str, Any],
request: _IndexerSearchRequest,
) -> Tuple[bool, List[dict[str, Any]], Optional[str]]:
"""通过同步解析器执行已冻结的搜索请求,返回错误标志、结果与失败原因"""
if request.parser_class:
spider = request.parser_class(site)
error_flag, result = cast(
Tuple[bool, List[dict[str, Any]]],
spider.search(**request.arguments),
)
return error_flag, result, getattr(spider, "error_detail", None)
return IndexerModule.__spider_search(**request.arguments)
@staticmethod
async def __async_execute_search(
site: dict[str, Any],
request: _IndexerSearchRequest,
) -> Tuple[bool, List[dict[str, Any]], Optional[str]]:
"""通过异步解析器执行已冻结的搜索请求,返回错误标志、结果与失败原因"""
if request.parser_class:
spider = request.parser_class(site)
error_flag, result = cast(
Tuple[bool, List[dict[str, Any]]],
await spider.async_search(**request.arguments),
)
return error_flag, result, getattr(spider, "error_detail", None)
return await IndexerModule.__async_spider_search(**request.arguments)
@staticmethod
def __create_search_outcome(
start_time: datetime,
error_flag: bool,
result: List[dict[str, Any]],
error: Optional[Exception] = None,
failure_detail: Optional[str] = None,
) -> _IndexerSearchOutcome:
"""把同步、异步 I/O 结果整理为共用的搜索完成状态"""
return _IndexerSearchOutcome(
error_flag=error_flag,
result=result,
seconds=(datetime.now() - start_time).seconds,
failure_detail=failure_detail,
error=error,
)
@staticmethod
def __log_search_error(site: dict[str, Any], search_type: str, error: Exception) -> None:
"""按搜索类型记录同步、异步共用的操作失败信息"""
resource_name = "字幕" if search_type == "subtitles" else ""
logger.error(f"{site.get('name')} {resource_name}搜索出错:{str(error)}")
def search_torrents(self, site: dict,
keyword: str = None,
mtype: MediaType = None,
cat: Optional[str] = None,
page: Optional[int] = 0) -> List[TorrentInfo]:
"""
搜索一个站点
:param site: 站点
:param keyword: 搜索关键词
:param mtype: 媒体类型
:param cat: 分类
:param page: 页码
:return: 资源列表
"""
# 索引结果
result = []
# 开始计时
start_time = datetime.now()
# 错误标志
error_flag = False
request = self.__create_search_request(
site=site,
keyword=keyword,
mtype=mtype,
cat=cat,
page=page,
)
if not request:
report_site_search_outcome(attempted=False, outcome="skipped")
return []
# 开始搜索
error: Optional[Exception] = None
failure_detail: Optional[str] = None
try:
error_flag, result, failure_detail = self.__execute_search(site, request)
except Exception as err:
error = err
self.__log_search_error(site, "torrents", err)
outcome = self.__create_search_outcome(start_time, error_flag, result, error, failure_detail)
# 统计索引情况
self.__indexer_statistic(
site=site,
error_flag=outcome.error_flag,
seconds=outcome.seconds,
)
report_site_search_outcome(
attempted=True,
outcome=(
_classify_search_failure(outcome.error)
if outcome.error_flag or outcome.error is not None
else "success"
),
error=_search_failure_message(outcome),
)
# 返回结果
return self.__parse_result(
site=site,
result_array=outcome.result,
seconds=outcome.seconds,
)
def search_subtitles(self, site: dict,
keyword: str = None,
page: Optional[int] = 0) -> List[SubtitleInfo]:
"""
搜索一个站点的字幕资源。
:param site: 站点
:param keyword: 搜索关键词
:param page: 页码
:return: 字幕列表
"""
result = []
start_time = datetime.now()
error_flag = False
error: Optional[Exception] = None
failure_detail: Optional[str] = None
request = self.__create_search_request(
site=site,
keyword=keyword,
page=page,
search_type="subtitles",
)
if not request:
return []
try:
error_flag, result, failure_detail = self.__execute_search(site, request)
except Exception as err:
error = err
self.__log_search_error(site, "subtitles", err)
outcome = self.__create_search_outcome(
start_time,
error_flag,
result,
error=error,
failure_detail=failure_detail,
)
self.__indexer_statistic(
site=site,
error_flag=outcome.error_flag,
seconds=outcome.seconds,
)
report_site_search_outcome(
attempted=True,
outcome=(
_classify_search_failure(outcome.error)
if outcome.error_flag or outcome.error is not None
else "success"
),
error=_search_failure_message(outcome),
)
return self.__parse_subtitle_result(
site=site,
result_array=outcome.result,
seconds=outcome.seconds,
)
async def async_search_torrents(self, site: dict,
keyword: str = None,
mtype: MediaType = None,
cat: Optional[str] = None,
page: Optional[int] = 0) -> List[TorrentInfo]:
"""
异步搜索一个站点
:param site: 站点
:param keyword: 搜索关键词
:param mtype: 媒体类型
:param cat: 分类
:param page: 页码
:return: 资源列表
"""
# 索引结果
result = []
# 开始计时
start_time = datetime.now()
# 错误标志
error_flag = False
request = self.__create_search_request(
site=site,
keyword=keyword,
mtype=mtype,
cat=cat,
page=page,
)
if not request:
report_site_search_outcome(attempted=False, outcome="skipped")
return []
# 开始搜索
error: Optional[Exception] = None
failure_detail: Optional[str] = None
try:
error_flag, result, failure_detail = await self.__async_execute_search(site, request)
except Exception as err:
error = err
self.__log_search_error(site, "torrents", err)
outcome = self.__create_search_outcome(start_time, error_flag, result, error, failure_detail)
# 统计索引情况
await self.__async_indexer_statistic(
site=site,
error_flag=outcome.error_flag,
seconds=outcome.seconds,
)
report_site_search_outcome(
attempted=True,
outcome=(
_classify_search_failure(outcome.error)
if outcome.error_flag or outcome.error is not None
else "success"
),
error=_search_failure_message(outcome),
)
# 返回结果
return self.__parse_result(
site=site,
result_array=outcome.result,
seconds=outcome.seconds,
)
async def async_search_subtitles(self, site: dict,
keyword: str = None,
page: Optional[int] = 0) -> List[SubtitleInfo]:
"""
异步搜索一个站点的字幕资源。
:param site: 站点
:param keyword: 搜索关键词
:param page: 页码
:return: 字幕列表
"""
result = []
start_time = datetime.now()
error_flag = False
error: Optional[Exception] = None
failure_detail: Optional[str] = None
request = self.__create_search_request(
site=site,
keyword=keyword,
page=page,
search_type="subtitles",
)
if not request:
return []
try:
error_flag, result, failure_detail = await self.__async_execute_search(site, request)
except Exception as err:
error = err
self.__log_search_error(site, "subtitles", err)
outcome = self.__create_search_outcome(
start_time,
error_flag,
result,
error=error,
failure_detail=failure_detail,
)
await self.__async_indexer_statistic(
site=site,
error_flag=outcome.error_flag,
seconds=outcome.seconds,
)
report_site_search_outcome(
attempted=True,
outcome=(
_classify_search_failure(outcome.error)
if outcome.error_flag or outcome.error is not None
else "success"
),
error=_search_failure_message(outcome),
)
return self.__parse_subtitle_result(
site=site,
result_array=outcome.result,
seconds=outcome.seconds,
)
@staticmethod
def __spider_search(indexer: dict,
search_word: Optional[str] = None,
mtype: MediaType = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
search_type: Optional[str] = "torrents") -> Tuple[bool, List[dict], Optional[str]]:
"""
根据关键字搜索单个站点
:param: indexer: 站点配置
:param: search_word: 关键字
:param: cat: 分类
:param: page: 页码
:param: mtype: 媒体类型
:param: timeout: 超时时间
:return: 是否发生错误, 种子列表, 失败原因
"""
_spider = SiteSpider(indexer=indexer,
keyword=search_word,
mtype=mtype,
cat=cat,
page=page,
search_type=search_type)
try:
result = _spider.get_torrents()
return _spider.is_error, result, _spider.error_detail
finally:
del _spider
@staticmethod
async def __async_spider_search(indexer: dict,
search_word: Optional[str] = None,
mtype: MediaType = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
search_type: Optional[str] = "torrents") -> Tuple[bool, List[dict], Optional[str]]:
"""
异步根据关键字搜索单个站点
:param: indexer: 站点配置
:param: search_word: 关键字
:param: cat: 分类
:param: page: 页码
:param: mtype: 媒体类型
:param: timeout: 超时时间
:return: 是否发生错误, 种子列表, 失败原因
"""
_spider = SiteSpider(indexer=indexer,
keyword=search_word,
mtype=mtype,
cat=cat,
page=page,
search_type=search_type)
try:
result = await _spider.async_get_torrents()
return _spider.is_error, result, _spider.error_detail
finally:
del _spider
def refresh_torrents(self, site: dict,
keyword: Optional[str] = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
"""
获取站点最新一页的种子,多个站点需要多线程处理
:param site: 站点
:param keyword: 关键字
:param cat: 分类
:param page: 页码
:param mtype: 媒体类型
:reutrn: 种子资源列表
"""
return self.search_torrents(
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
)
async def async_refresh_torrents(self, site: dict,
keyword: Optional[str] = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
"""
异步获取站点最新一页的种子,多个站点需要多线程处理
:param site: 站点
:param keyword: 关键字
:param cat: 分类
:param page: 页码
:param mtype: 媒体类型
:reutrn: 种子资源列表
"""
return await self.async_search_torrents(
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
)
def refresh_userdata(self, site: dict) -> Optional[SiteUserData]:
"""
刷新站点的用户数据
:param site: 站点
:return: 用户数据
"""
def __get_site_obj(schema_value: Optional[str] = None) -> Optional[SiteParserBase]:
"""
获取站点解析器
:param schema_value: 指定 schema, 默认取站点声明的 schema
"""
schema_value = schema_value or site.get("schema")
for site_schema in self._site_schemas:
if site_schema.schema and site_schema.schema.value == schema_value:
site_url = site.get("url")
userdata_path = str(site.get("userdata_path") or "").strip()
if userdata_path and site_url:
if userdata_path.startswith(("http://", "https://")):
site_url = userdata_path
else:
site_url = urljoin(
f"{str(site_url).rstrip('/')}/",
userdata_path.lstrip("/"),
)
return site_schema(
site_name=site.get("name"),
url=site_url,
site_cookie=site.get("cookie"),
apikey=site.get("apikey"),
token=site.get("token"),
ua=site.get("ua") or get_runtime_setting("USER_AGENT"),
proxy=site.get("proxy"),
api_url=site.get("api_url"))
return None
# 按站点声明的 schema 获取解析器
site_obj = __get_site_obj()
if not site_obj:
if not site.get("public"):
logger.warn(f"站点 {site.get('name')} 未找到站点解析器,schema:{site.get('schema')}")
return None
# 获取用户数据
try:
logger.info(f"站点 {site.get('name')} 开始以 {site.get('schema')} 模型解析数据...")
site_obj.parse()
logger.debug(f"站点 {site.get('name')} 数据解析完成")
# 站点声明的 schema 解析失败(userid 为空)时, 自动尝试其他解析器,
# 兼容资源文件 schema 标注错误/变种站点的场景
if not site_obj.userid and not site.get("public"):
tried = {site.get("schema")}
for site_schema in self._site_schemas:
if (
not site_schema.schema
or site_schema.schema.value in tried
or site_schema.schema.value not in _COMMON_SITE_USERDATA_FALLBACK_SCHEMAS
):
continue
tried.add(site_schema.schema.value)
logger.info(f"站点 {site.get('name')} schema {site.get('schema')} 解析失败, "
f"尝试 {site_schema.schema.value} 模型...")
alt_obj = __get_site_obj(site_schema.schema.value)
if not alt_obj:
continue
try:
alt_obj.parse()
except Exception as e:
logger.error(f"站点 {site.get('name')} 以 {site_schema.schema.value} 解析失败: {str(e)}")
continue
if alt_obj.userid:
site_obj = alt_obj
logger.info(f"站点 {site.get('name')} 改用 {site_schema.schema.value} 模型解析成功")
break
if not site_obj.userid and not site.get("public"):
site_obj.err_msg = site_obj.err_msg or "未获取到站点用户信息,请检查 Cookie 是否有效"
return SiteUserData(
domain=site_rules.extract_domain(site.get("url")),
userid=site_obj.userid,
username=site_obj.username,
user_level=site_obj.user_level,
join_at=site_obj.join_at,
upload=site_obj.upload,
download=site_obj.download,
ratio=site_obj.ratio,
bonus=site_obj.bonus,
seeding=site_obj.seeding,
seeding_size=site_obj.seeding_size,
seeding_info=site_obj.seeding_info.copy() if site_obj.seeding_info else [],
leeching=site_obj.leeching,
leeching_size=site_obj.leeching_size,
message_unread=site_obj.message_unread,
message_unread_contents=site_obj.message_unread_contents.copy() if site_obj.message_unread_contents else [],
updated_day=datetime.now().strftime('%Y-%m-%d'),
err_msg=site_obj.err_msg
)
finally:
site_obj.clear()