mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 05:17:23 +08:00
* fix(download): preserve protocol failures and isolate site updates * test: cover tracker token lifecycle and record dependency facts --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
224 lines
8.2 KiB
Python
224 lines
8.2 KiB
Python
"""
|
||
通过 TNode 接口搜索种子并转换为通用索引结果。
|
||
"""
|
||
|
||
import re
|
||
from typing import Any, List, Optional, Tuple
|
||
|
||
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
|
||
from app.foundation import temporal as time_tools
|
||
from app.foundation.crypto import HashUtils
|
||
from app.runtime.cache import cached
|
||
from app.runtime.log import logger
|
||
from app.runtime.settings import get_runtime_setting
|
||
|
||
|
||
class TNodeSpider:
|
||
"""
|
||
TNode 页面令牌与种子搜索 API 适配器。
|
||
"""
|
||
|
||
_size = 100
|
||
_timeout = 15
|
||
_proxy = None
|
||
_baseurl = "%sapi/torrent/advancedSearch"
|
||
_downloadurl = "%sapi/torrent/download/%s"
|
||
_pageurl = "%storrent/info/%s"
|
||
|
||
@classmethod
|
||
def get_search_page_size(cls, keyword: Optional[str] = None) -> Optional[int]:
|
||
"""
|
||
获取搜索接口单页容量。
|
||
|
||
:param keyword: 保留统一入口参数,不影响本站的分页容量
|
||
:return: 本站搜索接口的固定单页记录数
|
||
"""
|
||
return cls._size
|
||
|
||
def __init__(self, indexer: dict[str, Any]):
|
||
"""
|
||
使用站点配置初始化 TNode 请求上下文。
|
||
"""
|
||
if indexer:
|
||
self._indexerid = indexer.get('id')
|
||
self._domain = indexer.get('domain')
|
||
self._searchurl = self._baseurl % self._domain
|
||
self._name = indexer.get('name')
|
||
if indexer.get('proxy'):
|
||
self._proxy = get_runtime_setting('PROXY')
|
||
self._cookie = indexer.get('cookie')
|
||
self._token_key = HashUtils.md5(repr((self._domain, self._cookie, indexer.get('ua'))))
|
||
self._ua = indexer.get('ua')
|
||
self._timeout = indexer.get('timeout') or 15
|
||
|
||
def __get_token(self) -> Optional[str]:
|
||
"""
|
||
以当前站点和凭据隔离的缓存键获取同步CSRF令牌。
|
||
"""
|
||
return self.__cached_token(self._token_key)
|
||
|
||
async def __async_get_token(self) -> Optional[str]:
|
||
"""
|
||
以当前站点和凭据隔离的缓存键获取异步CSRF令牌。
|
||
"""
|
||
return await self.__async_cached_token(self._token_key)
|
||
|
||
@cached(region="indexer_spider", maxsize=64, ttl=60 * 60 * 24, skip_empty=True, shared_key="get_token")
|
||
def __cached_token(self, _identity: str) -> Optional[str]:
|
||
"""
|
||
同步请求站点首页并提取 CSRF 令牌。
|
||
|
||
:param _identity: 站点和凭据摘要,用于隔离共享令牌缓存
|
||
"""
|
||
if not self._domain:
|
||
return None
|
||
res = RequestUtils(ua=self._ua,
|
||
cookies=self._cookie,
|
||
proxies=self._proxy,
|
||
timeout=self._timeout).get_res(url=self._domain)
|
||
return self.__parse_token_response(res)
|
||
|
||
@cached(region="indexer_spider", maxsize=64, ttl=60 * 60 * 24, skip_empty=True, shared_key="get_token")
|
||
async def __async_cached_token(self, _identity: str) -> Optional[str]:
|
||
"""
|
||
异步请求站点首页并提取 CSRF 令牌。
|
||
|
||
:param _identity: 站点和凭据摘要,用于隔离共享令牌缓存
|
||
"""
|
||
if not self._domain:
|
||
return None
|
||
res = await AsyncRequestUtils(ua=self._ua,
|
||
cookies=self._cookie,
|
||
proxies=self._proxy,
|
||
timeout=self._timeout).get_res(url=self._domain)
|
||
return self.__parse_token_response(res)
|
||
|
||
@staticmethod
|
||
def __parse_token_response(res: Any) -> Optional[str]:
|
||
"""
|
||
从同步或异步首页响应中提取有效 CSRF 令牌。
|
||
"""
|
||
if not res or res.status_code != 200:
|
||
return None
|
||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||
return csrf_token.group(1) if csrf_token else None
|
||
|
||
def __get_params(self, keyword: Optional[str] = None, page: Optional[int] = 0) -> dict[str, object]:
|
||
"""
|
||
获取搜索参数。
|
||
"""
|
||
search_type = "imdbid" if (keyword and keyword.startswith('tt')) else "title"
|
||
return {
|
||
"page": int(page or 0) + 1,
|
||
"size": self._size,
|
||
"type": search_type,
|
||
"keyword": keyword or "",
|
||
"sorter": "id",
|
||
"order": "desc",
|
||
"tags": [],
|
||
"category": [501, 502, 503, 504],
|
||
"medium": [],
|
||
"videoCoding": [],
|
||
"audioCoding": [],
|
||
"resolution": [],
|
||
"group": []
|
||
}
|
||
|
||
def __parse_result(self, results: List[dict[str, Any]]) -> List[dict[str, Any]]:
|
||
"""
|
||
解析搜索结果。
|
||
"""
|
||
torrents: list[dict[str, object]] = []
|
||
if not results:
|
||
return torrents
|
||
|
||
for result in results:
|
||
torrent = {
|
||
'title': result.get('title'),
|
||
'description': result.get('subtitle'),
|
||
'enclosure': self._downloadurl % (self._domain, result.get('id')),
|
||
'pubdate': time_tools.format_timestamp(result.get('upload_time')),
|
||
'size': result.get('size'),
|
||
'seeders': result.get('seeding'),
|
||
'peers': result.get('leeching'),
|
||
'grabs': result.get('complete'),
|
||
'downloadvolumefactor': result.get('downloadRate'),
|
||
'uploadvolumefactor': result.get('uploadRate'),
|
||
'page_url': self._pageurl % (self._domain, result.get('id')),
|
||
'imdbid': result.get('imdb')
|
||
}
|
||
torrents.append(torrent)
|
||
|
||
return torrents
|
||
|
||
def __process_response(self, res: Any) -> Tuple[bool, List[dict[str, Any]]]:
|
||
"""
|
||
统一判定搜索响应状态并投影 TNode 种子结果。
|
||
"""
|
||
if res and res.status_code == 200:
|
||
results = res.json().get('data', {}).get("torrents") or []
|
||
return False, self.__parse_result(results)
|
||
if res is not None:
|
||
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
|
||
return True, []
|
||
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
|
||
return True, []
|
||
|
||
def search(
|
||
self, keyword: str, page: Optional[int] = 0
|
||
) -> Tuple[bool, List[dict[str, Any]]]:
|
||
"""
|
||
同步搜索 TNode 种子。
|
||
|
||
:param keyword: 搜索关键词;为空时按站点接口规则浏览资源
|
||
:param page: 从 0 开始的搜索页码
|
||
:return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源
|
||
"""
|
||
_token = self.__get_token()
|
||
if not _token:
|
||
logger.warn(f"{self._name} 未获取到token,无法搜索")
|
||
return True, []
|
||
|
||
params = self.__get_params(keyword, page)
|
||
|
||
res = RequestUtils(
|
||
headers={
|
||
'X-CSRF-TOKEN': _token,
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
"User-Agent": f"{self._ua}"
|
||
},
|
||
cookies=self._cookie,
|
||
proxies=self._proxy,
|
||
timeout=self._timeout
|
||
).post_res(url=self._searchurl, json=params)
|
||
return self.__process_response(res)
|
||
|
||
async def async_search(
|
||
self, keyword: str, page: Optional[int] = 0
|
||
) -> Tuple[bool, List[dict[str, Any]]]:
|
||
"""
|
||
异步搜索 TNode 种子。
|
||
|
||
:param keyword: 搜索关键词;为空时按站点接口规则浏览资源
|
||
:param page: 从 0 开始的搜索页码
|
||
:return: 是否发生错误及标准化资源列表;空列表也可能表示没有匹配资源
|
||
"""
|
||
_token = await self.__async_get_token()
|
||
if not _token:
|
||
logger.warn(f"{self._name} 未获取到token,无法搜索")
|
||
return True, []
|
||
|
||
params = self.__get_params(keyword, page)
|
||
|
||
res = await AsyncRequestUtils(
|
||
headers={
|
||
'x-csrf-token': _token,
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
"User-Agent": f"{self._ua}"
|
||
},
|
||
cookies=self._cookie,
|
||
proxies=self._proxy,
|
||
timeout=self._timeout
|
||
).post_res(url=self._searchurl, json=params)
|
||
return self.__process_response(res)
|