fix(identity): guard site aliases and add identity-cleanup script (FIX-5)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 19:37:11 +08:00
1 parent a10a39fde1
commit a51dc1590f
5 files changed
+683 -13

No files matched your search

@@ -0,0 +1,140 @@
import { isGenericAliasName, normalizeTitle, type ParsedRelease, type RankedCandidate } from "@umt/domain";
import type { Exec } from "./common.ts";
import { addAliasIfAbsent } from "./entities.ts";
// FIX-5 别名守卫:自动识别绑定后,把资源标题解析出的中 / 英文名登记为作品别名之前再过一道检查。
// 背景:VER-4 真机《东北有个周东北》挂着《征途》等别的作品的英文名(Spark of the Long March 等),
// 是站点标题"中文名 + 另一部作品的英文名"只靠中文名命中后,英文名被一并登记、再链式召回造成的。
/** 站点名称不登记为别名的原因(记在 resolution_attempt.best.aliases.skipped) */
export type SiteAliasSkipReason =
| "low_evidence"
| "year_conflict"
| "conflicts_external_title"
| "owned_by_other"
| "generic";
export interface SiteAliasGuardInput {
/** 本次识别最佳候选的证据等级 */
level: string;
/** 候选的年份比对结果 */
yearMatch: string;
/** 资源标题解析出的年份 / 作品年份 */
parsedYear: number | null;
entityYear: number | null;
seasonYears?: readonly number[] | undefined;
/** 已归一化的站点名称与它的文字 */
normalized: string;
script: "han" | "latin";
/** 作品已确认的外部标题(有已接受的外部身份时:主标题 / 原标题 / TMDB 译名与别名),按文字分组,已归一化 */
externalNames: { han: ReadonlySet<string>; latin: ReadonlySet<string> } | null;
/** 这个名称已挂在别的作品上 */
ownedByOther: boolean;
}
export const scriptOf = (s: string): "han" | "latin" => (/\p{Script=Han}/u.test(s) ? "han" : "latin");
/**
* 纯函数:证据等级不是最高(rule_confirmed)、年份与作品不相容、与作品已确认的同一种文字的外部标题对不上、
* 或已属于别的作品 → 返回不登记的原因;可以登记返回 null。
*/
export function siteAliasGuard(i: SiteAliasGuardInput): SiteAliasSkipReason | null {
if (!i.normalized || isGenericAliasName(i.normalized)) return "generic";
if (i.level !== "rule_confirmed") return "low_evidence";
if (i.yearMatch === "mismatch") return "year_conflict";
if (
i.parsedYear !== null &&
i.entityYear !== null &&
Math.abs(i.parsedYear - i.entityYear) > 1 &&
!(i.seasonYears ?? []).includes(i.parsedYear)
)
return "year_conflict";
const ext = i.externalNames?.[i.script];
if (ext && ext.size > 0 && !ext.has(i.normalized)) return "conflicts_external_title";
if (i.ownedByOther) return "owned_by_other";
return null;
}
/** 作品已确认的外部标题(按文字分组);没有已接受的外部身份时为 null */
export async function confirmedExternalNames(
exec: Exec,
entityId: string,
): Promise<{ han: Set<string>; latin: Set<string> } | null> {
const ident = await exec
.selectFrom("external_identity")
.select("id")
.where("media_entity_id", "=", entityId)
.where("status", "=", "accepted")
.executeTakeFirst();
if (!ident) return null;
const e = await exec
.selectFrom("media_entity")
.select(["title", "original_title"])
.where("id", "=", entityId)
.executeTakeFirst();
const aliases = await exec
.selectFrom("title_alias")
.select(["alias"])
.where("media_entity_id", "=", entityId)
.where("content_unit_id", "is", null)
.where("source", "=", "tmdb")
.execute();
const out = { han: new Set<string>(), latin: new Set<string>() };
for (const t of [e?.title, e?.original_title, ...aliases.map((a) => a.alias)]) {
if (!t) continue;
const n = normalizeTitle(t);
if (n) out[scriptOf(t)].add(n);
}
return out;
}
export interface SiteAliasOutcome {
added: string[];
skipped: { alias: string; reason: SiteAliasSkipReason }[];
}
/** 按守卫登记资源标题里的中 / 英文名;返回登记与跳过的清单 */
export async function addSiteAliases(
exec: Exec,
entityId: string,
best: RankedCandidate,
parsed: ParsedRelease,
): Promise<SiteAliasOutcome> {
const out: SiteAliasOutcome = { added: [], skipped: [] };
const names: [string, "zh" | "en"][] = [];
if (parsed.titleCn) names.push([parsed.titleCn, "zh"]);
if (parsed.titleEn) names.push([parsed.titleEn, "en"]);
if (!names.length) return out;
const entity = await exec.selectFrom("media_entity").select(["year"]).where("id", "=", entityId).executeTakeFirst();
const external = await confirmedExternalNames(exec, entityId);
for (const [t, lang] of names) {
const normalized = normalizeTitle(t);
const other = normalized
? await exec
.selectFrom("title_alias")
.select("id")
.where("normalized", "=", normalized)
.where("content_unit_id", "is", null)
.where("media_entity_id", "<>", entityId)
.executeTakeFirst()
: undefined;
const reason = siteAliasGuard({
level: best.level,
yearMatch: best.yearMatch,
parsedYear: parsed.year ?? null,
entityYear: entity?.year ?? null,
seasonYears: best.candidate.seasonYears,
normalized,
script: scriptOf(t),
externalNames: external,
ownedByOther: !!other,
});
if (reason) {
if (reason !== "generic") out.skipped.push({ alias: t, reason });
continue;
}
await addAliasIfAbsent(exec, entityId, t, "site_title", "site", lang);
out.added.push(t);
}
return out;
}
@@ -5,7 +5,6 @@ import { type SourceReleaseRow, sql } from "@umt/db";
import {
type IdentityCandidate as DomainCandidate,
type IdentityResolution,
isGenericAliasName,
normalizeTitle,
PARSER_VERSION,
type ParsedRelease,
@@ -23,11 +22,11 @@ import {
} from "@umt/mapping";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts";
import { type Actor, closeDecisions, type Exec, SYSTEM } from "./common.ts";
import { siteExplicitIds } from "./detail-page.ts";
import { maybeAutoEnrich } from "./enrich.ts";
import {
addAliasIfAbsent,
createEntityFromTmdb,
ensureUnits,
entityByExternal,
@@ -545,16 +544,11 @@ async function bindAuto(
.executeTakeFirstOrThrow();
const units = await ensureUnits(trx, entityId, entity.kind, parsed);
// 站点标题作为别名:下次同名资源可直接本地召回。只在名称本身命中过(不是仅靠别名单线命中)时登记,
// 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)
if (siteAliasAllowed(best)) {
for (const [t, lang] of [
[parsed.titleCn, "zh"],
[parsed.titleEn, "en"],
] as const) {
if (t && !isGenericAliasName(normalizeTitle(t)))
await addAliasIfAbsent(trx, entityId, t, "site_title", "site", lang);
}
}
// 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)。
// FIX-5:证据等级非最高、年份不相容、与已确认外部标题冲突、已属于别的作品的名称也不登记(alias-guard.ts)
const aliasOutcome: SiteAliasOutcome = siteAliasAllowed(best)
? await addSiteAliases(trx, entityId, best, parsed)
: { added: [], skipped: [] };
const evidence = {
level,
@@ -606,7 +600,10 @@ async function bindAuto(
version,
resolution.ranked.length,
entityId,
rankedSummary(best),
{
...rankedSummary(best),
...(aliasOutcome.added.length || aliasOutcome.skipped.length ? { aliases: aliasOutcome } : {}),
},
);
await ctx.outbox.emit(
trx,
@@ -0,0 +1,134 @@
import { normalizeTitle } from "@umt/domain";
import { createMockTmdb, type MockTmdb, type TmdbFixture } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { siteAliasGuard } from "../../src/modules/sources-identity/service/alias-guard.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
// FIX-5 别名守卫:站点标题里另一部作品的英文名不能挂到按中文名命中的作品上。
const up = await pgAvailable();
const cnTv = (id: number, zh: string, en: string | null, date: string): TmdbFixture => ({
type: "tv",
id,
original_title: zh,
original_language: "zh",
origin_country: ["CN"],
date,
titles: en ? { "zh-CN": zh, "en-US": en } : { "zh-CN": zh },
popularity: 5,
alternative_titles: [],
external_ids: {},
});
describe("siteAliasGuard(纯函数)", () => {
const base = {
level: "rule_confirmed",
yearMatch: "exact",
parsedYear: 2026,
entityYear: 2026,
normalized: "spark of the long march",
script: "latin" as const,
externalNames: null,
ownedByOther: false,
};
it("证据等级非最高、年份不相容、与外部标题冲突、已属于别的作品都不登记", () => {
expect(siteAliasGuard(base)).toBeNull();
expect(siteAliasGuard({ ...base, level: "multi_evidence" })).toBe("low_evidence");
expect(siteAliasGuard({ ...base, parsedYear: 2019 })).toBe("year_conflict");
expect(siteAliasGuard({ ...base, parsedYear: 2019, seasonYears: [2019] })).toBeNull();
expect(
siteAliasGuard({
...base,
externalNames: { han: new Set(["东北有个周东北"]), latin: new Set(["zhou dongbei"]) },
}),
).toBe("conflicts_external_title");
// 外部标题只有中文时,英文名不按外部标题判冲突
expect(siteAliasGuard({ ...base, externalNames: { han: new Set(["长河"]), latin: new Set() } })).toBeNull();
expect(siteAliasGuard({ ...base, ownedByOther: true })).toBe("owned_by_other");
expect(siteAliasGuard({ ...base, normalized: "电视剧" })).toBe("generic");
});
});
describe.skipIf(!up)("别名守卫(resolveObservation)", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
beforeAll(async () => {
tmdb = await createMockTmdb({
fixtures: [
cnTv(990001, "东北有个周东北", "Zhou Dongbei in the Northeast", "2026-01-10"),
cnTv(990003, "长河", null, "2026-02-01"),
],
});
h = await createHarness("aliasguard", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://guard.example/" })).site.id;
await h.db
.insertInto("settings")
.values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
.execute();
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const release = (title: string) =>
h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(880000 + ++n), title })
.returningAll()
.executeTakeFirstOrThrow();
const aliasesOf = async (entityId: string) =>
(
await h.db.selectFrom("title_alias").select(["alias", "source"]).where("media_entity_id", "=", entityId).execute()
).map((a) => a.alias);
const lastAttempt = (releaseId: string) =>
h.db
.selectFrom("resolution_attempt")
.select(["best"])
.where("source_release_id", "=", releaseId)
.orderBy("created_at", "desc")
.executeTakeFirstOrThrow();
it("与作品已确认的英文名对不上的站点英文名不登记,记入 resolution_attempt", async () => {
const r = await release("东北有个周东北.Spark.of.the.Long.March.2026.S01E38.1080p.WEB-DL.H264-GRP");
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
expect(res.outcome).toBe("auto");
const aliases = await aliasesOf(res.mediaEntityId!);
expect(aliases).toContain("东北有个周东北");
expect(aliases).not.toContain("Spark of the Long March");
const best = (await lastAttempt(r.id)).best as { aliases?: { skipped: { alias: string; reason: string }[] } };
expect(best.aliases?.skipped).toEqual([{ alias: "Spark of the Long March", reason: "conflicts_external_title" }]);
});
it("已属于别的作品的站点名称不登记", async () => {
const other = await h.db
.insertInto("media_entity")
.values({ kind: "series", title: "逆流", status: "active" } as never)
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("title_alias")
.values({
media_entity_id: other.id,
alias: "Against the Current",
normalized: normalizeTitle("Against the Current"),
kind: "other",
source: "user",
} as never)
.execute();
const r = await release("长河.Against.the.Current.2026.S01E01.1080p.WEB-DL.H264-GRP");
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
expect(res.outcome).toBe("auto");
expect(res.mediaEntityId).not.toBe(other.id);
expect(await aliasesOf(res.mediaEntityId!)).not.toContain("Against the Current");
const best = (await lastAttempt(r.id)).best as { aliases?: { skipped: { alias: string; reason: string }[] } };
expect(best.aliases?.skipped).toEqual([{ alias: "Against the Current", reason: "owned_by_other" }]);
});
});