From 5484223d1ec71d57b90f715944d664d12850814b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 26 Aug 2026 22:31:45 +0800 Subject: [PATCH] fix(indexer): adapt Rousi Pro PeerGo personal API key --- app/chain/site.py | 13 +- app/modules/indexer/parser/rousi.py | 20 ++- app/modules/indexer/spider/rousi.py | 59 ++++---- tests/test_rousi_spider.py | 216 +++++++++++++++++++++++++++- 4 files changed, 267 insertions(+), 41 deletions(-) diff --git a/app/chain/site.py b/app/chain/site.py index cfd010ca6..2102f4009 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -343,8 +343,10 @@ class SiteChain(InteractionChainMixin, ChainBase): def __rousi_test(self, site: Site) -> Tuple[bool, str]: """ - 判断站点是否已经登陆:rousi + 使用 PeerGo 个人 API Key 验证 Rousi.pro 站点连接。 """ + if not site.apikey: + return False, "未配置个人 API Key" url = f"https://{site_rules.extract_domain(site.url)}/api/v1/profile" headers = { "Content-Type": "application/json", @@ -359,10 +361,15 @@ class SiteChain(InteractionChainMixin, ChainBase): if res is None: return False, "无法打开网站!" if res.status_code == 200: - user_info = res.json() + try: + user_info = res.json() + except (TypeError, ValueError): + return False, "站点返回了无效的用户数据" if user_info and user_info.get("code") == 0: return True, "连接成功" - return False, "APIKEY已过期" + return False, "个人 API Key 已失效或权限不足" + elif res.status_code in (401, 403): + return False, "个人 API Key 已失效或权限不足" else: return False, f"错误:{res.status_code} {res.reason}" diff --git a/app/modules/indexer/parser/rousi.py b/app/modules/indexer/parser/rousi.py index 4cd4daa6f..b27ffd14d 100644 --- a/app/modules/indexer/parser/rousi.py +++ b/app/modules/indexer/parser/rousi.py @@ -14,8 +14,9 @@ from app.modules.indexer.parser import SiteParserBase, SiteSchema class RousiSiteUserInfo(SiteParserBase): """ - Rousi.pro 站点解析器 - 使用 API v1 接口,通过 Passkey (Bearer Token) 进行认证 + Rousi.pro PeerGo 用户数据解析器。 + + 使用具有 profile:read 权限的个人 API Key 访问兼容资料接口。 """ schema = SiteSchema.RousiPro request_mode = "apikey" @@ -23,10 +24,10 @@ class RousiSiteUserInfo(SiteParserBase): def _parse_site_page(self, html_text: str): """ 配置 API 请求地址和请求头 - 使用 API v1 的 /profile 接口获取用户信息 + 使用 PeerGo MoviePilot 兼容的 /profile 接口获取用户信息。 """ self._base_url = f"https://{site_rules.extract_domain(self._site_url)}" - self._user_basic_page = "api/v1/profile?include_fields[user]=seeding_leeching_data" + self._user_basic_page = "api/v1/profile" self._user_basic_params = {} self._user_basic_headers = { "Content-Type": "application/json", @@ -84,13 +85,18 @@ class RousiSiteUserInfo(SiteParserBase): logger.error(f"{self._site_name} JSON 解析失败") return - if not data or data.get("code") != 0: + if not isinstance(data, dict): + self.err_msg = "用户数据响应结构无效" + logger.warning(f"{self._site_name} API 响应结构无效") + return + + if data.get("code") != 0: self.err_msg = data.get("message", "未知错误") - logger.warn(f"{self._site_name} API 错误: {self.err_msg}") + logger.warning(f"{self._site_name} API 错误: {self.err_msg}") return user_info = data.get("data") - if not user_info: + if not isinstance(user_info, dict): return # 基本信息 diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 53fce5ae5..22aaedfbf 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -2,30 +2,28 @@ import base64 import json from typing import List, Optional, Tuple -from app.runtime.settings import get_runtime_setting - +from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.application.configuration import get_configured_system_config -from app.runtime.log import logger -from app.schemas.types import MediaType -from app.adapters.network.http import RequestUtils, AsyncRequestUtils from app.domain import site as site_rules from app.foundation import temporal as time_tools +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.schemas.types import MediaType class RousiSpider: """ - Rousi.pro API v1 Spider + Rousi.pro PeerGo 兼容 API 索引器。 - 使用 API v1 接口进行种子搜索 - - 认证方式:Bearer Token (Passkey) - - 搜索接口:/api/v1/torrents - - 详情接口:/api/v1/torrents/:id + 使用个人 API Key 访问搜索兼容接口,并通过详情接口换取短时下载地址。 + API Key 需要授予 torrent:read 和 torrent:download 权限。 """ _indexerid = None _domain = None _url = None _name = "" _proxy = None + _use_proxy = False _cookie = None _ua = None _size = 100 @@ -39,7 +37,7 @@ class RousiSpider: _tv_category = 'tv' _music_category = 'music' - # API KEY + # PeerGo 个人 API Key _apikey = None @classmethod @@ -60,6 +58,7 @@ class RousiSpider: self._name = indexer.get('name') if indexer.get('proxy'): self._proxy = get_runtime_setting('PROXY') + self._use_proxy = bool(indexer.get('proxy')) self._cookie = indexer.get('cookie') self._ua = indexer.get('ua') self._apikey = indexer.get('apikey') @@ -134,23 +133,25 @@ class RousiSpider: :param res: 请求响应对象 :return: (是否发生错误, 种子列表) """ - if res and res.status_code == 200: + if res is not None and res.status_code == 200: try: data = res.json() - if data.get('code') == 0: - results = data.get('data', {}).get('torrents', []) - return False, self.__parse_result(results) - else: - logger.warn(f"{self._name} 搜索失败,错误信息:{data.get('message')}") - return True, [] - except Exception as e: - logger.warn(f"{self._name} 解析响应失败:{e}") + except (TypeError, ValueError) as err: + logger.warning(f"{self._name} 解析搜索响应失败:{str(err)}") return True, [] + if not isinstance(data, dict): + logger.warning(f"{self._name} 搜索响应结构无效") + return True, [] + if data.get('code') == 0: + results = data.get('data', {}).get('torrents', []) + return False, self.__parse_result(results) + logger.warning(f"{self._name} 搜索失败,错误信息:{data.get('message')}") + return True, [] elif res is not None: - logger.warn(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") + logger.warning(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") return True, [] else: - logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") + logger.warning(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] def __parse_result(self, results: List[dict]) -> List[dict]: @@ -203,10 +204,11 @@ class RousiSpider: if promotion.get('until'): freedate = time_tools.normalize_datetime(promotion.get('until')) + torrent_id = result.get('id') torrent = { 'title': result.get('title'), 'description': result.get('subtitle'), - 'enclosure': self.__get_download_url(result.get('id')), + 'enclosure': self.__get_download_url(torrent_id), 'pubdate': time_tools.normalize_datetime(result.get('created_at')), 'size': int(result.get('size') or 0), 'seeders': int(result.get('seeders') or 0), @@ -215,7 +217,7 @@ class RousiSpider: 'downloadvolumefactor': downloadvolumefactor, 'uploadvolumefactor': uploadvolumefactor, 'freedate': freedate, - 'page_url': f"https://{self._domain}/torrent/{result.get('uuid')}", + 'page_url': f"https://{self._domain}/torrents/{torrent_id}", 'labels': [], 'category': category } @@ -233,7 +235,7 @@ class RousiSpider: :return: (是否发生错误, 种子列表) """ if not self._apikey: - logger.warn(f"{self._name} 未配置 API Key (Passkey)") + logger.warning(f"{self._name} 未配置个人 API Key") return True, [] params = self.__get_params(keyword, mtype, cat, page) @@ -261,7 +263,7 @@ class RousiSpider: :return: (是否发生错误, 种子列表) """ if not self._apikey: - logger.warn(f"{self._name} 未配置 API Key (Passkey)") + logger.warning(f"{self._name} 未配置个人 API Key") return True, [] params = self.__get_params(keyword, mtype, cat, page) @@ -282,8 +284,7 @@ class RousiSpider: """ 构建种子下载链接 - 使用 base64 编码的方式告诉 MoviePilot 如何获取真实下载地址 - MoviePilot 会先请求详情接口,然后从响应中提取 data.download_url + MoviePilot 会携带个人 API Key 请求详情接口,再提取带 capability 的短时下载地址。 :param torrent_id: 种子 ID :return: base64 编码的请求配置字符串 + 详情接口 URL @@ -294,10 +295,12 @@ class RousiSpider: # 2. 从 JSON 响应中提取 result 指定的字段值作为真实下载地址 params = { 'method': 'get', + 'cookie': False, 'header': { 'Authorization': f'Bearer {self._apikey}', 'Accept': 'application/json' }, + 'proxy': self._use_proxy, 'result': 'data.download_url' } base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8') diff --git a/tests/test_rousi_spider.py b/tests/test_rousi_spider.py index 3d749b933..37b462e77 100644 --- a/tests/test_rousi_spider.py +++ b/tests/test_rousi_spider.py @@ -1,22 +1,42 @@ # -*- coding: utf-8 -*- # pylint: disable=no-name-in-module +import base64 +import json +from types import SimpleNamespace + import pytest +from app.chain.site import SiteChain +from app.modules.indexer.parser.rousi import RousiSiteUserInfo from app.modules.indexer.spider import rousi as rousi_module from app.modules.indexer.spider.rousi import RousiSpider from app.schemas import MediaType -def _build_indexer() -> dict: +class _FakeResponse: + """构造 PeerGo 兼容 API 测试使用的最小响应对象。""" + + def __init__(self, payload: dict, status_code: int = 200): + """保存响应数据和状态码。""" + self._payload = payload + self.status_code = status_code + self.reason = "OK" + + def json(self) -> dict: + """返回预设 JSON 数据。""" + return self._payload + + +def _build_indexer(apikey: str = "rousi-secret", proxy: bool = False) -> dict: """构造 Rousi Pro API Spider 所需的最小站点配置。""" return { "id": "rousipro", "name": "Rousi Pro", "domain": "https://rousi.pro/", - "apikey": "rousi-secret", + "apikey": apikey, "ua": "MoviePilot-Test", - "proxy": False, + "proxy": proxy, } @@ -54,3 +74,193 @@ def test_parse_result_marks_music_torrents(rousi_spider): MediaType.MOVIE.value, MediaType.TV.value, ] + + +def test_search_uses_peergo_personal_api_key_contract(monkeypatch): + """搜索应使用个人 API Key 兼容响应并生成短时下载地址换取请求。""" + captured = {} + payload = { + "code": 0, + "message": "success", + "data": { + "page": 1, + "page_size": 100, + "total": 1, + "total_pages": 1, + "torrents": [{ + "category": "movie", + "category_name": "电影", + "created_at": "2026-06-09T12:04:15.184279Z", + "downloads": 21, + "id": 8461, + "leechers": 6, + "promotion": { + "down_multiplier": 0, + "is_active": True, + "until": "2026-09-21T16:36:31.008958Z", + "up_multiplier": 2, + }, + "seeders": 31, + "size": 190438784128, + "subtitle": "电影中文副标题", + "title": "Movie.2026.2160p.UHD.BluRay", + "uuid": "8461", + }], + }, + } + + def fake_get_res(request, url: str, params: dict = None, **_kwargs): + """记录搜索请求并回放 PeerGo MoviePilot 兼容响应。""" + captured.update({"url": url, "params": params, "headers": request._headers}) + return _FakeResponse(payload) + + monkeypatch.setattr(rousi_module, "get_configured_system_config", lambda: None) + monkeypatch.setattr(rousi_module, "get_runtime_setting", lambda _key: {"https": "proxy"}) + monkeypatch.setattr(rousi_module.RequestUtils, "get_res", fake_get_res) + + error, torrents = RousiSpider(_build_indexer(proxy=True)).search( + keyword="Movie", + mtype=MediaType.MOVIE, + page=0, + ) + + assert not error + assert captured == { + "url": "https://rousi.pro/api/v1/torrents", + "params": { + "page": 1, + "page_size": 100, + "keyword": "Movie", + "category": "movie", + }, + "headers": { + "Authorization": "Bearer rousi-secret", + "Accept": "application/json", + }, + } + assert len(torrents) == 1 + assert torrents[0]["title"] == "Movie.2026.2160p.UHD.BluRay" + assert torrents[0]["page_url"] == "https://rousi.pro/torrents/8461" + assert torrents[0]["downloadvolumefactor"] == 0 + assert torrents[0]["uploadvolumefactor"] == 2 + assert torrents[0]["category"] == MediaType.MOVIE.value + + encoded_config, detail_url = torrents[0]["enclosure"].split("]", 1) + request_config = json.loads(base64.b64decode(encoded_config[1:]).decode("utf-8")) + assert detail_url == "https://rousi.pro/api/v1/torrents/8461" + assert request_config == { + "method": "get", + "cookie": False, + "header": { + "Authorization": "Bearer rousi-secret", + "Accept": "application/json", + }, + "proxy": True, + "result": "data.download_url", + } + + +def test_search_rejects_missing_personal_api_key(monkeypatch): + """未配置个人 API Key 时不得向 PeerGo 发起搜索请求。""" + monkeypatch.setattr(rousi_module, "get_configured_system_config", lambda: None) + spider = RousiSpider(_build_indexer(apikey="")) + + error, torrents = spider.search(keyword="Movie") + + assert error + assert torrents == [] + + +def test_user_parser_reads_peergo_profile_with_personal_api_key(monkeypatch): + """用户数据解析应通过个人 API Key 读取 PeerGo 兼容资料结构。""" + captured = {} + profile = { + "code": 0, + "message": "success", + "data": { + "id": 619, + "username": "jxxghp", + "level_text": "Lv.1", + "registered_at": "2026-01-05T19:50:59.012Z", + "uploaded": 1099511627776, + "downloaded": 536870912000, + "ratio": 2.048, + "karma": 1079960, + "seeding_leeching_data": { + "seeding_count": 8, + "seeding_size": 2147483648000, + "leeching_count": 1, + "leeching_size": 10737418240, + }, + }, + } + + def fake_get_page_content(_self, url: str, headers: dict = None, **_kwargs): + """记录用户资料请求并回放 PeerGo 兼容响应。""" + captured.update({"url": url, "headers": headers}) + return json.dumps(profile) + + monkeypatch.setattr(RousiSiteUserInfo, "_get_page_content", fake_get_page_content) + parser = RousiSiteUserInfo( + site_name="Rousi Pro", + url="https://rousi.pro/", + site_cookie="", + apikey="rousi-secret", + token=None, + ua="MoviePilot-Test", + ) + + parser.parse() + + assert captured["url"] == "https://rousi.pro/api/v1/profile" + assert captured["headers"]["Authorization"] == "Bearer rousi-secret" + assert parser.userid == 619 + assert parser.username == "jxxghp" + assert parser.user_level == "Lv.1" + assert parser.join_at == "2026-01-05 19:50:59" + assert parser.upload == 1099511627776 + assert parser.download == 536870912000 + assert parser.ratio == 2.05 + assert parser.bonus == 1079960 + assert parser.seeding == 8 + assert parser.seeding_size == 2147483648000 + assert parser.leeching == 1 + assert parser.leeching_size == 10737418240 + + +def test_site_connectivity_uses_peergo_personal_api_key(monkeypatch): + """Rousi 连接测试应以 Bearer 个人 API Key 请求兼容资料接口。""" + captured = {} + + def fake_get_res(request, url: str, **_kwargs): + """记录连接测试请求并返回有效用户资料。""" + captured.update({"url": url, "headers": request._headers}) + return _FakeResponse({"code": 0, "message": "success", "data": {"id": 619}}) + + monkeypatch.setattr("app.chain.site.RequestUtils.get_res", fake_get_res) + site = SimpleNamespace( + url="https://rousi.pro/", + apikey="rousi-secret", + proxy=0, + timeout=15, + ) + chain = object.__new__(SiteChain) + chain.runtime_config = SimpleNamespace(proxy=None) + + state, message = chain._SiteChain__rousi_test(site) + + assert state + assert message == "连接成功" + assert captured["url"] == "https://rousi.pro/api/v1/profile" + assert captured["headers"]["Authorization"] == "Bearer rousi-secret" + + +def test_site_connectivity_rejects_missing_personal_api_key(): + """Rousi 连接测试应在请求前报告个人 API Key 缺失。""" + site = SimpleNamespace(url="https://rousi.pro/", apikey="", proxy=0, timeout=15) + chain = object.__new__(SiteChain) + + state, message = chain._SiteChain__rousi_test(site) + + assert not state + assert message == "未配置个人 API Key"