Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

157 lines
6.5 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.
"""
搜索会话的持久检查点端口;页面候选和游标必须一次保存。
"""
import json
import re
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Any, Optional, Protocol
from urllib.parse import parse_qsl, urlsplit
from app.domain.search import SearchCollection, SearchSourceCursor
_TORRENT_FIELDS = (
"site", "site_name", "title", "description", "media_source", "media_id",
"size", "seeders", "peers", "grabs", "pubdate", "freedate", "uploadvolumefactor",
"downloadvolumefactor", "hit_and_run", "labels", "pri_order", "category", "site_order",
)
_PUBLIC_QUERY_FIELDS = {"id", "tid", "torrentid", "torrent_id", "https", "type"}
# 路径中含字母的长十六进制串或字母数字混合长串可能是 passkey/RSS key;纯数字 ID 和带连字符的 slug 不受影响。
_SECRET_PATH_SEGMENT = re.compile(r"^(?:(?=[0-9]*[A-Fa-f])[0-9A-Fa-f]{16,}|(?=.*[0-9])(?=.*[A-Za-z])[A-Za-z0-9]{24,})$")
# 检查点超过该时长未更新时由保底清理删除;正常情况下任务进入终态即删除。
CHECKPOINT_RETENTION_SECONDS = 14 * 24 * 3600
def public_resource_url(value: Optional[str]) -> Optional[str]:
"""
只保存公开定位 URL,携带票据或内嵌请求凭据的下载地址交给当前站点重新生成。
"""
if not value:
return None
parts = urlsplit(value)
if parts.scheme not in {"http", "https"} or parts.username or parts.password:
return None
if any(key.lower() not in _PUBLIC_QUERY_FIELDS for key, _ in parse_qsl(parts.query)):
return None
if any(_SECRET_PATH_SEGMENT.match(segment.split(".", 1)[0]) for segment in parts.path.split("/")):
return None
return parts._replace(fragment="").geturl()
def torrent_snapshot(torrent: Any) -> dict[str, Any]:
"""
白名单保存候选事实;Cookie/API Key 和间接下载配方不得进入检查点。
"""
values = {name: getattr(torrent, name, None) for name in _TORRENT_FIELDS}
values["page_url"] = public_resource_url(getattr(torrent, "page_url", None))
enclosure = public_resource_url(getattr(torrent, "enclosure", None))
# 未验证的路径型票据也可能藏有 passkey,只有显式公共 ID 查询可直接保存。
values["enclosure"] = enclosure if enclosure and any(
key.lower() in {"id", "tid", "torrentid", "torrent_id"} for key, _ in parse_qsl(urlsplit(enclosure).query)
) else None
return values
def encode_search_state(value: dict[str, Any]) -> str:
"""
以确定性 JSON 编码页面事务,枚举和集合只保存值。
"""
def normalize(item: Any) -> Any:
"""
保留确定性枚举和集合值,不对未知对象做隐式字符串化。
"""
if isinstance(item, Enum):
return item.value
if isinstance(item, (set, frozenset)):
return sorted(item)
raise TypeError(f"不支持的搜索快照类型:{type(item).__name__}")
return json.dumps(value, default=normalize, ensure_ascii=False, sort_keys=True)
def collection_snapshot(collection: SearchCollection) -> dict[str, Any]:
"""
把纯收集状态投影为持久值;资源本身由候选白名单单独保存。
"""
return asdict(collection)
def restore_collection(values: dict[str, Any]) -> SearchCollection:
"""
恢复每来源的分页进度及关闭证据。
"""
sources = {}
for key, data in values["sources"].items():
params = dict(data)
# 旧检查点未记录作品匹配前史;已有进度时不能断言此前从未命中,只应用页数上限。
params.setdefault("matched_work", bool(params.get("next_page")))
for name in ("seen", "closed"):
params[name] = set(params.get(name, []))
sources[key] = SearchSourceCursor(**params)
return SearchCollection(set(values["targets"]), sources, set(values["settled"]), set(values["fallback"]),
dict(values.get("released") or {}))
@dataclass(frozen=True, slots=True)
class SearchSessionSnapshot:
"""
脱离数据库会话的不可变分页快照,不包含运行期站点凭据。
:param task_id: 分页检查点所属的持久搜索任务标识
:param version: 检查点的乐观并发版本,用于拒绝迟到写入
:param payload: 包含页面候选、收集状态和下一页位置的 JSON 文本,不保存站点凭据
"""
task_id: str
version: int
payload: str
class SearchSessionRepository(Protocol):
"""
订阅搜索任务的检查点;所有权由队列任务租约决定,版本比较交换排除迟到的执行者。
"""
def get(self, *, task_id: str) -> Optional[SearchSessionSnapshot]:
"""
读取任务的最新检查点。
:param task_id: 持久搜索任务的稳定标识
:return: 任务最新的分页检查点;不存在时返回 None
"""
...
def create(self, *, task_id: str, payload: str, task_lease: Optional[str]) -> Optional[SearchSessionSnapshot]:
"""
仍持有任务租约时创建初始检查点。
已有同任务检查点时递增版本并接管其内容,使旧执行者的快照失效。
:param task_id: 持久搜索任务的稳定标识
:param payload: 本页候选、收集状态及下一页位置的完整 JSON 文本
:param task_lease: 当前任务租约令牌;缺失或失效时不能修改检查点
:return: 成功创建或接管的检查点;未持有有效任务租约时返回 None
"""
...
def save(self, *, snapshot: SearchSessionSnapshot, payload: str,
task_lease: Optional[str]) -> Optional[SearchSessionSnapshot]:
"""
一次提交本页候选、收集状态和下一页;失去租约或版本冲突时返回空。
:param snapshot: 先前读取的检查点快照,其版本用于并发比较
:param payload: 本页候选、收集状态及下一页位置的完整 JSON 文本
:param task_lease: 当前任务租约令牌;缺失或失效时不能修改检查点
:return: 提交后的新版本检查点;租约失效或版本冲突时返回 None
"""
...
def delete(self, *, snapshot: SearchSessionSnapshot, task_lease: Optional[str]) -> None:
"""
本轮搜索结束后删除检查点;仅仍持有任务租约且版本未变时生效。
:param snapshot: 先前读取的检查点快照,其版本用于并发比较
:param task_lease: 当前任务租约令牌;缺失或失效时不能修改检查点
"""
...