fix(identity): bgm 按标题搜索的结果先与资源名称核对,唯一高分才采用(H1)

搜索结果按名称 / 中文名 / 别名做相似度核对,搜索结果没别名时为前几个条目拉详情再核对;
拿不准就不走 bgm 投影。投影出的 TMDB 结果再与资源自己的年份 / 续作序号 / 名称核对,
名称对不上且 bgm 条目只是相似的降为歧义。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:30:57 +08:00
1 parent 0085e45ca7
commit 4132324935
3 files changed
+180 -13

No files matched your search

@@ -6,6 +6,7 @@ import {
type MetaSubject,
} from "@umt/adapters";
import { pgCacheFor } from "@umt/db";
import { normalizeTitle, toSimplified } from "@umt/domain";
import type { SourcesRuntime } from "../runtime.ts";
import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.ts";
@@ -58,18 +59,119 @@ export async function loadSubject(rt: SourcesRuntime, source: MetaSource, id: st
}
}
/** bgm 按标题搜动画条目(公开 BT 站资源兜底):取第一个,顺手写缓存 */
export async function searchBangumi(rt: SourcesRuntime, keyword: string): Promise<DoubanSubject | null> {
/** 按标题搜到的 bgm 条目与资源名称的核对结果 */
export interface BangumiMatch {
subject: DoubanSubject;
/** 0~1:名称完全相同 1,去掉季名后相同 0.95,否则取字符二元组相似度 */
score: number;
/** 资源名称与条目名称(不去季名)完全相同 */
exact: boolean;
/** 命中的资源名称 / 条目名称 */
name: string;
matched: string;
}
/** 采用 bgm 条目的最低相似度 */
export const BANGUMI_MATCH_MIN = 0.85;
/** 搜索结果自身没带别名时,最多为几个条目拉详情(infobox 别名) */
const BANGUMI_DETAIL_FETCH = 3;
function bigrams(s: string): Map<string, number> {
const out = new Map<string, number>();
const chars = [...s];
for (let i = 0; i + 1 < chars.length; i++) {
const g = chars[i]! + chars[i + 1]!;
out.set(g, (out.get(g) ?? 0) + 1);
}
return out;
}
/** 归一化后的字符二元组 Dice 相似度 */
export function nameSimilarity(a: string, b: string): number {
if (!a || !b) return 0;
if (a === b) return 1;
const x = bigrams(a);
const y = bigrams(b);
let total = 0;
let common = 0;
for (const n of x.values()) total += n;
for (const n of y.values()) total += n;
for (const [g, n] of x) common += Math.min(n, y.get(g) ?? 0);
return total ? (2 * common) / total : 0;
}
/** 与 normalizeTitle 相同但不去季名后缀("葬送的芙莉莲 第二季" 与第一季条目区分开) */
const plainNorm = (s: string) =>
toSimplified(s.normalize("NFKC"))
.toLowerCase()
.replace(/&/g, "and")
.replace(/[^\p{L}\p{N}]+/gu, "");
/** 单个条目与资源名称的最佳匹配 */
export function scoreBangumiSubject(names: readonly string[], s: DoubanSubject): BangumiMatch {
let best: BangumiMatch = { subject: s, score: 0, exact: false, name: "", matched: "" };
const queries = [...new Set(names.map(normalizeTitle).filter((n) => n.length >= 2))];
for (const raw of [s.title, ...s.aliases]) {
const full = plainNorm(raw);
const stripped = normalizeTitle(raw);
for (const q of queries) {
const score =
q === full ? 1 : q === stripped ? 0.95 : Math.max(nameSimilarity(q, full), nameSimilarity(q, stripped));
if (score > best.score) best = { subject: s, score, exact: score === 1, name: q, matched: raw };
}
}
return best;
}
const yearCompatible = (year: number | undefined, s: DoubanSubject) =>
year === undefined || s.year === null || Math.abs(year - s.year) <= 1;
/**
* 从搜索结果里挑出与资源名称明确匹配的条目(H1:不再取第一个):相似度达到 BANGUMI_MATCH_MIN,且是唯一高分——
* 其它达标条目都更低且最高分是完全相同,或按年份只剩一个。拿不准就返回 null(不走 bgm 投影)。
*/
export function pickBangumiSubject(
names: readonly string[],
year: number | undefined,
subjects: readonly DoubanSubject[],
): BangumiMatch | null {
const scored = subjects
.map((s) => scoreBangumiSubject(names, s))
.filter((m) => m.score >= BANGUMI_MATCH_MIN)
.sort((a, b) => b.score - a.score);
const top = scored[0];
if (!top) return null;
if (scored.length === 1) return top;
if (top.exact && !scored[1]!.exact) return top;
const fit = scored.filter((m) => yearCompatible(year, m.subject));
return year !== undefined && fit.length === 1 ? fit[0]! : null;
}
/**
* bgm 按标题搜动画条目(公开 BT 站资源兜底),与资源名称(中文名 / 英文名 / 标题 / 副标题别名)核对后才采用。
* 搜索结果里没有达标的,为前几个结果拉条目详情(带 infobox 别名,写缓存)再核对一次。
*/
export async function searchBangumi(
rt: SourcesRuntime,
keyword: string,
names: readonly string[] = [keyword],
year?: number,
): Promise<BangumiMatch | null> {
const clients = metaClients(rt);
if (!clients || keyword.trim().length < 2) return null;
let found: DoubanSubject[];
try {
const top = (await clients.bangumi.search(keyword.trim(), 5))[0];
if (!top) return null;
const cached = toCached(top, rt.now());
await saveDoubanSubject(rt.ctx, cached, BANGUMI_SUBJECT_REGION);
return cached;
found = (await clients.bangumi.search(keyword.trim(), 5)).map((s) => toCached(s, rt.now()));
} catch (e) {
rt.ctx.logger.info({ keyword, err: (e as Error).message }, "bgm.tv 搜索失败");
return null;
}
if (!found.length) return null;
const hit = pickBangumiSubject(names, year, found);
if (hit) return hit;
// 已经有达标的条目(只是不唯一)时不再拉详情:别名只会让它更不唯一
if (found.some((s) => scoreBangumiSubject(names, s).score >= BANGUMI_MATCH_MIN)) return null;
const detailed: DoubanSubject[] = [];
for (const s of found.slice(0, BANGUMI_DETAIL_FETCH)) detailed.push((await loadSubject(rt, "bangumi", s.id)) ?? s);
return pickBangumiSubject(names, year, [...detailed, ...found.slice(BANGUMI_DETAIL_FETCH)]);
}
@@ -257,7 +257,7 @@ export interface EscalationEvidence {
/** 豆瓣条目(豆瓣路径) */
douban?: { id: string; title: string; year: number | null; source: string };
/** bgm 条目(bangumi 路径);search = 按资源标题搜到的 */
bangumi?: { id: string; title: string; year: number | null; search: boolean };
bangumi?: { id: string; title: string; year: number | null; search: boolean; score?: number };
}
/**
@@ -353,13 +353,24 @@ async function escalateIdentity(
const bgmId = after.find((x) => x.provider === "bangumi")?.id;
let subject = bgmId ? await loadSubject(rt, "bangumi", bgmId) : null;
const search = !subject && !best && (await isPublicBt(rt, release.site_id));
if (search) subject = await searchBangumi(rt, parsed.titleCn ?? parsed.title ?? "");
// H1:按标题搜到的条目要与资源名称明确匹配(唯一高分)才用,不再取第一个
const searched = search
? await searchBangumi(rt, parsed.titleCn ?? parsed.title ?? "", bangumiNames(parsed), parsed.year)
: null;
if (searched) subject = searched.subject;
if (subject) {
const d = await subjectRecall(rt, parsed, subject);
if (d) {
ev.bangumi = { id: subject.id, title: subject.title, year: subject.year, search };
const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]);
if (res.status === "auto" || (!best && res.status !== "no_match")) {
ev.bangumi = {
id: subject.id,
title: subject.title,
year: subject.year,
search,
...(searched ? { score: Math.round(searched.score * 100) / 100 } : {}),
};
let res: IdentityResolution | null = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]);
if (searched) res = checkAgainstRelease(parsed, res, searched.exact);
if (res && (res.status === "auto" || (!best && res.status !== "no_match"))) {
ev.via = "bangumi";
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
}
@@ -368,6 +379,27 @@ async function escalateIdentity(
return best ?? { evidence: ev };
}
/** 与 bgm 条目核对用的资源名称:中文名、英文名、标题、副标题别名 */
function bangumiNames(p: ParsedRelease): string[] {
return [p.titleCn, p.titleEn, p.title, ...(p.altTitles ?? [])].filter((n): n is string => !!n);
}
/**
* bgm 搜索投影出的 TMDB 结果再与资源自己的解析核对(不能只靠条目名):资源年份 / 续作序号与候选冲突 → 不采用;
* 资源名称对不上候选、且 bgm 条目也只是相似(不是同名)→ 降为歧义,只作候选。
*/
export function checkAgainstRelease(
parsed: ParsedRelease,
res: IdentityResolution,
bgmExact: boolean,
): IdentityResolution | null {
if (res.status !== "auto" || !res.best) return res;
const own = scoreCandidate(parsed, res.best.candidate);
if (own.yearMatch === "mismatch" || own.signals.sequel === "conflict") return null;
if (own.titleMatch === "none" && !bgmExact) return { ...res, status: "ambiguous", reason: "bangumi_unverified" };
return res;
}
async function isPublicBt(rt: SourcesRuntime, siteId: string | null): Promise<boolean> {
if (!siteId) return false;
const row = await rt.ctx.db.selectFrom("site").select("kind").where("id", "=", siteId).executeTakeFirst();
@@ -875,6 +907,7 @@ const DETAIL_TEXT: Record<string, string> = {
multiple_candidates_same_level: "多个候选同级",
automation_off: "自动化已关闭",
no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
bangumi_unverified: "bgm 条目与资源名称只是相似",
};
const detailText = (d: string) => DETAIL_TEXT[d] ?? d;