Files
MoviePilot/app/application/rss.py
Aqr-K 598d1663b1 fix(download): preserve protocol failures and isolate site updates (#7143)
* 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>
2026-10-23 07:22:22 +08:00

678 lines
25 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.
"""
RSS 订阅获取、站点链接生成、浏览器回退与资源解析。
"""
import traceback
from datetime import datetime
from typing import Any, List, Optional, Protocol, Tuple, Union
from urllib.parse import urljoin, urlparse
import dateutil.parser
from lxml import etree
from app.application.configuration import get_chain_runtime_config_snapshot
from app.foundation.url import UrlUtils
from app.runtime.log import logger
class RssResponsePort(Protocol):
"""
声明 RSS 用例实际读取的 HTTP 响应字段。
:ivar status_code: HTTP 响应状态码
:ivar content: 响应原始字节,供受控的 XML 编码探测使用
:ivar text: 客户端解码后的响应文本
:ivar reason: HTTP 响应原因短语,用于失败诊断
"""
status_code: int
content: bytes
text: str
reason: str
class RssHttpPort(Protocol):
"""
声明 RSS 用例所需的同步 HTTP 与 XML 解码能力。
"""
def get(self, *, url: str, ua: Optional[str], headers: Optional[dict[str, Any]],
proxies: Optional[dict[str, Any]], timeout: int) -> Optional[RssResponsePort]:
"""
获取 RSS 响应。
:param url: RSS 订阅地址或站点生成接口 URL
:param ua: 请求使用的 User-Agent
:param headers: 额外请求头,None 表示不提供额外头
:param proxies: 协议到代理连接参数的映射,None 表示不指定代理
:param timeout: HTTP 请求超时,单位为秒
:return: HTTP 响应;未取得响应时为 None
"""
def post(self, *, url: str, data: dict[str, Any], cookie: str, ua: str,
proxies: Optional[dict[str, Any]], timeout: int) -> Optional[RssResponsePort]:
"""
提交 RSS 链接生成表单。
:param url: RSS 订阅地址或站点生成接口 URL
:param data: 站点生成 RSS 链接所需的表单字段
:param cookie: 站点认证 Cookie 文本
:param ua: 请求使用的 User-Agent
:param proxies: 协议到代理连接参数的映射,None 表示不指定代理
:param timeout: HTTP 请求超时,单位为秒
:return: HTTP 响应;未取得响应时为 None
"""
def decode_xml(self, response: RssResponsePort, *, performance_mode: bool,
confidence_threshold: float) -> str:
"""
按宿主编码策略解码 XML 响应。
:param response: 待解码的原始 HTTP 响应
:param performance_mode: 是否采用减少编码探测开销的性能模式
:param confidence_threshold: 编码探测结果可接受的最低置信度
:return: 按宿主编码策略解码后的 XML 文本
"""
class RssBrowserPort(Protocol):
"""
声明 RSS 链接页面渲染能力。
"""
def render(self, *, url: str, cookie: str, ua: str,
proxies: Optional[dict[str, Any]], timeout: int) -> Optional[str]:
"""
渲染页面并返回源码。
:param url: 需要浏览器执行并渲染的站点页面 URL
:param cookie: 站点认证 Cookie 文本
:param ua: 浏览器请求使用的 User-Agent
:param proxies: 浏览器代理连接参数,None 表示不指定代理
:param timeout: 浏览器页面获取的超时时间,单位为秒
:return: 渲染后的页面源码,获取失败时为 None
"""
class RssParserPort(Protocol):
"""
声明可选原生 RSS 解析加速能力。
"""
def parse(self, content: str, max_items: int) -> Optional[List[dict[str, Any]]]:
"""
解析 RSS XML;不可用或失败时返回 None 触发 Python 兜底。
:param content: 待解析的 RSS XML 文本
:param max_items: 最多保留的资源条目数
:return: 解析后的资源条目;能力不可用或解析失败时为 None,成功但无资源时为空列表
"""
_rss_http_port: Optional[RssHttpPort] = None
_rss_browser_port: Optional[RssBrowserPort] = None
_rss_parser_port: Optional[RssParserPort] = None
def configure_rss_ports(*, http: RssHttpPort, browser: RssBrowserPort,
parser: RssParserPort) -> None:
"""
由组合根装配 RSS 用例依赖的技术端口。
"""
global _rss_http_port, _rss_browser_port, _rss_parser_port
_rss_http_port = http
_rss_browser_port = browser
_rss_parser_port = parser
def reset_rss_ports() -> None:
"""
清除 RSS 端口,供生命周期退出与隔离测试使用。
"""
global _rss_http_port, _rss_browser_port, _rss_parser_port
_rss_http_port = None
_rss_browser_port = None
_rss_parser_port = None
def _require_rss_ports() -> Tuple[RssHttpPort, RssBrowserPort, RssParserPort]:
"""
返回已装配端口,缺失时明确拒绝隐式构造 Adapter。
"""
if _rss_http_port is None or _rss_browser_port is None or _rss_parser_port is None:
raise RuntimeError("RSS 访问端口尚未由启动组合根装配")
return _rss_http_port, _rss_browser_port, _rss_parser_port
class RssHelper:
"""
RSS帮助类,解析RSS报文、获取RSS地址等
"""
# RSS解析限制配置
# 50MB最大RSS文件大小
MAX_RSS_SIZE = 50 * 1024 * 1024
# 最大解析条目数
MAX_RSS_ITEMS = 1000
# 各站点RSS链接获取配置
rss_link_conf: dict[str, dict[str, Any]] = {
"default": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
}
},
"hares.top": {
"xpath": "//*[@id='layui-layer100001']/div[2]/div/p[4]/a/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
}
},
"et8.org": {
"xpath": "//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/a[2]/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
}
},
"pttime.org": {
"xpath": "//*[@id='outer']/table/tbody/tr/td/table/tbody/tr/td/text()[5]",
"url": "getrss.php",
"params": {
"showrows": 10,
"inclbookmarked": 0,
"itemsmalldescr": 1
}
},
"ourbits.club": {
"xpath": "//a[@class='gen_rsslink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
}
},
"totheglory.im": {
"xpath": "//textarea/text()",
"url": "rsstools.php?c51=51&c52=52&c53=53&c54=54&c108=108&c109=109&c62=62&c63=63&c67=67&c69=69&c70=70&c73=73&c76=76&c75=75&c74=74&c87=87&c88=88&c99=99&c90=90&c58=58&c103=103&c101=101&c60=60",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
}
},
"monikadesign.uk": {
"xpath": "//a/@href",
"url": "rss",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
}
},
"zhuque.in": {
"xpath": "//a/@href",
"url": "user/rss",
"render": True,
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
}
},
"hdchina.org": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
"rsscart": 0
}
},
"audiences.me": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
"torrent_type": 1,
"exp": 180
}
},
"shadowflow.org": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"paid": 0,
"search_mode": 0,
"showrows": 30
}
},
"hddolby.com": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
"exp": 180
}
},
"hdhome.org": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
"exp": 180
}
},
"pthome.net": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
"exp": 180
}
},
"ptsbao.club": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
"size": 0
}
},
"leaves.red": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 0,
"paid": 2
}
},
"hdtime.org": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 0,
}
},
"m-team.io": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"showrows": 50,
"inclbookmarked": 0,
"itemsmalldescr": 1,
"https": 1
}
},
"u2.dmhy.org": {
"xpath": "//a[@class='faqlink']/@href",
"url": "getrss.php",
"params": {
"inclbookmarked": 0,
"itemsmalldescr": 1,
"showrows": 50,
"search_mode": 1,
"inclautochecked": 1,
"trackerssl": 1
}
},
}
@classmethod
def _get_site_domain(cls, url: str) -> str:
"""
按 RSS 站点配置匹配域名,未命中时回退到最后两级域名。
"""
hostname = (urlparse(url).hostname or "").lower()
for domain in cls.rss_link_conf:
if domain == "default":
continue
if hostname == domain or hostname.endswith(f".{domain}"):
return domain
parts = hostname.split(".")
return ".".join(parts[-2:]) if len(parts) >= 2 else hostname
@staticmethod
def _parse_publish_time(value: str) -> Optional[datetime]:
"""
将 RSS 常见日期表达解析为 datetime,无法解析时返回 None。
"""
try:
return dateutil.parser.parse(value)
except dateutil.parser.ParserError:
return None
def __parse_with_rust(self, ret_xml: Optional[str]) -> Optional[list[dict[str, Any]]]:
"""
调用 Rust RSS 解析器,并统一处理基础 XML 校验和最大条目限制。
"""
if not ret_xml or not ret_xml.strip():
return None
ret_xml_stripped = ret_xml.strip()
if not ret_xml_stripped.startswith('<'):
return None
_, _, parser_port = _require_rss_ports()
rust_items = parser_port.parse(ret_xml, self.MAX_RSS_ITEMS + 1)
if rust_items is None:
return None
if len(rust_items) > self.MAX_RSS_ITEMS:
logger.warning(f"RSS条目过多: 超过{self.MAX_RSS_ITEMS},仅处理前{self.MAX_RSS_ITEMS}个")
return rust_items[:self.MAX_RSS_ITEMS]
def parse(self, url: str, proxy: bool = False,
timeout: Optional[int] = 15, headers: Optional[dict[str, str]] = None, ua: Optional[str] = None) -> Union[List[dict[str, Any]], None, bool]:
"""
解析 RSS 地址并保留插件兼容的返回约定。
"""
normalized_url = UrlUtils.normalize_http_url(url)
if normalized_url is None:
return False
return self._parse_impl(normalized_url, proxy=proxy, timeout=timeout, headers=headers, ua=ua)
def _parse_impl(self, url: str, proxy: bool = False,
timeout: Optional[int] = 15, headers: Optional[dict[str, str]] = None,
ua: Optional[str] = None) -> Union[List[dict[str, Any]], None, bool]:
"""
解析RSS订阅URL,获取RSS中的种子信息
:param url: RSS地址
:param proxy: 是否使用代理
:param timeout: 请求超时
:param headers: 自定义请求头
:param ua: 自定义User-Agent
:return: 种子信息列表,如为None代表Rss过期,如果为False则为错误
"""
ret_array: list[dict[str, Any]] = []
http_port, _, _ = _require_rss_ports()
try:
ret = http_port.get(
url=url,
ua=ua,
proxies=get_chain_runtime_config_snapshot().proxy if proxy else None,
timeout=timeout or 30,
headers=headers,
)
if not ret:
logger.error(f"获取RSS失败:请求返回空值,URL: {url}")
return False
except Exception as err:
logger.error(f"获取RSS失败:{str(err)} - {traceback.format_exc()}")
return False
if ret:
if ret.status_code != 200:
logger.error(f"RSS请求失败,状态码: {ret.status_code}, URL: {url}")
return False
ret_xml = None
root = None
try:
# 检查响应大小,避免处理过大的RSS文件
raw_data = ret.content
if raw_data and len(raw_data) > self.MAX_RSS_SIZE:
logger.warning(f"RSS文件过大: {len(raw_data) / 1024 / 1024:.1f}MB,跳过解析")
return False
expired_messages = {
"RSS 链接已过期, 您需要获得一个新的!",
"RSS Link has expired, You need to get a new one!",
"RSS Link has expired, You need to get new!",
}
if ret.text.strip() in expired_messages:
return None
if raw_data:
ret_xml = http_port.decode_xml(
ret,
performance_mode=get_chain_runtime_config_snapshot().encoding_detection_performance_mode,
confidence_threshold=get_chain_runtime_config_snapshot().encoding_detection_min_confidence
)
rust_items = self.__parse_with_rust(ret_xml)
if rust_items is not None:
return rust_items
if not ret_xml:
ret_xml = ret.text
if not ret_xml or not ret_xml.strip():
logger.error("RSS内容为空")
return False
# 检查是否包含基本的RSS/XML结构
ret_xml_stripped = ret_xml.strip()
if not ret_xml_stripped.startswith('<'):
logger.error("RSS内容不是有效的XML格式")
return False
rust_items = self.__parse_with_rust(ret_xml)
if rust_items is not None:
return rust_items
parser = None
try:
# 创建解析器,禁用网络访问以提高安全性和性能
parser = etree.XMLParser(
# 容错模式
recover=True,
# 保留CDATA
strip_cdata=False,
# 禁用外部实体解析
resolve_entities=False,
# 禁用网络访问
no_network=True,
# 禁用大文档解析,避免内存问题
huge_tree=False
)
root = etree.fromstring(ret_xml.encode('utf-8'), parser=parser)
except etree.XMLSyntaxError as xml_error:
logger.debug(f"XML解析失败:{str(xml_error)},尝试HTML解析")
# 如果XML解析失败,尝试作为HTML解析
try:
root = etree.HTML(ret_xml)
if root is not None:
rss_root = root.xpath('//rss | //feed')
if rss_root:
root = rss_root[0]
except Exception as e:
logger.error(f"HTML解析也失败:{str(e)}")
return False
except Exception as general_error:
logger.error(f"解析RSS时发生未预期错误:{str(general_error)}")
return False
finally:
if parser is not None:
try:
parser.close()
except Exception as close_error:
logger.debug(f"关闭解析器时出错:{str(close_error)}")
del parser
if root is None:
logger.error("无法解析RSS内容")
return False
items = root.xpath('.//item | .//entry')
items_count = min(len(items), self.MAX_RSS_ITEMS)
if len(items) > self.MAX_RSS_ITEMS:
logger.warning(f"RSS条目过多: {len(items)},仅处理前{self.MAX_RSS_ITEMS}个")
try:
for item in items[:items_count]:
try:
title_nodes = item.xpath('.//title')
title = title_nodes[0].text if title_nodes and title_nodes[0].text else ""
if not title:
continue
# 描述
desc_nodes = item.xpath('.//description | .//summary')
description = desc_nodes[0].text if desc_nodes and desc_nodes[0].text else ""
# 种子页面
link_nodes = item.xpath('.//link')
if link_nodes:
link = link_nodes[0].text if hasattr(link_nodes[0], 'text') and link_nodes[0].text else link_nodes[0].get('href', '')
else:
link = ""
# 种子链接
enclosure_nodes = item.xpath('.//enclosure')
enclosure = enclosure_nodes[0].get('url', '') if enclosure_nodes else ""
if not enclosure and not link:
continue
# 部分RSS只有link没有enclosure
if not enclosure and link:
enclosure = link
# 大小
size = 0
if enclosure_nodes:
size_attr = enclosure_nodes[0].get('length', '0')
if size_attr and str(size_attr).isdigit():
size = int(size_attr)
# 发布日期
pubdate_nodes = item.xpath('./pubDate | ./published | ./updated')
if not pubdate_nodes:
pubdate_nodes = item.xpath('.//*[local-name()="pubDate"] | .//*[local-name()="published"] | .//*[local-name()="updated"]')
pubdate: Union[str, datetime] = ""
if pubdate_nodes and pubdate_nodes[0].text:
parsed_pubdate = self._parse_publish_time(pubdate_nodes[0].text)
if parsed_pubdate is not None:
# 转为本地时区
pubdate = parsed_pubdate.astimezone(tz=None)
# 获取豆瓣昵称
nickname_nodes = item.xpath('.//*[local-name()="creator"]')
nickname = nickname_nodes[0].text if nickname_nodes and nickname_nodes[0].text else ""
tmp_dict = {
'title': title,
'enclosure': enclosure,
'size': size,
'description': description,
'link': link,
'pubdate': pubdate
}
# 如果豆瓣昵称不为空,返回数据增加豆瓣昵称,供doubansync插件获取
if nickname:
tmp_dict['nickname'] = nickname
ret_array.append(tmp_dict)
except Exception as e1:
logger.debug(f"解析RSS条目失败:{str(e1)} - {traceback.format_exc()}")
continue
finally:
items.clear()
del items
except Exception as e2:
logger.error(f"解析RSS失败:{str(e2)} - {traceback.format_exc()}")
return False
finally:
if root is not None:
del root
if ret_xml is not None:
del ret_xml
return ret_array
def get_rss_link(self, url: str, cookie: str, ua: str, proxy: bool = False, timeout: Optional[int] = None) -> Tuple[str, str]:
"""
获取站点rss地址
:param url: 站点地址
:param cookie: 站点cookie
:param ua: 站点ua
:param proxy: 是否使用代理
:param timeout: 请求超时时间
:return: rss地址、错误信息
"""
try:
http_port, browser_port, _ = _require_rss_ports()
domain = self._get_site_domain(url)
site_conf = self.rss_link_conf.get(domain) or self.rss_link_conf.get("default")
if site_conf is None:
return "", f"获取RSS链接失败:未找到站点配置 {url}"
rss_url = urljoin(url, site_conf.get("url"))
rss_params = site_conf.get("params")
if site_conf.get("render"):
html_text = browser_port.render(
url=rss_url,
cookie=cookie,
ua=ua,
proxies=get_chain_runtime_config_snapshot().proxy_server if proxy else None,
timeout=timeout or 60
)
else:
res = http_port.post(
url=rss_url,
data=rss_params if isinstance(rss_params, dict) else {},
cookie=cookie,
ua=ua,
proxies=get_chain_runtime_config_snapshot().proxy if proxy else None,
timeout=timeout or 30,
)
if res:
html_text = res.text
elif res is not None:
return "", f"获取 {url} RSS链接失败,错误码:{res.status_code},错误原因:{res.reason}"
else:
return "", f"获取RSS链接失败:无法连接 {url} "
if html_text:
html = None
try:
html = etree.HTML(html_text)
if html is not None and len(html) > 0:
rss_link = html.xpath(site_conf.get("xpath"))
if rss_link:
return str(rss_link[-1]), ""
finally:
if html is not None:
del html
return "", f"获取RSS链接失败:{url}"
except Exception as e:
return "", f"获取 {url} RSS链接失败:{str(e)}"