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
This commit is contained in:
1 parent
12f8fc59c3
commit
63e94b308e
90 files changed
+6366
-2131
No files matched your search
@@ -0,0 +1,147 @@
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user