feat(scripts): TMDB 多语言别名清理脚本与保留规则文档

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-06 18:14:54 +08:00
1 parent 7efbe25c17
commit ce93ea3a93
5 files changed
+453

No files matched your search

@@ -0,0 +1,216 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createMockTmdb, type MockTmdb, type TmdbFixture } from "@umt/testkit";
import { sql } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts";
// TMDB 多语言保留规则(用户规则 2026-10-06):建档 / 刷新只写简中 / 繁中 / 英文 / 原语言的别名与字段;
// scripts/tmdb-alias-language-cleanup.ts 只删 TMDB 来源、语言不在保留范围的别名。
const up = await pgAvailable();
const ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
const SCRIPT = fileURLToPath(new URL("../../../../scripts/tmdb-alias-language-cleanup.ts", import.meta.url));
/** 日语原作的剧:各种语言的译名与别名 */
const AOT: TmdbFixture = {
type: "tv",
id: 1429,
original_title: "進撃の巨人",
original_language: "ja",
origin_country: ["JP"],
date: "2013-04-07",
titles: { "zh-CN": "进击的巨人", "en-US": "Attack on Titan", "ja-JP": "進撃の巨人", "zh-TW": "進擊的巨人" },
overviews: { "zh-CN": "巨人吃人的世界。", "ja-JP": "巨人が人を喰う世界。", "fr-FR": "Un monde de titans." },
popularity: 200,
alternative_titles: [
{ iso_3166_1: "JP", title: "Shingeki no Kyojin", type: "Romaji" },
{ iso_3166_1: "US", title: "AoT", type: "" },
{ iso_3166_1: "BR", title: "Ataque dos Titãs", type: "" },
{ iso_3166_1: "RU", title: "Атака на титанов", type: "" },
{ iso_3166_1: "CN", title: "巨人", type: "简称" },
{ iso_3166_1: "CN", title: "Attack Titan CN", type: "" },
{ iso_3166_1: "KR", title: "진격의 거인", type: "" },
],
extra_translations: [
{ iso_639_1: "ru", iso_3166_1: "RU", title: "Атака титанов" },
{ iso_639_1: "fr", iso_3166_1: "FR", title: "L'Attaque des Titans" },
{ iso_639_1: "ko", iso_3166_1: "KR", title: "진격의 거인" },
{ iso_639_1: "th", iso_3166_1: "TH", title: "ผ่าพิภพไททัน" },
],
external_ids: { imdb_id: "tt2560140", tvdb_id: 267440 },
poster_path: "/aot.jpg",
rich: { genres: ["动画"], status: "Ended" },
seasons: [
{
season_number: 1,
air_date: "2013-04-07",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
episodes: [
{
episode_number: 1,
air_date: "2013-04-07",
names: { "fr-FR": "À toi, dans 2000 ans", "ja-JP": "二千年後の君へ" },
overviews: { "fr-FR": "Le mur tombe.", "ja-JP": "壁が壊される。" },
},
],
},
],
};
const ALLOWED = /^(zh(-(CN|SG|TW|HK|MO|Hans|Hant))?|en(-[A-Z]{2})?|ja(-JP|-Latn)?)$/;
describe.skipIf(!up)("TMDB 多语言保留规则", () => {
let h: Harness;
let tmdb: MockTmdb;
let id: string;
let url: string;
const actor = { type: "user" as const, id: "tester" };
const run = (...args: string[]) =>
execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, ...args], {
cwd: ROOT,
env: { ...process.env, DATABASE_URL: url, NO_COLOR: "1" },
encoding: "utf8",
});
const aliases = () =>
h.db
.selectFrom("title_alias")
.select(["alias", "language", "kind", "source", "locked"])
.where("media_entity_id", "=", id)
.orderBy("alias")
.execute();
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [AOT] });
h = await createHarness("tmdblang", { tmdbUrl: tmdb.url, now: () => new Date("2026-10-06T00:00:00Z") });
const schema = (await sql<{ s: string }>`select current_schema() as s`.execute(h.db)).rows[0]!.s;
const u = new URL(PG_URL);
u.searchParams.set("options", `-c search_path=${schema},public`);
url = u.toString();
// 用户在语言链里放了 fr-FR(不是原语言,应被跳过)与 ja-JP(是原语言,保留)
await h.db
.insertInto("settings")
.values({ key: "metadata", value: JSON.stringify({ languages: ["fr-FR", "zh-CN", "ja-JP", "en-US"] }) } as never)
.execute();
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
it("建档:别名只含保留语言,不写空语言的 other;罗马字记 ja-Latn", async () => {
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "1429", kind: "tv" }, actor);
id = r.mediaEntityId;
const list = await aliases();
const texts = list.map((a) => a.alias);
for (const bad of [
"Атака титанов",
"Атака на титанов",
"L'Attaque des Titans",
"진격의 거인",
"ผ่าพิภพไททัน",
"Ataque dos Titãs",
"Attack Titan CN",
])
expect(texts).not.toContain(bad);
expect(list.find((a) => a.alias === "進撃の巨人")).toMatchObject({ kind: "original", language: "ja" });
expect(list.find((a) => a.alias === "Shingeki no Kyojin")).toMatchObject({
kind: "romanized",
language: "ja-Latn",
});
expect(list.find((a) => a.alias === "巨人")).toMatchObject({ kind: "other", language: "zh-CN" });
expect(list.find((a) => a.alias === "AoT")).toMatchObject({ language: "en-US" });
expect(list.filter((a) => a.kind === "other" && a.language === null)).toEqual([]);
for (const a of list) if (a.language !== null) expect(a.language).toMatch(ALLOWED);
});
it("刷新:语言链去掉 fr-FR,标题 / 简介 / 集名不取法语;别名仍只含保留语言", async () => {
const res = await refreshMetadata(h.rt, id, { full: true });
expect(res.status).toBe("refreshed");
expect(res.titles).toMatchObject({ title: "进击的巨人", language: "zh-CN" });
const fields = await h.db
.selectFrom("metadata_field")
.leftJoin("content_unit", "content_unit.id", "metadata_field.content_unit_id")
.select(["metadata_field.field", "metadata_field.language", "metadata_field.value"])
.where((eb) =>
eb.or([eb("metadata_field.media_entity_id", "=", id), eb("content_unit.media_entity_id", "=", id)]),
)
.where("metadata_field.language", "is not", null)
.execute();
expect(fields.length).toBeGreaterThan(0);
for (const f of fields) expect(f.language).toMatch(ALLOWED);
const ep = await h.db
.selectFrom("content_unit")
.select(["title", "metadata"])
.where("media_entity_id", "=", id)
.where("kind", "=", "episode")
.executeTakeFirstOrThrow();
// 集名:fr-FR 被跳过,ja-JP(原语言)可用
expect(ep.title).toBe("二千年後の君へ");
expect((ep.metadata as Record<string, { language: string | null }>).name?.language).toBe("ja-JP");
const list = await aliases();
expect(list.filter((a) => a.kind === "other" && a.language === null)).toEqual([]);
for (const a of list) if (a.language !== null) expect(a.language).toMatch(ALLOWED);
expect(list.map((a) => a.alias)).not.toContain("L'Attaque des Titans");
});
it("清理脚本:dry-run 统计、apply 只删 TMDB 来源的不合规别名,人工 / 站点 / 锁定的保留", async () => {
// 旧版本写下的数据:各语言译名(translated)、空语言 other;以及人工 / 站点 / 锁定的同类文本
const add = (alias: string, language: string | null, kind: string, source = "tmdb", locked = false) =>
sql`insert into title_alias (media_entity_id, content_unit_id, alias, normalized, language, kind, source, locked)
values (${id}, null, ${alias}, ${alias.toLowerCase()}, ${language}, ${kind}, ${source}, ${locked})`.execute(
h.db,
);
await add("Атака титанов", "ru-RU", "translated");
await add("Ataque dos Titãs", "pt-BR", "translated");
await add("L'Attaque des Titans", "fr-FR", "translated");
await add("Атака на титанов", null, "other");
await add("진격의 거인", null, "other");
await add("Ataque Titan", null, "other"); // 拉丁:按英文保留
await add("ผ่าพิภพไททัน", "th-TH", "translated", "user"); // 人工:不动
await add("Атака гигантов", "ru-RU", "site_title", "site"); // 站点:不动
await add("Attaque verrouillée", "fr-FR", "translated", "tmdb", true); // 锁定:不动
// 字段来源里的法语值(旧语言链写下的):只报告
await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at)
values (${id}, null, 'tagline', '"Le mur"'::jsonb, 'fr-FR', 'tmdb', 'x', now())`.execute(h.db);
const before = (await aliases()).length;
const out = run("--dry-run");
expect(out).toContain("DRY-RUN");
expect(out).toMatch(/删除 5 条/);
expect(out).toContain("ru-RU / translated:1 条");
expect(out).toContain("(空) / other:2 条");
expect(out).toContain("不动(非 tmdb 来源或已锁定):");
expect(out).toContain("## 字段来源(tmdb)语言不在保留范围:1 条");
expect(out).toContain("fr-FR / 作品·tagline:1 条");
expect((await aliases()).length).toBe(before);
const applied = run("--apply");
expect(applied).toContain("已删除 5 条别名");
const after = await aliases();
const texts = after.map((a) => a.alias);
for (const gone of ["Атака титанов", "Ataque dos Titãs", "L'Attaque des Titans", "Атака на титанов", "진격의 거인"])
expect(texts).not.toContain(gone);
for (const kept of [
"Ataque Titan",
"ผ่าพิภพไททัน",
"Атака гигантов",
"Attaque verrouillée",
"進撃の巨人",
"Shingeki no Kyojin",
])
expect(texts).toContain(kept);
expect(after.length).toBe(before - 5);
const audit = await h.db
.selectFrom("audit_event")
.select(["payload"])
.where("action", "=", "metadata.tmdb_alias_language_cleanup")
.executeTakeFirstOrThrow();
expect((audit.payload as { deleted: number }).deleted).toBe(5);
// 再跑一遍没有要删的
expect(run()).toContain("删除 0 条");
});
});
@@ -336,6 +336,7 @@ export interface TmdbPort { searchMovie(q: string, year?: number): Promise<TmdbS
| --- | --- | --- | | --- | --- | --- |
| 查询名顺序、按类型分步计划、年份容差 ±1、标题精确匹配优先、别名回退、季年份匹配、无类型 id 的双查加消歧、连接失败和不存在分开 | ① 所有 direct 或 alias 结果**只作为候选**,按证据打分输出 `candidates[]`;分数够高并且只有一个候选时才自动绑定,否则进入待识别池(REQ §7.5);② 别名回退要**限制拉详情的次数**(例如前 5 个),避免上游那样对每个候选都请求一次;③ 已经存在本地实体时,先查 `title_alias`(pg_trgm)召回本地实体,再查 TMDB;④ TMDB 缺失或失败**不阻断**有本地证据的入库(REQ §14.1.3) | 本地 UUID 优先;控制成本;REQ 要求不因外部服务阻断 | | 查询名顺序、按类型分步计划、年份容差 ±1、标题精确匹配优先、别名回退、季年份匹配、无类型 id 的双查加消歧、连接失败和不存在分开 | ① 所有 direct 或 alias 结果**只作为候选**,按证据打分输出 `candidates[]`;分数够高并且只有一个候选时才自动绑定,否则进入待识别池(REQ §7.5);② 别名回退要**限制拉详情的次数**(例如前 5 个),避免上游那样对每个候选都请求一次;③ 已经存在本地实体时,先查 `title_alias`(pg_trgm)召回本地实体,再查 TMDB;④ TMDB 缺失或失败**不阻断**有本地证据的入库(REQ §14.1.3) | 本地 UUID 优先;控制成本;REQ 要求不因外部服务阻断 |
| 中文标题补全 | 放进元数据协调层(REQ §7.3 字段级来源),**不改变路径绑定** | REQ §7.1 | | 中文标题补全 | 放进元数据协调层(REQ §7.3 字段级来源),**不改变路径绑定** | REQ §7.1 |
| 别名与译名(`__get_names` 用全部 `alternative_titles` + 全部 `translations`;`__update_tmdbinfo_extra_title` 取 en / hk / tw / sg 译名,`__update_tmdbinfo_cn_title` 取 CN 地区别名) | **多语言保留规则**(2026-10-06 用户规则,`packages/adapters/src/tmdb/language-policy.ts`):译名、别名、简介、标语只留简中(zh-CN / zh-SG / zh-Hans)、繁中(zh-TW / zh-HK / zh-MO / zh-Hant)、英文(en-*)、作品原语言;`alternative_titles` 按地区推断语言(CN / SG 简中、TW / HK / MO 繁中、英语区英文、原产地区原语言)并校验文字,推断不出或文字不符的丢弃;原产地区的罗马字别名(type 含 roma)记 `<原语言>-Latn`。识别比较(`details().aliases`、`match.ts` 别名回退、`tmdbToCandidate`、按作品搜资源的 `work-match.ts`)同样只用这些 | 用户规则:俄 / 葡 / 越 / 西 / 泰等与作品无关的语言别名只会制造误匹配和噪声;MP 的 en / hk / tw / sg / CN 取值都在保留范围内 |
--- ---
+47
View File
@@ -292,6 +292,53 @@ DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity
`POST /media-entities/{id}/refresh?full=1`,季 / 集单元会按 TMDB 重建为 confirmed。季 0(特别篇)自 DH-1 起也在 full 刷新 `POST /media-entities/{id}/refresh?full=1`,季 / 集单元会按 TMDB 重建为 confirmed。季 0(特别篇)自 DH-1 起也在 full 刷新
范围内(`metadata.includeSpecials` 缺省开),回刷即可重建。 范围内(`metadata.includeSpecials` 缺省开),回刷即可重建。
## TMDB 多语言保留规则(2026-10-06)
用户规则:「除了简繁、英文,还有源语言之外,不需要获取其他更多的别名」,TMDB 的多语言数据整体按这个保留。
规则是一个纯函数模块 `packages/adapters/src/tmdb/language-policy.ts`(单测 `language-policy.test.ts`)。
- **保留的语言**:简中(zh-CN / zh-SG / zh-Hans,及不带地区的 zh)、繁中(zh-TW / zh-HK / zh-MO / zh-Hant)、英文(en-*)、
作品原语言(TMDB `original_language`,按主语言比较,如 ja → ja-JP / ja 都算;原语言是中文 / 英文时不额外增加,
原语言是中文时 zh 的其它地区也算原语言)。原名(`original_title / original_name`)总是保留。
- **alternative_titles**(只有地区没有语言):CN / SG → 简中,TW / HK / MO → 繁中,US / GB / AU / CA / NZ / IE → 英文,
作品 `origin_country` 里的地区 → 原语言;再校验文字:中文要汉字且不含假名 / 谚文,带地区时看简繁特有字(明显是另一种的不要),
日文要假名或汉字,韩文要谚文,泰 / 俄 / 阿 / 希伯来 / 印地等按各自文字,英文和其它拉丁语言要拉丁字母且不含非拉丁文字。
推断不出(没有地区、地区不在上面)或文字都不符的丢弃。例外:原产地区、type 含 roma(TMDB 的 "Romaji")的罗马字别名记
`<原语言>-Latn`(kind `romanized`),资源标题常写罗马字。
- **应用位置**:`TmdbClient.details()`(识别比较用的 `aliases` / `titles`,`match.ts` 别名回退、`tmdbToCandidate`、
`work-match.ts` 按作品搜资源都只看到保留语言)、`fetchRichDetail`(`translations` / `alternativeTitles` 只留保留语言,
别名带推断语言)、建档 `seedAliases` 与刷新 `tagTitleAliases` 写 `title_alias`(语言写规范化后的值,不再写空语言的 `other`;
本地化名文字与语言不符时——TMDB 缺该语言译名时回退成原名——不按该语言登记)。
- **语言链**(设置 `metadata.languages`):缺省 `zh-CN, zh-SG, zh-TW, zh-HK, en-US` 全在保留范围。设置仍允许填任意 `xx-YY`,
但按作品收敛:不在保留范围的语言(对这部作品既不是中英文也不是原语言)从链里去掉,标题 / 简介 / 标语 / 季集名都不取它;
请求语言(链首)被去掉时请求语言下的值不拿来兜底;链全被去掉时用缺省链。季集详情不知道原语言时(链里有中英文以外的语言)
先读一次作品详情(走请求缓存)。
- 作品与单元的 `metadata` 只存按语言链取到的单个值(带语言),不存 translations 列表,没有别的多语言数据。
### 存量清理(`scripts/tmdb-alias-language-cleanup.ts`)
缺省只读(`--dry-run` 显式只读,与 `--apply` 互斥);`--apply` 在一个事务里删除并写审计 `metadata.tmdb_alias_language_cleanup`
(payload 列出每条删除的作品 / 别名 / 语言 / kind / 原因);`--entity <id>` 只处理一部作品;`--samples N` 每类样例条数(缺省 5)。
连接方式同上(用户栈的 PG 取容器 IP):
```sh
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/tmdb-alias-language-cleanup.ts # 看统计与样例
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/tmdb-alias-language-cleanup.ts --apply # 逐类目过目后执行
```
- **判定口径**(`title_alias.source` 能区分来源):只动 `source = tmdb` 且未锁定的行;`user` / `site` / `agent` / `nfo` / `import`
来源与锁定的行一律不动(清单里只报数量)。tmdb 行中:`kind = original` 保留;与作品主标题 / 原标题归一化相同的保留;
`language` 非空 → 规范化后在保留范围(原语言取 `media_entity.metadata.originalLanguage`,没有时取 tmdb 字段来源)保留,否则删
(原因 `language`);`language` 为空(旧版建档写的 `other` 等)→ 汉字(不含假名 / 谚文)、拉丁字母、没有字母的保留,其它文字
只在与原语言相符时保留,否则删(原因 `script`)。拉丁字母分不出英 / 法 / 西 / 葡,空语言的拉丁别名一律按英文保留——
要彻底收敛这部分,对作品跑一次元数据刷新不会删它们(刷新只增不删),需要时人工处理。
- **字段来源**:`metadata_field`(来源 tmdb)里语言不在保留范围的值只报告不删(删了会让最终值暂时没有来源)。对清单里的作品
跑一次元数据刷新即收敛(同字段换语言时删旧行),批量用计划页「元数据语言回刷」(`metadata.backfill_languages`)。
- 输出:别名总数 / 删除 / 保留,保留按原因计数;删除按「语言 / kind」分组计数,每组列样例(作品、原语言、别名);
字段来源按「语言 / 作品或单元·字段」计数并列涉及的作品。
- **对部署库**(lessons 第 22 条):先 dry-run,协调者逐类目看清单(重点看删除组里有没有人工补录却被记成 tmdb 来源的、
原语言为空导致误删的日 / 韩 / 泰文别名)再 `--apply`。原语言为空的作品只保留中英文,先对这些作品刷新元数据补上原语言再清理更稳。
## 排查 ## 排查
```sql ```sql
+2
View File
@@ -9,6 +9,8 @@
- 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停(简介只从简介链里的语言取;只缺简介且剩下的语言都不在简介链里时不再补拉);链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。 - 季 / 集:先按链首请求 `/tv/{id}/season/{n}`;已播出的集缺标题(TMDB 占位名"第 N 集""Episode N")或缺简介时,按链逐个语言补拉同一端点,补齐即停(简介只从简介链里的语言取;只缺简介且剩下的语言都不在简介链里时不再补拉);链走完仍没有标题的集保留占位名(语言记为空)。未播出的集不触发补拉。
- 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。 - 每个字段的来源值写 `metadata_field`,`language` 记实际取到的语言;同字段语言变了,旧语言的 tmdb 行删掉。
- `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。 - `translations` 与 `alternative_titles` 写 `title_alias`(来源 tmdb,标语言;别名只在地区与文字相符时标,如 CN + 汉字 → zh-CN)。已有同文本别名(繁简归一化后同键)不重复登记,只给语言为空的 tmdb 行补语言。
- 多语言保留规则(2026-10-06):TMDB 的译名 / 别名 / 简介 / 标语只保留简中、繁中、英文和作品原语言,语言链按作品收敛
(链里的 ja-JP 只对日语原作生效,fr-FR 这类对原语言不是它的作品跳过)。规则、清理脚本见 `identity.md`「TMDB 多语言保留规则」。
## 刷新 ## 刷新
+187
View File
@@ -0,0 +1,187 @@
// 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:***@<host>: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 <T>(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<Row>(
`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<string, number>();
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<string, (Row & { why: string })[]>();
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<string, number>();
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();