# -*- 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: """去掉搜索结果里的 ```` 高亮标签。""" 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 matched_words(title: str, words: Any) -> list[str]: """返回标题命中(归一化子串)的词,保持 ``words`` 顺序、跳过空词。""" normalized = normalize_title(title) if not isinstance(words, (list, tuple)): return [] return [str(word) for word in words if word and normalize_title(str(word)) in normalized] def contains_any(title: str, words: Any) -> bool: return bool(matched_words(title, words)) 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) # 标题带日期通常意味着录播/回放/录像(按具体某场直播命名),作为强负向信号。 # 覆盖 `2026-10-28` / `2026/10/25` / `2026.10.26` / `2024年11月24日` / `10月25日`。 _DATE_PATTERNS = ( r"\d{4}\s*[-/.]\s*\d{1,2}\s*[-/.]\s*\d{1,2}", r"\d{4}\s*年\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*日)?", r"\d{1,2}\s*月\s*\d{1,2}\s*日", ) _DATE_RE = re.compile("|".join(_DATE_PATTERNS)) # 各信号的权重(见 project.md §3.3)。 REACTION_SCORE = 3 CHARGE_MULTI_SCORE = 3 CHARGE_SINGLE_SCORE = 1 WEAK_SCORE = 1 NEGATIVE_SCORE = 6 DATE_SCORE = 6 SELF_SCORE = 3 def date_hits(title: str) -> list[str]: """返回标题中的日期片段,用于判定「带日期≈录播/回放」。""" return _DATE_RE.findall(title or "") def score_item( *, title: str, up_mid: int | None, duration_seconds: int, classify: Mapping[str, Any], ) -> int: """按 project.md §3.3 计算候选分数。 强正向 ``reaction`` 只在标题同时命中 ``require_title`` 时成立;时长只作为 已存在 reaction / charge 信号时的加权,避免纯长录像仅靠时长进池。 """ score = 0 require_title = str(classify.get("require_title") or "") reaction_matched = bool(matched_words(title, classify.get("reaction"))) reaction_hit = reaction_matched and ( not require_title or passes_required_title(title, require_title) ) if reaction_hit: score += REACTION_SCORE charge_hits = matched_words(title, classify.get("charge")) if len(charge_hits) >= 2: score += CHARGE_MULTI_SCORE elif len(charge_hits) == 1: score += CHARGE_SINGLE_SCORE if matched_words(title, classify.get("weak_positive")): score += WEAK_SCORE if reaction_hit or charge_hits: 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 matched_words(title, classify.get("negative")): score -= NEGATIVE_SCORE if classify.get("date_negative", True) and date_hits(title): score -= DATE_SCORE if up_mid is not None and int(up_mid) == int(classify.get("self_mid", 37754047)): score -= SELF_SCORE return score