Files
MoviePilot/app/modules/indexer/spider/yema.py
T

336 lines
12 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.
import base64
import json
from typing import List, Optional, Tuple
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.runtime.log import logger
from app.schemas.types import MediaType
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.foundation import temporal as time_tools
class YemaSpider:
"""
YemaPT 开放 API 索引器
"""
# YemaPT 开放 API 使用更大的分页容量时会返回空结果。
_size = 40
_movie_category = [4]
_tv_category = [5, 6, 13, 14, 15, 17]
_music_category = [8, 16]
_labels = {
"1": "禁转",
"2": "首发",
"3": "官方",
"4": "自制",
"5": "国语",
"6": "中字",
"7": "粤语",
"8": "英字",
"9": "HDR10",
"10": "杜比视界",
"11": "分集",
"12": "完结",
}
def __init__(self, indexer: dict):
"""
初始化 YemaPT 开放 API 索引器
:param indexer: 合并站点认证信息后的索引配置
"""
indexer = indexer or {}
self._name = indexer.get("name") or "YemaPT"
self._site_url = str(indexer.get("domain") or "https://www.yemapt.org/").rstrip("/")
self._proxy = settings.PROXY if indexer.get("proxy") else None
self._use_proxy = bool(indexer.get("proxy"))
self._user_agent = indexer.get("ua") or settings.USER_AGENT
self._api_key = indexer.get("apikey")
self._timeout = indexer.get("timeout") or 15
self._search_url = f"{self._site_url}/openApi/torrent/fetchOpenTorrentList.json"
self._download_key_url = f"{self._site_url}/openApi/torrent/generateDownloadKey.json"
@classmethod
def get_search_page_size(cls, keyword: Optional[str] = None) -> Optional[int]:
"""
获取搜索接口单页容量
:param keyword: 搜索关键字,YemaPT 不按关键字改变分页容量
:return: 搜索接口单页容量
"""
return cls._size
def _request_headers(self) -> dict:
"""
构造开放 API 请求头
:return: 不包含 Cookie 的 AuthKey 请求头
"""
return {
"Authorization": self._api_key,
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"User-Agent": self._user_agent,
}
def _build_params(
self,
keyword: Optional[str],
page: Optional[int],
category_id: Optional[int] = None,
) -> dict:
"""
构造公开种子列表查询参数
:param keyword: 搜索关键字
:param page: MoviePilot 从 0 开始的页码
:param category_id: 可选的站点分类 ID
:return: YemaPT 开放 API 请求体
"""
params = {
"pageParam": {
"current": int(page or 0) + 1,
"pageSize": self._size,
},
"sorter": {},
}
if keyword:
params["keyword"] = keyword
if category_id is not None:
params["categoryId"] = category_id
return params
def _search_category_ids(self, mtype: MediaType = None) -> List[Optional[int]]:
"""
返回当前媒体类型需要分别查询的站点分类。
YemaPT 分类参数不支持数组,音乐需要分别查询音频音乐和 MV/演唱会分类。
"""
if mtype == MediaType.MOVIE:
return list(self._movie_category)
if mtype == MediaType.TV:
return list(self._tv_category)
if mtype == MediaType.MUSIC:
return list(self._music_category)
return [None]
@staticmethod
def _merge_search_results(result_groups: List[List[dict]]) -> List[dict]:
"""按种子详情链接合并多个分类查询结果并保持首次出现顺序。"""
merged = []
seen = set()
for results in result_groups:
for torrent in results:
identity = torrent.get("page_url") or torrent.get("enclosure")
if identity and identity in seen:
continue
if identity:
seen.add(identity)
merged.append(torrent)
return merged
def _parse_result(self, results: List[dict]) -> List[dict]:
"""
将开放 API 种子数据转换为 MoviePilot 标准字段
:param results: 公开种子列表接口 data 数组
:return: MoviePilot 标准种子字典列表
"""
torrents = []
for result in results or []:
if not isinstance(result, dict):
continue
category_value = result.get("categoryId")
if category_value in self._tv_category:
category = MediaType.TV.value
elif category_value in self._movie_category:
category = MediaType.MOVIE.value
elif category_value in self._music_category:
category = MediaType.MUSIC.value
else:
category = MediaType.UNKNOWN.value
labels = [
self._labels[label_id]
for label_id in result.get("tagList") or []
if label_id in self._labels
]
torrent_id = result.get("id")
torrents.append({
"title": result.get("showName"),
"description": result.get("shortDesc"),
"enclosure": self._build_download_url(torrent_id),
"pubdate": time_tools.normalize_datetime(result.get("listingTime")),
"size": result.get("fileSize"),
"seeders": result.get("seedNum"),
"peers": result.get("leechNum"),
"grabs": result.get("completedNum"),
"downloadvolumefactor": self._download_factor(result.get("downloadPromotion")),
"uploadvolumefactor": self._upload_factor(result.get("uploadPromotion")),
"freedate": time_tools.normalize_datetime(result.get("downloadPromotionEndTime")),
"page_url": f"{self._site_url}/#/torrent/detail/{torrent_id}/",
"labels": labels,
"hit_and_run": bool(result.get("hrPunishEnable")),
"category": category,
})
return torrents
def _process_search_response(self, response) -> Tuple[bool, List[dict]]:
"""
校验开放 API 通用响应并解析搜索结果
:param response: RequestUtils 返回的响应对象
:return: 是否失败及标准种子列表
"""
if response is None:
logger.warning(f"{self._name} 搜索失败,无法连接开放 API")
return True, []
if response.status_code != 200:
logger.warning(f"{self._name} 搜索失败,HTTP 错误码:{response.status_code}")
return True, []
try:
payload = response.json() or {}
except (TypeError, ValueError) as err:
logger.warning(f"{self._name} 搜索响应不是有效 JSON{str(err)}")
return True, []
if not isinstance(payload, dict):
logger.warning(f"{self._name} 搜索响应结构无效")
return True, []
if not payload.get("success"):
logger.warning(f"{self._name} 搜索失败:{payload.get('errorMessage') or '未知错误'}")
return True, []
results = payload.get("data")
if not isinstance(results, list):
logger.warning(f"{self._name} 搜索响应 data 不是数组")
return True, []
return False, self._parse_result(results)
def search(
self,
keyword: Optional[str],
mtype: MediaType = None,
page: Optional[int] = 0,
) -> Tuple[bool, List[dict]]:
"""
同步搜索 YemaPT 公开种子
:param keyword: 搜索关键字
:param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询
:param page: MoviePilot 从 0 开始的页码
:return: 是否失败及标准种子列表
"""
if not self._api_key:
logger.warning(f"{self._name} 未配置 API AuthKey")
return True, []
request = RequestUtils(
headers=self._request_headers(),
proxies=self._proxy,
timeout=self._timeout,
)
result_groups = []
errors = []
for category_id in self._search_category_ids(mtype):
response = request.post_res(
url=self._search_url,
json=self._build_params(keyword, page, category_id),
)
error, results = self._process_search_response(response)
errors.append(error)
if not error:
result_groups.append(results)
return all(errors), self._merge_search_results(result_groups)
async def async_search(
self,
keyword: Optional[str],
mtype: MediaType = None,
page: Optional[int] = 0,
) -> Tuple[bool, List[dict]]:
"""
异步搜索 YemaPT 公开种子
:param keyword: 搜索关键字
:param mtype: MoviePilot 媒体类型,指定后按真实站点分类分别查询
:param page: MoviePilot 从 0 开始的页码
:return: 是否失败及标准种子列表
"""
if not self._api_key:
logger.warning(f"{self._name} 未配置 API AuthKey")
return True, []
request = AsyncRequestUtils(
headers=self._request_headers(),
proxies=self._proxy,
timeout=self._timeout,
)
result_groups = []
errors = []
for category_id in self._search_category_ids(mtype):
response = await request.post_res(
url=self._search_url,
json=self._build_params(keyword, page, category_id),
)
error, results = self._process_search_response(response)
errors.append(error)
if not error:
result_groups.append(results)
return all(errors), self._merge_search_results(result_groups)
@staticmethod
def _download_factor(promotion: str) -> float:
"""
转换下载促销类型
:param promotion: 开放 API 下载促销枚举
:return: MoviePilot 下载系数
"""
return {
"free": 0,
"half": 0.5,
"none": 1,
}.get(promotion, 1)
@staticmethod
def _upload_factor(promotion: str) -> float:
"""
转换上传促销类型
:param promotion: 开放 API 上传促销枚举
:return: MoviePilot 上传系数
"""
return {
"none": 1,
"one_half": 1.5,
"double_upload": 2,
}.get(promotion, 1)
def _build_download_url(self, torrent_id: int) -> str:
"""
构造先生成下载凭证再获取种子文件的两段式链接
:param torrent_id: YemaPT 种子 ID
:return: Base64 请求配置与下载凭证接口 URL
"""
request_config = {
"method": "post",
"cookie": False,
"header": {
"Authorization": self._api_key,
"Accept": "application/json",
},
"params": {"id": torrent_id},
"proxy": self._use_proxy,
"success": "success",
"result": "data",
"result_base_url": self._site_url,
"result_path": "api/torrent/download1",
"result_query_param": "token",
}
encoded_config = base64.b64encode(
json.dumps(request_config).encode("utf-8")
).decode("ascii")
return f"[{encoded_config}]{self._download_key_url}"