mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
* 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>
678 lines
25 KiB
Python
678 lines
25 KiB
Python
"""
|
||
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)}"
|