From 41323249353e380c726a3b5fb381fa511ecd91cf Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:30:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(identity):=20bgm=20=E6=8C=89=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=90=9C=E7=B4=A2=E7=9A=84=E7=BB=93=E6=9E=9C=E5=85=88?= =?UTF-8?q?=E4=B8=8E=E8=B5=84=E6=BA=90=E5=90=8D=E7=A7=B0=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?,=E5=94=AF=E4=B8=80=E9=AB=98=E5=88=86=E6=89=8D=E9=87=87?= =?UTF-8?q?=E7=94=A8(H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 搜索结果按名称 / 中文名 / 别名做相似度核对,搜索结果没别名时为前几个条目拉详情再核对; 拿不准就不走 bgm 投影。投影出的 TMDB 结果再与资源自己的年份 / 续作序号 / 名称核对, 名称对不上且 bgm 条目只是相似的降为歧义。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../sources-identity/service/meta-subjects.ts | 116 ++++++++++++++++-- .../sources-identity/service/resolve.ts | 43 ++++++- .../sources-identity/id-escalation.test.ts | 34 ++++- 3 files changed, 180 insertions(+), 13 deletions(-) diff --git a/apps/server/src/modules/sources-identity/service/meta-subjects.ts b/apps/server/src/modules/sources-identity/service/meta-subjects.ts index 1145d6a9..cb19e591 100644 --- a/apps/server/src/modules/sources-identity/service/meta-subjects.ts +++ b/apps/server/src/modules/sources-identity/service/meta-subjects.ts @@ -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 { +/** 按标题搜到的 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 { + const out = new Map(); + 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 { 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)]); } diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 98b18813..7b7beab1 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -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 { 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 = { multiple_candidates_same_level: "多个候选同级", automation_off: "自动化已关闭", no_local_match: "没有命中订阅或本地作品,未查询 TMDB", + bangumi_unverified: "bgm 条目与资源名称只是相似", }; const detailText = (d: string) => DETAIL_TEXT[d] ?? d; diff --git a/apps/server/test/sources-identity/id-escalation.test.ts b/apps/server/test/sources-identity/id-escalation.test.ts index 544fa970..095942a2 100644 --- a/apps/server/test/sources-identity/id-escalation.test.ts +++ b/apps/server/test/sources-identity/id-escalation.test.ts @@ -110,10 +110,31 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => { name_cn: "葬送的芙莉莲 第二季", 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({ data: [{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }], }); + } return new Response("", { status: 404 }); }, }); @@ -379,6 +400,17 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => { expect(res3.outcome).toBe("auto"); expect(await tmdbIdOf(res3.mediaEntityId!)).toBe("990031"); 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 const m = metaRequests.length; const r4 = await release(hhan, "91099", "Sousou no Frieren S01E03 1080p WEB-DL");