// TMDB 多语言别名清理(用户规则 2026-10-06):TMDB 的多语言数据只保留简中 / 繁中 / 英文 / 作品原语言。 // 规则见 packages/adapters/src/tmdb/language-policy.ts、docs/runbook/identity.md「TMDB 多语言保留规则」。 // 默认只读(dry-run),加 --apply 才删(单个事务,写一条审计 metadata.tmdb_alias_language_cleanup)。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): // DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/tmdb-alias-language-cleanup.ts # 看统计与样例 // DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --apply # 删除 // DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --entity <作品 id> # 只看 / 只处理一部作品 // DATABASE_URL=… deno run -A scripts/tmdb-alias-language-cleanup.ts --samples 20 # 每类样例条数(缺省 5) // // 别名(title_alias)判定——只动 source = 'tmdb' 且未锁定的行;人工(user)/ 站点(site)/ 智能体(agent)/ NFO / 导入来源与 // 锁定的行一律不动: // - kind = original(原名)保留;与作品主标题 / 原标题归一化相同的保留(作品自己的名字); // - language 非空:规范化后是简中(zh-CN / zh-SG / zh-Hans / zh)、繁中(zh-TW / zh-HK / zh-MO / zh-Hant)、英文(en-*)、 // 作品原语言(media_entity.metadata.originalLanguage,没有时取 TMDB 字段来源)→ 保留,否则删除(原因 language); // - language 为空(旧版建档写的 other 等):按文字判断——汉字(不含假名 / 谚文)、拉丁字母、没有字母的保留; // 其它文字(西里尔 / 阿拉伯 / 希伯来 / 泰文 / 谚文 / 假名 …)只在与作品原语言相符时保留,否则删除(原因 script)。 // 拉丁字母无法区分英 / 法 / 西 / 葡,一律按英文保留。 // 字段来源(metadata_field,来源 tmdb)里语言不在保留范围的标题 / 简介 / 集名只报告不删:删了会让最终值暂时没有来源, // 对这些作品跑一次元数据刷新(语言链已按作品收敛)即被同字段的保留语言值替换(同字段换语言时删旧行)。 // 作品与单元的 metadata 只存按语言链取到的单个值,不存 translations 列表,没有别的多语言数据要收敛。 import { allowedLanguage, untaggedTextAllowed } from "@umt/adapters"; import { normalizeTitle } from "@umt/domain"; import pg from "pg"; const argv = [...Deno.args]; const APPLY = argv.includes("--apply"); if (APPLY && argv.includes("--dry-run")) { console.error("--apply 与 --dry-run 不能同时给出"); Deno.exit(2); } const argOf = (k: string) => { const i = argv.indexOf(k); return i >= 0 ? (argv[i + 1] ?? null) : null; }; const ONLY_ENTITY = argOf("--entity"); const SAMPLES = Number(argOf("--samples") ?? 5) || 5; const url = Deno.env.get("DATABASE_URL"); if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); Deno.exit(2); } const db = new pg.Client({ connectionString: url }); await db.connect(); const q = async (text: string, values: unknown[] = []) => (await db.query(text, values)).rows as T[]; console.log( `# tmdb-alias-language-cleanup (${APPLY ? "APPLY" : "DRY-RUN"})${ONLY_ENTITY ? ` --entity ${ONLY_ENTITY}` : ""}\n`, ); interface Row { id: string; media_entity_id: string; alias: string; normalized: string; language: string | null; kind: string; title: string; original_title: string | null; orig_lang: string | null; } // 作品原语言:投影值优先,没有时取 tmdb 字段来源 const ORIG_LANG = `coalesce(e.metadata->'originalLanguage'->>'value', (select f.value#>>'{}' from metadata_field f where f.media_entity_id = e.id and f.content_unit_id is null and f.field = 'originalLanguage' and f.source = 'tmdb' order by f.fetched_at desc limit 1))`; const rows = await q( `select a.id, a.media_entity_id, a.alias, a.normalized, a.language, a.kind::text as kind, e.title, e.original_title, ${ORIG_LANG} as orig_lang from title_alias a join media_entity e on e.id = a.media_entity_id where a.source = 'tmdb' and not a.locked and ($1::uuid is null or a.media_entity_id = $1::uuid) order by e.title, a.alias`, [ONLY_ENTITY], ); const other = await q<{ source: string; locked: boolean; n: number }>( `select source::text as source, locked, count(*)::int as n from title_alias where (source <> 'tmdb' or locked) and ($1::uuid is null or media_entity_id = $1::uuid) group by 1, 2 order by 1, 2`, [ONLY_ENTITY], ); type Verdict = { del: false; why: string } | { del: true; why: "language" | "script" }; function judge(r: Row): Verdict { if (r.kind === "original") return { del: false, why: "原名" }; const own = [r.title, r.original_title].filter((x): x is string => !!x).map(normalizeTitle); if (own.includes(r.normalized)) return { del: false, why: "作品自己的名字" }; const work = { originalLanguage: r.orig_lang }; if (r.language !== null) return allowedLanguage(r.language, work) ? { del: false, why: "保留语言" } : { del: true, why: "language" }; return untaggedTextAllowed(r.alias, work) ? { del: false, why: "无语言·文字相符" } : { del: true, why: "script" }; } const deletes: (Row & { why: string })[] = []; const keepBy = new Map(); for (const r of rows) { const v = judge(r); if (v.del) deletes.push({ ...r, why: v.why }); else keepBy.set(v.why, (keepBy.get(v.why) ?? 0) + 1); } console.log( `## 别名(来源 tmdb、未锁定):${rows.length} 条,删除 ${deletes.length} 条,保留 ${rows.length - deletes.length} 条`, ); for (const [why, n] of [...keepBy].sort((a, b) => b[1] - a[1])) console.log(` 保留·${why}:${n} 条`); console.log( ` 不动(非 tmdb 来源或已锁定):${other.map((o) => `${o.source}${o.locked ? "·锁定" : ""} ${o.n}`).join(",") || "0"}`, ); const groups = new Map(); for (const d of deletes) { const k = `${d.language ?? "(空)"} / ${d.kind}`; groups.set(k, [...(groups.get(k) ?? []), d]); } if (deletes.length) console.log(`\n### 删除:按语言 / kind`); for (const [k, list] of [...groups].sort((a, b) => b[1].length - a[1].length)) { console.log(` ${k}:${list.length} 条`); for (const d of list.slice(0, SAMPLES)) console.log(` - 《${d.title}》(原语言 ${d.orig_lang ?? "未知"}):${d.alias}`); } // 字段来源里语言不在保留范围的(只报告) const fields = await q<{ entity_id: string; title: string; orig_lang: string | null; field: string; language: string; unit: boolean; }>( `select e.id as entity_id, e.title, ${ORIG_LANG} as orig_lang, f.field, f.language, f.content_unit_id is not null as unit from metadata_field f left join content_unit u on u.id = f.content_unit_id join media_entity e on e.id = coalesce(f.media_entity_id, u.media_entity_id) where f.source = 'tmdb' and f.language is not null and ($1::uuid is null or e.id = $1::uuid)`, [ONLY_ENTITY], ); const badFields = fields.filter((f) => !allowedLanguage(f.language, { originalLanguage: f.orig_lang })); console.log(`\n## 字段来源(tmdb)语言不在保留范围:${badFields.length} 条(只报告,对涉及的作品跑元数据刷新即收敛)`); const byLang = new Map(); for (const f of badFields) byLang.set( `${f.language} / ${f.unit ? "单元" : "作品"}·${f.field}`, (byLang.get(`${f.language} / ${f.unit ? "单元" : "作品"}·${f.field}`) ?? 0) + 1, ); for (const [k, n] of [...byLang].sort((a, b) => b[1] - a[1])) console.log(` ${k}:${n} 条`); const badEntities = [...new Map(badFields.map((f) => [f.entity_id, f.title])).entries()]; for (const [id, title] of badEntities.slice(0, SAMPLES * 4)) console.log(` - 《${title}》 ${id}`); if (!APPLY) { console.log(`\n(dry-run,未改库;确认后加 --apply)`); } else if (deletes.length) { try { await db.query("begin"); const ids = deletes.map((d) => d.id); const res = await db.query( `delete from title_alias where id = any($1::uuid[]) and source = 'tmdb' and not locked`, [ids], ); await db.query( `insert into audit_event (actor_type, actor_id, action, target_type, target_id, payload) values ('system', 'tmdb-alias-language-cleanup', 'metadata.tmdb_alias_language_cleanup', 'media_entity', $1, $2::jsonb)`, [ ONLY_ENTITY, JSON.stringify({ deleted: res.rowCount, aliases: deletes.map((d) => ({ entityId: d.media_entity_id, alias: d.alias, language: d.language, kind: d.kind, reason: d.why, })), }), ], ); await db.query("commit"); console.log(`\n已删除 ${res.rowCount} 条别名`); } catch (e) { await db.query("rollback"); console.error("执行失败,已回滚:", (e as Error).message); await db.end(); Deno.exit(1); } } else { console.log(`\n没有要删除的别名`); } await db.end();