Files
rootandClaude Opus 5.5 63e94b308e feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 17:03:00 +08:00

148 lines
6.3 KiB
TypeScript

import { compileEntityWords } from "@umt/contracts";
import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { up } from "../../../../packages/db/migrations/0094_identify_words.ts";
import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts";
// 0094:episode_mapping_rule 结构化规则逐作品转成作品识别词文本行后删表。部署库只有一部作品 9 条规则
// (药屋少女的呢喃:S2 任意组 EP+24 → S1;S3 任意组 EP+48 → S1;S3 七个发布组 EP → S1),按它造数据。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("0094 映射规则迁成作品识别词", () => {
const schema = `iw_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const admin = async (q: string) => {
const c = new pg.Client({ connectionString: PG_URL });
await c.connect();
await c.query(q).finally(() => c.end());
};
let db: Kysely<DB>;
afterAll(async () => {
await db?.destroy();
await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`));
});
// biome-ignore lint/suspicious/noExplicitAny: 迁移前的旧表不在类型里
const raw = () => db as unknown as Kysely<any>;
const rule = (entity: string, v: Record<string, unknown>) =>
raw()
.insertInto("episode_mapping_rule")
.values({ media_entity_id: entity, kind: "expression", created_by_type: "user", ...v })
.execute();
const groups = ["ADWeb", "MWeb", "CHDWEB", "黒ネズミたち", "Judas", "绿茶字幕组", "綠茶字幕組"];
it("药屋 9 条规则合成 3 行;停用的变注释;单集对照一行一条;智能体的带注释;重跑只补缺的行", async () => {
await admin(`create schema ${schema}`);
db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }),
}),
});
await withSchemaLock(async () => {
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0093_pi_azure_provider");
if (r.error) throw r.error;
});
const entity = async (title: string) =>
(
await sql<{
id: string;
}>`insert into media_entity (kind, title) values ('series', ${title}) returning id`.execute(db)
).rows[0]!.id;
const kusuriya = await entity("药屋少女的呢喃");
const other = await entity("别的剧");
await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 });
await rule(kusuriya, { season: 3, expression: "EP+48", target_season: 1 });
for (const g of groups) await rule(kusuriya, { season: 3, release_group: g, expression: "EP", target_season: 1 });
// 别的剧:停用(自动停用留下的)、单集对照、特别篇、智能体写的
await rule(other, {
season: 1,
release_group: "ANi",
expression: "EP-12",
target_season: 2,
status: "disabled",
disabled_reason: JSON.stringify({ source: { season: 1 } }),
});
await rule(other, {
season: 1,
release_group: "Baha",
kind: "table",
expression: null,
table_entries: JSON.stringify([
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } },
]),
});
await rule(other, { season: null, release_group: "SweetSub", kind: "special", expression: null, special: "extra" });
await rule(other, {
season: 2,
release_group: "Nekomoe",
expression: "EP-30",
created_by_type: "agent",
evidence: JSON.stringify({ reasons: ["发布时间对上播出日期"] }),
});
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
const text = async (id: string) =>
(await db.selectFrom("identify_words").select("text").where("media_entity_id", "=", id).executeTakeFirstOrThrow())
.text;
expect(await text(kusuriya)).toBe(
["S02 >> S01 EP+24", "S03 >> S01 EP+48", `S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`, ""].join("\n"),
);
expect(await text(other)).toBe(
[
"S01E13 @Baha >> S00E01",
"S01E27-E28 @Baha >> S02E02",
"# S01 @ANi >> S02 EP-12",
"# 智能体:发布时间对上播出日期",
"S02 @Nekomoe >> EP-30",
"* @SweetSub >> 花絮",
"",
].join("\n"),
);
// 迁出来的文本能原样编译,规则与原来等价(药屋 2 + 7 条)
const c = compileEntityWords(await text(kusuriya));
expect(c.errors).toEqual([]);
expect(c.rules.map((r) => [r.season, r.releaseGroup, r.expression, r.targetSeason])).toEqual([
[2, null, "EP+24", 1],
[3, null, "EP+48", 1],
...groups.map((g) => [3, g, "EP", 1]),
]);
expect(compileEntityWords(await text(other)).errors).toEqual([]);
const tables = await sql<{
t: string | null;
}>`select to_regclass(${`${schema}.episode_mapping_rule`})::text as t`.execute(db);
expect(tables.rows[0]!.t).toBeNull();
const version = await db
.selectFrom("identify_words")
.select(["version", "updated_by_type"])
.where("media_entity_id", "=", kusuriya)
.executeTakeFirstOrThrow();
expect(version).toEqual({ version: 1, updated_by_type: "system" });
// 重跑(表还在的情况):已有的行不重复,只追加新的
await sql
.raw(`create table episode_mapping_rule (
id uuid primary key default gen_random_uuid(), media_entity_id uuid not null, season integer, release_group text,
kind text not null, expression text, target_season integer, table_entries jsonb, special text,
status text not null default 'active', evidence jsonb not null default '{}'::jsonb, created_by_type text not null,
created_at timestamptz not null default now())`)
.execute(db);
await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 });
await rule(kusuriya, { season: 4, expression: "EP+72", target_season: 1 });
await up(raw());
expect(await text(kusuriya)).toBe(
[
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
`S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`,
"S04 >> S01 EP+72",
"",
].join("\n"),
);
});
});