- adapters: overviewLanguages() limits overview/tagline chain to Chinese variants (zh-CN→zh-SG→zh-TW→zh-HK); titles keep full chain; season supplement fetch skips languages useless for overview - setting metadata.overviewNonChineseFallback (default off) + UI switch - media detail shows "暂无简介" for empty entity/episode overview - scripts/overview-recompute.ts: recompute stored non-Chinese overviews from kv_cache only (no TMDB requests), dry-run by default, --apply - tests: chain unit tests, Heart Signal (103147) fixture, server recompute test, MP differential for episode overview - runbook + CHANGELOG Co-Authored-By: Claude Opus 5.5 <[email protected]>
95 lines
4.9 KiB
TypeScript
95 lines
4.9 KiB
TypeScript
// 存量简介重算(MD-2):简介的语言回退只到中文变体后,把之前回退到英文等非中文的作品 / 季 / 集简介按新规则重算。
|
|
// 不请求 TMDB,只读 kv_cache(tmdb.request)里已缓存的 TMDB 响应;缓存里没有的按"当时中文为空"清空。
|
|
// 默认只读(dry-run),加 --apply 才改库(单个事务,写一条审计 metadata.overview_recompute)。
|
|
//
|
|
// 用法(DATABASE_URL 必须显式给出,脚本不带默认库):
|
|
// DATABASE_URL=postgres://umt:***@<host>: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<DB>({ 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<string, OverviewChange[]>();
|
|
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();
|
|
}
|