// 存量简介重算(MD-2):简介的语言回退只到中文变体后,把之前回退到英文等非中文的作品 / 季 / 集简介按新规则重算。 // 不请求 TMDB,只读 kv_cache(tmdb.request)里已缓存的 TMDB 响应;缓存里没有的按"当时中文为空"清空。 // 默认只读(dry-run),加 --apply 才改库(单个事务,写一条审计 metadata.overview_recompute)。 // // 用法(DATABASE_URL 必须显式给出,脚本不带默认库): // DATABASE_URL=postgres://umt:***@:5432/umt deno run -A scripts/overview-recompute.ts # 看清单 // DATABASE_URL=… deno run -A scripts/overview-recompute.ts --apply # 写库 // DATABASE_URL=… deno run -A scripts/overview-recompute.ts --entity <作品 id> # 只看 / 只处理一部作品 // DATABASE_URL=… deno run -A scripts/overview-recompute.ts --json # 清单输出 JSON // // 语言链与"简介是否允许回退到非中文"取库里的设置 metadata.languages / metadata.overviewNonChineseFallback。 // 规则与实现见 apps/server/src/modules/sources-identity/service/overview-recompute.ts、docs/runbook/metadata.md。 import { DEFAULT_METADATA_LANGUAGES } from "@umt/adapters"; import type { DB } from "@umt/db"; import { Kysely, PostgresDialect, sql } from "kysely"; import pg from "pg"; import { applyOverviewRecompute, type OverviewChange, planOverviewRecompute, } from "../apps/server/src/modules/sources-identity/service/overview-recompute.ts"; const argv = [...Deno.args]; const APPLY = argv.includes("--apply"); const JSON_OUT = argv.includes("--json"); if (APPLY && argv.includes("--dry-run")) { console.error("--apply 与 --dry-run 不能同时给出"); Deno.exit(2); } const entityArg = argv.indexOf("--entity"); const ONLY_ENTITY = entityArg >= 0 ? (argv[entityArg + 1] ?? null) : null; const url = Deno.env.get("DATABASE_URL"); if (!url) { console.error("需要 DATABASE_URL(脚本不带默认库)"); Deno.exit(2); } const db = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 2 }) }) }); const meta = ( await sql<{ value: { languages?: string[]; overviewNonChineseFallback?: boolean } | null }>` select value from settings where key = 'metadata'`.execute(db) ).rows[0]?.value; const languages = meta?.languages?.length ? meta.languages : [...DEFAULT_METADATA_LANGUAGES]; const overviewNonChineseFallback = meta?.overviewNonChineseFallback === true; const now = new Date(); const cut = (s: string | null, n = 40) => (s === null ? "(空)" : s.length > n ? `${s.slice(0, n)}…` : s); const where = (c: OverviewChange) => c.target === "entity" ? "作品" : c.target === "season" ? `S${String(c.season).padStart(2, "0")}` : `S${String(c.season).padStart(2, "0")}E${String(c.episode).padStart(2, "0")}`; try { const plan = await planOverviewRecompute(db, { languages, overviewNonChineseFallback, now, entityId: ONLY_ENTITY }); if (JSON_OUT && !APPLY) { console.log(JSON.stringify(plan, null, 2)); } else { console.log( `# overview-recompute (${APPLY ? "APPLY" : "DRY-RUN"})${ONLY_ENTITY ? ` --entity ${ONLY_ENTITY}` : ""}`, ); console.log(`语言链 ${languages.join(" → ")};简介链 ${plan.overviewLanguages.join(" → ")}`); console.log( `非中文简介:作品 ${plan.scanned.entities},季 / 集 ${plan.scanned.units};读缓存 ${plan.cache.reads} 次,未命中 ${plan.cache.misses}\n`, ); const byEntity = new Map(); for (const c of plan.changes) byEntity.set(c.mediaEntityId, [...(byEntity.get(c.mediaEntityId) ?? []), c]); for (const [id, list] of byEntity) { console.log(`## ${list[0]!.entityTitle} (${id}) ${list.length} 项`); for (const c of list) { const basis = c.basis === "cache" ? "缓存" : "无缓存→清空"; console.log( ` ${where(c).padEnd(8)} ${c.field.padEnd(8)} [${c.from.language ?? "-"}] ${cut(c.from.value)} → [${c.to.language ?? "-"}] ${cut(c.to.value)} (${basis})`, ); } } const count = (t: OverviewChange["target"]) => plan.changes.filter((c) => c.target === t).length; console.log( `\n合计 ${plan.changes.length} 项:作品 ${count("entity")},季 ${count("season")},集 ${count("episode")};` + `其中无缓存清空 ${plan.changes.filter((c) => c.basis === "no_cache").length},` + `改成中文 ${plan.changes.filter((c) => c.to.value !== null).length}`, ); } if (APPLY && plan.changes.length) { const r = await db.transaction().execute((trx) => applyOverviewRecompute(trx, plan, { now })); console.log(`已写库:最终值有变化的作品 ${r.entities},季 / 集 ${r.units}(锁定 / 覆盖的字段不变)`); } else if (!APPLY) { console.log("dry-run:未改库。确认清单后加 --apply 执行"); } } finally { await db.destroy(); }