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:
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 取值都在保留范围内 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 多语言保留规则」。
|
||||||
|
|
||||||
## 刷新
|
## 刷新
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in new issue
Block a user