Files
MoviePilot/app/application/rss.py
InfinityPacer a13d13aeba fix: 原子合并订阅搜索任务并跳过无效 RSS 地址 (#6626)
* fix(subscription): coalesce active search tasks with atomic upsert

* fix(rss): skip invalid feed URLs before network access
2026-09-09 17:00:50 +08:00

638 lines
24 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 traceback
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.runtime.log import logger
class RssResponsePort(Protocol):
"""声明 RSS 用例实际读取的 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 响应。"""
def post(self, *, url: str, data: dict[str, Any], cookie: str, ua: str,
proxies: Optional[dict[str, Any]], timeout: int) -> Optional[RssResponsePort]:
"""提交 RSS 链接生成表单。"""
def decode_xml(self, response: RssResponsePort, *, performance_mode: bool,
confidence_threshold: float) -> str:
"""按宿主编码策略解码 XML 响应。"""
class RssBrowserPort(Protocol):
"""声明 RSS 链接页面渲染能力。"""
def render(self, *, url: str, cookie: str, ua: str,
proxies: Optional[dict[str, Any]], timeout: int) -> Optional[str]:
"""渲染页面并返回源码。"""
class RssParserPort(Protocol):
"""声明可选原生 RSS 解析加速能力。"""
def parse(self, content: str, max_items: int) -> Optional[List[dict[str, Any]]]:
"""解析 RSS XML不可用或失败时返回 None 触发 Python 兜底。"""
_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解析限制配置
MAX_RSS_SIZE = 50 * 1024 * 1024 # 50MB最大RSS文件大小
MAX_RSS_ITEMS = 1000 # 最大解析条目数
# 各站点RSS链接获取配置
rss_link_conf = {
"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 normalize_url(url: Any) -> Optional[str]:
"""校验并规范化 RSS HTTP 地址,保留合法地址中的 fragment。"""
if not isinstance(url, str):
return None
normalized_url = url.strip()
if not normalized_url:
return None
if any(character.isspace() for character in normalized_url):
return None
try:
parsed_url = urlparse(normalized_url)
hostname = parsed_url.hostname
port = parsed_url.port
except ValueError:
return None
if (
parsed_url.scheme.lower() not in ("http", "https")
or not hostname
or (port is not None and not 0 <= port <= 65535)
):
return None
return normalized_url
@staticmethod
def _parse_publish_time(value: str):
"""将 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]:
"""
调用 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, proxy: bool = False,
timeout: Optional[int] = 15, headers: dict = None, ua: str = None) -> Union[List[dict], None, bool]:
"""解析 RSS 地址并保留插件兼容的返回约定。"""
return self._parse_impl(url, proxy=proxy, timeout=timeout, headers=headers, ua=ua)
def _parse_impl(self, url, proxy: bool = False,
timeout: Optional[int] = 15, headers: dict = None,
ua: str = None) -> Union[List[dict], 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 = []
normalized_url = self.normalize_url(url)
if normalized_url is None:
return False
url = normalized_url
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:
# 检查HTTP状态码
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
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
# 验证RSS内容是否有效
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
# 使用lxml.etree解析XML
parser = None
try:
# 创建解析器,禁用网络访问以提高安全性和性能
parser = etree.XMLParser(
recover=True, # 容错模式
strip_cdata=False, # 保留CDATA
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根节点
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
# 查找所有item或entry节点
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:
# 使用xpath提取信息更高效
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 = ""
if pubdate_nodes and pubdate_nodes[0].text:
pubdate = self._parse_publish_time(pubdate_nodes[0].text)
if pubdate is not None:
# 转为本地时区
pubdate = 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()}")
# RSS过期检查
_rss_expired_msg = [
"RSS 链接已过期, 您需要获得一个新的!",
"RSS Link has expired, You need to get a new one!",
"RSS Link has expired, You need to get new!"
]
if ret_xml in _rss_expired_msg:
return None
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: 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")
# RSS地址
rss_url = urljoin(url, site_conf.get("url"))
# RSS请求参数
rss_params = site_conf.get("params")
# 请求RSS页面
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,
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} "
# 解析HTML
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)}"