feat(classify): zero title-only titles, penalize <10min, add discover.candidate_min_score

标题仅含 require_title 不再计分(必须咻咻满+任一正向词);时长 <10 分钟 -1(时长未知不扣);新增 discover.candidate_min_score(默认=notify.min_score)控制是否进候选池,只过标题关的不再入池。库内 796 条:仅过标题关的 8 条从 1/3 分降为 0/-1,其余不变。
This commit is contained in:
kairee committed 2026-11-01 20:18:43 +08:00
1 parent aa9aa7c589
commit c6790f0ba1
10 files changed
+138 -69

No files matched your search

+2 -2
View File
@@ -140,7 +140,7 @@ uv run python app.py run
- 搜索使用 `x/web-interface/wbi/search/type`,WBI 密钥缓存 6 小时,失败时降级旧版 `search/type`; - 搜索使用 `x/web-interface/wbi/search/type`,WBI 密钥缓存 6 小时,失败时降级旧版 `search/type`;
- 每轮按 `discover.keywords` 搜索前 `discover.pages` 页,过滤已入库/已见/超时间窗/黑名单/标题不含要求词的结果; - 每轮按 `discover.keywords` 搜索前 `discover.pages` 页,过滤已入库/已见/超时间窗/黑名单/标题不含要求词的结果;
- 分数规则与 `classify` 配置一致;`score >= notify.min_score` 才发送 Telegram; - 分数规则与 `classify` 配置一致;`score >= discover.candidate_min_score` 才写入候选池,`score >= notify.min_score` 才发送 Telegram;
- 通知按钮:**入库**、**忽略**、**拉黑**;回调只接受 `telegram.chat_id` 对应的私聊用户; - 通知按钮:**入库**、**忽略**、**拉黑**;回调只接受 `telegram.chat_id` 对应的私聊用户;
- 「入库」把视频写入本地库并置为 `active`,**不写 B 站收藏夹**;同时用取到的 `view` 数据补一条当天快照; - 「入库」把视频写入本地库并置为 `active`,**不写 B 站收藏夹**;同时用取到的 `view` 数据补一条当天快照;
- 私聊文本消息会自动解析其中的视频链接 / BV 号 / av 号 / `b23.tv` 短链并逐条入库,回执汇总结果; - 私聊文本消息会自动解析其中的视频链接 / BV 号 / av 号 / `b23.tv` 短链并逐条入库,回执汇总结果;
@@ -320,7 +320,7 @@ Copy-Item config.example.json config.json
- `folders`:历史来源与重排序目标(`year` + `media_id` + `title`),系统不再自动同步或建夹; - `folders`:历史来源与重排序目标(`year` + `media_id` + `title`),系统不再自动同步或建夹;
- `up_blacklist_seed`:首次启动写入数据库的 UP 黑名单种子; - `up_blacklist_seed`:首次启动写入数据库的 UP 黑名单种子;
- `track_fans`:需要每日追踪粉丝数的 UP mid; - `track_fans`:需要每日追踪粉丝数的 UP mid;
- `discover`:搜索关键词、页数与时间窗(`keywords` / `pages` / `window_days`); - `discover`:搜索关键词、页数、时间窗与候选线(`keywords` / `pages` / `window_days` / `candidate_min_score`);
- `storage`:数据库与备份目录; - `storage`:数据库与备份目录;
- `web`:绑定地址、端口、访问令牌与 `web.track`(访问统计开关、可信代理网段、上报头顺序、落库周期与保留期); - `web`:绑定地址、端口、访问令牌与 `web.track`(访问统计开关、可信代理网段、上报头顺序、落库周期与保留期);
- `schedule` / `notify` / `classify` / `heat_weights`:任务节奏、通知阈值、分类规则和热度权重。 - `schedule` / `notify` / `classify` / `heat_weights`:任务节奏、通知阈值、分类规则和热度权重。
+2 -1
View File
@@ -69,7 +69,8 @@
"discover": { "discover": {
"keywords": ["咻咻满"], "keywords": ["咻咻满"],
"pages": 3, "pages": 3,
"window_days": 7 "window_days": 7,
"candidate_min_score": 4
}, },
"notify": { "notify": {
"min_score": 4, "min_score": 4,
+2 -1
View File
@@ -79,7 +79,8 @@
"discover": { "discover": {
"keywords": ["咻咻满"], "keywords": ["咻咻满"],
"pages": 3, "pages": 3,
"window_days": 7 "window_days": 7,
"candidate_min_score": 4
}, },
"notify": { "notify": {
"min_score": 4, "min_score": 4,
+15 -10
View File
@@ -118,7 +118,8 @@ Telegram 私聊发链接/BV 号 ───┘
], ],
"schedule": { "discover_minutes": 60, "schedule": { "discover_minutes": 60,
"snapshot_at": "00:01", "candidate_ttl_days": 7 }, "snapshot_at": "00:01", "candidate_ttl_days": 7 },
"discover": { "keywords": ["咻咻满"], "pages": 3, "window_days": 7 }, "discover": { "keywords": ["咻咻满"], "pages": 3, "window_days": 7,
"candidate_min_score": 4 },
"notify": { "min_score": 4, "max_items_per_message": 10 }, "notify": { "min_score": 4, "max_items_per_message": 10 },
"classify": { "classify": {
"require_title": "咻咻满", "require_title": "咻咻满",
@@ -257,33 +258,37 @@ Telegram 私聊发链接/BV 号 ───┘
``` ```
# 硬门槛 # 硬门槛
title 含 "咻咻满"(全半角/大小写归一化后),否则直接 0 分(丢弃) title 含 "咻咻满"(全半角/大小写归一化后),否则直接 0 分
up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进) up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进)
# 正向命中次数 n(每个命中记 1) # 正向命中次数 n
n = 1 # 标题含 "咻咻满" pos = reaction|反应 命中次数 + count(加急|特急|充电|专场) + count(双人|路人|第一次|首次)
+ 1 # 命中 reaction 类(re|reaction|反应;须与"咻咻满"或"充电"同现) n = pos + (pos > 0 ? 1 : 0) # 标题含"咻咻满"仅在已有正向命中时计 1 次
+ count(加急|特急|充电|专场)
+ count(双人|路人|第一次|首次)
score = n * n # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5) score = n * n # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5)
+ 2 # 时长 > 60 分钟(与下一条不叠加,取大) + 2 # 时长 > 60 分钟(与下一条不叠加,取大)
+ 1 # 时长 > 30 分钟 + 1 # 时长 > 30 分钟
- 1 # 时长 < 10 分钟(切片/片段;时长未知=0 不扣)
- 2 * k # 每个负向词:歌切|切片|搬运|完整版|录播|录像 - 2 * k # 每个负向词:歌切|切片|搬运|完整版|录播|录像
- k*(k-1) # 同时命中 2 个额外 -2、3 个额外 -6、4 个额外 -12 - k*(k-1) # 同时命中 2 个额外 -2、3 个额外 -6、4 个额外 -12
- 4 # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日) - 4 # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日)
- 3 # UP 为 咻咻满本人(mid=37754047) - 3 # UP 为 咻咻满本人(mid=37754047)
通知阈值 = 4 候选线 = discover.candidate_min_score(默认 = notify.min_score)
0 <= score < 4 且含"咻咻满" → 进看板"候选池"(7 天后删除),不通知 通知线 = notify.min_score
score < 0 → 丢弃 score < 候选线 → 丢弃(只过标题关、无正向命中的不再进候选池)
候选线 <= score < 通知线 → 进看板"候选池"(7 天后删除),不通知
score >= 通知线 → 通知
``` ```
**几条设计依据**: **几条设计依据**:
- **reaction 类用宽松 `re` 但须同现**:`re|reaction|反应` 必须与"咻咻满"或"充电"同时出现才计命中——既避免通用词(任意含 "re" 的拉丁串)加分,又能兼容 `reactio`/`reation`/`recation` 等拼写; - **reaction 类用宽松 `re` 但须同现**:`re|reaction|反应` 必须与"咻咻满"或"充电"同时出现才计命中——既避免通用词(任意含 "re" 的拉丁串)加分,又能兼容 `reactio`/`reation`/`recation` 等拼写;
- **只过标题关不计分**:标题仅含 `require_title`("咻咻满")而没有命中任何正向词时 **0 分**——"咻咻满"单独出现和 "reaction" 单独出现一样没有正向意义,必须"咻咻满 + 任一正向词"才计分;
- **正向按命中次数超线性加权(n²)**:多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词就能把多信号真 reaction 打到线下;`1+1>2`、`1+1+1>5` 用平方实现; - **正向按命中次数超线性加权(n²)**:多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词就能把多信号真 reaction 打到线下;`1+1>2`、`1+1+1>5` 用平方实现;
- **负向也非线性**:每个负向词 -2,命中 2 个额外 -2、3 个额外 -6(`k*(k-1)`),带日期再 -4;单一负向词不再一票否决多信号 reaction; - **负向也非线性**:每个负向词 -2,命中 2 个额外 -2、3 个额外 -6(`k*(k-1)`),带日期再 -4;单一负向词不再一票否决多信号 reaction;
- **时长 <10 分钟 -1**:这类基本是切片/片段;时长未知(0)不扣;
- **候选线与通知线分离**:`discover.candidate_min_score`(默认取 `notify.min_score`)控制是否进候选池——低于候选线的直接丢弃(只过标题关的不再入池),避免看板被无意义候选刷屏;
- **"听/看"等宽泛词已移除**;`纯享`(会误伤"纯享受")已从负向移除,`转场`("专场"错别字)已从正向移除; - **"听/看"等宽泛词已移除**;`纯享`(会误伤"纯享受")已从负向移除,`转场`("专场"错别字)已从正向移除;
- **时长只在已命中正向词时加权**:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池; - **时长只在已命中正向词时加权**:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池;
- **带日期的标题按强负向处理**:`2026-10-28`、`10月25日` 这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉; - **带日期的标题按强负向处理**:`2026-10-28`、`10月25日` 这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉;
+22 -12
View File
@@ -74,10 +74,13 @@ _DATE_PATTERNS = (
) )
_DATE_RE = re.compile("|".join(_DATE_PATTERNS)) _DATE_RE = re.compile("|".join(_DATE_PATTERNS))
# 负向权重(见 project.md §3.3):每个负向词 -2,命中多条额外叠加;日期单独 -4。 # 负向权重(见 project.md §3.3):每个负向词 -2,命中多条额外叠加;日期单独 -4;
# 时长 <10 分钟视作切片,-1(时长未知=0 时不扣)。
NEGATIVE_PER_WORD = 2 NEGATIVE_PER_WORD = 2
DATE_SCORE = 4 DATE_SCORE = 4
SELF_SCORE = 3 SELF_SCORE = 3
SHORT_DURATION_SECONDS = 600
SHORT_DURATION_SCORE = 1
def date_hits(title: str) -> list[str]: def date_hits(title: str) -> list[str]:
@@ -94,16 +97,18 @@ def score_item(
) -> int: ) -> int:
"""按 project.md §3.3 计算候选分数。 """按 project.md §3.3 计算候选分数。
正向按**命中次数**超线性加权:每命中一个正向词(``require_title`` / ``reaction`` 正向按**命中次数**超线性加权:正向词(``reaction`` / ``charge`` / ``weak_positive``)
/ ``charge`` / ``weak_positive``)记 1 次,正向得分 = 命中次数²(1→1、2→4、 每命中一个记 1 次;仅在已有正向命中时,``require_title`` 才额外记 1 次。得分 =
3→9……),使多信号同现的真 reaction 不会被单个负向词打穿(1+1>2、1+1+1>5)。 命中次数²(1→1、2→4、3→9……)。也就是说:**只含 ``require_title`` 的标题 0 分**
("咻咻满"单独出现和 "reaction" 单独出现一样没有正向意义),必须
"require_title + 任一正向词" 才得分;多信号同现时不会被单个负向词打穿。
``reaction`` 词表含 ``re`` 这类通用子串(用于兼容 reaction/reactio/reation 等 ``reaction`` 词表含 ``re`` 这类通用子串(用于兼容 reaction/reactio/reation 等
拼写),因此必须与 ``require_title`` 或 ``充电`` 同现才计一次命中。标题不含 拼写),因此必须与 ``require_title`` 或 ``充电`` 同现才计一次命中。标题不含
``require_title`` 直接 0 分;时长只在此基础上已有命中时加权。 ``require_title`` 直接 0 分;时长只在已有命中时加权。
负向按命中词数递减:每个词 -2,命中 2 个额外 -2、3 个额外 -6(k*(k-1)), 负向按命中词数递减:每个词 -2,命中 2 个额外 -2、3 个额外 -6(k*(k-1)),
带日期再 -4。 带日期再 -4;时长 <10 分钟(切片/片段)再 -1。
""" """
require_title = str(classify.get("require_title") or "") require_title = str(classify.get("require_title") or "")
if require_title and not passes_required_title(title, require_title): if require_title and not passes_required_title(title, require_title):
@@ -113,12 +118,13 @@ def score_item(
reaction_gate = passes_required_title(title, require_title) or contains_any(title, ["充电"]) reaction_gate = passes_required_title(title, require_title) or contains_any(title, ["充电"])
reaction_hit = 1 if (matched_words(title, classify.get("reaction")) and reaction_gate) else 0 reaction_hit = 1 if (matched_words(title, classify.get("reaction")) and reaction_gate) else 0
hits = 0 positive_hits = (
if require_title: reaction_hit
hits += 1 + len(matched_words(title, classify.get("charge")))
hits += reaction_hit + len(matched_words(title, classify.get("weak_positive")))
hits += len(matched_words(title, classify.get("charge"))) )
hits += len(matched_words(title, classify.get("weak_positive"))) # 只过标题关(标题仅含 require_title)不给分;require_title 仅在已有正向命中时才算 1 次。
hits = positive_hits + (1 if (require_title and positive_hits) else 0)
score = hits * hits score = hits * hits
@@ -131,6 +137,10 @@ def score_item(
elif duration_seconds > 30 * 60: elif duration_seconds > 30 * 60:
score += gt_30 score += gt_30
# 时长 <10 分钟视作切片/片段,扣 1 分(时长未知 0 不扣)。
if 0 < duration_seconds < SHORT_DURATION_SECONDS:
score -= SHORT_DURATION_SCORE
neg_words = matched_words(title, classify.get("negative")) neg_words = matched_words(title, classify.get("negative"))
k = len(neg_words) k = len(neg_words)
if k: if k:
+6
View File
@@ -190,6 +190,7 @@ class DiscoverConfig:
keywords: tuple[str, ...] keywords: tuple[str, ...]
pages: int pages: int
window_days: int window_days: int
candidate_min_score: int
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -485,6 +486,11 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
window_days=_as_int( window_days=_as_int(
discover_raw.get("window_days", 14), "discover.window_days", minimum=1 discover_raw.get("window_days", 14), "discover.window_days", minimum=1
), ),
candidate_min_score=_as_int(
discover_raw.get("candidate_min_score", notify.min_score),
"discover.candidate_min_score",
minimum=0,
),
) )
classify_raw = data["classify"] classify_raw = data["classify"]
+2 -1
View File
@@ -157,7 +157,8 @@ class DiscoveryService:
classify=cfg.classify, classify=cfg.classify,
) )
summary.scored += 1 summary.scored += 1
if score < 0: # 低于候选线直接丢弃:只过标题关(无正向命中)的不再进候选池。
if score < cfg.discover.candidate_min_score:
return return
candidate = self.repo.upsert_candidate( candidate = self.repo.upsert_candidate(
+58 -38
View File
@@ -29,8 +29,13 @@ CLASSIFY = {
"duration_boost": {"gt_30min": 1, "gt_60min": 2}, "duration_boost": {"gt_30min": 1, "gt_60min": 2},
} }
NEUTRAL = 600 # 10min:不触发 <10min 惩罚,也不触发 >30min 加权
SHORT = 60 # 1min:<10min
MID = 31 * 60 # >30min
LONG = 61 * 60 # >60min
def score(title, up_mid=100, duration=0, classify=CLASSIFY):
def score(title, up_mid=100, duration=NEUTRAL, classify=CLASSIFY):
return score_item( return score_item(
title=title, up_mid=up_mid, title=title, up_mid=up_mid,
duration_seconds=duration, classify=classify, duration_seconds=duration, classify=classify,
@@ -68,88 +73,103 @@ def test_date_hits():
assert date_hits("咻咻满 reaction") == [] assert date_hits("咻咻满 reaction") == []
def test_title_only_scores_zero():
# 只过标题关(标题仅含 require_title、无任何正向词)不给分,时长也不计入
assert score("咻咻满") == 0
assert score("咻咻满", duration=LONG) == 0
assert score("咻咻满 日常") == 0
assert score("【咻咻满】交换余生(升2)") == 0
# 但时长未知(0) vs <10min 的惩罚不同
assert score("咻咻满", duration=0) == 0
assert score("咻咻满", duration=SHORT) == -1
def test_hit_count_is_superlinear(): def test_hit_count_is_superlinear():
# 正向命中次数 n:require_title + reaction(0/1) + charge 词数 + weak 词数,得分 = n^2 # 正向命中次数 n:require_title(仅在有正向命中时计 1)+ reaction(0/1) + charge 词数 + weak 词数
assert score("咻咻满", duration=60) == 1 assert score("咻咻满 reaction") == 4
assert score("咻咻满 reaction", duration=60) == 4 assert score("咻咻满 反应") == 4
assert score("咻咻满 反应", duration=60) == 4 assert score("咻咻满 reaction 充电") == 9
assert score("咻咻满 reaction 充电", duration=60) == 9 assert score("咻咻满 reaction 加急充电专场") == 25
assert score("咻咻满 reaction 加急充电专场", duration=60) == 25 assert score("咻咻满 充电") == 4
assert score("咻咻满 充电", duration=60) == 4 assert score("咻咻满 加急专场") == 9
assert score("咻咻满 加急专场", duration=60) == 9 assert score("咻咻满 双人") == 4
assert score("咻咻满 双人", duration=60) == 4 assert score("咻咻满 reaction 双人") == 9
assert score("咻咻满 reaction 双人", duration=60) == 9
def test_reaction_typo_and_gate(): def test_reaction_typo_and_gate():
# "re" 兼容 reaction/reactio/reation 等拼写 # "re" 兼容 reaction/reactio/reation 等拼写
assert score("咻咻满 reactio", duration=60) == 4 assert score("咻咻满 reactio") == 4
assert score("咻咻满 Reactuon 充电", duration=60) == 9 assert score("咻咻满 Reactuon 充电") == 9
# 标题不含 require_title 直接 0 分 # 标题不含 require_title 直接 0 分
assert score("其他 UP 的 reaction", duration=61 * 60) == 0 assert score("其他 UP 的 reaction", duration=LONG) == 0
assert score("普通视频 reactio", duration=61 * 60) == 0 assert score("普通视频 reactio", duration=LONG) == 0
def test_duration_boost(): def test_duration_boost_and_short_penalty():
assert score("咻咻满 reaction", duration=61 * 60) == 6 assert score("咻咻满 reaction", duration=LONG) == 6
assert score("咻咻满 reaction", duration=31 * 60) == 5 assert score("咻咻满 reaction", duration=MID) == 5
assert score("咻咻满 reaction", duration=30 * 60) == 4 assert score("咻咻满 reaction", duration=30 * 60) == 4
assert score("咻咻满", duration=0) == 1 assert score("咻咻满 reaction", duration=NEUTRAL) == 4
assert score("咻咻满", duration=61 * 60) == 3 # 时长 <10 分钟 -1
assert score("咻咻满 双人", duration=61 * 60) == 6 assert score("咻咻满 reaction", duration=SHORT) == 3
assert score("咻咻满 reaction 充电", duration=SHORT) == 8
assert score("咻咻满 双人", duration=SHORT) == 3
# 时长未知(0) 不扣
assert score("咻咻满 reaction", duration=0) == 4
def test_negative_single_and_multi(): def test_negative_single_and_multi():
# 单个负向词 -2 # 单个负向词 -2("咻咻满 切片" 无正向命中,基础为 0)
assert score("咻咻满 切片", duration=60) == -1 assert score("咻咻满 切片") == -2
assert score("咻咻满 reaction 切片", duration=60) == 2 assert score("咻咻满 reaction 切片") == 2
assert score("咻咻满 reaction 直播录像", duration=60) == 2 assert score("咻咻满 reaction 直播录像") == 2
# 2 个负向词:-2*2 -2 = -6 # 2 个负向词:-2*2 -2 = -6
assert score("咻咻满 歌切 切片", duration=60) == -5 assert score("咻咻满 歌切 切片") == -6
# 3 个负向词:-2*3 -6 = -12 # 3 个负向词:-2*3 -6 = -12
assert score("咻咻满 reaction 歌切 切片 完整版", duration=60) == -8 assert score("咻咻满 reaction 歌切 切片 完整版") == -8
def test_self_penalty(): def test_self_penalty():
assert score("咻咻满 reaction", up_mid=37754047, duration=60) == 1 assert score("咻咻满 reaction", up_mid=37754047) == 1
def test_date_negative(): def test_date_negative():
assert score("咻咻满 reaction 2026-10-28 完整录播", duration=60) == -2 assert score("咻咻满 reaction 2026-10-28 完整录播") == -2
assert score("咻咻满 reaction 2026-10-28", duration=60) == 0 assert score("咻咻满 reaction 2026-10-28") == 0
assert score("咻咻满 充电 10月25日", duration=60) == 0 assert score("咻咻满 充电 10月25日") == 0
disabled = dict(CLASSIFY, date_negative=False) disabled = dict(CLASSIFY, date_negative=False)
assert score("咻咻满 reaction 2026-10-28", duration=60, classify=disabled) == 4 assert score("咻咻满 reaction 2026-10-28", classify=disabled) == 4
def test_removed_words(): def test_removed_words():
# 纯享不再算负向 # 纯享不再算负向
assert score("咻咻满 reaction 纯享受", duration=60) == 4 assert score("咻咻满 reaction 纯享受") == 4
# 转场不再算正向(作为错别字忽略) # 转场不再算正向(作为错别字忽略),且只过标题关不给分
assert score("咻咻满 转场", duration=60) == 1 assert score("咻咻满 转场") == 0
def test_multi_hit_survives_single_negative(): def test_multi_hit_survives_single_negative():
# 回归:命中 咻咻满+reaction+充电 的 204min reaction 标题,扣掉单个「切片」后仍应过线 # 回归:命中 咻咻满+reaction+充电 的 204min reaction 标题,扣掉单个「切片」后仍应过线
title = "【充电2.0 | 咻咻满reaction】翻唱和直播切片完全是在开盲盒呀!不知道今天会开到哪一款" title = "【充电2.0 | 咻咻满reaction】翻唱和直播切片完全是在开盲盒呀!不知道今天会开到哪一款"
assert score(title, duration=204 * 60) == 9 assert score(title, duration=204 * 60) == 9
assert score(title, duration=60) == 7 assert score(title, duration=NEUTRAL) == 7
def main(): def main():
test_title_helpers() test_title_helpers()
test_parse_duration() test_parse_duration()
test_date_hits() test_date_hits()
test_title_only_scores_zero()
test_hit_count_is_superlinear() test_hit_count_is_superlinear()
test_reaction_typo_and_gate() test_reaction_typo_and_gate()
test_duration_boost() test_duration_boost_and_short_penalty()
test_negative_single_and_multi() test_negative_single_and_multi()
test_self_penalty() test_self_penalty()
test_date_negative() test_date_negative()
test_removed_words() test_removed_words()
test_multi_hit_survives_single_negative() test_multi_hit_survives_single_negative()
# 直播回放示例:命中 require+专场+路人+第一次 = 4 -> 16;39min > 30 -> +1 = 17 # 直播回放示例:命中 require+专场+路人+第一次 = 4 次 -> 16;39min > 30 -> +1 = 17
title = "6【直播回放】咻咻满专场 路人第一次听满满唱歌 真的超级绝超级好听耶~" title = "6【直播回放】咻咻满专场 路人第一次听满满唱歌 真的超级绝超级好听耶~"
assert score(title, duration=39 * 60) == 17 assert score(title, duration=39 * 60) == 17
print("OK: 候选标题归一化、时长解析与分类打分测试全部通过") print("OK: 候选标题归一化、时长解析与分类打分测试全部通过")
+1
View File
@@ -56,6 +56,7 @@ def test_dual_accounts_and_env_override():
assert cfg.up_blacklist_seed == (1, 2) assert cfg.up_blacklist_seed == (1, 2)
assert cfg.discover.keywords == ("咻咻满",) assert cfg.discover.keywords == ("咻咻满",)
assert cfg.discover.pages == 3 assert cfg.discover.pages == 3
assert cfg.discover.candidate_min_score == 4
assert cfg.classify["self_mid"] == 37754047 assert cfg.classify["self_mid"] == 37754047
assert cfg.classify["reaction"] == ["re", "reaction", "反应"] assert cfg.classify["reaction"] == ["re", "reaction", "反应"]
assert cfg.classify["date_negative"] is True assert cfg.classify["date_negative"] is True
+28 -4
View File
@@ -35,6 +35,7 @@ def write_config(tmp, **discover):
"keywords": ["咻咻满 reaction"], "keywords": ["咻咻满 reaction"],
"pages": 1, "pages": 1,
"window_days": 14, "window_days": 14,
"candidate_min_score": 0,
**discover, **discover,
}, },
"classify": { "classify": {
@@ -91,8 +92,8 @@ def item(aid, *, title="咻咻满 reaction", up_mid=100, pubdate=NOW - 3600,
) )
def make_service(tmp, items, *, notifier=None): def make_service(tmp, items, *, notifier=None, **discover):
path = write_config(tmp) path = write_config(tmp, **discover)
store = ConfigStore(path, env={}) store = ConfigStore(path, env={})
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup") db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize() db.initialize()
@@ -128,9 +129,9 @@ def test_filters_and_scoring():
assert summary.title_miss == 1 assert summary.title_miss == 1
assert summary.old == 1 assert summary.old == 1
assert summary.blacklisted == 1 assert summary.blacklisted == 1
assert summary.candidates == 2 # aid 6(1 分)与 aid 2(2 分) assert summary.candidates == 2 # aid 6(0 分)与 aid 2(2 分)
assert summary.notified == 0 assert summary.notified == 0
assert repo.get_candidate(6)["score"] == 1 assert repo.get_candidate(6)["score"] == 0 # 只过标题关:0 分
assert repo.get_candidate(2)["score"] == 2 # reaction 命中后单个「切片」不再清零 assert repo.get_candidate(2)["score"] == 2 # reaction 命中后单个「切片」不再清零
assert repo.get_candidate(4) is None assert repo.get_candidate(4) is None
assert repo.get_candidate(5) is None assert repo.get_candidate(5) is None
@@ -217,12 +218,35 @@ def test_duplicate_search_results_notified_once():
db.close() db.close()
def test_candidate_min_score_floor():
with tempfile.TemporaryDirectory() as tmp:
db, repo, service = make_service(
tmp,
[
item(1, title="咻咻满 reaction 充电"), # 9 分 -> 入池
item(2, title="咻咻满 日常"), # 0 分 -> 只过标题关,不入池
item(3, title="咻咻满 reaction 切片"), # 2 分 -> 低于候选线,不入池
],
candidate_min_score=4,
)
try:
summary = service.run()
assert summary.scored == 3
assert summary.candidates == 1
assert repo.get_candidate(1) is not None
assert repo.get_candidate(2) is None
assert repo.get_candidate(3) is None
finally:
db.close()
def main(): def main():
test_filters_and_scoring() test_filters_and_scoring()
test_notification_batch_and_failure() test_notification_batch_and_failure()
test_search_error_does_not_abort_other_keywords() test_search_error_does_not_abort_other_keywords()
test_pending_candidate_notified_on_later_run() test_pending_candidate_notified_on_later_run()
test_duplicate_search_results_notified_once() test_duplicate_search_results_notified_once()
test_candidate_min_score_floor()
print("OK: 发现过滤、打分、候选池与通知批次测试全部通过") print("OK: 发现过滤、打分、候选池与通知批次测试全部通过")