feat(p3): add discovery, telegram notifications, and one-click favorites
Implement WBI video search with legacy fallback, classification scoring, candidate pool, permanent seen-aid dedupe, blacklist filtering, and batched Telegram notifications. Add write client for fav deal/sort, target-folder selection, insertion position calculation, read-back verification, fav_action locking, retry on failure, ignore and blacklist actions. Wire discovery, candidate cleanup, Telegram long polling, and new discover/candidates CLI commands into the scheduler. Add migration v3 for candidate duration and index stats.
This commit is contained in:
1 parent
6a21b5d905
commit
d971e718e9
31 files changed
+2886
-55
No files matched your search
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""候选分类与时长解析。
|
||||
|
||||
规则见 project.md §3.3:分类只决定“是否通知”,不决定是否入库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any, Mapping
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def normalize_title(text: str) -> str:
|
||||
"""全半角归一化并转小写,便于标题关键词匹配。"""
|
||||
return unicodedata.normalize("NFKC", text or "").lower()
|
||||
|
||||
|
||||
def clean_title(text: str) -> str:
|
||||
"""去掉搜索结果里的 ``<em class="keyword">`` 高亮标签。"""
|
||||
return _TAG_RE.sub("", text or "").strip()
|
||||
|
||||
|
||||
def parse_duration(value: Any) -> int:
|
||||
"""解析 B 站搜索结果时长。
|
||||
|
||||
支持 ``105:5``(分钟:秒)、``1:02:03``(时:分:秒)和整数秒。
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if not isinstance(value, str):
|
||||
return 0
|
||||
parts = value.strip().split(":")
|
||||
if not parts or any(not part.isdigit() for part in parts):
|
||||
return 0
|
||||
numbers = [int(part) for part in parts]
|
||||
if len(numbers) == 1:
|
||||
return numbers[0]
|
||||
if len(numbers) == 2:
|
||||
return numbers[0] * 60 + numbers[1]
|
||||
if len(numbers) == 3:
|
||||
return numbers[0] * 3600 + numbers[1] * 60 + numbers[2]
|
||||
return 0
|
||||
|
||||
|
||||
def contains_any(title: str, words: Any) -> bool:
|
||||
normalized = normalize_title(title)
|
||||
if not isinstance(words, (list, tuple)):
|
||||
return False
|
||||
return any(normalize_title(str(word)) in normalized for word in words if word)
|
||||
|
||||
|
||||
def passes_required_title(title: str, require_title: str) -> bool:
|
||||
if not require_title:
|
||||
return True
|
||||
return normalize_title(require_title) in normalize_title(title)
|
||||
|
||||
|
||||
def score_item(
|
||||
*,
|
||||
title: str,
|
||||
up_mid: int | None,
|
||||
duration_seconds: int,
|
||||
classify: Mapping[str, Any],
|
||||
) -> int:
|
||||
"""按 project.md §3.3 计算候选分数。"""
|
||||
score = 0
|
||||
if contains_any(title, classify.get("positive")):
|
||||
score += 2
|
||||
if contains_any(title, classify.get("charge")):
|
||||
score += 2
|
||||
|
||||
boost = classify.get("duration_boost") or {}
|
||||
gt_60 = int(boost.get("gt_60min", 2))
|
||||
gt_30 = int(boost.get("gt_30min", 1))
|
||||
if duration_seconds > 60 * 60:
|
||||
score += gt_60
|
||||
elif duration_seconds > 30 * 60:
|
||||
score += gt_30
|
||||
|
||||
if contains_any(title, classify.get("negative")):
|
||||
score -= 5
|
||||
if up_mid is not None and int(up_mid) == int(classify.get("self_mid", 37754047)):
|
||||
score -= 3
|
||||
return score
|
||||
Reference in new issue
Block a user