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, type MetaSubject,
} from "@umt/adapters"; } from "@umt/adapters";
import { pgCacheFor } from "@umt/db"; import { pgCacheFor } from "@umt/db";
import { normalizeTitle, toSimplified } from "@umt/domain";
import type { SourcesRuntime } from "../runtime.ts"; import type { SourcesRuntime } from "../runtime.ts";
import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.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 站资源兜底):取第一个,顺手写缓存 */ /** 按标题搜到的 bgm 条目与资源名称的核对结果 */
export async function searchBangumi(rt: SourcesRuntime, keyword: string): Promise<DoubanSubject | null> { 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); const clients = metaClients(rt);
if (!clients || keyword.trim().length < 2) return null; if (!clients || keyword.trim().length < 2) return null;
let found: DoubanSubject[];
try { try {
const top = (await clients.bangumi.search(keyword.trim(), 5))[0]; found = (await clients.bangumi.search(keyword.trim(), 5)).map((s) => toCached(s, rt.now()));
if (!top) return null;
const cached = toCached(top, rt.now());
await saveDoubanSubject(rt.ctx, cached, BANGUMI_SUBJECT_REGION);
return cached;
} catch (e) { } catch (e) {
rt.ctx.logger.info({ keyword, err: (e as Error).message }, "bgm.tv 搜索失败"); rt.ctx.logger.info({ keyword, err: (e as Error).message }, "bgm.tv 搜索失败");
return null; 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 }; douban?: { id: string; title: string; year: number | null; source: string };
/** bgm 条目(bangumi 路径);search = 按资源标题搜到的 */ /** 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; const bgmId = after.find((x) => x.provider === "bangumi")?.id;
let subject = bgmId ? await loadSubject(rt, "bangumi", bgmId) : null; let subject = bgmId ? await loadSubject(rt, "bangumi", bgmId) : null;
const search = !subject && !best && (await isPublicBt(rt, release.site_id)); 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) { if (subject) {
const d = await subjectRecall(rt, parsed, subject); const d = await subjectRecall(rt, parsed, subject);
if (d) { if (d) {
ev.bangumi = { id: subject.id, title: subject.title, year: subject.year, search }; ev.bangumi = {
const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]); id: subject.id,
if (res.status === "auto" || (!best && res.status !== "no_match")) { 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"; ev.via = "bangumi";
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed }; best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
} }
@@ -368,6 +379,27 @@ async function escalateIdentity(
return best ?? { evidence: ev }; 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> { async function isPublicBt(rt: SourcesRuntime, siteId: string | null): Promise<boolean> {
if (!siteId) return false; if (!siteId) return false;
const row = await rt.ctx.db.selectFrom("site").select("kind").where("id", "=", siteId).executeTakeFirst(); 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: "多个候选同级", multiple_candidates_same_level: "多个候选同级",
automation_off: "自动化已关闭", automation_off: "自动化已关闭",
no_local_match: "没有命中订阅或本地作品,未查询 TMDB", no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
bangumi_unverified: "bgm 条目与资源名称只是相似",
}; };
const detailText = (d: string) => DETAIL_TEXT[d] ?? d; const detailText = (d: string) => DETAIL_TEXT[d] ?? d;
@@ -110,10 +110,31 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
name_cn: "葬送的芙莉莲 第二季", name_cn: "葬送的芙莉莲 第二季",
date: "2026-01-16", date: "2026-01-16",
}); });
if (u.host === "api.bgm.tv" && u.pathname === "/v0/search/subjects") // 条目详情带 infobox 别名(搜索结果没有别名,罗马音标题靠详情里的别名核对)
if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects/400602")
return json({
id: 400602,
name: "葬送のフリーレン",
name_cn: "葬送的芙莉莲",
date: "2023-09-29",
infobox: [{ key: "别名", value: [{ v: "Sousou no Frieren" }, { v: "Frieren: Beyond Journey's End" }] }],
});
if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects/400777")
return json({ id: 400777, name: "迷宮のフリーレン", name_cn: "迷宫芙莉莲", date: "2023-09-29" });
if (u.host === "api.bgm.tv" && u.pathname === "/v0/search/subjects") {
const kw = (JSON.parse(String(init?.body ?? "{}")) as { keyword?: string }).keyword ?? "";
// 「Labyrinth Frieren」:bgm 模糊搜索的第一条是名字不同的条目,不能采用
if (kw.startsWith("Labyrinth"))
return json({
data: [
{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" },
{ id: 400777, name: "迷宮のフリーレン", name_cn: "迷宫芙莉莲", date: "2023-09-29" },
],
});
return json({ return json({
data: [{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }], data: [{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }],
}); });
}
return new Response("", { status: 404 }); return new Response("", { status: 404 });
}, },
}); });
@@ -379,6 +400,17 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
expect(res3.outcome).toBe("auto"); expect(res3.outcome).toBe("auto");
expect(await tmdbIdOf(res3.mediaEntityId!)).toBe("990031"); expect(await tmdbIdOf(res3.mediaEntityId!)).toBe("990031");
expect(metaRequests).toContain("POST api.bgm.tv/v0/search/subjects"); expect(metaRequests).toContain("POST api.bgm.tv/v0/search/subjects");
expect((await row(r3.id)).resolution_evidence).toMatchObject({
escalation: { via: "bangumi", bangumi: { id: "400602", search: true, score: 1 } },
});
// H1:搜索结果第一条与资源名称对不上(也没有别名能对上)→ 不走 bgm 投影,不自动绑定
const r5 = await release(pub.id, "rel_A5", "[SubsPlease] Labyrinth Frieren - 03 (1080p)");
const res5 = await resolveRelease(h.rt, { sourceReleaseId: r5.id, trigger: "manual" });
expect(res5.outcome).not.toBe("auto");
expect((await row(r5.id)).media_entity_id).toBeNull();
const ev5 = (await row(r5.id)).resolution_evidence as { escalation?: { via: string | null; bangumi?: unknown } };
expect(ev5.escalation?.via ?? null).toBeNull();
expect(ev5.escalation?.bangumi).toBeUndefined();
// PT 站的资源不按标题搜 bgm // PT 站的资源不按标题搜 bgm
const m = metaRequests.length; const m = metaRequests.length;
const r4 = await release(hhan, "91099", "Sousou no Frieren S01E03 1080p WEB-DL"); const r4 = await release(hhan, "91099", "Sousou no Frieren S01E03 1080p WEB-DL");