From cb3cef70e5d459a2e9829f8110c1b6845782bdf5 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:08:24 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20RousiPro=20?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/site.py | 27 +++ app/modules/indexer/__init__.py | 13 ++ app/modules/indexer/parser/__init__.py | 1 + app/modules/indexer/parser/rousi.py | 164 ++++++++++++++++ app/modules/indexer/spider/rousi.py | 254 +++++++++++++++++++++++++ 5 files changed, 459 insertions(+) create mode 100644 app/modules/indexer/parser/rousi.py create mode 100644 app/modules/indexer/spider/rousi.py diff --git a/app/chain/site.py b/app/chain/site.py index 7ebc09ae..8dec2031 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -44,6 +44,7 @@ class SiteChain(ChainBase): "star-space.net": self.__indexphp_test, "yemapt.org": self.__yema_test, "hddolby.com": self.__hddolby_test, + "rousi.pro": self.__rousi_test, } def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]: @@ -249,6 +250,32 @@ class SiteChain(ChainBase): else: return False, f"错误:{res.status_code} {res.reason}" + @staticmethod + def __rousi_test(site: Site) -> Tuple[bool, str]: + """ + 判断站点是否已经登陆:rousi + """ + url = f"https://{StringUtils.get_url_domain(site.url)}/api/v1/profile" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {site.apikey}", + } + res = RequestUtils( + headers=headers, + proxies=settings.PROXY if site.proxy else None, + timeout=site.timeout or 15 + ).get_res(url=url) + if res is None: + return False, "无法打开网站!" + if res.status_code == 200: + user_info = res.json() + if user_info and user_info.get("code") == 0: + return True, "连接成功" + return False, "APIKEY已过期" + else: + return False, f"错误:{res.status_code} {res.reason}" + @staticmethod def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]: """ diff --git a/app/modules/indexer/__init__.py b/app/modules/indexer/__init__.py index e1472946..ba1e5931 100644 --- a/app/modules/indexer/__init__.py +++ b/app/modules/indexer/__init__.py @@ -12,6 +12,7 @@ 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.mtorrent import MTorrentSpider +from app.modules.indexer.spider.rousi import RousiSpider from app.modules.indexer.spider.tnode import TNodeSpider from app.modules.indexer.spider.torrentleech import TorrentLeech from app.modules.indexer.spider.yema import YemaSpider @@ -212,6 +213,12 @@ class IndexerModule(_ModuleBase): mtype=mtype, page=page ) + elif site.get('parser') == "RousiPro": + error_flag, result = RousiSpider(site).search( + keyword=search_word, + mtype=mtype, + page=page + ) else: error_flag, result = self.__spider_search( search_word=search_word, @@ -300,6 +307,12 @@ class IndexerModule(_ModuleBase): mtype=mtype, page=page ) + elif site.get('parser') == "RousiPro": + error_flag, result = await RousiSpider(site).async_search( + keyword=search_word, + mtype=mtype, + page=page + ) else: error_flag, result = await self.__async_spider_search( search_word=search_word, diff --git a/app/modules/indexer/parser/__init__.py b/app/modules/indexer/parser/__init__.py index b008151a..7cf2c8b6 100644 --- a/app/modules/indexer/parser/__init__.py +++ b/app/modules/indexer/parser/__init__.py @@ -35,6 +35,7 @@ class SiteSchema(Enum): HDDolby = "HDDolby" Zhixing = "Zhixing" Bitpt = "Bitpt" + RousiPro = "RousiPro" class SiteParserBase(metaclass=ABCMeta): diff --git a/app/modules/indexer/parser/rousi.py b/app/modules/indexer/parser/rousi.py new file mode 100644 index 00000000..4cbbf356 --- /dev/null +++ b/app/modules/indexer/parser/rousi.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +import json +from typing import Optional, Tuple + +from app.log import logger +from app.modules.indexer.parser import SiteParserBase, SiteSchema +from app.utils.string import StringUtils + + +class RousiSiteUserInfo(SiteParserBase): + """ + Rousi.pro 站点解析器 + 使用 API v1 接口,通过 Passkey (Bearer Token) 进行认证 + """ + schema = SiteSchema.RousiPro + request_mode = "apikey" + + def _parse_site_page(self, html_text: str): + """ + 配置 API 请求地址和请求头 + 使用 API v1 的 /profile 接口获取用户信息 + """ + self._base_url = f"https://{StringUtils.get_url_domain(self._site_url)}" + self._user_basic_page = "api/v1/profile?include_fields[user]=seeding_leeching_data" + self._user_basic_params = {} + self._user_basic_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Bearer {self.apikey}" + } + + # Rousi.pro API v1 在单个接口返回所有信息,无需额外页面 + self._user_traffic_page = None + self._user_detail_page = None + self._torrent_seeding_page = None + self._user_mail_unread_page = None + self._sys_mail_unread_page = None + + def _parse_logged_in(self, html_text): + """ + 判断是否登录成功 + API 认证模式下,通过 HTTP 状态码判断,此处始终返回 True + """ + return True + + def _parse_user_base_info(self, html_text: str): + """ + 解析用户基本信息 + 通过 API v1 接口获取用户完整信息,包括上传下载量、做种数据等 + + API 响应示例: + { + "code": 0, + "message": "success", + "data": { + "id": 1, + "username": "example", + "level_text": "Lv.5", + "registered_at": "2024-01-01T00:00:00Z", + "uploaded": 1073741824, + "downloaded": 536870912, + "ratio": 2.0, + "karma": 1000.5, + "seeding_leeching_data": { + "seeding_count": 10, + "seeding_size": 10737418240, + "leeching_count": 2, + "leeching_size": 2147483648 + } + } + } + """ + if not html_text: + return + + try: + data = json.loads(html_text) + except json.JSONDecodeError: + logger.error(f"{self._site_name} JSON 解析失败") + return + + if not data or data.get("code") != 0: + self.err_msg = data.get("message", "未知错误") + logger.warn(f"{self._site_name} API 错误: {self.err_msg}") + return + + user_info = data.get("data") + if not user_info: + return + + # 基本信息 + self.userid = user_info.get("id") + self.username = user_info.get("username") + self.user_level = user_info.get("level_text") or user_info.get("role_text") + + # 注册时间:统一格式为 YYYY-MM-DD HH:MM:SS + join_at = StringUtils.unify_datetime_str(user_info.get("registered_at")) + if join_at: + # 确保格式为 YYYY-MM-DD HH:MM:SS (19位) + if len(join_at) >= 19: + self.join_at = join_at[:19] + else: + self.join_at = join_at + + # 流量信息 + self.upload = int(user_info.get("uploaded") or 0) + self.download = int(user_info.get("downloaded") or 0) + self.ratio = round(float(user_info.get("ratio") or 0), 2) + + # 魔力值(站点称为 karma) + self.bonus = float(user_info.get("karma") or 0) + + # 做种/下载中数据 + sl_data = user_info.get("seeding_leeching_data", {}) + self.seeding = int(sl_data.get("seeding_count") or 0) + self.seeding_size = int(sl_data.get("seeding_size") or 0) + self.leeching = int(sl_data.get("leeching_count") or 0) + self.leeching_size = int(sl_data.get("leeching_size") or 0) + + def _parse_user_traffic_info(self, html_text: str): + """ + 解析用户流量信息 + Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析,此方法无需实现 + """ + pass + + def _parse_user_detail_info(self, html_text: str): + """ + 解析用户详细信息 + Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析,此方法无需实现 + """ + pass + + def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: + """ + 解析用户做种信息 + Rousi.pro API v1 在 _parse_user_base_info 中已通过 seeding_leeching_data 获取做种数据 + + :param html_text: 页面内容 + :param multi_page: 是否多页数据 + :return: 下页地址(无下页返回 None) + """ + return None + + def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]: + """ + 解析未读消息链接 + Rousi.pro API v1 暂未提供消息相关接口 + + :param html_text: 页面内容 + :param msg_links: 消息链接列表 + :return: 下页地址(无下页返回 None) + """ + return None + + def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + 解析消息内容 + Rousi.pro API v1 暂未提供消息相关接口 + + :param html_text: 页面内容 + :return: (标题, 日期, 内容) + """ + return None, None, None diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py new file mode 100644 index 00000000..7dab1fdf --- /dev/null +++ b/app/modules/indexer/spider/rousi.py @@ -0,0 +1,254 @@ +import base64 +import json +from typing import List, Optional, Tuple + +from app.core.config import settings +from app.db.systemconfig_oper import SystemConfigOper +from app.log import logger +from app.schemas import MediaType +from app.utils.http import RequestUtils, AsyncRequestUtils +from app.utils.singleton import SingletonClass +from app.utils.string import StringUtils + + +class RousiSpider(metaclass=SingletonClass): + """ + Rousi.pro API v1 Spider + + 使用 API v1 接口进行种子搜索 + - 认证方式:Bearer Token (Passkey) + - 搜索接口:/api/v1/torrents + - 详情接口:/api/v1/torrents/:id + """ + _indexerid = None + _domain = None + _url = None + _name = "" + _proxy = None + _cookie = None + _ua = None + _size = 20 + _searchurl = "https://%s/api/v1/torrents" + _downloadurl = "https://%s/api/v1/torrents/%s" + _timeout = 15 + _apikey = None + + def __init__(self, indexer: dict): + self.systemconfig = SystemConfigOper() + if indexer: + self._indexerid = indexer.get('id') + self._url = indexer.get('domain') + self._domain = StringUtils.get_url_domain(self._url) + self._searchurl = self._searchurl % self._domain + self._downloadurl = self._downloadurl % (self._domain, "%s") + self._name = indexer.get('name') + if indexer.get('proxy'): + self._proxy = settings.PROXY + self._cookie = indexer.get('cookie') + self._ua = indexer.get('ua') + self._apikey = indexer.get('apikey') + self._timeout = indexer.get('timeout') or 15 + + def __get_params(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> dict: + """ + 构建 API 请求参数 + + :param keyword: 搜索关键词 + :param mtype: 媒体类型 (MOVIE/TV) + :param page: 页码(从 0 开始,API 需要从 1 开始) + :return: 请求参数字典 + """ + params = { + "page": int(page) + 1, + "page_size": self._size + } + if keyword: + params["keyword"] = keyword + + # 注意:API 的 category 参数为字符串类型,仅支持单个分类 + # 这里优先选择主分类,如需多分类搜索需要分别请求 + if mtype: + if mtype == MediaType.MOVIE: + params["category"] = "movie" + elif mtype == MediaType.TV: + params["category"] = "tv" + + return params + + def __parse_result(self, results: List[dict]) -> List[dict]: + """ + 解析搜索结果 + + 将 API 返回的种子数据转换为 MoviePilot 标准格式 + + :param results: API 返回的种子列表 + :return: 标准化的种子信息列表 + """ + torrents = [] + if not results: + return torrents + + for result in results: + # 解析分类信息 + raw_cat = result.get('category') + cat_val = None + + category = MediaType.UNKNOWN.value + + if isinstance(raw_cat, dict): + cat_val = raw_cat.get('slug') or raw_cat.get('name') + elif isinstance(raw_cat, str): + cat_val = raw_cat + + if cat_val: + cat_val = str(cat_val).lower() + if cat_val in ['movie', 'documentary']: + category = MediaType.MOVIE.value + elif cat_val in ['tv', 'animation', 'variety', 'sports']: + category = MediaType.TV.value + + # 解析促销信息 + # API 后端已处理全站促销优先级,直接使用返回的 promotion 数据 + downloadvolumefactor = 1.0 + uploadvolumefactor = 1.0 + freedate = None + + promotion = result.get('promotion') + if promotion and promotion.get('is_active'): + downloadvolumefactor = float(promotion.get('down_multiplier', 1.0)) + uploadvolumefactor = float(promotion.get('up_multiplier', 1.0)) + # 促销到期时间,格式化为 YYYY-MM-DD HH:MM:SS + if promotion.get('until'): + freedate = StringUtils.unify_datetime_str(promotion.get('until')) + + torrent = { + 'title': result.get('title'), + 'description': result.get('subtitle'), + 'enclosure': self.__get_download_url(result.get('id')), + 'pubdate': StringUtils.unify_datetime_str(result.get('created_at')), + 'size': int(result.get('size') or 0), + 'seeders': int(result.get('seeders') or 0), + 'peers': int(result.get('leechers') or 0), + 'grabs': int(result.get('downloads') or 0), + 'downloadvolumefactor': downloadvolumefactor, + 'uploadvolumefactor': uploadvolumefactor, + 'freedate': freedate, + 'page_url': f"https://{self._domain}/torrent/{result.get('uuid')}", + 'labels': [], + 'category': category + } + torrents.append(torrent) + return torrents + + def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + """ + 同步搜索种子 + + :param keyword: 搜索关键词 + :param mtype: 媒体类型 (MOVIE/TV) + :param page: 页码(从 0 开始) + :return: (是否发生错误, 种子列表) + """ + if not self._apikey: + logger.warn(f"{self._name} 未配置 API Key (Passkey)") + return True, [] + + params = self.__get_params(keyword, mtype, page) + headers = { + "Authorization": f"Bearer {self._apikey}", + "Accept": "application/json" + } + + res = RequestUtils( + headers=headers, + proxies=self._proxy, + timeout=self._timeout + ).get_res(url=self._searchurl, params=params) + + if res 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}") + return True, [] + elif res is not None: + logger.warn(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") + return True, [] + else: + logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") + return True, [] + + async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + """ + 异步搜索种子 + + :param keyword: 搜索关键词 + :param mtype: 媒体类型 (MOVIE/TV) + :param page: 页码(从 0 开始) + :return: (是否发生错误, 种子列表) + """ + if not self._apikey: + logger.warn(f"{self._name} 未配置 API Key (Passkey)") + return True, [] + + params = self.__get_params(keyword, mtype, page) + headers = { + "Authorization": f"Bearer {self._apikey}", + "Accept": "application/json" + } + + res = await AsyncRequestUtils( + headers=headers, + proxies=self._proxy, + timeout=self._timeout + ).get_res(url=self._searchurl, params=params) + + if res 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}") + return True, [] + elif res is not None: + logger.warn(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") + return True, [] + else: + logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") + return True, [] + + def __get_download_url(self, torrent_id: int) -> str: + """ + 构建种子下载链接 + + 使用 base64 编码的方式告诉 MoviePilot 如何获取真实下载地址 + MoviePilot 会先请求详情接口,然后从响应中提取 data.download_url + + :param torrent_id: 种子 ID + :return: base64 编码的请求配置字符串 + 详情接口 URL + """ + url = self._downloadurl % torrent_id + # MoviePilot 会解析这个特殊格式的 URL: + # 1. 使用指定的 method 和 header 请求 URL + # 2. 从 JSON 响应中提取 result 指定的字段值作为真实下载地址 + params = { + 'method': 'get', + 'header': { + 'Authorization': f'Bearer {self._apikey}', + 'Accept': 'application/json' + }, + 'result': 'data.download_url' + } + base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8') + return f"[{base64_str}]{url}" From aeccf78957448f145897e4b88001625e01676f52 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:05:02 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(rousi):=20=E6=96=B0=E5=A2=9E=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=8F=82=E6=95=B0=E6=94=AF=E6=8C=81=E4=BB=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/indexer/__init__.py | 2 + app/modules/indexer/spider/rousi.py | 60 +++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/app/modules/indexer/__init__.py b/app/modules/indexer/__init__.py index ba1e5931..fb6e8a78 100644 --- a/app/modules/indexer/__init__.py +++ b/app/modules/indexer/__init__.py @@ -217,6 +217,7 @@ class IndexerModule(_ModuleBase): error_flag, result = RousiSpider(site).search( keyword=search_word, mtype=mtype, + cat=cat, page=page ) else: @@ -311,6 +312,7 @@ class IndexerModule(_ModuleBase): error_flag, result = await RousiSpider(site).async_search( keyword=search_word, mtype=mtype, + cat=cat, page=page ) else: diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 7dab1fdf..c2d9509f 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -49,12 +49,13 @@ class RousiSpider(metaclass=SingletonClass): self._apikey = indexer.get('apikey') self._timeout = indexer.get('timeout') or 15 - def __get_params(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> dict: + def __get_params(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> dict: """ 构建 API 请求参数 :param keyword: 搜索关键词 :param mtype: 媒体类型 (MOVIE/TV) + :param cat: 用户选择的分类 ID(逗号分隔的字符串) :param page: 页码(从 0 开始,API 需要从 1 开始) :return: 请求参数字典 """ @@ -65,9 +66,20 @@ class RousiSpider(metaclass=SingletonClass): if keyword: params["keyword"] = keyword - # 注意:API 的 category 参数为字符串类型,仅支持单个分类 - # 这里优先选择主分类,如需多分类搜索需要分别请求 - if mtype: + # API 支持多分类搜索,需要使用数组格式:category[]=xxx&category[]=yyy + # 优先使用用户选择的分类,如果用户未选择则根据 mtype 推断 + if cat: + # 用户选择了特定分类,需要将分类 ID 映射回 API 的 category name + category_names = self.__get_category_names_by_ids(cat) + if category_names: + # 如果只有一个分类,使用 category=xxx + if len(category_names) == 1: + params["category"] = category_names[0] + else: + # 多个分类使用数组格式 category[]=xxx + params["category[]"] = category_names + elif mtype: + # 用户未选择分类,根据媒体类型推断 if mtype == MediaType.MOVIE: params["category"] = "movie" elif mtype == MediaType.TV: @@ -75,6 +87,36 @@ class RousiSpider(metaclass=SingletonClass): return params + def __get_category_names_by_ids(self, cat: str) -> Optional[list]: + """ + 根据用户选择的分类 ID 获取 API 的 category names + + :param cat: 用户选择的分类 ID(逗号分隔的多个ID,如 "1,2,3") + :return: API 的 category names 列表(如 ["movie", "tv", "documentary"]) + """ + if not cat: + return None + + # ID 到 category name 的映射 + id_to_name = { + '1': 'movie', + '2': 'tv', + '3': 'documentary', + '4': 'animation', + '5': 'variety', + '6': 'sports', + '7': 'music', + '8': 'software', + '9': 'ebook', + '10': 'other' + } + + # 分割多个分类 ID 并映射为 category names + cat_ids = [c.strip() for c in cat.split(',') if c.strip()] + category_names = [id_to_name.get(cat_id) for cat_id in cat_ids if cat_id in id_to_name] + + return category_names if category_names else None + def __parse_result(self, results: List[dict]) -> List[dict]: """ 解析搜索结果 @@ -140,12 +182,13 @@ class RousiSpider(metaclass=SingletonClass): torrents.append(torrent) return torrents - def search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + def search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 同步搜索种子 :param keyword: 搜索关键词 :param mtype: 媒体类型 (MOVIE/TV) + :param cat: 用户选择的分类 ID(逗号分隔) :param page: 页码(从 0 开始) :return: (是否发生错误, 种子列表) """ @@ -153,7 +196,7 @@ class RousiSpider(metaclass=SingletonClass): logger.warn(f"{self._name} 未配置 API Key (Passkey)") return True, [] - params = self.__get_params(keyword, mtype, page) + params = self.__get_params(keyword, mtype, cat, page) headers = { "Authorization": f"Bearer {self._apikey}", "Accept": "application/json" @@ -184,12 +227,13 @@ class RousiSpider(metaclass=SingletonClass): logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") return True, [] - async def async_search(self, keyword: str, mtype: MediaType = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: + async def async_search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ 异步搜索种子 :param keyword: 搜索关键词 :param mtype: 媒体类型 (MOVIE/TV) + :param cat: 用户选择的分类 ID(逗号分隔) :param page: 页码(从 0 开始) :return: (是否发生错误, 种子列表) """ @@ -197,7 +241,7 @@ class RousiSpider(metaclass=SingletonClass): logger.warn(f"{self._name} 未配置 API Key (Passkey)") return True, [] - params = self.__get_params(keyword, mtype, page) + params = self.__get_params(keyword, mtype, cat, page) headers = { "Authorization": f"Bearer {self._apikey}", "Accept": "application/json" From 50e275a2f94c3b71bd3f037e85ef9f8015c0adb8 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:53:09 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(config):=20=E5=A2=9E=E5=8A=A0=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E6=90=9C=E7=B4=A2=E5=90=8D=E7=A7=B0=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E9=99=90=E5=88=B6=E8=87=B33=20=E7=A1=AE=E4=BF=9D=E5=8C=85?= =?UTF-8?q?=E5=90=AB=20en=5Ftitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/config.py b/app/core/config.py index 090cb0ce..c1e58593 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -278,7 +278,7 @@ class ConfigModel(BaseModel): # 搜索多个名称 SEARCH_MULTIPLE_NAME: bool = False # 最大搜索名称数量 - MAX_SEARCH_NAME_LIMIT: int = 2 + MAX_SEARCH_NAME_LIMIT: int = 3 # ==================== 下载配置 ==================== # 种子标签 From 3ca419b7354e9c325fb3994fd92212d7ef035b96 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:27:45 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(rousi):=20=E7=B2=BE=E7=AE=80=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=88=86=E7=B1=BB=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/indexer/spider/rousi.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index c2d9509f..3ce9de76 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -27,10 +27,15 @@ class RousiSpider(metaclass=SingletonClass): _proxy = None _cookie = None _ua = None - _size = 20 + _size = 100 _searchurl = "https://%s/api/v1/torrents" _downloadurl = "https://%s/api/v1/torrents/%s" _timeout = 15 + + # 分类定义 + _movie_category = ['movie'] + _tv_category = ['tv', 'documentary', 'animation', 'variety'] + _apikey = None def __init__(self, indexer: dict): @@ -72,18 +77,14 @@ class RousiSpider(metaclass=SingletonClass): # 用户选择了特定分类,需要将分类 ID 映射回 API 的 category name category_names = self.__get_category_names_by_ids(cat) if category_names: - # 如果只有一个分类,使用 category=xxx - if len(category_names) == 1: - params["category"] = category_names[0] - else: - # 多个分类使用数组格式 category[]=xxx - params["category[]"] = category_names + # 使用数组格式 category[]=xxx + params["category[]"] = category_names elif mtype: # 用户未选择分类,根据媒体类型推断 if mtype == MediaType.MOVIE: - params["category"] = "movie" + params["category[]"] = self._movie_category elif mtype == MediaType.TV: - params["category"] = "tv" + params["category[]"] = self._tv_category return params @@ -103,12 +104,7 @@ class RousiSpider(metaclass=SingletonClass): '2': 'tv', '3': 'documentary', '4': 'animation', - '5': 'variety', - '6': 'sports', - '7': 'music', - '8': 'software', - '9': 'ebook', - '10': 'other' + '6': 'variety' } # 分割多个分类 ID 并映射为 category names From d2b5d69051836c9b37336e2d816d744928bd00c7 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:54:43 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(rousi):=20=E9=87=8D=E6=9E=84=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=E4=BB=A5=E6=8F=90?= =?UTF-8?q?=E9=AB=98=E4=BB=A3=E7=A0=81=E5=8F=AF=E8=AF=BB=E6=80=A7=E5=92=8C?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/indexer/spider/rousi.py | 64 +++++++++++++---------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 3ce9de76..4e782d44 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -113,6 +113,32 @@ class RousiSpider(metaclass=SingletonClass): return category_names if category_names else None + def __process_response(self, res) -> Tuple[bool, List[dict]]: + """ + 处理 API 响应 + + :param res: 请求响应对象 + :return: (是否发生错误, 种子列表) + """ + if res 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}") + return True, [] + elif res is not None: + logger.warn(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") + return True, [] + else: + logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") + return True, [] + def __parse_result(self, results: List[dict]) -> List[dict]: """ 解析搜索结果 @@ -204,24 +230,7 @@ class RousiSpider(metaclass=SingletonClass): timeout=self._timeout ).get_res(url=self._searchurl, params=params) - if res 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}") - return True, [] - elif res is not None: - logger.warn(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") - return True, [] - else: - logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") - return True, [] + return self.__process_response(res) async def async_search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]: """ @@ -249,24 +258,7 @@ class RousiSpider(metaclass=SingletonClass): timeout=self._timeout ).get_res(url=self._searchurl, params=params) - if res 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}") - return True, [] - elif res is not None: - logger.warn(f"{self._name} 搜索失败,HTTP 错误码:{res.status_code}") - return True, [] - else: - logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}") - return True, [] + return self.__process_response(res) def __get_download_url(self, torrent_id: int) -> str: """ From f95b1fa68aa2d5204cd35acd4bc181e1b852fc3c Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:31:12 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(rousi):=20=E4=BF=AE=E6=AD=A3=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/indexer/spider/rousi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 4e782d44..aacbee83 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -33,7 +33,7 @@ class RousiSpider(metaclass=SingletonClass): _timeout = 15 # 分类定义 - _movie_category = ['movie'] + _movie_category = ['movie', 'documentary', 'animation'] _tv_category = ['tv', 'documentary', 'animation', 'variety'] _apikey = None @@ -166,9 +166,9 @@ class RousiSpider(metaclass=SingletonClass): if cat_val: cat_val = str(cat_val).lower() - if cat_val in ['movie', 'documentary']: + if cat_val in self._movie_category: category = MediaType.MOVIE.value - elif cat_val in ['tv', 'animation', 'variety', 'sports']: + elif cat_val in self._tv_category: category = MediaType.TV.value # 解析促销信息 From 248a25eaee6799bc6f9e158ea065100acadb87bf Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:39:40 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(rousi):=20=E7=A7=BB=E9=99=A4=E5=8D=95?= =?UTF-8?q?=E4=BE=8B=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/indexer/spider/rousi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index aacbee83..e6dec24b 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -7,11 +7,10 @@ from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas import MediaType from app.utils.http import RequestUtils, AsyncRequestUtils -from app.utils.singleton import SingletonClass from app.utils.string import StringUtils -class RousiSpider(metaclass=SingletonClass): +class RousiSpider: """ Rousi.pro API v1 Spider @@ -36,6 +35,7 @@ class RousiSpider(metaclass=SingletonClass): _movie_category = ['movie', 'documentary', 'animation'] _tv_category = ['tv', 'documentary', 'animation', 'variety'] + # API KEY _apikey = None def __init__(self, indexer: dict): From 0979163b791d7c385b2d23e41959e2d0cc8d395e Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Sat, 10 Jan 2026 02:12:33 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(rousi):=20=E4=BF=AE=E6=AD=A3=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=8F=82=E6=95=B0=E4=B8=BA=E5=8D=95=E4=B8=80=E5=80=BC?= =?UTF-8?q?=E4=BB=A5=E7=AC=A6=E5=90=88API=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/indexer/spider/rousi.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index e6dec24b..87c6a8f2 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -32,8 +32,9 @@ class RousiSpider: _timeout = 15 # 分类定义 - _movie_category = ['movie', 'documentary', 'animation'] - _tv_category = ['tv', 'documentary', 'animation', 'variety'] + # API 不支持多分类搜索,每次只使用一个分类 + _movie_category = 'movie' + _tv_category = 'tv' # API KEY _apikey = None @@ -71,20 +72,20 @@ class RousiSpider: if keyword: params["keyword"] = keyword - # API 支持多分类搜索,需要使用数组格式:category[]=xxx&category[]=yyy - # 优先使用用户选择的分类,如果用户未选择则根据 mtype 推断 + # API 不支持多分类搜索,只使用单个 category 参数 + # 优先使用用户选择的分类,如果用户未选择则根据 mtype 推断 if cat: - # 用户选择了特定分类,需要将分类 ID 映射回 API 的 category name + # 用户选择了特定分类,需要将分类 ID 映射回 API 的 category name category_names = self.__get_category_names_by_ids(cat) if category_names: - # 使用数组格式 category[]=xxx - params["category[]"] = category_names + # 如果用户选择了多个分类,只取第一个 + params["category"] = category_names[0] elif mtype: - # 用户未选择分类,根据媒体类型推断 + # 用户未选择分类,根据媒体类型推断 if mtype == MediaType.MOVIE: - params["category[]"] = self._movie_category + params["category"] = self._movie_category elif mtype == MediaType.TV: - params["category[]"] = self._tv_category + params["category"] = self._tv_category return params @@ -166,10 +167,12 @@ class RousiSpider: if cat_val: cat_val = str(cat_val).lower() - if cat_val in self._movie_category: + if cat_val == self._movie_category: category = MediaType.MOVIE.value - elif cat_val in self._tv_category: + elif cat_val == self._tv_category: category = MediaType.TV.value + else: + category = MediaType.UNKNOWN.value # 解析促销信息 # API 后端已处理全站促销优先级,直接使用返回的 promotion 数据