Files
MoviePilot/app/chain/site.py

1413 lines
55 KiB
Python
Raw Permalink 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.
"""
站点连通性、用户数据、Cookie 同步与消息交互编排。
"""
import base64
import re
import threading
from contextlib import AbstractContextManager
from dataclasses import dataclass, replace
from datetime import datetime
from typing import Any, Callable, Dict, Mapping, Optional, Protocol, Tuple, Union
from urllib.parse import urljoin, urlparse
from lxml import etree
from app.application.configuration import get_configured_system_config
from app.application.messaging.site import SiteInteractionHandler
from app.application.rss import RssHelper
from app.application.security.cookie import CookieHelper
from app.application.site.contract import (
SiteCookieLoginSource,
SiteMutation,
SiteRepository,
SiteSnapshot,
SiteUserDataMutation,
)
# 站点资源包在启动时加载,静态检查环境可能无法解析其实现。
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.chain._interaction import InteractionChainMixin
from app.chain.base import ChainBase
from app.domain import site as site_rules
from app.domain.site import SiteUtils
from app.foundation import size as size_tools
from app.foundation import url as url_tools
from app.foundation.dom import DomUtils
from app.runtime.events import Event, eventmanager
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.schemas.message import Message
from app.schemas.site import SiteUserData
from app.schemas.types import EventType, MessageType, NotificationChannel
class SiteResponsePort(Protocol):
"""
声明站点链在响应有效期内读取的最小 HTTP 字段。
:ivar status_code: 站点 HTTP 响应状态码
:ivar reason: HTTP 状态原因短语,用于连接失败诊断
:ivar text: 解码后的站点页面或接口正文
:ivar content: 原始响应字节,用于图标等二进制资源
"""
status_code: int
reason: str
text: str
content: bytes
def __bool__(self) -> bool:
"""
保留 requests.Response 对错误状态返回 False 的兼容语义。
:return: HTTP 状态小于 400 时为 True,错误状态为 False
"""
...
def json(self) -> Any:
"""
解析响应 JSON,格式错误继续向调用方抛出。
:return: 解析后的 JSON 值,格式错误时传播解析异常
"""
...
class SiteHttpPort(Protocol):
"""
声明站点探测所需的同步 HTTP 上下文能力。
"""
def open(
self,
*,
method: str,
url: str,
headers: Optional[Mapping[str, Any]] = None,
cookie: Optional[str] = None,
ua: Optional[str] = None,
proxies: Optional[Mapping[str, Any]] = None,
timeout: int = 20,
json_body: Optional[Mapping[str, Any]] = None,
) -> AbstractContextManager[Optional[SiteResponsePort]]:
"""
打开有界响应上下文,退出时必须关闭底层响应。
:param method: HTTP 请求方法,如 GET 或 POST
:param url: 站点页面或接口的完整请求地址
:param headers: 额外请求头,None 表示未提供
:param cookie: 站点认证 Cookie 文本,None 表示未提供
:param ua: 本次使用的 User-Agent,None 时使用客户端默认值
:param proxies: 协议到代理地址的映射,None 表示未指定代理
:param timeout: 请求超时秒数,默认 20 秒
:param json_body: JSON 请求体,None 表示不发送 JSON
:return: 进入后提供响应或 None 的上下文管理器;退出时关闭底层响应
"""
...
class SiteBrowserPort(Protocol):
"""
声明站点连通性测试所需的浏览器渲染能力。
"""
def render(
self,
*,
url: str,
cookies: str,
ua: str,
proxies: Optional[Mapping[str, Any]],
timeout: int,
) -> Optional[str]:
"""
在受控浏览器会话内渲染页面并返回源码。
:param url: 需要浏览器渲染的完整站点地址
:param cookies: 站点认证 Cookie 文本
:param ua: 浏览器请求使用的 User-Agent
:param proxies: 协议到代理地址的映射,None 表示未指定代理
:param timeout: 浏览器页面访问超时秒数
:return: 渲染后的页面源码,未取得页面时为 None
"""
...
class SiteChallengePort(Protocol):
"""
声明站点页面挑战识别能力。
"""
def detected(self, html_text: str) -> bool:
"""
返回页面是否命中 Cloudflare 等挑战特征。
:param html_text: 待检测的站点 HTML 页面源码
:return: 命中 Cloudflare 等浏览器挑战特征时为 True,否则为 False
"""
...
class SiteCookieCloudPort(Protocol):
"""
声明 CookieCloud 同步入口的最小能力。
"""
def download(self) -> Tuple[Optional[dict[str, str]], str]:
"""
下载并解密按站点域名聚合的 Cookie。
:return: 按站点域名聚合的 Cookie 映射与错误说明;配置或请求失败时映射为 None,
无数据或解密失败时可为空字典,成功时错误说明为空字符串
"""
...
@dataclass(frozen=True, slots=True)
class _SiteAccessPorts:
"""
保存一次原子发布的站点链技术端口。
:param http: 有界 HTTP 响应访问端口
:param browser: 站点页面浏览器渲染端口
:param challenge: 站点页面挑战特征检测端口
:param cookiecloud: CookieCloud 下载和解密端口
"""
http: SiteHttpPort
browser: SiteBrowserPort
challenge: SiteChallengePort
cookiecloud: SiteCookieCloudPort
@dataclass(frozen=True, slots=True)
class _SiteConnectionResponse:
"""
保存 CookieCloud 新站点探测完成后的必要响应数据。
:param status_code: 新站点探测得到的 HTTP 状态码
:param text: 已复制的页面正文,用于登录状态与站点数据识别
"""
status_code: int
text: str
_site_ports_lock = threading.Lock()
_site_ports: Optional[_SiteAccessPorts] = None
def configure_site_ports(
*,
http: SiteHttpPort,
browser: SiteBrowserPort,
challenge: SiteChallengePort,
cookiecloud: SiteCookieCloudPort,
) -> None:
"""
由启动组合根一次发布站点链的全部技术端口。
"""
global _site_ports
ports = _SiteAccessPorts(
http=http,
browser=browser,
challenge=challenge,
cookiecloud=cookiecloud,
)
with _site_ports_lock:
_site_ports = ports
def reset_site_ports() -> None:
"""
原子清除站点链端口,支持重复关闭与失败回滚。
"""
global _site_ports
with _site_ports_lock:
_site_ports = None
def _site_ports_snapshot() -> _SiteAccessPorts:
"""
返回一致端口快照,未装配时稳定失败。
"""
with _site_ports_lock:
ports = _site_ports
if ports is None:
raise RuntimeError("站点链访问端口尚未由启动组合根装配")
return ports
def _open_site_response(
*,
method: str,
url: str,
headers: Optional[Mapping[str, Any]] = None,
cookie: Optional[str] = None,
ua: Optional[str] = None,
proxies: Optional[Mapping[str, Any]] = None,
timeout: int = 20,
json_body: Optional[Mapping[str, Any]] = None,
) -> AbstractContextManager[Optional[SiteResponsePort]]:
"""
通过当前 HTTP 端口打开一个必定有界关闭的响应上下文。
:param method: HTTP 请求方法
:param url: 完整请求地址
:param headers: 额外请求头
:param cookie: 登录 Cookie
:param ua: 用户代理字符串
:param proxies: 代理配置
:param timeout: 请求超时秒数
:param json_body: JSON 请求体,None 表示不发送 JSON
:return: 由调用方进入并负责退出的响应上下文
"""
return _site_ports_snapshot().http.open(
method=method,
url=url,
headers=headers,
cookie=cookie,
ua=ua,
proxies=proxies,
timeout=timeout,
json_body=json_body,
)
class SiteChain(InteractionChainMixin, ChainBase):
"""
站点管理处理链
"""
# 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托
_interaction_handler_type = SiteInteractionHandler
def __init__(self) -> None:
"""
初始化站点管理处理链及按域名、资源协议选择的连接测试器。
"""
super().__init__()
# 特殊站点登录验证
self.special_site_test = {
"zhuque.in": self.__zhuque_test,
"m-team.io": self.__mteam_test,
"m-team.cc": self.__mteam_test,
"yemapt.org": self.__yema_test,
"hddolby.com": self.__hddolby_test,
"rousi.pro": self.__rousi_test,
"sunnypt.top": self.__sunnypt_test,
"milkie.cc": self.__milkie_test,
}
# 资源的 schema 描述资料协议,parser 描述搜索协议;二者均可识别 API 站点。
self.protocol_site_test = {
"MTorrent": self.__mteam_test,
"mTorrent": self.__mteam_test,
"HDDolby": self.__hddolby_test,
"RousiPro": self.__rousi_test,
"SunnyPT": self.__sunnypt_test,
"Yema": self.__yema_api_test,
"Milkie": self.__milkie_test,
"TNode": self.__zhuque_test,
"TNodeSpider": self.__zhuque_test,
}
def refresh_userdata(self, site: Optional[dict[str, Any]] = None) -> Optional[SiteUserData]:
"""
刷新站点的用户数据
:param site: 站点
:return: 用户数据
"""
userdata: SiteUserData = self.run_module("refresh_userdata", site=site)
if userdata and userdata.userid and site:
domain = site_rules.extract_domain(
str(site.get("domain") or site.get("url") or "")
)
name = str(site.get("name") or domain)
self.site_repository.update_userdata(
domain=domain,
name=name,
mutation=SiteUserDataMutation(userdata.model_dump()),
)
self.eventmanager.send_event(EventType.SiteRefreshed, {
"site_id": site.get("id")
})
self._post_site_messages(site=site, userdata=userdata)
# 低分享率警告
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)):
self.post_message(Message(
mtype=MessageType.SiteMessage,
title="【站点分享率低预警】",
text=f"站点 {site.get('name')} 分享率 {userdata.ratio},请注意!"
))
return userdata
def _post_site_messages(self, site: dict[str, Any], userdata: SiteUserData) -> None:
"""
发送站点未读消息,并按解析器提供的来源标识做持久化去重。
:param site: 站点索引配置
:param userdata: 本次刷新的站点用户数据
"""
if not userdata.message_unread:
return
if not userdata.message_unread_contents:
self.post_message(Message(
mtype=MessageType.SiteMessage,
title=f"站点 {site.get('name')} 收到 "
f"{userdata.message_unread} 条新消息,请登陆查看",
link=site.get("url")
))
return
for message in userdata.message_unread_contents:
head, date, content, *metadata = message
message_source = metadata[0] if metadata else None
if message_source and self.messageoper.exists_by_source(message_source):
continue
msg_title = f"【站点 {site.get('name')} 消息】"
msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}"
self.post_message(Message(
source=message_source,
mtype=MessageType.SiteMessage,
title=msg_title,
text=msg_text,
link=site.get("url")
))
def refresh_userdatas(
self,
progress_callback: Optional[Callable[..., None]] = None,
) -> Optional[Dict[str, SiteUserData]]:
"""
刷新所有站点的用户数据
:param progress_callback: 定时服务进度更新回调
"""
any_site_updated = False
result: dict[str, SiteUserData] = {}
sites = [site for site in SitesHelper().get_indexers() if site.get("is_active")]
total_num = len(sites)
if progress_callback:
progress_callback(
value=0,
text=f"开始刷新站点数据,共 {total_num} 个站点 ...",
data={"total": total_num, "finished": 0},
)
for index, site in enumerate(sites, start=1):
if runtime_stop_state.is_system_stopped:
return None
if progress_callback:
progress_callback(
value=(index - 1) / total_num * 100 if total_num else 100,
text=f"正在刷新站点数据({index}/{total_num}){site.get('name')} ...",
data={
"total": total_num,
"finished": index - 1,
"current": site.get("id"),
},
)
userdata = self.refresh_userdata(site)
if userdata and userdata.userid:
any_site_updated = True
site_name = str(site.get("name") or site.get("domain") or "")
if site_name:
result[site_name] = userdata
if progress_callback:
progress_callback(
value=index / total_num * 100 if total_num else 100,
text=f"站点数据({index}/{total_num})刷新完成",
data={"total": total_num, "finished": index},
)
if any_site_updated:
self.eventmanager.send_event(EventType.SiteRefreshed, {
"site_id": "*"
})
if progress_callback:
progress_callback(value=100, text="站点数据刷新完成")
return result
def is_special_site(self, domain: str) -> bool:
"""
判断是否特殊站点
"""
return domain in self.special_site_test
def __zhuque_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
提取 TNode CSRF 令牌并通过当前用户 API 验证 Cookie 会话。
:param site: TNode 协议站点配置
:return: 是否可用及认证或连接失败原因
"""
token = None
user_agent = site.ua or self.runtime_config.user_agent
with _open_site_response(
method="GET",
url=site.url,
ua=user_agent,
cookie=site.cookie or "",
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
csrf_token = re.search(
r'<meta name="x-csrf-token" content="(.+?)">', res.text
)
if csrf_token:
token = csrf_token.group(1)
else:
return False, f"错误:{res.status_code} {res.reason}"
if not token:
return False, "无法获取Token"
with _open_site_response(
method="GET",
url=f"{site.url}api/user/getInfo",
headers={
'X-CSRF-TOKEN': token,
"Content-Type": "application/json; charset=utf-8",
"User-Agent": f"{user_agent}"
},
cookie=site.cookie or "",
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as user_res:
if user_res is None:
return False, "无法打开网站!"
if user_res.status_code == 200:
user_info = user_res.json()
if user_info and user_info.get("data"):
return True, "连接成功"
return False, "Cookie已失效"
return False, f"错误:{user_res.status_code} {user_res.reason}"
def __mteam_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
通过 MTorrent 用户资料接口验证 API Key,不依赖首页登录标记。
:param site: M-Team 或兼容 MTorrent 协议的站点配置
:return: 是否可用及接口返回的状态信息
"""
user_agent = site.ua or self.runtime_config.user_agent
domain = site_rules.extract_domain(site.url)
indexer = SitesHelper().get_indexer(domain) or {}
configured_host = indexer.get("api_domain") or indexer.get("api_host")
api_host = urlparse(site.url).netloc
if configured_host:
value = str(configured_host).strip()
api_host = urlparse(value if "://" in value else f"//{value}").netloc
elif not api_host.startswith("api."):
api_host = f"api.{domain}"
url = f"https://{api_host}/api/member/profile"
headers = {
"User-Agent": user_agent,
"Accept": "application/json, text/plain, */*",
"x-api-key": site.apikey,
}
with _open_site_response(
method="POST",
url=url,
headers=headers,
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
user_info = res.json() or {}
if str(user_info.get("code")) == "0" and user_info.get("data"):
return True, "连接成功"
return False, user_info.get("message", "鉴权已过期或无效")
return False, f"错误:{res.status_code} {res.reason}"
def __milkie_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
通过种子搜索接口测试 Milkie API Key
:param site: Milkie 站点配置
:return: 是否可用及状态信息
"""
with _open_site_response(
method="GET",
url=f"{site.url.rstrip('/')}/api/v1/torrents?ps=1",
headers={
"Accept": "application/json",
"User-Agent": site.ua or self.runtime_config.user_agent,
"x-milkie-auth": site.apikey,
},
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
payload = res.json()
if isinstance(payload, dict) and isinstance(payload.get("torrents"), list):
return True, "连接成功"
return False, "站点返回了无效的种子数据"
if res.status_code in (401, 403):
return False, "API Key 无效或已过期"
return False, f"错误:{res.status_code} {res.reason}"
def __sunnypt_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
通过 profile 接口测试 SunnyPT API Key 和下载权限
:param site: SunnyPT 站点配置
:return: 是否可用及状态信息
"""
domain = site.domain or site_rules.extract_domain(site.url)
indexer = SitesHelper().get_indexer(domain) or {}
api_url = str(
indexer.get("api_url") or f"https://api.{domain}/api/v1/mp"
).rstrip("/")
with _open_site_response(
method="GET",
url=f"{api_url}/profile",
headers={
"Accept": "application/json",
"User-Agent": site.ua or self.runtime_config.user_agent,
"X-API-Key": site.apikey,
},
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, "无法连接 SunnyPT API 服务"
if res.status_code != 200:
return False, f"错误:{res.status_code} {res.reason}"
try:
payload = res.json() or {}
except (TypeError, ValueError):
return False, "SunnyPT API 响应不是有效 JSON"
if str(payload.get("code")) != "0" or not isinstance(
payload.get("data"), dict
):
return False, payload.get("msg") or "API Key 已过期或无效"
if payload["data"].get("download_allowed") is False:
return False, "当前账号没有下载权限"
return True, "连接成功"
def __yema_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
优先验证 Yema AuthKey,未配置时保留旧版 Cookie 会话探测。
:param site: YemaPT 站点配置
:return: 是否可用及认证或连接失败原因
"""
if site.apikey:
return self.__yema_api_test(site)
user_agent = site.ua or self.runtime_config.user_agent
url = f"{site.url}api/consumer/fetchSelfDetail"
headers = {
"User-Agent": user_agent,
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
}
with _open_site_response(
method="GET",
url=url,
headers=headers,
cookie=site.cookie or "",
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("success"):
return True, "连接成功"
return False, "Cookie已过期"
return False, f"错误:{res.status_code} {res.reason}"
def __yema_api_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
通过 Yema 开放 API 验证 AuthKey,与搜索和资料解析使用相同认证方式。
:param site: Yema 协议站点配置
:return: 是否可用及认证或连接失败原因
"""
if not site.apikey:
return False, "未配置 AuthKey"
with _open_site_response(
method="POST",
url=f"{site.url.rstrip('/')}/openApi/user/fetchBasicInfo.json",
headers={
"Authorization": site.apikey,
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"User-Agent": site.ua or self.runtime_config.user_agent,
},
json_body={},
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, "无法打开网站!"
if res.status_code != 200:
return False, f"错误:{res.status_code} {res.reason}"
payload = res.json() or {}
if payload.get("success") and isinstance(payload.get("data"), dict) and payload["data"].get("id"):
return True, "连接成功"
return False, payload.get("errorMessage") or "AuthKey 已过期或无效"
def __hddolby_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
通过 HDDolby 用户 API 验证 API Key,与搜索和资料解析使用相同资源 API 基础地址。
未配置 api_url 时沿用旧站的 api 子域,兼容存量资源和自定义索引。
API 失败提示仅描述接口主机,不据此断言网页不可访问,也不以网页成功掩盖 API 故障。
:param site: HDDolby 协议站点配置
:return: 是否可用及认证或连接失败原因
"""
domain = site.domain or site_rules.extract_domain(site.url)
indexer = SitesHelper().get_indexer(domain) or {}
api_url = str(indexer.get("api_url") or f"https://api.{domain}/api/v1").rstrip("/")
api_host = urlparse(api_url).hostname
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"x-api-key": site.apikey,
}
with _open_site_response(
method="GET",
url=f"{api_url}/user/data",
headers=headers,
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, f"HDDolby API 主机 {api_host} 无法连接,请检查 API 地址及网络代理"
if res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("status") == 0:
return True, "连接成功"
return False, "APIKEY已过期"
return False, f"HDDolby API 主机 {api_host} 返回 HTTP {res.status_code} {res.reason}"
def __rousi_test(self, site: SiteSnapshot) -> Tuple[bool, str]:
"""
使用 PeerGo 个人 API Key 验证 RousiPro 协议站点连接。
:param site: 使用 Bearer 个人 API Key 的站点配置
:return: 是否可用及认证或连接失败原因
"""
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",
"Accept": "application/json",
"Authorization": f"Bearer {site.apikey}",
}
with _open_site_response(
method="GET",
url=url,
headers=headers,
proxies=(self.runtime_config.proxy or {}) if site.proxy else {},
timeout=site.timeout or 15,
) as res:
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
try:
user_info = res.json()
except (TypeError, ValueError):
return False, "站点返回了无效的用户数据"
if user_info and user_info.get("code") == 0:
return True, "连接成功"
return False, "个人 API Key 已失效或权限不足"
if res.status_code in (401, 403):
return False, "个人 API Key 已失效或权限不足"
return False, f"错误:{res.status_code} {res.reason}"
@staticmethod
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
"""
解析站点图标地址,并读取其 Base64 编码内容。
:param url: 站点地址
:param cookie: 站点认证 Cookie 文本
:param ua: 请求使用的 User-Agent
:return: 图标完整地址及 Base64 编码内容,读取失败时内容为 None
"""
favicon_url = urljoin(url, "favicon.ico")
with _open_site_response(
method="GET", url=url, cookie=cookie, timeout=30, ua=ua
) as res:
if res:
html_text = res.text
else:
logger.error(f"获取站点页面失败:{url_tools.url_origin_for_log(url)}")
return favicon_url, None
html = etree.HTML(html_text)
try:
if DomUtils.has_child_elements(html):
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
if fav_link:
favicon_url = urljoin(url, fav_link[0])
with _open_site_response(
method="GET", url=favicon_url,
cookie=cookie if url_tools.UrlUtils.same_origin(url, favicon_url) else None, timeout=15, ua=ua
) as res:
if res:
return favicon_url, base64.b64encode(res.content).decode()
logger.error(f"获取站点图标失败:{url_tools.url_origin_for_log(favicon_url)}")
finally:
if html is not None:
del html
return favicon_url, None
def sync_cookies(
self,
manual: bool = False,
progress_callback: Optional[Callable[..., None]] = None,
) -> Tuple[bool, str]:
"""
通过CookieCloud同步站点Cookie
:param manual: 是否手动同步
:param progress_callback: 定时服务进度更新回调
"""
logger.info("开始同步CookieCloud站点 ...")
if progress_callback:
progress_callback(value=0, text="开始下载 CookieCloud 数据 ...")
cookies, msg = _site_ports_snapshot().cookiecloud.download()
if not cookies:
logger.error(f"CookieCloud同步失败:{msg}")
if progress_callback:
progress_callback(value=100, text=f"CookieCloud同步失败:{msg}")
if manual:
self.messagehelper.put(msg, title="CookieCloud同步失败", role="system", level="error")
return False, msg
siteshelper = SitesHelper()
repository = self.site_repository
rsshelper = RssHelper()
total_num = len(cookies)
update_count = add_count = fail_count = 0
for index, (domain, cookie) in enumerate(cookies.items(), start=1):
if runtime_stop_state.is_system_stopped:
logger.info("系统正在停止,中断CookieCloud同步")
return False, "系统正在停止,同步被中断"
if progress_callback:
progress_callback(
value=(index - 1) / total_num * 100 if total_num else 100,
text=f"正在同步 CookieCloud 站点({index}/{total_num}){domain} ...",
data={
"total": total_num,
"finished": index - 1,
"current": domain,
},
)
indexer = siteshelper.get_indexer(domain)
site_info = repository.get_by_domain(domain)
updated, added, failed, should_finalize = self._sync_cookiecloud_domain(
domain=domain,
cookie=cookie,
indexer=indexer,
site_info=site_info,
repository=repository,
rsshelper=rsshelper,
)
update_count += updated
add_count += added
fail_count += failed
if not should_finalize:
continue
if indexer:
self.eventmanager.send_event(EventType.SiteUpdated, {
"domain": domain,
})
if progress_callback:
progress_callback(
value=index / total_num * 100 if total_num else 100,
text=f"CookieCloud 站点({index}/{total_num})同步完成",
data={"total": total_num, "finished": index},
)
ret_msg = f"更新了{update_count}个站点,新增了{add_count}个站点"
if fail_count > 0:
ret_msg += f",{fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
if manual:
self.messagehelper.put(ret_msg, title="CookieCloud同步成功", role="system", level="success")
logger.info(f"CookieCloud同步成功:{ret_msg}")
if progress_callback:
progress_callback(value=100, text=f"CookieCloud同步成功:{ret_msg}")
return True, ret_msg
def _sync_cookiecloud_domain(
self,
domain: str,
cookie: str,
indexer: Optional[dict[str, Any]],
site_info: Optional[SiteSnapshot],
repository: SiteRepository,
rsshelper: RssHelper,
) -> Tuple[int, int, int, bool]:
"""
处理单个域名,并返回计数与是否继续发送更新事件。
"""
if site_info and site_info.is_active:
status, _ = self.test(domain)
if status:
logger.info(f"站点【{site_info.name}】连通性正常,不同步CookieCloud数据")
if not site_info.public and not site_info.rss:
rss_url: Optional[str]
rss_url, errmsg = rsshelper.get_rss_link(
url=site_info.url,
cookie=cookie,
ua=site_info.ua or self.runtime_config.user_agent,
proxy=bool(site_info.proxy),
timeout=site_info.timeout or 15,
)
if rss_url:
repository.update_rss(domain=domain, rss=rss_url)
else:
logger.warning(errmsg)
return 0, 0, 0, False
logger.info(f"更新站点 {domain} Cookie ...")
repository.update_cookie(domain=domain, cookies=cookie)
return 1, 0, 0, True
if not indexer:
return 0, 0, 0, True
if self._cookiecloud_blacklisted(domain):
logger.warning(f"站点 {domain} 已在黑名单中,不添加站点")
return 0, 0, 0, False
domain_url = self._cookiecloud_indexer_domain(indexer, domain)
login_url = site_rules.resolve_page_url(domain_url, indexer.get("login_path"))
proxy, response = self._cookiecloud_connect(login_url, cookie, indexer)
if response is None:
return 0, 0, 1, False
if response.status_code not in [200, 500, 403]:
logger.warning(f"站点 {indexer.get('name')} 连接状态码:{response.status_code},无法添加站点")
return 0, 0, 1, False
if not indexer.get("public") and not SiteUtils.is_logged_in(response.text):
logger.warning(f"站点 {indexer.get('name')} 登录失败,无法添加站点")
return 0, 0, 1, False
rss_url = None
if not indexer.get("public") and domain_url:
rss_url, errmsg = rsshelper.get_rss_link(
url=domain_url, cookie=cookie, ua=self.runtime_config.user_agent, proxy=proxy
)
if errmsg:
logger.warning(errmsg)
repository.add(SiteMutation({
"name": indexer.get("name"),
"url": domain_url,
"domain": domain,
"cookie": cookie,
"rss": rss_url,
"proxy": 1 if proxy else 0,
"public": 1 if indexer.get("public") else 0,
}))
return 0, 1, 0, True
def _cookiecloud_blacklisted(self, domain: str) -> bool:
"""
判断域名是否命中 CookieCloud 黑名单。
"""
blacklist = self.runtime_config.cookiecloud_blacklist
return bool(blacklist) and any(
site_rules.extract_domain(domain) == site_rules.extract_domain(item)
for item in str(blacklist).split(",")
)
@staticmethod
def _cookiecloud_indexer_domain(indexer: dict[str, Any], sub_domain: str) -> str:
"""
根据索引器主域名和扩展域名解析实际访问地址。
"""
indexer_domain = indexer.get("domain")
if isinstance(indexer_domain, str) and site_rules.extract_domain(indexer_domain) == sub_domain:
return indexer_domain
ext_domains = indexer.get("ext_domains")
if isinstance(ext_domains, list):
for ext_domain in ext_domains:
if isinstance(ext_domain, str) and site_rules.extract_domain(ext_domain) == sub_domain:
return ext_domain
return sub_domain
def _cookiecloud_connect(
self, domain_url: str, cookie: str, indexer: dict[str, Any]
) -> Tuple[bool, Optional[_SiteConnectionResponse]]:
"""
连接新站点,必要时通过已配置代理重试。
"""
with _open_site_response(
method="GET",
url=domain_url,
cookie=cookie,
ua=self.runtime_config.user_agent,
) as response:
if response is not None or not self.runtime_config.proxy_host:
return False, self._site_connection_response(response)
logger.info(f"站点 {indexer.get('name')} 初次连接失败,尝试通过代理重试...")
with _open_site_response(
method="GET",
url=domain_url,
cookie=cookie,
ua=self.runtime_config.user_agent,
proxies=self.runtime_config.proxy,
) as response:
return True, self._site_connection_response(response)
@staticmethod
def _site_connection_response(
response: Optional[SiteResponsePort],
) -> Optional[_SiteConnectionResponse]:
"""
在关闭响应前投影 CookieCloud 新站点探测所需字段。
"""
if response is None:
return None
return _SiteConnectionResponse(
status_code=response.status_code,
text=response.text,
)
@eventmanager.register(EventType.SiteUpdated)
def cache_site_icon(self, event: Event) -> None:
"""
站点更新后缓存对应图标。
:param event: 包含站点域名的更新事件
:return: 无返回值
"""
if not event:
return
event_data = event.event_data if isinstance(event.event_data, dict) else {}
domain = event_data.get("domain")
if not domain:
return
if str(domain).startswith("http"):
domain = site_rules.extract_domain(domain)
repository = self.site_repository
siteshelper = SitesHelper()
siteinfo = repository.get_by_domain(domain)
if not siteinfo:
logger.warn(f"未维护站点 {domain} 信息!")
return
cookie = siteinfo.cookie
indexer = siteshelper.get_indexer(domain)
if not indexer:
logger.warn(f"站点 {domain} 索引器不存在!")
return
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
icon_domain = indexer.get("domain")
icon_name = indexer.get("name")
if not isinstance(icon_domain, str) or not isinstance(icon_name, str):
logger.warn(f"站点 {domain} 索引器缺少图标域名或名称!")
return
icon_url, icon_base64 = self.__parse_favicon(url=icon_domain,
cookie=cookie or "",
ua=self.runtime_config.user_agent)
if icon_url:
repository.update_icon(name=icon_name,
domain=domain,
icon_url=icon_url,
icon_base64=icon_base64 or "")
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
else:
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
@eventmanager.register(EventType.SiteUpdated)
def clear_site_data(self, event: Event) -> None:
"""
站点更新后清理对应的站点配置。
:param event: 包含站点域名的更新事件
:return: 无返回值
"""
if not event:
return
event_data = event.event_data if isinstance(event.event_data, dict) else {}
domain = event_data.get("domain")
if not domain:
return
domain_host = url_tools.host_label(domain)
# 查询以"site.domain_host"开头的配置项,并清除
# SystemConfigService 未提供 all(),无参 get() 经仓库返回全部配置字典
systemconfig = get_configured_system_config()
site_keys = systemconfig.get().keys()
for key in site_keys:
if key.startswith(f"site.{domain_host}"):
logger.info(f"清理站点配置:{key}")
systemconfig.delete(key)
@eventmanager.register(EventType.SiteUpdated)
def cache_site_userdata(self, event: Event) -> None:
"""
站点更新后刷新对应的用户资料。
:param event: 包含站点域名的更新事件
:return: 无返回值
"""
if not event:
return
event_data = event.event_data if isinstance(event.event_data, dict) else {}
domain = event_data.get("domain")
if not domain:
return
if str(domain).startswith("http"):
domain = site_rules.extract_domain(domain)
indexer = SitesHelper().get_indexer(domain)
if not indexer:
return
self.refresh_userdata(site=indexer) or {}
def test(self, url: str) -> Tuple[bool, str]:
"""
测试站点是否可用
:param url: 站点域名
:return: (是否可用, 错误信息)
"""
domain = site_rules.extract_domain(url)
repository = self.site_repository
site_info = repository.get_by_domain(domain)
if not site_info:
return False, f"站点【{url}】不存在"
try:
start_time = datetime.now()
special_test = self.special_site_test.get(domain)
if special_test:
state, message = special_test(site_info)
else:
indexer = SitesHelper().get_indexer(domain) or {}
protocol_test = self.protocol_site_test.get(str(indexer.get("schema") or "")) \
or self.protocol_site_test.get(str(indexer.get("parser") or ""))
if protocol_test:
state, message = protocol_test(site_info)
else:
state, message = self.__test(
replace(
site_info,
url=site_rules.resolve_page_url(
site_info.url, indexer.get("login_path")
),
)
)
seconds = (datetime.now() - start_time).seconds
if state:
repository.success(domain=domain, seconds=seconds)
else:
repository.fail(domain)
return state, message
except Exception as e:
return False, f"{str(e)}!"
def __test(self, site_info: SiteSnapshot) -> Tuple[bool, str]:
"""
通用站点测试
"""
site_url = site_info.url
site_cookie = site_info.cookie or ""
ua = site_info.ua or self.runtime_config.user_agent
render = site_info.render
public = site_info.public
proxies = (self.runtime_config.proxy or {}) if site_info.proxy else {}
proxy_server = self.runtime_config.proxy_server if site_info.proxy else None
timeout = site_info.timeout or 60
if render:
ports = _site_ports_snapshot()
page_source = ports.browser.render(
url=site_url,
cookies=site_cookie,
ua=ua,
proxies=proxy_server,
timeout=timeout,
) or ""
if not public and not SiteUtils.is_logged_in(page_source):
if ports.challenge.detected(page_source):
return False, "无法通过Cloudflare!"
return False, "仿真登录失败,Cookie已失效!"
else:
with _open_site_response(
method="GET",
url=site_url,
cookie=site_cookie,
ua=ua,
proxies=proxies,
) as res:
if res and res.status_code in [200, 500, 403]:
content = res.text
if not public and not SiteUtils.is_logged_in(content):
if _site_ports_snapshot().challenge.detected(content):
msg = "站点被Cloudflare防护,请打开站点浏览器仿真"
elif res.status_code == 200:
msg = "Cookie已失效"
else:
msg = f"错误:{res.status_code} {res.reason}"
return False, f"{msg}!"
if public and res.status_code != 200:
return False, f"错误:{res.status_code} {res.reason}!"
elif res is not None:
return False, f"错误:{res.status_code} {res.reason}!"
else:
return False, "无法打开网站!"
return True, "连接成功"
def _interaction_handler(self) -> "SiteInteractionHandler":
"""
构造 /sites 交互处理器,Cookie 更新动作由本链提供。
"""
return SiteInteractionHandler(
messenger=self,
cookie_updater=self.update_cookie,
repository=self.site_repository,
)
def remote_disable(self, arg_str: str, channel: NotificationChannel,
userid: Optional[Union[str, int]] = None, source: Optional[str] = None) -> None:
"""
按远程命令中的编号禁用站点,并反馈操作结果。
:param arg_str: 站点编号文本
:param channel: 回复消息的渠道
:param userid: 渠道用户标识,未提供时由渠道决定接收者
:param source: 消息来源标识
:return: 无返回值
"""
if not arg_str:
return
arg_str = str(arg_str).strip()
if not arg_str.isdigit():
return
site_id = int(arg_str)
repository = self.site_repository
site = repository.get(site_id)
if not site:
self.post_message(Message(
channel=channel,
title=f"站点编号 {site_id} 不存在!",
userid=userid,
save_history=False))
return
repository.update(site_id, SiteMutation({"is_active": False}))
self.remote_list(channel=channel, userid=userid, source=source)
def remote_enable(self, arg_str: str, channel: NotificationChannel,
userid: Optional[Union[str, int]] = None, source: Optional[str] = None) -> None:
"""
按远程命令中的编号启用站点,并反馈操作结果。
:param arg_str: 一个或多个站点编号
:param channel: 回复消息的渠道
:param userid: 渠道用户标识,未提供时由渠道决定接收者
:param source: 消息来源标识
:return: 无返回值
"""
if not arg_str:
return
arg_strs = str(arg_str).split()
repository = self.site_repository
for arg_str in arg_strs:
arg_str = arg_str.strip()
if not arg_str.isdigit():
continue
site_id = int(arg_str)
site = repository.get(site_id)
if not site:
self.post_message(Message(
channel=channel,
title=f"站点编号 {site_id} 不存在!",
userid=userid,
save_history=False))
return
repository.update(site_id, SiteMutation({"is_active": True}))
self.remote_list(channel=channel, userid=userid, source=source)
def update_cookie(self, site_info: SiteCookieLoginSource,
username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]:
"""
根据用户名密码更新站点Cookie
:param site_info: 站点信息
:param username: 用户名
:param password: 密码
:param two_step_code: 二步验证码或密钥
:return: (是否成功, 错误信息)
"""
site_id = site_info.id
site_url = site_info.url
if site_id is None or not site_url:
return False, "站点配置不完整"
result = CookieHelper().get_site_cookie_ua(
url=site_url,
username=username,
password=password,
two_step_code=two_step_code,
proxies=self.runtime_config.proxy_server if site_info.proxy else None,
timeout=site_info.timeout or 60
)
if result:
cookie, ua, msg = result
if not cookie:
return False, msg
self.site_repository.update(site_id, SiteMutation({
"cookie": cookie,
"ua": ua
}))
return True, msg
return False, "未知错误"
def remote_cookie(self, arg_str: str, channel: NotificationChannel,
userid: Optional[Union[str, int]] = None, source: Optional[str] = None) -> None:
"""
从远程命令读取凭据并更新站点 Cookie。
:param arg_str: 站点编号、用户名、密码及可选二次验证码
:param channel: 回复消息的渠道
:param userid: 渠道用户标识,未提供时由渠道决定接收者
:param source: 消息来源标识
:return: 无返回值
"""
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password] [2fa_code/secret]," \
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码,[2fa_code/secret]为站点二步验证码或密钥"
if not arg_str:
self.post_message(Message(
channel=channel,
source=source,
title=err_title,
userid=userid,
save_history=False))
return
arg_str = str(arg_str).strip()
args = arg_str.split()
two_step_code = None
if len(args) == 4:
two_step_code = args[3]
elif len(args) != 3:
self.post_message(Message(
channel=channel,
source=source,
title=err_title,
userid=userid,
save_history=False))
return
raw_site_id = args[0]
if not raw_site_id.isdigit():
self.post_message(Message(
channel=channel,
source=source,
title=err_title,
userid=userid,
save_history=False))
return
site_id = int(raw_site_id)
site_info = self.site_repository.get(site_id)
if not site_info:
self.post_message(Message(
channel=channel,
source=source,
title=f"站点编号 {site_id} 不存在!",
userid=userid,
save_history=False))
return
self.post_message(Message(
channel=channel,
source=source,
title=f"开始更新【{site_info.name}】Cookie&UA ...",
userid=userid,
save_history=False))
username = args[1]
password = args[2]
status, msg = self.update_cookie(site_info=site_info,
username=username,
password=password,
two_step_code=two_step_code)
if not status:
logger.error(msg)
self.post_message(Message(
channel=channel,
source=source,
title=f"【{site_info.name}】 Cookie&UA更新失败!",
text=f"错误原因:{msg}",
userid=userid,
save_history=False))
else:
self.post_message(Message(
channel=channel,
source=source,
title=f"【{site_info.name}】 Cookie&UA更新成功",
userid=userid,
save_history=False))
def remote_refresh_userdatas(self, channel: NotificationChannel,
userid: Optional[Union[str, int]] = None, source: Optional[str] = None) -> None:
"""
刷新站点用户资料并向请求渠道发送流量汇总。
:param channel: 回复消息的渠道
:param userid: 渠道用户标识,未提供时由渠道决定接收者
:param source: 消息来源标识
:return: 无返回值
"""
logger.info("收到命令,开始刷新站点数据 ...")
self.post_message(Message(
channel=channel,
source=source,
title="开始刷新站点数据 ...",
userid=userid,
save_history=False,
))
site_datas = self.refresh_userdatas()
if site_datas:
messages = {}
incUploads = 0
incDownloads = 0
today_date = datetime.now().strftime("%Y-%m-%d")
for rand, site in enumerate(site_datas.keys()):
upload = int(site_datas[site].upload or 0)
download = int(site_datas[site].download or 0)
updated_date = site_datas[site].updated_day
if updated_date and updated_date != today_date:
updated_date = f"({updated_date})"
else:
updated_date = ""
if upload > 0 or download > 0:
incUploads += upload
incDownloads += download
messages[upload + (rand / 1000)] = (
f"【{site}】{updated_date}\n"
+ f"上传量:{size_tools.format_compact_size(upload)}\n"
+ f"下载量:{size_tools.format_compact_size(download)}\n"
+ "————————————"
)
if incDownloads or incUploads:
sorted_messages = [messages[key] for key in sorted(messages.keys(), reverse=True)]
sorted_messages.insert(0, f"【汇总】\n"
f"总上传:{size_tools.format_compact_size(incUploads)}\n"
f"总下载:{size_tools.format_compact_size(incDownloads)}\n"
f"————————————")
self.post_message(Message(
channel=channel,
source=source,
title="【站点数据统计】",
text="\n".join(sorted_messages),
userid=userid,
save_history=False
))
else:
self.post_message(Message(
channel=channel,
source=source,
title="没有刷新到任何站点数据!",
userid=userid,
save_history=False,
))