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:
rootandClaude Opus 5.5 committed 2026-10-08 17:03:00 +08:00
1 parent 12f8fc59c3
commit 63e94b308e
90 files changed
+6366 -2131

No files matched your search

+16 -15
View File
@@ -1,7 +1,7 @@
---
name: episode-mapping
title: 集数映射
description: 资源的集数对不上作品季表(绝对集号、连续编号、特别篇混在正片里)时,看清上下文、试一条结构化的映射规则,模拟全部通过才写入,写入后同类资源由规则自动处理。集数待确认事项的后台调查,或用户说「某部剧集数不对」「集数待确认」时用。
description: 资源的集数对不上作品季表(绝对集号、连续编号、特别篇混在正片里)时,看清上下文、试一行作品识别词,模拟全部通过才写进作品识别词,写入后同类资源由这一行自动处理。集数待确认事项的后台调查,或用户说「某部剧集数不对」「集数待确认」时用。
triggers:
- 集数(待确认|对不上|不对|错了?|映射|乱了)
- (绝对|连续)(集号|编号)|集号(不对|对不上|错)
@@ -24,33 +24,34 @@ sessions: [chat, investigate, child]
## 步骤
1. 定位:后台调查直接用事项 ID;对话里先 decision.list 找这部作品的集数待确认事项,没有事项时用 catalog.lookup 拿作品 ID,再按作品 + 来源季 + 发布组看。
2. 先 episode_mapping.inspect:看事项内资源的原标题、发布时间、原始季集,同组相邻资源已经怎么对上的,季表每季总集数与每集播出日期,现有规则(停用的看停用原因),以及确定性建议。
3. 想规则,按优先级:
- 首选 expression(偏移表达式):连续编号、整体差一个固定数,如 `EP-12` 换到第 2 季、`EP*2-1`;确定性建议给了就从它开始。
- 其次 table(对照表):编号不规则、只有少数几集要对,逐行写 `来源集号 → 第几季第几集`。
- 特别篇、总集篇、花絮、PV 用 special(特别篇 / 花絮 / 忽略),不要硬塞成某一集。
- 作用域默认用事项的来源季和发布组;只有确信所有发布组都这么编号时才放宽到任意发布组。
4. 再 episode_mapping.simulate(带事项 ID 和一两句理由):看事项内样本是否全部解决、有没有让已对上的资源变成待确认。没通过就按失败原因改规则再试,最多试三次。
5. 通过且有把握时,episode_mapping.apply_rule 写一次(带理由:依据哪些播出日期 / 标题 / 相邻资源)。服务端会再模拟一次,不通过会拒绝。写入后同类资源由规则自动处理,不要逐集再调用。
6. 拿不准(样本太少、几种规则都说得通、季表缺集)就不写:只读调查里模拟通过的规则会自动作为「智能体建议」留在事项上,让用户确认;说清楚还差什么证据。
2. 先 episode_mapping.inspect:看事项内资源的原标题、发布时间、原始季集,同组相邻资源已经怎么对上的,季表每季总集数与每集播出日期,作品识别词原文(注释掉的行是用户停用的,不要再写同范围的行),以及确定性建议。
3. 写一行作品识别词(语法见 docs/design/identify-words.md),按优先级:
- 首选季集换算:连续编号、整体差一个固定数,如 `S01 @Lilith-Raws >> S02 EP-12`(第 1 季的 13 集起换到第 2 季)、`S02 >> S01 EP+24`(第 2 季接着第 1 季编号)、`S01 >> EP*2-1`;`EP` 单独写表示集号不变只换季。确定性建议给了就从它开始。
- 其次单集对照:编号不规则、只有少数几集要对,一行一集 `S01E13 @发布组 >> S00E01`,区间写 `S01E27-E28 >> S02E02`。
- 特别篇、总集篇、花絮、PV 写 `S01 @发布组 >> 特别篇`(或 花絮 / 忽略),不要硬塞成某一集。
- 范围默认用事项的来源季和发布组;只有确信所有发布组都这么编号时才去掉 `@发布组`,任意季写 `*`。
4. 再 episode_mapping.simulate(带事项 ID 和一两句理由):看事项内样本是否全部解决、有没有让已对上的资源变成待确认。没通过就按失败原因改这一行再试,最多试三次。
5. 通过且有把握时,episode_mapping.apply_rule 写一次(带理由:依据哪些播出日期 / 标题 / 相邻资源)。服务端会再模拟一次,不通过会拒绝;通过后这一行追加到作品识别词末尾,上方一行注释写明是智能体写的和理由。写入后同类资源由这一行自动处理,不要逐集再调用。
6. 拿不准(样本太少、几种规则都说得通、季表缺集)就不写:只读调查里模拟通过的那一行会自动作为「智能体建议」留在事项上,让用户确认;说清楚还差什么证据。
## 需要批准
- episode_mapping.apply_rule(写映射规则)需要持续授权;没有授权时写一句「没做:写集数映射规则,需要你批准」,模拟通过的规则已留在待处理里可一键确认。
- episode_mapping.apply_rule(写映射规则)需要持续授权;没有授权时写一句「没做:把规则写进作品识别词,需要你批准」,模拟通过的那一行已留在作品页的集数映射里,确认后生效。
- episode_mapping.inspect、episode_mapping.simulate 只读,不需要批准。
## 汇报模板
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的 40、41 集是连续编号,已按「减 30,换到第 2 季」写入规则,两条资源分别对上第 2 季第 10、11 集,播出日期一致。
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的 40、41 集是连续编号,已在作品识别词里加了「S01 @Lilith-Raws >> S02 EP-30」,两条资源分别对上第 2 季第 10、11 集,播出日期一致。
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的集数还没定:模拟通过的规则是「减 30,换到第 2 季」,已作为智能体建议留在待处理里,确认后生效。
> 《药屋少女的呢喃》第 1 季 · Lilith-Raws 的集数还没定:模拟通过的是「S01 @Lilith-Raws >> S02 EP-30」,已作为智能体建议留在作品页的集数映射里,确认后生效。
- 第一句写结论(写了什么规则 / 为什么没写),第二句写依据(播出日期、标题、相邻资源),需要用户做的放最后。
## 不要做
- 不要绕过模拟:没跑过 episode_mapping.simulate 或模拟没通过,不调用 episode_mapping.apply_rule。
- 不要逐集处理,也不要为每条资源各写一条规则:一条规则覆盖这个发布组这一季。
- 不要逐集处理,也不要为每条资源各写一行:一行覆盖这个发布组这一季。
- 不要把特别篇、总集篇硬算成正片集号。
- 不要改别的发布组、别的季已经对上的资源;模拟显示会让已对上的资源变成待确认就换规则。
- 汇报里不写规则 ID、资源 ID 和能力名。
- 不要取消用户注释掉的行,也不要改作品识别词里别的行。
- 汇报里不写资源 ID 和能力名;写进去的那一行原样给用户看。
@@ -70,9 +70,9 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [
{
intent: "集数对不上、集数待确认(绝对集号、连续编号、特别篇)",
steps: [
{ cap: "episode_mapping.inspect", note: "事项内资源、同组相邻资源、季表、现有规则与确定性建议" },
{ cap: "episode_mapping.simulate", note: "试草稿规则,看样本是否全部解决、有没有误伤已对上的资源" },
{ cap: "episode_mapping.apply_rule", note: "模拟通过才写规则,写入后同类资源自动处理" },
{ cap: "episode_mapping.inspect", note: "事项内资源、同组相邻资源、季表、作品识别词原文与确定性建议" },
{ cap: "episode_mapping.simulate", note: "试一行作品识别词,看样本是否全部解决、有没有误伤已对上的资源" },
{ cap: "episode_mapping.apply_rule", note: "模拟通过才把这一行写进作品识别词,写入后同类资源自动处理" },
],
tips: [
"优先 expression(偏移表达式),其次 table(对照表),特别篇 / 花絮用 special;拿不准就不写,只把模拟通过的规则留作建议。",
+65 -31
View File
@@ -1,51 +1,85 @@
import { IdParam } from "../common.ts";
import * as em from "../episode-mapping.ts";
import * as iw from "../identify-words.ts";
import { ep } from "./shared.ts";
// 集数映射(docs/plan/episode-resolution.md 批次 4):作品的映射规则增删改、模拟预览。
// 写入后立即按作品重算已绑定资源(返回受影响数量);待处理 episode_mapping 的确认 / 忽略走 /decisions/{id}/resolve 的选项。
// 识别词(docs/design/identify-words.md):全局一块、每部作品一块,原文整块读写;保存前试算,保存后重算。
// 集数映射预览(纯读);集数待确认事项的确认 / 忽略走 /decisions/{id}/resolve 的选项(把一行写进作品识别词)。
export const episodeMappingApi = {
episodeMapping: {
listRules: ep({
identifyWords: {
getGlobal: ep({
method: "get",
path: "/media-entities/{id}/episode-mapping-rules",
path: "/identify-words/global",
scope: "read",
summary: "作品的集数映射规则(含停用的与停用原因)",
params: IdParam,
response: em.EpisodeMappingRuleList,
summary: "全局识别词原文、版本与保存后的重算进度",
response: iw.IdentifyWordsBlock,
}),
createRule: ep({
dryRunGlobal: ep({
method: "post",
path: "/media-entities/{id}/episode-mapping-rules",
scope: "organize.execute",
summary: "新建集数映射规则并按作品重算(同作品 + 季 + 发布组只能有一条启用的规则)",
params: IdParam,
body: em.EpisodeMappingRuleCreate,
response: em.EpisodeMappingRuleWriteResult,
path: "/identify-words/global/dry-run",
scope: "read",
summary: "试算全局识别词:逐行错误、改动的行、近期资源里识别结果会变的条目(纯读)",
body: iw.IdentifyWordsDryRunInput,
response: iw.GlobalWordsDryRun,
}),
updateRule: ep({
method: "patch",
path: "/episode-mapping-rules/{id}",
scope: "organize.execute",
summary: "修改 / 停用 / 启用集数映射规则并按作品重算",
params: IdParam,
body: em.EpisodeMappingRuleUpdate,
response: em.EpisodeMappingRuleWriteResult,
saveGlobal: ep({
method: "put",
path: "/identify-words/global",
scope: "admin",
summary: "保存全局识别词(版本不符返回 409),后台重新识别标题命中改动行的资源",
body: iw.IdentifyWordsSave,
response: iw.GlobalWordsSaveResult,
}),
deleteRule: ep({
method: "delete",
path: "/episode-mapping-rules/{id}",
scope: "organize.execute",
summary: "删除集数映射规则并按作品重算",
params: IdParam,
response: em.EpisodeMappingRuleWriteResult,
listEntities: ep({
method: "get",
path: "/identify-words/entities",
scope: "read",
summary: "有作品识别词的作品:行数、停用行数、错误行数、最后修改",
query: iw.EntityWordsListQuery,
response: iw.EntityWordsList,
}),
try: ep({
method: "post",
path: "/identify-words/try",
scope: "read",
summary: "拿一个标题试一行识别词:替换后的标题、识别出的季集、作品识别词换算后的季集与校验(纯读)",
body: iw.WordsTryInput,
response: iw.WordsTryResult,
}),
getEntity: ep({
method: "get",
path: "/media-entities/{id}/identify-words",
scope: "read",
summary: "作品识别词原文与版本,附这部作品资源里出现过的发布组",
params: IdParam,
response: iw.EntityWordsView,
}),
dryRunEntity: ep({
method: "post",
path: "/media-entities/{id}/identify-words/dry-run",
scope: "read",
summary: "试算作品识别词:逐行错误、近期资源换算前后与仍对不上季表的条目(纯读)",
params: IdParam,
body: iw.IdentifyWordsDryRunInput,
response: iw.EntityWordsDryRun,
}),
saveEntity: ep({
method: "put",
path: "/media-entities/{id}/identify-words",
scope: "organize.execute",
summary: "保存作品识别词(版本不符返回 409),按作品重算已绑定资源的集数",
params: IdParam,
body: iw.IdentifyWordsSave,
response: iw.EntityWordsSaveResult,
}),
},
episodeMapping: {
preview: ep({
method: "post",
path: "/media-entities/{id}/episode-mapping/preview",
scope: "read",
summary: "模拟预览:近期资源的原始季集 → 当前结果 → 套用草稿规则后的结果(纯读)",
summary: "模拟预览:近期资源的原始季集 → 当前结果 → 套用草稿识别词后的结果(纯读)",
params: IdParam,
body: em.EpisodeMappingPreviewInput,
response: em.EpisodeMappingPreview,
+25 -41
View File
@@ -7,9 +7,6 @@ import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from
import { Id, JsonObject, NonNegInt } from "./common.ts";
import { MediaEntity, MediaKind } from "./content.ts";
import {
EpisodeMappingRuleSpec,
EpisodeMappingRuleStatus,
EpisodeMappingTableEntry,
EpisodeReresolveResult,
EpisodeResolutionMethod,
EpisodeSpecialKind,
@@ -17,14 +14,7 @@ import {
EpisodeValidationState,
} from "./episode-mapping.ts";
import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from "./execution.ts";
import {
EnrichmentStatus,
EnrichSuggestion,
EnrichUsage,
EvidenceLevel,
ExternalEntityType,
ExternalIdentity,
} from "./identity.ts";
import { EnrichmentStatus, EnrichSuggestion, EnrichUsage, ExternalEntityType, ExternalIdentity } from "./identity.ts";
import { OrganizeMetadataInput, OrganizeMetadataOutput } from "./local-metadata.ts";
import { ServerMapping } from "./ops.ts";
import { OrganizationAction, OrganizationRecord } from "./organization.ts";
@@ -927,7 +917,7 @@ export const WebSearchTestResult = z.object({
export type WebSearchTestResult = z.infer<typeof WebSearchTestResult>;
// ── episode_mapping.*:集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5) ──
// inspect 给上下文包(有上限),simulate 试草稿规则,apply_rule 服务端强制先模拟、通过才写规则并重算。
// inspect 给上下文包(有上限),simulate 试一行作品识别词,apply_rule 服务端强制先模拟、通过才把这一行写进作品识别词并重算。
const EpisodeRange = z.object({ start: z.number().int(), end: z.number().int() });
const EpisodeSourceView = z.object({
@@ -974,8 +964,6 @@ export const EpisodeMappingInspectOutput = z.object({
status: DecisionStatus,
title: z.string(),
summary: z.string().nullable(),
/** 算出越界被停用的规则 */
failedRules: z.array(z.string()),
})
.nullable(),
/** 事项内资源(没有事项时 = 这个范围里当前待确认的资源),最多 50 条 */
@@ -994,25 +982,18 @@ export const EpisodeMappingInspectOutput = z.object({
),
latestSeason: z.number().int().nullable(),
ended: z.boolean(),
/** 这部作品的映射规则(含停用及原因);byAgent = 智能体写的 */
rules: z.array(
z.object({
id: Id,
season: z.number().int().nullable(),
releaseGroup: z.string().nullable(),
kind: z.enum(["expression", "table", "special"]),
expression: z.string().nullable(),
targetSeason: z.number().int().nullable(),
tableEntries: z.array(EpisodeMappingTableEntry).nullable(),
special: EpisodeSpecialKind.nullable(),
status: EpisodeMappingRuleStatus,
disabledReason: z.string().nullable(),
evidenceLevel: EvidenceLevel,
byAgent: z.boolean(),
/** 智能体写规则时的理由 */
reasons: z.array(z.string()),
}),
),
/** 这部作品的作品识别词原文与逐行解析(注释掉的行是用户停用的,不要再写同范围的行) */
words: z.object({
text: z.string(),
lines: z.array(
z.object({
line: z.number().int(),
text: z.string(),
kind: z.enum(["blank", "comment", "map", "table", "special"]),
error: z.string().nullable(),
}),
),
}),
/** 确定性建议(按发布时间对播出日期,全部样本成立且唯一才有) */
suggestion: EpisodeSuggestion.nullable(),
truncated: z.object({ releases: z.boolean(), neighbors: z.boolean() }),
@@ -1023,8 +1004,8 @@ export const EpisodeMappingSimulateInput = z.object({
decisionId: Id.optional(),
/** 不给 decisionId 时必填 */
mediaEntityId: Id.optional(),
/** 草稿规则(expression / table / special + 作用域) */
rule: EpisodeMappingRuleSpec,
/** 一行作品识别词,如 `S02 @ANi >> S01 EP+12`、`S01E13 >> S00E01`、`S03 @Grp >> 忽略` */
line: z.string().trim().min(1).max(500),
/** 理由:模拟通过时作为智能体建议留在事项上(后台调查没有写规则授权时) */
reasons: z.array(z.string().trim().min(1).max(300)).max(10).optional(),
});
@@ -1061,7 +1042,8 @@ export const EpisodeSimulationSummary = z.object({
});
export type EpisodeSimulationSummary = z.infer<typeof EpisodeSimulationSummary>;
export const EpisodeMappingSimulateOutput = z.object({
rule: EpisodeMappingRuleSpec,
/** 规范写法的那一行 */
line: z.string(),
summary: EpisodeSimulationSummary,
/** 没通过的原因(通过时为空) */
failures: z.array(z.string()),
@@ -1074,14 +1056,16 @@ export const EpisodeMappingSimulateOutput = z.object({
export const EpisodeMappingApplyRuleInput = z.object({
/** 集数待确认事项 ID */
decisionId: Id,
/** 草稿规则;季 / 发布组不填时用事项的 */
rule: EpisodeMappingRuleSpec,
/** 为什么这样映射(依据),至少一条 */
/** 一行作品识别词(写法同 simulate) */
line: z.string().trim().min(1).max(500),
/** 为什么这样映射(依据),至少一条;写进识别词时作为这一行上方的注释 */
reasons: z.array(z.string().trim().min(1).max(300)).min(1).max(10),
});
export const EpisodeMappingApplyRuleOutput = z.object({
ruleId: Id,
evidenceLevel: EvidenceLevel,
/** 追加进作品识别词的原文(注释 + 规则行) */
appended: z.string(),
/** 规则行在识别词里的行号 */
lineNumber: z.number().int(),
summary: EpisodeSimulationSummary,
reresolve: EpisodeReresolveResult,
/** 写入后事项的状态(资源都解决时已自动关闭) */
+3 -3
View File
@@ -639,7 +639,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"episode_mapping.inspect": d(
"episode_mapping.inspect",
"看集数映射上下文",
"集数待确认时用:按事项(或作品 + 来源季 + 发布组)取上下文——事项内资源的原标题、发布时间、原始季集与当前结果,同组近期其他资源,季表(每季总集数、每集播出日期与标题),现有规则(含停用原因),确定性建议规则。写规则前先看这个",
"集数待确认时用:按事项(或作品 + 来源季 + 发布组)取上下文——事项内资源的原标题、发布时间、原始季集与当前结果,同组近期其他资源,季表(每季总集数、每集播出日期与标题),这部作品的作品识别词原文(注释掉的行是用户停用的),确定性建议规则。写规则前先看这个",
"read",
"EpisodeMappingInspect",
"read",
@@ -650,7 +650,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"episode_mapping.simulate": d(
"episode_mapping.simulate",
"模拟映射规则",
"试一条草稿映射规则(expression 偏移表达式 / table 对照表 / special 特别篇·花絮·忽略),逐条给出套用后的季集与校验,并汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认。不写规则;智能体会话里带 decisionId 且模拟通过时,规则和理由会作为智能体建议留在事项上,由用户确认",
"试一行作品识别词(季集换算 `S02 @发布组 >> S01 EP+12`、单集对照 `S01E13 >> S00E01`、特别篇 / 花絮 / 忽略 `S03 @发布组 >> 忽略`),同范围的已有行被它替换,逐条给出套用后的季集与校验,并汇总事项内样本是否全部解决、有没有让已对上的资源变成待确认。不写规则;智能体会话里带 decisionId 且模拟通过时,这一行和理由会作为智能体建议留在事项上,由用户确认",
"preview",
"EpisodeMappingSimulate",
"read",
@@ -661,7 +661,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
"episode_mapping.apply_rule": d(
"episode_mapping.apply_rule",
"写映射规则",
"把草稿映射规则写进作品并按作品重算资源的集数。服务端先强制模拟:事项内样本必须全部解决、已对上的资源不能变成待确认,否则拒绝不写。先用 episode_mapping.simulate 确认通过再调用,每个事项只写一次;拿不准就不要写",
"把一行作品识别词追加到作品识别词末尾(上方带一行写明理由的注释),再按作品重算资源的集数。服务端先强制模拟:事项内样本必须全部解决、已对上的资源不能变成待确认,否则拒绝不写;同范围的行被用户注释掉过也不写。先用 episode_mapping.simulate 确认通过再调用,每个事项只写一次;拿不准就不要写",
"execute",
"EpisodeMappingApplyRule",
"organize.execute",
+11 -81
View File
@@ -1,48 +1,14 @@
import { z } from "zod";
import { ActorType, EntityBase, Id, JsonObject } from "./common.ts";
import { EvidenceLevel } from "./identity.ts";
import { Id } from "./common.ts";
// 集数映射规则(docs/plan/episode-resolution.md):作品 + 来源季(可空)+ 发布组(可空)的作用域,
// 同一作用域同时只有一条 active。取值与 0066 迁移的 CHECK 一致(db-sync.test.ts 会查)。
// 集数映射(docs/plan/episode-resolution.md):规则改由作品识别词写(docs/design/identify-words.md),
// 这里是解析结果、预览与剧集组的结构。
export const EpisodeMappingRuleKind = z.enum(["expression", "table", "special"]);
export type EpisodeMappingRuleKind = z.infer<typeof EpisodeMappingRuleKind>;
export const EpisodeSpecialKind = z.enum(["special", "extra", "ignore"]);
export type EpisodeSpecialKind = z.infer<typeof EpisodeSpecialKind>;
export const EpisodeMappingRuleStatus = z.enum(["active", "disabled"]);
export type EpisodeMappingRuleStatus = z.infer<typeof EpisodeMappingRuleStatus>;
const EpisodeRangeSchema = z.object({ start: z.number().int(), end: z.number().int() });
/** 表格规则的一条:来源集号区间 → 目标的一集 */
export const EpisodeMappingTableEntry = z.object({
from: EpisodeRangeSchema,
to: z.object({ season: z.number().int(), episode: z.number().int() }),
});
export type EpisodeMappingTableEntry = z.infer<typeof EpisodeMappingTableEntry>;
export const EpisodeMappingRuleRow = EntityBase.extend({
mediaEntityId: Id,
/** 来源季;null = 任意季 */
season: z.number().int().nullable(),
/** 发布组;null = 任意发布组,比较忽略大小写 */
releaseGroup: z.string().nullable(),
kind: EpisodeMappingRuleKind,
/** expression 规则的偏移表达式(如 `EP-12`) */
expression: z.string().nullable(),
targetSeason: z.number().int().nullable(),
tableEntries: z.array(EpisodeMappingTableEntry).nullable(),
special: EpisodeSpecialKind.nullable(),
status: EpisodeMappingRuleStatus,
evidenceLevel: EvidenceLevel,
evidence: JsonObject,
/** 规则失效被停用时的证据 */
disabledReason: z.string().nullable(),
createdByType: ActorType,
createdById: z.string().nullable(),
});
export type EpisodeMappingRuleRow = z.infer<typeof EpisodeMappingRuleRow>;
// ── 接口(批次 4):规则增删改、模拟预览、应用后的重算结果 ──
export const EpisodeResolutionMethod = z.enum([
@@ -67,44 +33,15 @@ export const EpisodeResolutionView = z.object({
airDate: z.string().optional(),
}),
canonical: z.object({ season: z.number().int(), episodes: EpisodeRangeSchema }).nullable(),
/** 命中的作品识别词:编译后的规则 id、行号与原文 */
ruleId: z.string().optional(),
ruleFailed: z.string().optional(),
ruleLine: z.number().int().optional(),
ruleText: z.string().optional(),
special: EpisodeSpecialKind.optional(),
evidence: z.array(z.string()),
});
export type EpisodeResolutionView = z.infer<typeof EpisodeResolutionView>;
/** 规则内容(新建 / 草稿共用):按 kind 只看对应字段,其余忽略 */
export const EpisodeMappingRuleSpec = z.object({
/** 来源季;null / 不填 = 任意季 */
season: z.number().int().min(0).max(999).nullable().optional(),
/** 发布组;null / 空 = 任意发布组 */
releaseGroup: z.string().trim().max(200).nullable().optional(),
kind: EpisodeMappingRuleKind,
/** expression:偏移表达式,如 `EP-12`、`EP*2-1`(省略 EP 按 MP 约定补全) */
expression: z.string().trim().max(64).nullable().optional(),
/** expression:目标季,缺省为来源季 */
targetSeason: z.number().int().min(0).max(999).nullable().optional(),
tableEntries: z.array(EpisodeMappingTableEntry).max(500).nullable().optional(),
special: EpisodeSpecialKind.nullable().optional(),
});
export type EpisodeMappingRuleSpec = z.infer<typeof EpisodeMappingRuleSpec>;
export const EpisodeMappingRuleCreate = EpisodeMappingRuleSpec.extend({
/** 规则依据(如来自待处理建议的样本),原样存证 */
evidence: JsonObject.optional(),
});
export type EpisodeMappingRuleCreate = z.infer<typeof EpisodeMappingRuleCreate>;
/** 修改:内容字段整体按 kind 重新校验;status 切换启用 / 停用 */
export const EpisodeMappingRuleUpdate = EpisodeMappingRuleSpec.partial().extend({
status: EpisodeMappingRuleStatus.optional(),
});
export type EpisodeMappingRuleUpdate = z.infer<typeof EpisodeMappingRuleUpdate>;
export const EpisodeMappingRuleList = z.object({ items: z.array(EpisodeMappingRuleRow) });
export type EpisodeMappingRuleList = z.infer<typeof EpisodeMappingRuleList>;
/** 按作品重算已绑定资源的结果 */
export const EpisodeReresolveResult = z.object({
/** 重算的资源数 */
@@ -116,22 +53,13 @@ export const EpisodeReresolveResult = z.object({
});
export type EpisodeReresolveResult = z.infer<typeof EpisodeReresolveResult>;
/** 规则写入(新建 / 修改 / 停用 / 删除)后立即按作品重算 */
export const EpisodeMappingRuleWriteResult = z.object({
rule: EpisodeMappingRuleRow,
reresolve: EpisodeReresolveResult,
});
export type EpisodeMappingRuleWriteResult = z.infer<typeof EpisodeMappingRuleWriteResult>;
export const EpisodeMappingPreviewInput = z.object({
/** 只看这个季(来源季或规范季任一匹配);不填 = 全部 */
season: z.number().int().min(0).max(999).nullable().optional(),
/** 只看这个发布组(忽略大小写);不填 = 全部 */
releaseGroup: z.string().trim().max(200).nullable().optional(),
/** 草稿规则:替换同作用域的已有规则后试算 */
draft: EpisodeMappingRuleSpec.nullable().optional(),
/** 正在修改的规则:试算时用草稿替换它 */
replaceRuleId: Id.optional(),
/** 草稿:整块作品识别词原文,按它试算(不填 = 当前已保存的) */
words: z.string().max(200_000).optional(),
/** 最多看多少条近期资源(按发布时间倒序) */
limit: z.number().int().min(1).max(300).optional(),
});
@@ -147,8 +75,10 @@ export const EpisodeMappingPreviewItem = z.object({
releaseGroup: z.string().nullable(),
/** 库里的解析结果;没解析过为 null */
current: EpisodeResolutionView.nullable(),
/** 按原标题重解析、套用草稿规则后的结果(没有草稿时 = 当前规则重算) */
/** 按原标题重解析、套用草稿后的结果(没有草稿时 = 当前识别词重算) */
preview: EpisodeResolutionView,
/** 命中的全局识别词 */
words: z.array(z.string()),
validation: EpisodeValidationState,
/** 预览结果与库里的不同(方式、规范季集或特别篇) */
changed: z.boolean(),
@@ -0,0 +1,793 @@
// 识别词语法(docs/design/identify-words.md):全局识别词照搬 MoviePilot 的自定义识别词,作品识别词是本项目的季集换算行。
// 这里只有纯函数(解析、打印、可视化行模型、安全算术),server、domain、web 共用;套用到标题的引擎在 domain。
// ── Python 正则 → JS 正则(MP 的识别词按 Python regex 写) ──
export class WordsSyntaxError extends Error {
override name = "WordsSyntaxError";
}
const SCRIPTS = new Set([
"han",
"hiragana",
"katakana",
"hangul",
"latin",
"cyrillic",
"greek",
"thai",
"arabic",
"hebrew",
]);
/**
* 把 Python 正则改写成 JS 能编译的写法:`(?P<n>…)` → `(?<n>…)`、`(?P=n)` → `\k<n>`、`\A` / `\Z` → `^` / `$`,
* 开头的内联标志 `(?i)(?s)(?m)` 变成 RegExp 标志。先按 Unicode 模式编译(`\p{Han}` 需要),不行再退回普通模式。
*/
export function pyRegex(pattern: string, flags = ""): RegExp {
let src = pattern;
let f = flags;
for (let m = /^\(\?([aiLmsux]+)\)/.exec(src); m; m = /^\(\?([aiLmsux]+)\)/.exec(src)) {
for (const c of m[1]!) {
if (c === "x") throw new WordsSyntaxError("不支持 (?x) 写法");
if ("ims".includes(c) && !f.includes(c)) f += c;
}
src = src.slice(m[0].length);
}
let out = "";
let inClass = false;
for (let i = 0; i < src.length; i++) {
const c = src[i]!;
if (c === "\\") {
const n = src[i + 1] ?? "";
// Python regex 的 \p{Han} 等脚本名,JS 要写成 \p{Script=Han}
const prop = /^[pP]\{([A-Za-z_]+)\}/.exec(src.slice(i + 1));
if (prop && SCRIPTS.has(prop[1]!.toLowerCase())) {
out += `\\${prop[0][0]}{Script=${prop[1]}}`;
i += prop[0].length;
continue;
}
if (!inClass && n === "A") out += "^";
else if (!inClass && n === "Z") out += "$";
else out += c + n;
i++;
continue;
}
if (inClass) {
if (c === "]") inClass = false;
out += c;
continue;
}
if (c === "[") {
inClass = true;
out += c;
// `[]…]` 与 `[^]…]` 里开头的 ] 是字面量
if (src[i + 1] === "^") out += src[++i];
if (src[i + 1] === "]") out += `\\${src[++i]}`;
continue;
}
if (src.startsWith("(?P<", i)) {
out += "(?<";
i += 3;
continue;
}
const back = /^\(\?P=(\w+)\)/.exec(src.slice(i));
if (back) {
out += `\\k<${back[1]}>`;
i += back[0].length - 1;
continue;
}
out += c;
}
try {
return new RegExp(out, `${f}u`);
} catch {
try {
return new RegExp(out, f);
} catch (e) {
throw new WordsSyntaxError(`正则写法不对:${(e as Error).message.replace(/^Invalid regular expression: /, "")}`);
}
}
}
/** 正则里的捕获组数 */
const groupCount = (re: RegExp) => (new RegExp(`${re.source}|`, re.flags.replace("g", "")).exec("")?.length ?? 1) - 1;
const PY_ESCAPES: Record<string, string> = { n: "\n", t: "\t", r: "\r", f: "\f", v: "\v", a: "\x07", b: "\b" };
type ReplacePart = string | { group: number | string };
/**
* Python `re.sub` 的替换模板:`\1`、`\g<1>`、`\g<name>` 引用分组,`\\` 是反斜杠,`\n` 等是控制字符;
* 其余反斜杠 + ASCII 字母报错(Python 同样报错),反斜杠 + 其他字符原样保留。返回按匹配拼替换串的函数。
*/
export function pyReplacement(
template: string,
re?: RegExp,
): (match: string, groups: (string | undefined)[], named?: Record<string, string | undefined>) => string {
const parts: ReplacePart[] = [];
let lit = "";
const max = re ? groupCount(re) : Number.POSITIVE_INFINITY;
const push = (g: number | string) => {
if (typeof g === "number" && g > max) throw new WordsSyntaxError(`替换词引用了不存在的分组 \\${g}`);
if (lit) parts.push(lit);
lit = "";
parts.push({ group: g });
};
for (let i = 0; i < template.length; i++) {
const c = template[i]!;
if (c !== "\\") {
lit += c;
continue;
}
const n = template[i + 1];
if (n === undefined) throw new WordsSyntaxError("替换词末尾多了一个反斜杠");
if (n === "g") {
const m = /^<(\w+)>/.exec(template.slice(i + 2));
if (!m) throw new WordsSyntaxError("替换词里的 \\g 要写成 \\g<1> 或 \\g<名字>");
push(/^\d+$/.test(m[1]!) ? Number(m[1]) : m[1]!);
i += 1 + m[0].length;
continue;
}
const d = /^[1-9]\d?/.exec(template.slice(i + 1));
if (d) {
push(Number(d[0]));
i += d[0].length;
continue;
}
if (n === "\\") lit += "\\";
else if (n in PY_ESCAPES) lit += PY_ESCAPES[n];
else if (/[A-Za-z]/.test(n)) throw new WordsSyntaxError(`替换词里的 \\${n} 不是合法的转义`);
else lit += `\\${n}`;
i++;
}
if (lit) parts.push(lit);
return (match, groups, named) =>
parts
.map((p) => {
if (typeof p === "string") return p;
if (p.group === 0) return match;
if (typeof p.group === "number") return groups[p.group - 1] ?? "";
return named?.[p.group] ?? "";
})
.join("");
}
/** Python `pattern.subn(template, text)`:全部替换,返回新串与次数 */
export function pySubn(re: RegExp, template: string, text: string): [string, number] {
const g = re.flags.includes("g") ? re : new RegExp(re.source, `${re.flags}g`);
const build = pyReplacement(template, g);
let count = 0;
const out = text.replace(g, (...args: unknown[]) => {
count++;
const hasNamed = typeof args[args.length - 1] === "object" && args[args.length - 1] !== null;
const named = hasNamed ? (args[args.length - 1] as Record<string, string | undefined>) : undefined;
const groups = args.slice(1, hasNamed ? -3 : -2) as (string | undefined)[];
return build(args[0] as string, groups, named);
});
return [out, count];
}
// ── 集号算术(MP calculate_episode_offset 的白名单求值) ──
const IMPLICIT_EP = /(?:\d|\))\s*EP|EP\s*(?:\d|\()/;
const EP_TOKEN = /(?<![A-Za-z0-9_])EP(?![A-Za-z0-9_])/g;
type Tok = { t: "num"; v: number } | { t: "op"; v: string } | { t: "lp" } | { t: "rp" };
function lexArith(src: string): Tok[] {
const out: Tok[] = [];
for (let i = 0; i < src.length; ) {
const c = src[i]!;
if (c === " " || c === "\t") {
i++;
continue;
}
if (/\d/.test(c)) {
let j = i;
while (j < src.length && /\d/.test(src[j]!)) j++;
out.push({ t: "num", v: Number(src.slice(i, j)) });
i = j;
continue;
}
if (src.startsWith("//", i)) {
out.push({ t: "op", v: "//" });
i += 2;
continue;
}
if (src.startsWith("**", i)) throw new WordsSyntaxError("集号表达式不支持 **");
if ("+-*/%".includes(c)) out.push({ t: "op", v: c });
else if (c === "(") out.push({ t: "lp" });
else if (c === ")") out.push({ t: "rp" });
else throw new WordsSyntaxError(`集号表达式只能有数字、EP、括号和 + - * / // %,多了「${c}」`);
i++;
}
return out;
}
/** Python 的 % 与 //:结果符号跟除数 */
const pyMod = (a: number, b: number) => a - b * Math.floor(a / b);
function evalArith(src: string): number {
const toks = lexArith(src);
let pos = 0;
const peek = () => toks[pos];
const expr = (): number => {
let v = term();
for (let p = peek(); p?.t === "op" && (p.v === "+" || p.v === "-"); p = peek()) {
pos++;
const r = term();
v = p.v === "+" ? v + r : v - r;
}
return v;
};
const term = (): number => {
let v = unary();
for (let p = peek(); p?.t === "op" && ["*", "/", "//", "%"].includes(p.v); p = peek()) {
pos++;
const r = unary();
if (p.v !== "*" && r === 0) throw new WordsSyntaxError("集号表达式除以 0");
if (p.v === "*") v *= r;
else if (p.v === "/") v /= r;
else if (p.v === "//") v = Math.floor(v / r);
else v = pyMod(v, r);
}
return v;
};
const unary = (): number => {
const p = peek();
if (p?.t === "op" && (p.v === "-" || p.v === "+")) {
pos++;
const v = unary();
return p.v === "-" ? -v : v;
}
return atom();
};
const atom = (): number => {
const p = toks[pos++];
if (!p) throw new WordsSyntaxError("集号表达式不完整");
if (p.t === "num") return p.v;
if (p.t === "lp") {
const v = expr();
if (toks[pos++]?.t !== "rp") throw new WordsSyntaxError("集号表达式括号不配对");
return v;
}
throw new WordsSyntaxError("集号表达式写法不对");
};
const v = expr();
if (pos !== toks.length) throw new WordsSyntaxError("集号表达式写法不对");
if (!Number.isFinite(v)) throw new WordsSyntaxError("集号表达式算不出结果");
return Math.trunc(v);
}
/**
* MP calculate_episode_offset:`EP` 换成集号后按白名单算术求值(+ - * / // % 与括号,结果取整)。
* `2EP`、`EP(…)` 这类省略运算符的写法报错;不含 EP 的表达式直接算出常数(与 MP 一致)。
*/
export function calculateEpisodeOffset(offset: string, episode: number): number {
if (IMPLICIT_EP.test(offset)) throw new WordsSyntaxError("EP 前后要写运算符,如 2*EP");
let count = 0;
const expr = offset.replace(EP_TOKEN, () => {
count++;
return String(episode);
});
if (offset.includes("EP") && count === 0) throw new WordsSyntaxError("EP 占位符写法不对");
return evalArith(expr);
}
/**
* 作品识别词的集号换算:同一套算术,EP 不分大小写,必须出现 EP(`EP` 单独写 = 集号不变)。
*/
export function evalEpisodeExpr(expr: string, episode: number): number {
const e = expr.replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP");
if (!e.includes("EP")) throw new WordsSyntaxError("集号换算里要有 EP,如 EP+12");
return calculateEpisodeOffset(e, episode);
}
/** 规范写法:EP 大写、运算符两边不留空格(`ep + 12` → `EP+12`) */
export const normalizeEpisodeExpr = (expr: string) =>
expr.replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP").replace(/\s+/g, "");
// ── 全局识别词(MP 自定义识别词,逐行) ──
export type GlobalWordKind = "blank" | "comment" | "block" | "replace" | "offset" | "replace_offset";
export interface GlobalWordFields {
/** 屏蔽词 / 被替换词(正则) */
pattern?: string;
/** 替换词 */
replacement?: string;
/** 集数偏移的前定位词 / 后定位词(正则)与偏移表达式 */
front?: string;
back?: string;
offset?: string;
}
export interface GlobalWordLine extends GlobalWordFields {
/** 行号(从 1 起) */
line: number;
/** 原文 */
text: string;
kind: GlobalWordKind;
error?: string;
}
const COMBINED = /^\s*(.*?)\s*=>\s*(.*?)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*?)\s*$/;
/** 按 MP __parse_word 拆一行(不校验正则);注释与空行另算 */
export function parseGlobalWordLine(text: string, line = 1): GlobalWordLine {
const base = { line, text };
if (!text.trim()) return { ...base, kind: "blank" };
if (text.trimStart().startsWith("#")) return { ...base, kind: "comment" };
let w: GlobalWordLine;
if (text.includes(" => ") && text.includes(" && ") && text.includes(" >> ") && text.includes(" <> ")) {
const m = COMBINED.exec(text);
if (!m)
return {
...base,
kind: "replace_offset",
error: "替换加偏移的写法是:被替换词 => 替换词 && 前定位词 <> 后定位词 >> 偏移",
};
w = {
...base,
kind: "replace_offset",
pattern: m[1]!,
replacement: m[2]!,
front: m[3]!,
back: m[4]!,
offset: m[5]!,
};
} else if (text.includes(" => ")) {
const parts = text.split(" => ");
w = { ...base, kind: "replace", pattern: parts[0]!, replacement: parts[1]! };
} else if (text.includes(" >> ") && text.includes(" <> ")) {
const parts = text.split(" <> ");
const rest = parts[1]!.split(" >> ");
w = { ...base, kind: "offset", front: parts[0]!, back: rest[0]!, offset: rest[1]! };
} else {
w = { ...base, kind: "block", pattern: text };
}
const error = validateGlobalWord(w);
return error ? { ...w, error } : w;
}
/** 校验正则、替换模板与偏移表达式能否用(MP 在套用时才发现,这里提前报) */
export function validateGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string | undefined {
try {
if (w.kind === "block" || w.kind === "replace" || w.kind === "replace_offset") {
if (!w.pattern) return "被替换词不能为空";
const re = pyRegex(w.pattern, "g");
if (w.kind !== "block") pyReplacement(w.replacement ?? "", re);
}
if (w.kind === "offset" || w.kind === "replace_offset") {
if (!w.front && !w.back) return "前定位词和后定位词至少写一个";
if (w.front) pyRegex(w.front);
if (w.back) pyRegex(w.back);
// copy-allow: 正则,不是文案
pyRegex(`(?<=${w.front ?? ""}.*?)[0-9一二三四五六七八九十]+(?=.*?${w.back ?? ""})`, "g");
if (!w.offset?.trim()) return "偏移不能为空,如 EP+12";
calculateEpisodeOffset(w.offset, 1);
}
} catch (e) {
return (e as Error).message;
}
return undefined;
}
export function printGlobalWord(w: GlobalWordFields & { kind: GlobalWordKind }): string {
switch (w.kind) {
case "block":
return w.pattern ?? "";
case "replace":
return `${w.pattern ?? ""} => ${w.replacement ?? ""}`;
case "offset":
return `${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`;
case "replace_offset":
return `${w.pattern ?? ""} => ${w.replacement ?? ""} && ${w.front ?? ""} <> ${w.back ?? ""} >> ${w.offset ?? ""}`;
default:
return "";
}
}
export const splitLines = (text: string) => text.replace(/\r\n?/g, "\n").split("\n");
export function parseGlobalWords(text: string): GlobalWordLine[] {
return splitLines(text).map((t, i) => parseGlobalWordLine(t, i + 1));
}
/** 生效的识别词行(非空、非注释、没有错误),顺序同原文 */
export const activeGlobalWords = (text: string) =>
parseGlobalWords(text).filter((w) => w.kind !== "blank" && w.kind !== "comment" && !w.error);
// ── `{[tmdbid=…;type=…;s=…;e=…]}` 指定作品 ──
export interface IdentityTag {
tmdbId?: string;
doubanId?: string;
type?: "movie" | "tv";
season?: string;
episode?: string;
/** 其余键(g=剧集组等)原样保留 */
extra: string[];
}
const TAG_RE = /\{\[([^\]]*)\]\}/;
/** 替换词里的 `{[…]}`;没有返回 null */
export function parseIdentityTag(replacement: string): { tag: IdentityTag; before: string; after: string } | null {
const m = TAG_RE.exec(replacement);
if (!m) return null;
const tag: IdentityTag = { extra: [] };
for (const kv of m[1]!.split(";")) {
const [k, ...rest] = kv.split("=");
const key = k?.trim().toLowerCase() ?? "";
const v = rest.join("=").trim();
if (!key) continue;
if (key === "tmdbid") tag.tmdbId = v;
else if (key === "doubanid") tag.doubanId = v;
else if (key === "type") tag.type = v === "tv" ? "tv" : v === "movie" || v === "movies" ? "movie" : undefined;
else if (key === "s") tag.season = v;
else if (key === "e") tag.episode = v;
else tag.extra.push(kv.trim());
}
return { tag, before: replacement.slice(0, m.index), after: replacement.slice(m.index + m[0].length) };
}
export function printIdentityTag(t: IdentityTag): string {
const kv = [
t.tmdbId ? `tmdbid=${t.tmdbId}` : null,
t.doubanId ? `doubanid=${t.doubanId}` : null,
t.type ? `type=${t.type}` : null,
t.season ? `s=${t.season}` : null,
t.episode ? `e=${t.episode}` : null,
...t.extra,
].filter(Boolean);
return `{[${kv.join(";")}]}`;
}
// ── 作品识别词(季集换算,逐行) ──
export type EntityWordKind = "blank" | "comment" | "map" | "table" | "special";
export type EntityWordSpecial = "special" | "extra" | "ignore";
export const ENTITY_SPECIAL_WORDS: Record<EntityWordSpecial, string> = {
special: "特别篇",
extra: "花絮",
ignore: "忽略",
};
const SPECIAL_OF: Record<string, EntityWordSpecial> = {
特别篇: "special",
特別篇: "special",
special: "special",
花絮: "extra",
extra: "extra",
忽略: "ignore",
ignore: "ignore",
};
export interface EntityWordFields {
/** 来源季;null = 任意季(写 `*`) */
season: number | null;
/** 发布组;空 = 任意发布组 */
groups: string[];
/** map:目标季(null = 来源季)与集号换算 */
targetSeason?: number | null;
expr?: string;
/** table:来源集号区间 → 目标季集 */
from?: { start: number; end: number };
to?: { season: number; episode: number };
/** special:特别篇 / 花絮 / 忽略 */
special?: EntityWordSpecial;
}
export interface EntityWordLine extends Partial<EntityWordFields> {
line: number;
text: string;
kind: EntityWordKind;
error?: string;
}
/** 按空白切词,`@"带 空格"` 引号内不切 */
function tokens(s: string): string[] {
const out: string[] = [];
const re = /@"([^"]*)"|"([^"]*)"|(\S+)/g;
for (let m = re.exec(s); m; m = re.exec(s)) out.push(m[1] !== undefined ? `@${m[1]}` : (m[2] ?? m[3]!));
return out;
}
const SEASON_RE = /^S(\d{1,3})$/i;
const EPISODE_RE = /^(?:S(\d{1,3}))?E(\d{1,4})(?:-E?(\d{1,4}))?$/i;
const TARGET_EP_RE = /^S(\d{1,3})E(\d{1,4})$/i;
/** 解析作品识别词的一行(不含跨行检查) */
export function parseEntityWordLine(text: string, line = 1): EntityWordLine {
const base = { line, text };
const t = text.trim();
if (!t) return { ...base, kind: "blank" };
if (t.startsWith("#")) return { ...base, kind: "comment" };
const fail = (kind: EntityWordKind, error: string): EntityWordLine => ({ ...base, kind, error });
const i = t.indexOf(">>");
if (i < 0) return fail("map", "缺少 >>,写成 S02 >> S01 EP+24");
const left = tokens(t.slice(0, i));
const right = t.slice(i + 2).trim();
const head = left[0];
if (!head) return fail("map", ">> 左边要写来源季,如 S02,任意季写 *");
const groups: string[] = [];
for (const g of left.slice(1)) {
if (!g.startsWith("@") || g.length < 2) return fail("map", `「${g}」前面要加 @ 才是发布组`);
const name = g.slice(1).trim();
if (!groups.some((x) => x.toLowerCase() === name.toLowerCase())) groups.push(name);
}
// 单集对照:S01E13 >> S00E01
const ep = EPISODE_RE.exec(head);
if (ep) {
const start = Number(ep[2]);
const end = ep[3] ? Number(ep[3]) : start;
if (end < start) return fail("table", "集号区间写反了");
const to = TARGET_EP_RE.exec(right);
if (!to) return fail("table", "单集对照的右边写目标季集,如 S00E01");
return {
...base,
kind: "table",
season: ep[1] !== undefined ? Number(ep[1]) : null,
groups,
from: { start, end },
to: { season: Number(to[1]), episode: Number(to[2]) },
...(Number(to[2]) < 1 ? { error: "目标集号从 1 开始" } : {}),
};
}
let season: number | null;
if (head === "*" || /^S\*$/i.test(head)) season = null;
else {
const s = SEASON_RE.exec(head);
if (!s) return fail("map", `「${head}」不是季号,写成 S02,任意季写 *`);
season = Number(s[1]);
}
const special = SPECIAL_OF[right.toLowerCase()];
if (special) return { ...base, kind: "special", season, groups, special };
if (!right) return fail("map", ">> 右边要写目标季和集号换算,如 S01 EP+24");
const parts = right.split(/\s+/);
let targetSeason: number | null = null;
let exprParts = parts;
const ts = SEASON_RE.exec(parts[0]!);
if (ts) {
targetSeason = Number(ts[1]);
exprParts = parts.slice(1);
}
const expr = exprParts.length ? normalizeEpisodeExpr(exprParts.join(" ")) : "EP";
try {
evalEpisodeExpr(expr, 1);
} catch (e) {
return { ...base, kind: "map", season, groups, targetSeason, expr, error: (e as Error).message };
}
return { ...base, kind: "map", season, groups, targetSeason, expr };
}
const pad = (n: number) => String(n).padStart(2, "0");
const quoteGroup = (g: string) => (/[\s"]|>>/.test(g) ? `@"${g.replace(/"/g, "")}"` : `@${g}`);
export function printEntityWord(w: EntityWordFields & { kind: EntityWordKind }): string {
const groups = w.groups.map(quoteGroup).join(" ");
const g = groups ? ` ${groups}` : "";
if (w.kind === "table") {
const f = w.from ?? { start: 1, end: 1 };
const range = f.start === f.end ? `E${pad(f.start)}` : `E${pad(f.start)}-E${pad(f.end)}`;
const s = w.season === null ? "" : `S${pad(w.season)}`;
const to = w.to ?? { season: 0, episode: 1 };
return `${s}${range}${g} >> S${pad(to.season)}E${pad(to.episode)}`;
}
const scope = `${w.season === null ? "*" : `S${pad(w.season)}`}${g}`;
if (w.kind === "special") return `${scope} >> ${ENTITY_SPECIAL_WORDS[w.special ?? "ignore"]}`;
const target = w.targetSeason == null ? "" : `S${pad(w.targetSeason)} `;
return `${scope} >> ${target}${normalizeEpisodeExpr(w.expr?.trim() || "EP")}`;
}
/** 编译后的规则:与 domain EpisodeMappingRule 同形,多了出处行 */
export interface CompiledEntityRule {
id: string;
season: number | null;
releaseGroup: string | null;
kind: "expression" | "table" | "special";
expression?: string;
targetSeason?: number;
table?: { from: { start: number; end: number }; to: { season: number; episode: number } }[];
special?: EntityWordSpecial;
/** 出处:行号与原文(单集对照合并多行时取第一行) */
line: number;
text: string;
lines?: number[];
}
export interface EntityWordsCompiled {
lines: EntityWordLine[];
rules: CompiledEntityRule[];
errors: { line: number; message: string }[];
}
const scopeKey = (season: number | null, group: string | null) => `${season ?? "*"}|${group?.toLowerCase() ?? ""}`;
const scopeLabel = (season: number | null, group: string | null) =>
`${season === null ? "任意季" : `S${pad(season)}`}${group ? ` @${group}` : ""}`;
/**
* 解析整块作品识别词并编译成规则:一行多个发布组拆成多条;同一范围(季 + 发布组)只能有一条换算或特别篇,
* 单集对照可以多行(合并成一张对照表),但不能与换算 / 特别篇同范围,区间不能重叠。
*/
export function compileEntityWords(text: string): EntityWordsCompiled {
const lines = splitLines(text).map((t, i) => parseEntityWordLine(t, i + 1));
const errors: { line: number; message: string }[] = [];
const byScope = new Map<string, CompiledEntityRule>();
const rules: CompiledEntityRule[] = [];
for (const l of lines) {
if (l.error) errors.push({ line: l.line, message: l.error });
if (l.error || l.kind === "blank" || l.kind === "comment") continue;
const groups = l.groups?.length ? l.groups : [null];
const season = l.season ?? null;
for (const [gi, group] of groups.entries()) {
const key = scopeKey(season, group);
const prev = byScope.get(key);
if (l.kind === "table") {
const entry = { from: l.from!, to: l.to! };
if (prev && prev.kind !== "table") {
errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` });
continue;
}
if (prev) {
const hit = prev.table!.find((e) => entry.from.start <= e.from.end && entry.from.end >= e.from.start);
if (hit) {
errors.push({ line: l.line, message: `集号与第 ${prev.lines?.find(Boolean) ?? prev.line} 行的对照重叠` });
continue;
}
prev.table!.push(entry);
prev.lines!.push(l.line);
continue;
}
const r: CompiledEntityRule = {
id: ruleId(l.line, gi),
season,
releaseGroup: group,
kind: "table",
table: [entry],
line: l.line,
text: l.text.trim(),
lines: [l.line],
};
byScope.set(key, r);
rules.push(r);
continue;
}
if (prev) {
errors.push({ line: l.line, message: `${scopeLabel(season, group)} 在第 ${prev.line} 行已有规则` });
continue;
}
const r: CompiledEntityRule = {
id: ruleId(l.line, gi),
season,
releaseGroup: group,
kind: l.kind === "special" ? "special" : "expression",
...(l.kind === "special" ? { special: l.special! } : { expression: l.expr! }),
...(l.kind === "map" && l.targetSeason != null ? { targetSeason: l.targetSeason } : {}),
line: l.line,
text: l.text.trim(),
};
byScope.set(key, r);
rules.push(r);
}
}
return { lines, rules, errors };
}
const ruleId = (line: number, gi: number) => `L${String(line).padStart(4, "0")}${gi ? `.${gi}` : ""}`;
// ── 可视化行模型:文本 ⇄ 行,原文逐字保留 ──
export type WordsScope = "global" | "entity";
/**
* 一行一个规则行;紧贴在规则上方(中间没有空行)的普通注释是它的备注。
* 注释掉的规则(`# S02 >> S01 EP+24`)是停用的规则行;解析不了的行是原文行(可视化里只读)。
* lines 是这一行在文本里占的全部原文(备注 + 规则),没改过就原样拼回,保证往返不丢字。
*/
export type WordsRow =
| { kind: "rule"; id: string; lines: string[]; note: string[]; enabled: boolean; rule: string }
| { kind: "comment"; id: string; lines: string[] }
| { kind: "blank"; id: string; lines: string[] }
| { kind: "invalid"; id: string; lines: string[]; rule: string; error: string };
/** 去掉注释号:`# xxx` / `#xxx` → `xxx` */
export const uncomment = (line: string) => line.trimStart().replace(/^#\s?/, "");
/** 注释行的内容是不是一条规则(停用的规则) */
function commentedRule(line: string, scope: WordsScope): string | null {
const body = uncomment(line);
if (!body.trim()) return null;
if (scope === "entity") {
const p = parseEntityWordLine(body);
return p.kind !== "comment" && p.kind !== "blank" && !p.error ? body : null;
}
// 全局:任何文字都是合法的屏蔽词,只把带运算符的当成停用规则
const p = parseGlobalWordLine(body);
return (p.kind === "replace" || p.kind === "offset" || p.kind === "replace_offset") && !p.error ? body : null;
}
export function lineError(line: string, scope: WordsScope): string | undefined {
return scope === "entity" ? parseEntityWordLine(line).error : parseGlobalWordLine(line).error;
}
/** 规则文本 → 可视化行模型 */
export function wordsToRows(text: string, scope: WordsScope): WordsRow[] {
const src = splitLines(text);
const rows: WordsRow[] = [];
let pendingNote: string[] = [];
let n = 0;
const id = () => `r${n++}`;
const flushNote = () => {
if (pendingNote.length) rows.push({ kind: "comment", id: id(), lines: pendingNote });
pendingNote = [];
};
// 文本以换行结尾时 split 多出一个空串,不算一行
const body = src.length > 1 && src[src.length - 1] === "" ? src.slice(0, -1) : src;
for (const line of body) {
const trimmed = line.trim();
if (!trimmed) {
flushNote();
const last = rows[rows.length - 1];
if (last?.kind === "blank") last.lines.push(line);
else rows.push({ kind: "blank", id: id(), lines: [line] });
continue;
}
if (trimmed.startsWith("#")) {
const rule = commentedRule(line, scope);
if (rule !== null) {
rows.push({
kind: "rule",
id: id(),
lines: [...pendingNote, line],
note: pendingNote.map(uncomment),
enabled: false,
rule,
});
pendingNote = [];
} else pendingNote.push(line);
continue;
}
const error = lineError(line, scope);
if (error) {
flushNote();
rows.push({ kind: "invalid", id: id(), lines: [line], rule: line, error });
continue;
}
rows.push({
kind: "rule",
id: id(),
lines: [...pendingNote, line],
note: pendingNote.map(uncomment),
enabled: true,
rule: line,
});
pendingNote = [];
}
flushNote();
return rows;
}
/** 可视化行模型 → 文本;结尾带换行与否跟原文一致由调用方决定(trailingNewline) */
export function rowsToText(rows: readonly WordsRow[], trailingNewline = false): string {
const text = rows.flatMap((r) => r.lines).join("\n");
return trailingNewline && text ? `${text}\n` : text;
}
/** 按备注、启用状态、规则文本重新生成规则行的原文 */
export function ruleRowLines(note: readonly string[], enabled: boolean, rule: string): string[] {
return [...note.map((n) => `# ${n}`.trimEnd()), enabled ? rule : `# ${rule}`];
}
/** 改规则行的某一部分,返回新行(lines 随之重写) */
export function updateRuleRow(
row: Extract<WordsRow, { kind: "rule" }>,
patch: Partial<Pick<Extract<WordsRow, { kind: "rule" }>, "note" | "enabled" | "rule">>,
): Extract<WordsRow, { kind: "rule" }> {
const next = { ...row, ...patch };
// 只改启用状态时备注原文不动
const noteLines = patch.note ? next.note.map((x) => `# ${x}`.trimEnd()) : row.lines.slice(0, row.note.length);
return { ...next, lines: [...noteLines, next.enabled ? next.rule : `# ${next.rule}`] };
}
+179
View File
@@ -0,0 +1,179 @@
import { z } from "zod";
import { ActorType, EntityBase, Id } from "./common.ts";
import { EpisodeMappingPreview, EpisodeReresolveResult, EpisodeResolutionView } from "./episode-mapping.ts";
export * from "./identify-words-syntax.ts";
// 识别词(docs/design/identify-words.md):全局一块(MP 自定义识别词原样)+ 每部作品一块(季集换算)。
// 原文整块存储(注释原样保留),保存前先试算;保存后按影响范围重算。
const NonNeg = z.number().int().min(0);
export const WordsLineError = z.object({ line: z.number().int().min(1), message: z.string() });
export type WordsLineError = z.infer<typeof WordsLineError>;
/** 全局识别词保存后的后台重算进度 */
export const WordsReresolveProgress = z.object({
status: z.enum(["queued", "running", "done", "failed"]),
/** 要扫描的资源数 */
total: NonNeg,
scanned: NonNeg,
/** 标题 / 副标题命中改动行的资源数 */
matched: NonNeg,
/** 已重新识别的资源数 */
queued: NonNeg,
startedAt: z.string(),
finishedAt: z.string().nullable(),
error: z.string().nullable().optional(),
});
export type WordsReresolveProgress = z.infer<typeof WordsReresolveProgress>;
/** identify_words 表的行(审计 / 排查用;接口用下面的 IdentifyWordsBlock) */
export const IdentifyWordsRow = EntityBase.extend({
mediaEntityId: Id.nullable(),
text: z.string(),
version: NonNeg,
updatedByType: ActorType.nullable(),
updatedById: z.string().nullable(),
reresolve: WordsReresolveProgress.nullable(),
});
export type IdentifyWordsRow = z.infer<typeof IdentifyWordsRow>;
export const IdentifyWordsBlock = z.object({
/** null = 全局 */
mediaEntityId: Id.nullable(),
text: z.string(),
/** 每次保存 +1;保存时带上读到的版本,别人先改过返回 409 */
version: NonNeg,
updatedAt: z.string().nullable(),
/** `<actor_type>:<actor_id>` */
updatedBy: z.string().nullable(),
reresolve: WordsReresolveProgress.nullable(),
});
export type IdentifyWordsBlock = z.infer<typeof IdentifyWordsBlock>;
/** 识别词原文上限:MP 的列表通常几百行,留足余量 */
const WordsText = z.string().max(200_000);
export const IdentifyWordsSave = z.object({
text: WordsText,
/** 读到的版本;新建时为 0 */
expectedVersion: NonNeg,
});
export type IdentifyWordsSave = z.infer<typeof IdentifyWordsSave>;
export const IdentifyWordsDryRunInput = z.object({ text: WordsText });
export type IdentifyWordsDryRunInput = z.infer<typeof IdentifyWordsDryRunInput>;
/** 识别结果摘要(试算前后对照用) */
export const WordsParsedBrief = z.object({
title: z.string().nullable(),
year: z.number().int().nullable(),
season: z.number().int().nullable(),
episodes: z.object({ start: z.number().int(), end: z.number().int() }).nullable(),
releaseGroup: z.string().nullable(),
/** `{[tmdbid=…]}` 等指定的身份 */
explicitIds: z.array(z.object({ provider: z.string(), id: z.string() })),
});
export type WordsParsedBrief = z.infer<typeof WordsParsedBrief>;
export const GlobalWordsDryRunItem = z.object({
sourceReleaseId: Id,
title: z.string(),
subtitle: z.string().nullable(),
/** 当前绑定的作品 */
mediaEntityId: Id.nullable(),
entityTitle: z.string().nullable(),
/** 用新识别词命中的行 */
words: z.array(z.string()),
before: WordsParsedBrief,
after: WordsParsedBrief,
});
export type GlobalWordsDryRunItem = z.infer<typeof GlobalWordsDryRunItem>;
export const GlobalWordsDryRun = z.object({
errors: z.array(WordsLineError),
/** 生效行里新增 / 去掉的(改一行 = 去掉旧的 + 新增新的) */
added: z.array(z.string()),
removed: z.array(z.string()),
/** 扫描的近期资源数与其中命中改动行的条数 */
scanned: NonNeg,
matched: NonNeg,
/** 识别结果会变的资源(最多 100 条);保存后后台按全库重新识别命中改动行的资源 */
items: z.array(GlobalWordsDryRunItem),
});
export type GlobalWordsDryRun = z.infer<typeof GlobalWordsDryRun>;
export const GlobalWordsSaveResult = z.object({ block: IdentifyWordsBlock });
export type GlobalWordsSaveResult = z.infer<typeof GlobalWordsSaveResult>;
export const EntityWordsSummary = z.object({
mediaEntityId: Id,
title: z.string(),
year: z.number().int().nullable(),
/** 生效的规则行数 */
rules: NonNeg,
/** 注释掉(停用)的规则行数 */
disabled: NonNeg,
/** 有错误的行数 */
errors: NonNeg,
updatedAt: z.string().nullable(),
updatedBy: z.string().nullable(),
});
export type EntityWordsSummary = z.infer<typeof EntityWordsSummary>;
export const EntityWordsListQuery = z.object({ q: z.string().trim().max(200).optional() });
export const EntityWordsList = z.object({ items: z.array(EntityWordsSummary) });
export type EntityWordsList = z.infer<typeof EntityWordsList>;
export const EntityWordsView = z.object({
block: IdentifyWordsBlock,
title: z.string(),
/** 剧集 / 综艺才参与集数换算 */
applicable: z.boolean(),
/** 这部作品资源里出现过的发布组(填发布组时提示) */
knownGroups: z.array(z.string()),
});
export type EntityWordsView = z.infer<typeof EntityWordsView>;
export const EntityWordsDryRun = z.object({
errors: z.array(WordsLineError),
/** 生效的规则数(一行多个发布组算多条) */
rules: NonNeg,
preview: EpisodeMappingPreview,
/** 结果会变的条数;套用后仍对不上季表(待确认)的条数 */
changed: NonNeg,
mismatched: NonNeg,
});
export type EntityWordsDryRun = z.infer<typeof EntityWordsDryRun>;
export const EntityWordsSaveResult = z.object({ block: IdentifyWordsBlock, reresolve: EpisodeReresolveResult });
export type EntityWordsSaveResult = z.infer<typeof EntityWordsSaveResult>;
/** 可视化编辑里一行的实时示例:拿一个标题试这一行 */
export const WordsTryInput = z.object({
scope: z.enum(["global", "entity"]),
/** entity 时必填 */
mediaEntityId: Id.optional(),
/** 要试的那一行 */
line: z.string().max(2000),
/** 示例标题;不填就从近期资源里找一条这一行能命中的 */
title: z.string().max(1000).optional(),
subtitle: z.string().max(1000).nullable().optional(),
});
export type WordsTryInput = z.infer<typeof WordsTryInput>;
export const WordsTryResult = z.object({
/** 实际用的示例;找不到命中的近期资源时为 null */
sample: z.object({ sourceReleaseId: Id.nullable(), title: z.string(), subtitle: z.string().nullable() }).nullable(),
/** 这一行有没有命中示例 */
matched: z.boolean(),
error: z.string().nullable(),
titleAfter: z.string().nullable(),
subtitleAfter: z.string().nullable(),
parsed: WordsParsedBrief.nullable(),
/** entity:套用这一行后的季集与是否对得上季表 */
episode: EpisodeResolutionView.nullable(),
validation: z.enum(["pass", "unverified", "fail", "special", "skipped"]).nullable(),
});
export type WordsTryResult = z.infer<typeof WordsTryResult>;
+1
View File
@@ -16,6 +16,7 @@ export * from "./events.ts";
export * from "./execution.ts";
export * from "./features.ts";
export * from "./hooks.ts";
export * from "./identify-words.ts";
export * from "./identity.ts";
export * from "./instances.ts";
export * from "./library-versions.ts";
+2 -6
View File
@@ -57,7 +57,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
Decision: { row: C.Decision },
DownloadTask: { row: C.DownloadTask },
DownloaderInstance: { row: C.DownloaderInstance },
EpisodeMappingRule: { row: C.EpisodeMappingRuleRow },
IdentifyWords: { row: C.IdentifyWordsRow },
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
IdentityRevision: { row: C.IdentityRevision },
@@ -200,11 +200,7 @@ const ENUMS: Record<string, z.ZodEnum> = {
DownloadTaskPauseReason: C.PauseReason,
DownloaderInstanceKind: C.DownloaderKind,
DownloaderInstanceHealthStatus: C.InstanceHealth,
EpisodeMappingRuleKind: C.EpisodeMappingRuleKind,
EpisodeMappingRuleSpecial: C.EpisodeSpecialKind,
EpisodeMappingRuleStatus: C.EpisodeMappingRuleStatus,
EpisodeMappingRuleEvidenceLevel: C.EvidenceLevel,
EpisodeMappingRuleCreatedByType: C.ActorType,
IdentifyWordsUpdatedByType: C.ActorType,
ExternalIdentityEntityType: C.ExternalEntityType,
ExternalIdentityStatus: C.IdentityStatus,
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
+15 -38
View File
@@ -3,60 +3,37 @@ import {
Api,
EpisodeMappingPreview,
EpisodeMappingPreviewInput,
EpisodeMappingRuleCreate,
EpisodeMappingRuleUpdate,
EpisodeResolutionView,
listEndpoints,
} from "../src/index.ts";
// 集数映射接口(批次 4)的契约:端点登记、写入与预览的入参、解析结果结构。
// 集数映射与识别词接口的契约:端点登记、预览入参、解析结果结构。
describe("集数映射契约", () => {
it("端点登记在 Api 注册表:读用 read,写用 organize.execute", () => {
it("端点登记在 Api 注册表:读和试算用 read,作品识别词写 organize.execute,全局写 admin", () => {
const keys = listEndpoints()
.filter((e) => e.path.includes("episode-mapping"))
.filter((e) => e.path.includes("episode-mapping") || e.path.includes("identify-words"))
.map((e) => `${e.method.toUpperCase()} ${e.path} ${e.scope}`)
.sort();
expect(keys).toEqual([
"DELETE /episode-mapping-rules/{id} organize.execute",
"GET /media-entities/{id}/episode-mapping-rules read",
"PATCH /episode-mapping-rules/{id} organize.execute",
"POST /media-entities/{id}/episode-mapping-rules organize.execute",
"GET /identify-words/entities read",
"GET /identify-words/global read",
"GET /media-entities/{id}/identify-words read",
"POST /identify-words/global/dry-run read",
"POST /identify-words/try read",
"POST /media-entities/{id}/episode-mapping/preview read",
"POST /media-entities/{id}/identify-words/dry-run read",
"PUT /identify-words/global admin",
"PUT /media-entities/{id}/identify-words organize.execute",
]);
expect(Api.episodeMapping.preview.response).toBe(EpisodeMappingPreview);
});
it("新建:三种规则都接受;季号不能为负,发布组去首尾空白", () => {
expect(EpisodeMappingRuleCreate.parse({ kind: "expression", expression: "EP-12", targetSeason: 2 })).toEqual({
kind: "expression",
expression: "EP-12",
targetSeason: 2,
});
expect(
EpisodeMappingRuleCreate.safeParse({
season: 1,
releaseGroup: "ANi",
kind: "table",
tableEntries: [{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } }],
}).success,
).toBe(true);
expect(
EpisodeMappingRuleCreate.parse({ kind: "special", special: "ignore", releaseGroup: " ANi " }).releaseGroup,
).toBe("ANi");
expect(EpisodeMappingRuleCreate.safeParse({ kind: "special", special: "skip" }).success).toBe(false);
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", season: -1 }).success).toBe(false);
expect(EpisodeMappingRuleCreate.safeParse({ kind: "expression", expression: "E".repeat(65) }).success).toBe(false);
});
it("修改:全部可选,可只改状态", () => {
expect(EpisodeMappingRuleUpdate.parse({ status: "disabled" })).toEqual({ status: "disabled" });
expect(EpisodeMappingRuleUpdate.safeParse({ status: "deleted" }).success).toBe(false);
});
it("预览入参:草稿可空,条数上限 300", () => {
it("预览入参:草稿是整块识别词原文,条数上限 300", () => {
expect(EpisodeMappingPreviewInput.parse({})).toEqual({});
expect(EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", draft: null }).success).toBe(true);
expect(
EpisodeMappingPreviewInput.safeParse({ season: 2, releaseGroup: "ANi", words: "S02 >> S01 EP+24" }).success,
).toBe(true);
expect(EpisodeMappingPreviewInput.safeParse({ limit: 301 }).success).toBe(false);
});
@@ -0,0 +1,277 @@
import { describe, expect, it } from "vitest";
import {
compileEntityWords,
evalEpisodeExpr,
parseEntityWordLine,
parseGlobalWordLine,
parseGlobalWords,
parseIdentityTag,
printEntityWord,
printGlobalWord,
printIdentityTag,
pyRegex,
pySubn,
rowsToText,
ruleRowLines,
updateRuleRow,
type WordsRow,
wordsToRows,
} from "../src/index.ts";
// 识别词语法(docs/design/identify-words.md):全局行按 MP __parse_word 拆;作品识别词的解析 / 打印往返、
// 编译(范围、优先级、冲突)、可视化行模型与文本往返(注释、停用、顺序、写错的行都不丢)。
describe("全局识别词(MP 语法)", () => {
it("四种写法与注释、空行", () => {
expect(parseGlobalWordLine("国语中字")).toMatchObject({ kind: "block", pattern: "国语中字" });
expect(parseGlobalWordLine("A => B")).toMatchObject({ kind: "replace", pattern: "A", replacement: "B" });
expect(parseGlobalWordLine("测试替换 => ")).toMatchObject({
kind: "replace",
pattern: "测试替换",
replacement: "",
});
expect(parseGlobalWordLine("第 <> 集 >> EP+1")).toMatchObject({
kind: "offset",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("旧名 => 新名 && 第 <> 集 >> EP+1")).toMatchObject({
kind: "replace_offset",
pattern: "旧名",
replacement: "新名",
front: "第",
back: "集",
offset: "EP+1",
});
expect(parseGlobalWordLine("# 动漫").kind).toBe("comment");
expect(parseGlobalWordLine(" ").kind).toBe("blank");
});
it("打印与解析往返", () => {
for (const t of ["国语中字", "A => B", "第 <> 集 >> EP+1", "旧名 => 新名 && 第 <> 集 >> 2*EP-1"]) {
const p = parseGlobalWordLine(t);
expect(printGlobalWord(p)).toBe(t);
}
});
it("提前报错:正则写错、替换引用了不存在的分组、偏移表达式写错、两个定位词都空", () => {
expect(parseGlobalWordLine("([ => X").error).toContain("正则");
expect(parseGlobalWordLine("(a) => \\2").error).toContain("分组");
expect(parseGlobalWordLine("第 <> 集 >> 2EP").error).toContain("运算符");
expect(parseGlobalWordLine(" <> >> EP+1").error).toBeDefined();
expect(parseGlobalWords("A => B\n\n# x\nC").map((w) => [w.line, w.kind])).toEqual([
[1, "replace"],
[2, "blank"],
[3, "comment"],
[4, "block"],
]);
});
it("Python 正则与替换模板", () => {
expect(pySubn(pyRegex("(?P<n>\\d+)集"), "第\\g<n>话", "12集")).toEqual(["第12话", 1]);
expect(pySubn(pyRegex("(a)(b)"), "\\2\\1", "abab")).toEqual(["baba", 2]);
expect(pySubn(pyRegex("(?i)foo"), "bar", "FOO foo")).toEqual(["bar bar", 2]);
expect(pySubn(pyRegex("x"), "$1\\\\", "x")).toEqual(["$1\\", 1]);
expect(pySubn(pyRegex("\\p{Han}+"), "中", "ab汉字cd")).toEqual(["ab中cd", 1]);
});
it("指定作品标签", () => {
const t = parseIdentityTag("一念永恒{[tmdbid=107371;type=tv;s=1;g=abc]}S01");
expect(t).toMatchObject({
before: "一念永恒",
after: "S01",
tag: { tmdbId: "107371", type: "tv", season: "1", extra: ["g=abc"] },
});
expect(printIdentityTag(t!.tag)).toBe("{[tmdbid=107371;type=tv;s=1;g=abc]}");
expect(parseIdentityTag("没有标签")).toBeNull();
});
});
describe("作品识别词", () => {
it("季集换算:目标季可省、EP 单独写 = 集号不变、发布组可多个、任意季写 *", () => {
expect(parseEntityWordLine("S02 >> S01 EP+24")).toMatchObject({
kind: "map",
season: 2,
groups: [],
targetSeason: 1,
expr: "EP+24",
});
expect(parseEntityWordLine("S03 @ADWeb @MWeb >> S01 EP")).toMatchObject({
kind: "map",
season: 3,
groups: ["ADWeb", "MWeb"],
targetSeason: 1,
expr: "EP",
});
expect(parseEntityWordLine("S01 @Lilith-Raws >> ep - 12")).toMatchObject({ targetSeason: null, expr: "EP-12" });
expect(parseEntityWordLine("S02 >> S01")).toMatchObject({ targetSeason: 1, expr: "EP" });
expect(parseEntityWordLine("* @Judas >> EP*2-1")).toMatchObject({ season: null, groups: ["Judas"] });
expect(parseEntityWordLine('S01 @"Some Group" >> EP')).toMatchObject({ groups: ["Some Group"] });
});
it("单集对照与特别篇", () => {
expect(parseEntityWordLine("S01E13 >> S00E01")).toMatchObject({
kind: "table",
season: 1,
from: { start: 13, end: 13 },
to: { season: 0, episode: 1 },
});
expect(parseEntityWordLine("S01E27-E28 @Baha >> S02E02")).toMatchObject({
kind: "table",
groups: ["Baha"],
from: { start: 27, end: 28 },
});
expect(parseEntityWordLine("E05 >> S00E02")).toMatchObject({ kind: "table", season: null });
expect(parseEntityWordLine("S00 @SweetSub >> 特别篇")).toMatchObject({ kind: "special", special: "special" });
expect(parseEntityWordLine("S01 >> 花絮")).toMatchObject({ kind: "special", special: "extra" });
expect(parseEntityWordLine("S01 >> 忽略")).toMatchObject({ kind: "special", special: "ignore" });
});
it("写错的行给出原因", () => {
expect(parseEntityWordLine("S02 S01 EP+1").error).toContain(">>");
expect(parseEntityWordLine("Season2 >> EP").error).toContain("不是季号");
expect(parseEntityWordLine("S02 ADWeb >> EP").error).toContain("@");
expect(parseEntityWordLine("S02 >> S01 2EP").error).toContain("运算符");
expect(parseEntityWordLine("S02 >> S01 12").error).toContain("EP");
expect(parseEntityWordLine("S01E13 >> 第一集").error).toContain("目标季集");
expect(parseEntityWordLine("S01E13-E10 >> S00E01").error).toContain("写反");
});
it("打印与解析往返(规范写法)", () => {
for (const t of [
"S02 >> S01 EP+24",
"S03 @ADWeb @MWeb @黒ネズミたち >> S01 EP",
"* @Judas >> EP*2-1",
"S01E13 >> S00E01",
"S01E27-E28 @Baha >> S02E02",
"E05 >> S00E02",
"S01 @SweetSub >> 花絮",
'S01 @"Some Group" >> EP-1',
]) {
const p = parseEntityWordLine(t);
expect(p.error).toBeUndefined();
expect(printEntityWord({ ...p, kind: p.kind, season: p.season ?? null, groups: p.groups ?? [] })).toBe(t);
}
});
it("编译:一行多个发布组拆成多条;单集对照多行合成一张表;同范围冲突报错带行号", () => {
const c = compileEntityWords(
[
"# 药屋",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"# S03 @Judas >> S01 EP",
"S01E13 @Baha >> S00E01",
"S01E14 @Baha >> S00E02",
"S01E14 @Baha >> S00E03",
"S01 @Baha >> 忽略",
"s02 >> EP",
].join("\n"),
);
expect(c.rules.map((r) => [r.id, r.season, r.releaseGroup, r.kind, r.line])).toEqual([
["L0002", 2, null, "expression", 2],
["L0003", 3, null, "expression", 3],
["L0004", 3, "ADWeb", "expression", 4],
["L0004.1", 3, "MWeb", "expression", 4],
["L0006", 1, "Baha", "table", 6],
]);
expect(c.rules[4]!.table).toEqual([
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
{ from: { start: 14, end: 14 }, to: { season: 0, episode: 2 } },
]);
expect(c.errors).toEqual([
{ line: 8, message: expect.stringContaining("重叠") },
{ line: 9, message: expect.stringContaining("第 6 行已有规则") },
{ line: 10, message: expect.stringContaining("第 2 行已有规则") },
]);
});
it("集号算术与 MP 一致,EP 必须出现", () => {
expect(evalEpisodeExpr("EP+24", 1)).toBe(25);
expect(evalEpisodeExpr("ep*2-1", 3)).toBe(5);
expect(() => evalEpisodeExpr("5", 1)).toThrow();
});
});
describe("可视化行模型 ⇄ 文本", () => {
const ENTITY = [
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"S03 >> S01 EP+48",
"# S03 @Judas >> S01 EP",
"S03 @ADWeb @MWeb >> S01 EP",
"这一行写错了",
"",
"",
"# 结尾注释",
].join("\n");
it("原文逐字往返(含结尾换行、连续空行、注释、写错的行)", () => {
for (const text of [ENTITY, `${ENTITY}\n`, "", "S02 >> S01 EP+24", "#x\n#y\n"]) {
const rows = wordsToRows(text, "entity");
expect(rowsToText(rows, text.endsWith("\n"))).toBe(text);
}
});
it("备注贴在规则上方;注释掉的规则是停用行;写错的行是原文行", () => {
const rows = wordsToRows(ENTITY, "entity");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? [r.enabled, r.note, r.rule] : r.lines])).toEqual([
["comment", ["# 药屋少女的呢喃"]],
["blank", [""]],
["rule", [true, ["第二季接着第一季编号"], "S02 >> S01 EP+24"]],
["rule", [true, [], "S03 >> S01 EP+48"]],
["rule", [false, [], "S03 @Judas >> S01 EP"]],
["rule", [true, [], "S03 @ADWeb @MWeb >> S01 EP"]],
["invalid", ["这一行写错了"]],
["blank", ["", ""]],
["comment", ["# 结尾注释"]],
]);
expect((rows[6] as Extract<WordsRow, { kind: "invalid" }>).error).toContain(">>");
});
it("停用 / 启用、改备注、调顺序、删除,其余行原样", () => {
const rows = wordsToRows(ENTITY, "entity");
const r2 = rows[3] as Extract<WordsRow, { kind: "rule" }>;
const r4 = rows[4] as Extract<WordsRow, { kind: "rule" }>;
const next = [...rows];
next[3] = updateRuleRow(r2, { enabled: false });
next[4] = updateRuleRow(r4, { enabled: true, note: ["Judas 接着第一季"] });
// 交换两行
[next[3], next[4]] = [next[4]!, next[3]!];
next.splice(6, 1);
expect(rowsToText(next)).toBe(
[
"# 药屋少女的呢喃",
"",
"# 第二季接着第一季编号",
"S02 >> S01 EP+24",
"# Judas 接着第一季",
"S03 @Judas >> S01 EP",
"# S03 >> S01 EP+48",
"S03 @ADWeb @MWeb >> S01 EP",
"",
"",
"# 结尾注释",
].join("\n"),
);
expect(ruleRowLines(["备注"], false, "S01 >> EP")).toEqual(["# 备注", "# S01 >> EP"]);
});
it("全局:带运算符的注释是停用的识别词,其余注释是备注;MP 列表原样往返", () => {
const text = ["#动漫", "Fake => 真", "# 旧名 => 新名", "#屏蔽词也行", "国语中字", "([ => 写错"].join("\n");
const rows = wordsToRows(text, "global");
expect(rows.map((r) => [r.kind, r.kind === "rule" ? r.enabled : null])).toEqual([
["rule", true],
["rule", false],
["rule", true],
["invalid", null],
]);
expect((rows[0] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["动漫"]);
expect((rows[2] as Extract<WordsRow, { kind: "rule" }>).note).toEqual(["屏蔽词也行"]);
expect(rowsToText(rows)).toBe(text);
});
});
@@ -0,0 +1,167 @@
import { type Kysely, sql } from "kysely";
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// 识别词(docs/design/identify-words.md):
// - identify_words:全局一块(media_entity_id 为空,只能有一行)+ 每部作品一块;原文整块存,version 每次保存 +1;
// reresolve 是全局识别词保存后后台重算的进度
// - 0066 的 episode_mapping_rule 结构化规则逐作品转成作品识别词的文本行后删表:同一作品、同一季、同一换算的
// 多个发布组合成一行,单集对照一行一条,停用的规则变成注释行。已有文本时只追加还没有的行(可重复执行)
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
type Db = Kysely<any>;
interface RuleRow {
media_entity_id: string;
season: number | null;
release_group: string | null;
kind: "expression" | "table" | "special";
expression: string | null;
target_season: number | null;
table_entries: { from: { start: number; end: number }; to: { season: number; episode: number } }[] | null;
special: "special" | "extra" | "ignore" | null;
status: "active" | "disabled";
created_by_type: string;
evidence: { reasons?: unknown } | null;
}
const pad = (n: number) => String(n).padStart(2, "0");
const SPECIAL = { special: "特别篇", extra: "花絮", ignore: "忽略" } as const;
const group = (g: string) => (/[\s"]|>>/.test(g) ? `@"${g.replace(/"/g, "")}"` : `@${g}`);
/** 一组同季、同内容的规则 → 文本行(作品识别词语法,与 contracts printEntityWord 一致) */
function linesOf(rules: RuleRow[]): string[] {
const r = rules[0]!;
const groups = [...new Set(rules.map((x) => x.release_group?.trim()).filter((g): g is string => !!g))];
const g = groups.length ? ` ${groups.map(group).join(" ")}` : "";
const scope = `${r.season === null ? "*" : `S${pad(r.season)}`}${g}`;
if (r.kind === "table") {
return (r.table_entries ?? []).map((e) => {
const range = e.from.start === e.from.end ? `E${pad(e.from.start)}` : `E${pad(e.from.start)}-E${pad(e.from.end)}`;
return `${r.season === null ? "" : `S${pad(r.season)}`}${range}${g} >> S${pad(e.to.season)}E${pad(e.to.episode)}`;
});
}
if (r.kind === "special") return [`${scope} >> ${SPECIAL[r.special ?? "ignore"]}`];
const expr = (r.expression ?? "EP").replace(/\s+/g, "").replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP");
return [`${scope} >> ${r.target_season === null ? "" : `S${pad(r.target_season)} `}${expr || "EP"}`];
}
export function entityWordsText(rows: RuleRow[]): string[] {
const buckets = new Map<string, RuleRow[]>();
for (const r of rows) {
// 有发布组的同内容规则合成一行;没有发布组的单独一行;表格规则各自成行(条目不同)
const content =
r.kind === "table"
? `table:${r.release_group ?? ""}`
: `${r.kind}:${r.expression ?? ""}:${r.target_season ?? ""}:${r.special ?? ""}:${r.release_group ? "g" : "-"}`;
const key = `${r.status}|${r.created_by_type === "agent" ? "agent" : "-"}|${r.season ?? "*"}|${content}`;
buckets.set(key, [...(buckets.get(key) ?? []), r]);
}
const ordered = [...buckets.values()].sort((a, b) => {
const x = a[0]!;
const y = b[0]!;
const s = (x.season ?? 9999) - (y.season ?? 9999);
if (s) return s;
if (!!x.release_group !== !!y.release_group) return x.release_group ? 1 : -1;
return x.status === y.status ? 0 : x.status === "active" ? -1 : 1;
});
const out: string[] = [];
for (const b of ordered) {
const first = b[0]!;
const reasons = b.flatMap((r) =>
Array.isArray(r.evidence?.reasons) ? r.evidence.reasons.filter((x): x is string => typeof x === "string") : [],
);
if (first.created_by_type === "agent") out.push(`# 智能体${reasons.length ? `:${reasons.join(";")}` : ""}`);
for (const l of linesOf(b)) out.push(first.status === "active" ? l : `# ${l}`);
}
return out;
}
/** 当前 schema 里有没有这张表(测试按 schema 隔离,search_path 后面的 public 不算) */
const regclass = async (db: Db, name: string) =>
(
await sql<{
t: string | null;
}>`select to_regclass(quote_ident(current_schema()) || '.' || ${name})::text as t`.execute(db)
).rows[0]?.t ?? null;
export async function up(db: Db): Promise<void> {
if (!(await regclass(db, "identify_words")))
await execDdl(
db,
`${entityTable(
"identify_words",
`media_entity_id uuid references media_entity (id) on delete cascade,
text text not null default '',
version integer not null default 0,
updated_by_type text ${oneOf("updated_by_type", ["user", "agent", "system", "api_key"])},
updated_by_id text,
reresolve jsonb`,
)}
create unique index if not exists identify_words_entity on identify_words (media_entity_id) where media_entity_id is not null;
create unique index if not exists identify_words_global on identify_words ((media_entity_id is null)) where media_entity_id is null;`,
);
const rows: RuleRow[] = (await regclass(db, "episode_mapping_rule"))
? ((await db
.selectFrom("episode_mapping_rule")
.selectAll()
.orderBy("media_entity_id")
.orderBy("created_at")
.execute()) as RuleRow[])
: [];
const byEntity = new Map<string, RuleRow[]>();
for (const r of rows) byEntity.set(r.media_entity_id, [...(byEntity.get(r.media_entity_id) ?? []), r]);
for (const [entityId, list] of byEntity) {
const lines = entityWordsText(list);
const cur = await db
.selectFrom("identify_words")
.select(["id", "text"])
.where("media_entity_id", "=", entityId)
.executeTakeFirst();
if (!cur) {
await db
.insertInto("identify_words")
.values({ media_entity_id: entityId, text: `${lines.join("\n")}\n`, version: 1, updated_by_type: "system" })
.execute();
continue;
}
const have = new Set((cur.text as string).split("\n").map((l) => l.trim()));
const missing = lines.filter((l) => !have.has(l.trim()));
if (!missing.length) continue;
const base = (cur.text as string).replace(/\n*$/, "");
await db
.updateTable("identify_words")
.set({ text: `${base ? `${base}\n` : ""}${missing.join("\n")}\n` })
.where("id", "=", cur.id)
.execute();
}
await execDdl(db, `drop table if exists episode_mapping_rule;`);
}
export async function down(db: Db): Promise<void> {
// 文本里的规则不回写成结构化规则:恢复空表结构,识别词原文随表删除
await execDdl(
db,
`${entityTable(
"episode_mapping_rule",
`media_entity_id uuid not null references media_entity (id) on delete cascade,
season integer,
release_group text,
kind text not null ${oneOf("kind", ["expression", "table", "special"])},
expression text,
target_season integer,
table_entries jsonb,
special text ${oneOf("special", ["special", "extra", "ignore"])},
status text not null default 'active' ${oneOf("status", ["active", "disabled"])},
evidence_level text not null default 'manual' ${oneOf("evidence_level", ["manual", "rule_confirmed", "multi_evidence", "needs_review"])},
evidence jsonb not null default '{}'::jsonb,
disabled_reason text,
created_by_type text not null ${oneOf("created_by_type", ["user", "agent", "system", "api_key"])},
created_by_id text`,
)}
create unique index episode_mapping_rule_active_scope on episode_mapping_rule
(media_entity_id, coalesce(season, -1), coalesce(lower(release_group), '')) where status = 'active';
create index episode_mapping_rule_entity on episode_mapping_rule (media_entity_id);
drop table if exists identify_words;`,
);
}
+17 -29
View File
@@ -133,11 +133,6 @@ export type DownloadTaskStatus =
export type DownloadTaskPauseReason = "user" | "threshold" | "policy" | "agent";
export type DownloaderInstanceKind = "qbittorrent";
export type DownloaderInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
export type EpisodeMappingRuleKind = "expression" | "table" | "special";
export type EpisodeMappingRuleSpecial = "special" | "extra" | "ignore";
export type EpisodeMappingRuleStatus = "active" | "disabled";
export type EpisodeMappingRuleEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review";
export type EpisodeMappingRuleCreatedByType = "user" | "agent" | "system" | "api_key";
export type ExternalIdentityEntityType = "movie" | "tv" | "season" | "episode" | "collection" | "person";
export type ExternalIdentityStatus = "accepted" | "proposed" | "conflicted" | "retired";
export type ExternalIdentityEvidenceLevel = "manual" | "rule_confirmed" | "multi_evidence" | "needs_review";
@@ -147,6 +142,7 @@ export type FeedSourceKind = "rss" | "atom" | "list" | "query";
export type FeedSourceHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "circuit_open" | "disabled";
export type FieldOverrideSource = "tmdb" | "user" | "agent" | "nfo" | "site" | "import" | "web";
export type FieldOverrideActorType = "user" | "agent" | "system" | "api_key";
export type IdentifyWordsUpdatedByType = "user" | "agent" | "system" | "api_key";
export type IdentityCandidateStatus = "pending" | "resolved" | "dismissed" | "archived";
export type IdentityCandidateReason = "ambiguous" | "no_match" | "proposed" | "lookup_failed" | "unmatched";
export type IdentityRedirectActorType = "user" | "agent" | "system" | "api_key";
@@ -763,26 +759,6 @@ export interface DownloaderInstanceTable {
is_default: Generated<boolean>;
}
export interface EpisodeMappingRuleTable {
id: Generated<string>;
media_entity_id: string;
season: number | null;
release_group: string | null;
kind: EpisodeMappingRuleKind;
expression: string | null;
target_season: number | null;
table_entries: Json | null;
special: EpisodeMappingRuleSpecial | null;
status: Generated<EpisodeMappingRuleStatus>;
evidence_level: Generated<EpisodeMappingRuleEvidenceLevel>;
evidence: Generated<Json>;
disabled_reason: string | null;
created_by_type: EpisodeMappingRuleCreatedByType;
created_by_id: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface ExternalIdentityTable {
id: Generated<string>;
media_entity_id: string | null;
@@ -846,6 +822,18 @@ export interface FieldOverrideTable {
updated_at: GeneratedTimestamptz;
}
export interface IdentifyWordsTable {
id: Generated<string>;
media_entity_id: string | null;
text: Generated<string>;
version: Generated<number>;
updated_by_type: IdentifyWordsUpdatedByType | null;
updated_by_id: string | null;
reresolve: Json | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface IdentityCandidateTable {
id: Generated<string>;
source_release_id: string;
@@ -1844,10 +1832,10 @@ export interface DB {
decision: DecisionTable;
download_task: DownloadTaskTable;
downloader_instance: DownloaderInstanceTable;
episode_mapping_rule: EpisodeMappingRuleTable;
external_identity: ExternalIdentityTable;
feed_source: FeedSourceTable;
field_override: FieldOverrideTable;
identify_words: IdentifyWordsTable;
identity_candidate: IdentityCandidateTable;
identity_redirect: IdentityRedirectTable;
identity_revision: IdentityRevisionTable;
@@ -1989,9 +1977,6 @@ export type DownloadTaskUpdate = Updateable<DownloadTaskTable>;
export type DownloaderInstanceRow = Selectable<DownloaderInstanceTable>;
export type NewDownloaderInstance = Insertable<DownloaderInstanceTable>;
export type DownloaderInstanceUpdate = Updateable<DownloaderInstanceTable>;
export type EpisodeMappingRuleRow = Selectable<EpisodeMappingRuleTable>;
export type NewEpisodeMappingRule = Insertable<EpisodeMappingRuleTable>;
export type EpisodeMappingRuleUpdate = Updateable<EpisodeMappingRuleTable>;
export type ExternalIdentityRow = Selectable<ExternalIdentityTable>;
export type NewExternalIdentity = Insertable<ExternalIdentityTable>;
export type ExternalIdentityUpdate = Updateable<ExternalIdentityTable>;
@@ -2001,6 +1986,9 @@ export type FeedSourceUpdate = Updateable<FeedSourceTable>;
export type FieldOverrideRow = Selectable<FieldOverrideTable>;
export type NewFieldOverride = Insertable<FieldOverrideTable>;
export type FieldOverrideUpdate = Updateable<FieldOverrideTable>;
export type IdentifyWordsRow = Selectable<IdentifyWordsTable>;
export type NewIdentifyWords = Insertable<IdentifyWordsTable>;
export type IdentifyWordsUpdate = Updateable<IdentifyWordsTable>;
export type IdentityCandidateRow = Selectable<IdentityCandidateTable>;
export type NewIdentityCandidate = Insertable<IdentityCandidateTable>;
export type IdentityCandidateUpdate = Updateable<IdentityCandidateTable>;
+2 -2
View File
@@ -129,8 +129,8 @@ const EXPECTED_TABLES = [
"scrape_record",
// 补字幕写入历史(0064)
"subtitle_write",
// 集数映射规则(0066)
"episode_mapping_rule",
// 识别词(0094,替代 0066 的 episode_mapping_rule)
"identify_words",
// 站点请求失败留样(0077)
"site_failure_sample",
// 缓存命中统计(0086,按天 × region)
+2 -1
View File
@@ -12,7 +12,8 @@
| --- | --- | --- |
| `release-parser/` | `parseRelease(title, opts)`、`parseReleasePath(path \| segments, opts)`、`mergeParsed`、`scanCnMarkers`、`evalEpisodeOffset`、`cnToInt`、`fileKindOf`、`expandEpisodes`、`RELEASE_GROUP_SEED`、`PARSER_VERSION` | 标题/文件名/目录名 → `ParsedRelease`。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 |
| `identity/` | `scoreCandidate`、`rankCandidates`、`resolveIdentity`、`normalizeTitle`、`stripSeasonSuffix`、`toSimplified` | 候选(TMDB 或本地别名召回,统一为 `IdentityCandidate`)按证据等级排序;年份容差 ±1;只给等级不给概率 |
| `episode-resolution/` | `resolveEpisodes`、`validateEpisodes`、`convertEpisodes`、`selectRule`、`applyRule`、`suggestRule` | 解析出的季集号 → 媒体库规范季集:规则 → 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md |
| `identify-words/` | `applyGlobalWords`、`compileGlobalWords`、`wordTouches` | 全局识别词套用(移植 MP WordsMatcher);`parseRelease(title, { words })` 解析前先套。见 docs/design/identify-words.md |
| `episode-resolution/` | `resolveEpisodes`、`validateEpisodes`、`convertEpisodes`、`selectRule`、`applyRule`、`suggestRule` | 解析出的季集号 → 媒体库规范季集:规则(作品识别词编译而来,照写的用、不停用)→ 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md |
| `slot/` | `evaluateCandidate(spec, parsed, current?, ctx?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`priorityLadder`/`currentPri`、`improvableUnits`、`describeViolation`、`validateKeywordPattern`、`transition`/`canTransition`、`transitionWithInflight`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets`、`resolveEpisodeTotal`、`computeUnitGaps`/`seasonProgress`/`unitsOverlap` | 硬条件(区分缺字段与不满足;含排除来源、HDR/编码集合、每集体积、关键词正则、站点/做种/免费,站点事实经 `EvaluationContext`)、软偏好排序、优先级阶梯(MP `>` 分层 → pri 0–100 + 逐层 trace)、升级阈值与洗版模式(movie/per_unit/full_pack)、状态机(含 Slot×单元多在途聚合)、完成条件、季总集数账本(只升、回退不低于已覆盖最高集)、缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 |
| `rules/` | `parseRuleString`/`parseExpr`、`serializeLayers`/`serializeExpr`、`normalizeExpr`、`BUILTIN_ATOMS`、`customAtomFromMp`、`evaluateRuleGroup`/`evaluateRuleChain`、`testAtom` | MP 规则串 ↔ 结构化层(`>` 分层,层内 `! & \|` 与括号,错误带位置);内置关键字对齐 MP(能用解析字段的用字段,其余沿用 MP 正则,音乐 13 条不支持);多组串联取交集、pri 取最后一个带层的组;经 `SlotSpec.rules` 接入 `evaluateCandidate`。差分见 `test/differential/rules.test.ts` 与 `docs/runbook/rules.md` |
| `acquisition/` | `determineMode`、`sortByPriority`/`priorityOf`、`checkBudget`、`evaluateCacheVolume` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 |
@@ -10,7 +10,7 @@ export {
tmdbSeasonCandidate,
} from "./convert.ts";
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
export { applyRule, groupKey, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
export {
SUGGEST_MIN_SAMPLES,
SUGGEST_PAIR_DAYS,
@@ -14,7 +14,7 @@ export const CONVERSION_NEAR_DAYS = 7;
/**
* 集数解析总入口(设计文档判定流程 1→4)。纯函数,时间等事实由调用方传入。
* 1 有适用规则 → 按规则换算并校验;fail → unresolved + ruleFailed(不回退到确定性换算)
* 1 有适用规则 → 按规则换算并校验;fail → unresolved(规则照写的用,不回退到确定性换算,也不停用规则)
* 2 原号校验 pass → raw
* 3 确定性换算(只取唯一解):原号 fail 时唯一解即采用;原号滞后放行(unverified)时,
* 唯一解还要发布时间贴近目标集播出日期(按日期对集的除外),否则按滞后放行
@@ -45,32 +45,37 @@ export function resolveEpisodes(input: ResolveEpisodesInput): EpisodeResolution
// 1 规则
const rule = selectRule(rules, season, parsed.releaseGroup?.name ?? null);
if (rule && season !== null) {
const ref = {
ruleId: rule.id,
...(rule.line !== undefined ? { ruleLine: rule.line } : {}),
...(rule.text !== undefined ? { ruleText: rule.text } : {}),
};
const applied = applyRule(rule, season, episodes);
if (applied.kind === "special") {
return result({
method: "rule",
canonical: null,
ruleId: rule.id,
...ref,
special: applied.special,
evidence: [`rule:special:${applied.special}`],
});
}
if (applied.kind === "error") {
return result({ method: "unresolved", canonical: null, ruleFailed: rule.id, evidence: [applied.evidence] });
return result({ method: "unresolved", canonical: null, ...ref, evidence: [applied.evidence] });
}
const v = validateEpisodes(applied.canonical.season, applied.canonical.episodes, table);
if (v === "fail") {
return result({
method: "unresolved",
canonical: null,
ruleFailed: rule.id,
...ref,
evidence: [applied.evidence, "rule:validate_fail"],
});
}
return result({
method: "rule",
canonical: applied.canonical,
ruleId: rule.id,
...ref,
evidence: [applied.evidence, `validate:${v}`],
});
}
@@ -1,8 +1,11 @@
import { evalEpisodeOffset } from "../release-parser/offset.ts";
import { evalEpisodeExpr } from "@umt/contracts";
import { toSimplified } from "../identity/zh-map.ts";
import type { EpisodeRange } from "../release-parser/types.ts";
import type { CanonicalEpisodes, EpisodeMappingRule } from "./types.ts";
const sameGroup = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
/** 发布组比较:忽略大小写与首尾空白,繁简视为同一个(綠茶字幕組 = 绿茶字幕组) */
export const groupKey = (g: string) => toSimplified(g.trim().toLowerCase());
const sameGroup = (a: string, b: string) => groupKey(a) === groupKey(b);
/** 规则对(来源季, 发布组)的具体度:季 + 发布组 3 > 仅季 2 > 仅发布组 1 > 都空 0;不适用返回 -1 */
export function ruleSpecificity(rule: EpisodeMappingRule, season: number | null, releaseGroup: string | null): number {
@@ -49,8 +52,8 @@ export function applyRule(rule: EpisodeMappingRule, season: number, episodes: Ep
if (rule.kind === "expression") {
if (!rule.expression) return { kind: "error", evidence: "rule:expression_missing" };
try {
const start = evalEpisodeOffset(rule.expression, episodes.start);
const end = evalEpisodeOffset(rule.expression, episodes.end);
const start = evalEpisodeExpr(rule.expression, episodes.start);
const end = evalEpisodeExpr(rule.expression, episodes.end);
return {
kind: "mapped",
canonical: { season: rule.targetSeason ?? season, episodes: { start, end } },
@@ -1,4 +1,4 @@
import { evalEpisodeOffset } from "../release-parser/offset.ts";
import { evalEpisodeExpr } from "@umt/contracts";
import type { EpisodeRange } from "../release-parser/types.ts";
import { daysBetween } from "./table.ts";
import type { SeasonTable } from "./types.ts";
@@ -61,7 +61,7 @@ export function suggestRule(samples: readonly SuggestSample[], table: SeasonTabl
for (const c of candidates.values()) {
const pairs: SuggestedRule["samples"] = [];
for (const s of samples) {
const target = evalEpisodeOffset(c.expression, s.episodes.start);
const target = evalEpisodeExpr(c.expression, s.episodes.start);
const hit = aired.find((a) => a.season === c.targetSeason && a.episode === target);
const d = hit ? daysBetween(s.publishedAt, hit.airDate) : null;
if (!hit || d === null || d > SUGGEST_PAIR_DAYS) break;
@@ -45,7 +45,8 @@ export interface EpisodeMappingTableEntry {
}
/**
* 映射规则。作品由调用方过滤,停用的规则也由调用方剔除,这里只看季 + 发布组。
* 映射规则(由作品识别词编译而来,docs/design/identify-words.md)。作品由调用方过滤,注释掉的行不会编译出规则,
* 这里只看季 + 发布组。
* - expression:复用 `evalEpisodeOffset`,目标季缺省为来源季
* - table:按来源区间逐条映射
* - special:映射为特别篇 / 花絮 / 忽略,不再校验
@@ -61,6 +62,9 @@ export interface EpisodeMappingRule {
targetSeason?: number;
table?: EpisodeMappingTableEntry[];
special?: EpisodeSpecialKind;
/** 出处:作品识别词里的行号与原文 */
line?: number;
text?: string;
}
export type EpisodeValidation = "pass" | "unverified" | "fail";
@@ -92,9 +96,10 @@ export interface EpisodeResolution {
* - unresolved / skipped / special 规则:null(skipped 时调用方照用解析结果)
*/
canonical: CanonicalEpisodes | null;
/** 命中的规则(按写的换算,算出来对不上季表时也照记,结果为待确认) */
ruleId?: string;
/** 规则算出的结果校验不通过时,记失败规则的 id(调用方停用规则并开待处理) */
ruleFailed?: string;
ruleLine?: number;
ruleText?: string;
special?: EpisodeSpecialKind;
/** 判定依据(规则名式短串),不含概率 */
evidence: string[];
+244
View File
@@ -0,0 +1,244 @@
import { calculateEpisodeOffset, parseGlobalWords, pyRegex, pySubn } from "@umt/contracts";
import { cnToInt } from "../release-parser/cn-number.ts";
// 全局识别词套用(移植 MP app/domain/meta/words.py WordsMatcher,docs/design/identify-words.md):
// 按行序处理标题与副标题:屏蔽词、`A => B` 正则替换、`前 <> 后 >> EP±N` 集数偏移(标题没有就偏移副标题里的集数)、
// `A => B && 前 <> 后 >> 偏移`(替换成功才偏移)。某行出错只跳过这一行。正则按 Python 写法转成 JS。
export interface CompiledGlobalWord {
/** 原文(命中记录用) */
text: string;
kind: "block" | "replace" | "offset" | "replace_offset";
pattern?: string;
replacement?: string;
front?: string;
back?: string;
offset?: string;
}
/**
* 编译全局识别词原文:留下注释与空行以外的全部行。编辑器里标错的行照样参与(MP 也是套用时才失败):
* 「替换 + 偏移」的偏移写错时替换照做,正则写错的行套用时跳过。
*/
export function compileGlobalWords(text: string): CompiledGlobalWord[] {
return parseGlobalWords(text)
.filter((w) => w.kind !== "blank" && w.kind !== "comment" && (w.pattern !== undefined || w.front !== undefined))
.map((w) => ({
text: w.text,
kind: w.kind as CompiledGlobalWord["kind"],
...(w.pattern !== undefined ? { pattern: w.pattern } : {}),
...(w.replacement !== undefined ? { replacement: w.replacement } : {}),
...(w.front !== undefined ? { front: w.front } : {}),
...(w.back !== undefined ? { back: w.back } : {}),
...(w.offset !== undefined ? { offset: w.offset } : {}),
}));
}
export interface WordsApplied {
title: string;
subtitle: string | null;
/** 命中的识别词原文,按行序 */
applied: string[];
}
const CACHE = new Map<string, RegExp>();
/** 编译缓存(MP 同样 lru_cache 编译结果) */
function re(pattern: string, flags = ""): RegExp {
const key = `${flags}\u0000${pattern}`;
let r = CACHE.get(key);
if (!r) {
if (CACHE.size > 2048) CACHE.clear();
r = pyRegex(pattern, flags);
CACHE.set(key, r);
}
r.lastIndex = 0;
return r;
}
export function applyGlobalWords(
title: string,
subtitle: string | null | undefined,
words: readonly CompiledGlobalWord[],
): WordsApplied {
let t = title;
let sub = subtitle ?? null;
const applied: string[] = [];
for (const w of words) {
try {
let state = false;
if (w.kind === "replace_offset") {
const [next, ok] = replaceRegex(t, w.pattern!, w.replacement ?? "");
t = next;
if (ok) [t, sub, state] = offsetWithSubtitle(t, sub, w.front ?? "", w.back ?? "", w.offset ?? "");
} else if (w.kind === "replace") {
[t, state] = replaceRegex(t, w.pattern!, w.replacement ?? "");
} else if (w.kind === "offset") {
[t, sub, state] = offsetWithSubtitle(t, sub, w.front ?? "", w.back ?? "", w.offset ?? "");
} else {
[t, state] = replaceRegex(t, w.pattern!, "");
}
if (state) applied.push(w.text);
} catch {
// 与 MP 一致:这一行出错只记日志跳过,标题保持当时的样子
}
}
return { title: t, subtitle: sub, applied };
}
/** 这一行对标题 / 副标题有没有作用(重算范围判断用) */
export function wordTouches(word: CompiledGlobalWord, title: string, subtitle: string | null | undefined): boolean {
return applyGlobalWords(title, subtitle, [word]).applied.length > 0;
}
function replaceRegex(title: string, pattern: string, replacement: string): [string, boolean] {
try {
const [out, n] = pySubn(re(pattern, "g"), replacement, title);
return [out, n > 0];
} catch {
return [title, false];
}
}
const NUM = "[0-9一二三四五六七八九十]+";
/** cn2an(…, "smart"):阿拉伯数字、带十百的中文数字、逐位的中文数字(一二 → 12) */
export function smartNumber(s: string): number {
if (/^\d+$/.test(s)) return Number(s);
const digits = "零一二三四五六七八九";
if (/^[零一二三四五六七八九]+$/.test(s) && s.length > 1) return Number([...s].map((c) => digits.indexOf(c)).join(""));
const n = cnToInt(s);
if (n === undefined) throw new Error(`无法识别的数字 ${s}`);
return n;
}
const CN_DIGITS = "零一二三四五六七八九";
/** an2cn(n, "low"):小写中文数字(负数前加「负」) */
export function toChineseNumber(n: number): string {
if (n < 0) return `负${toChineseNumber(-n)}`;
if (n < 10) return CN_DIGITS[n]!;
if (n < 20) return `十${n % 10 ? CN_DIGITS[n % 10] : ""}`;
const units = ["", "十", "百", "千", "万"];
const ds = String(n).split("").map(Number);
let out = "";
let zero = false;
ds.forEach((d, i) => {
const u = units[ds.length - 1 - i] ?? "";
if (d === 0) {
zero = true;
return;
}
if (zero && out) out += "零";
zero = false;
out += CN_DIGITS[d] + u;
});
return out;
}
/** 按原集数字符串的格式写偏移后的集数:前导零保留宽度,中文数字仍写中文 */
function formatOffset(original: string, value: number): string {
if (!/^\d+$/.test(original)) return toChineseNumber(value);
const width = /^0/.test(original) ? original.length : 0;
if (value < 0) return `-${String(Math.abs(value)).padStart(width, "0")}`;
return String(value).padStart(width, "0");
}
function episodeOffset(title: string, front: string, back: string, offset: string): [string, boolean] {
try {
if (back && !re(back).test(title)) return [title, false];
if (front && !re(front).test(title)) return [title, false];
const found = title.match(re(`(?<=${front}.*?)${NUM}(?=.*?${back})`, "g"));
if (!found?.length) return [title, false];
const pairs = new Map<string, string>();
let backward = false;
for (const s of found) {
const n = smartNumber(s);
const v = calculateEpisodeOffset(offset, n);
if (n > v) backward = true;
else if (n < v) backward = false;
pairs.set(s, formatOffset(s, v));
}
// 向前偏移按新值升序、向后偏移按降序逐个替换,避免替换出来的数字再被后面的替换命中
const list = [...pairs.entries()].sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
if (!backward) list.reverse();
let out = title;
for (const [from, to] of list) {
out = out.replace(re(`(?<=${front}.*?)${from}(?=.*?${back})`, "g"), () => to);
}
return [out, true];
} catch {
return [title, false];
}
}
function locatorsMatch(text: string, front: string, back: string): boolean {
try {
if (front && !re(front).test(text)) return false;
if (back && !re(back).test(text)) return false;
return true;
} catch {
return false;
}
}
const SUB_RANGE =
/(?<!\d)\[?\s*(?<begin>\d{1,4})\s*-\s*(?<end>\d{1,4})\s*(?:(?:Fin|End)(?![a-z0-9])|完结(?![一-鿿]))(?:\s*\](?!\d)|(?!\s*(?:\]\d|\d))\s*)/dgi;
const SUB_EPISODE = /(?<![全共])(?<episode>[0-9一二三四五六七八九十百零]+)\s*[集话話期幕](?!\s*[全共])/dgi;
const SUB_EPISODE_TITLE = /(?<![A-Za-z0-9_])Episode\s+(?<episode>\d{1,4})(?![A-Za-z0-9_])/dgi;
const SUB_EPISODE_TOKEN = /(?<![A-Za-z0-9_])(?:EP|E)(?<episode>\d{1,4})(?![A-Za-z0-9_])/dgi;
/** 副标题里会被识别成集数的数字位置(MP __subtitle_episode_spans) */
function subtitleEpisodeSpans(subtitle: string): [number, number][] {
const spans: [number, number][] = [];
const patterns: [RegExp, string[]][] = [
[SUB_RANGE, ["begin", "end"]],
[SUB_EPISODE, ["episode"]],
[SUB_EPISODE_TITLE, ["episode"]],
[SUB_EPISODE_TOKEN, ["episode"]],
];
for (const [pattern, names] of patterns) {
pattern.lastIndex = 0;
for (const m of subtitle.matchAll(pattern)) {
for (const name of names) {
const span = m.indices?.groups?.[name];
if (!span) continue;
const [start, end] = span;
if (!spans.some(([s, e]) => start < e && end > s)) spans.push([start, end]);
}
}
}
return spans.sort((a, b) => a[0] - b[0]);
}
function offsetSubtitle(subtitle: string, offset: string): [string, boolean] {
const spans = subtitleEpisodeSpans(subtitle);
if (!spans.length) return [subtitle, false];
try {
const repl = spans.map(([s, e]) => {
const str = subtitle.slice(s, e);
return [s, e, formatOffset(str, calculateEpisodeOffset(offset, smartNumber(str)))] as const;
});
let out = subtitle;
for (const [s, e, r] of [...repl].reverse()) out = `${out.slice(0, s)}${r}${out.slice(e)}`;
return [out, true];
} catch {
return [subtitle, false];
}
}
/** 标题里能偏移就偏移标题;否则偏移副标题(定位词在副标题里,或标题 + 副标题合起来能定位时只改副标题里的集数) */
function offsetWithSubtitle(
title: string,
subtitle: string | null,
front: string,
back: string,
offset: string,
): [string, string | null, boolean] {
const [t, ok] = episodeOffset(title, front, back, offset);
if (ok) return [t, subtitle, true];
if (!subtitle) return [t, subtitle, false];
const [s, ok2] = episodeOffset(subtitle, front, back, offset);
if (ok2) return [t, s, true];
if (!locatorsMatch(`${t} ${subtitle}`, front, back)) return [t, subtitle, false];
const [s3, ok3] = offsetSubtitle(subtitle, offset);
return [t, ok3 ? s3 : subtitle, ok3];
}
@@ -0,0 +1,9 @@
export {
applyGlobalWords,
type CompiledGlobalWord,
compileGlobalWords,
smartNumber,
toChineseNumber,
type WordsApplied,
wordTouches,
} from "./apply.ts";
+1
View File
@@ -5,6 +5,7 @@ export * from "./acquisition/index.ts";
export * from "./classification.ts";
export * from "./download-state/index.ts";
export * from "./episode-resolution/index.ts";
export * from "./identify-words/index.ts";
export * from "./identity/index.ts";
export * from "./library/versions.ts";
export * from "./naming/index.ts";
@@ -1,3 +1,4 @@
import { applyGlobalWords } from "../identify-words/apply.ts";
import { CN_NUM, cnToInt } from "./cn-number.ts";
import { evalEpisodeOffset } from "./offset.ts";
import { PLATFORMS } from "./platforms.ts";
@@ -858,6 +859,19 @@ const PINYIN_SYLLABLE =
* 与 MoviePilot MetaInfo 的行为对照及偏离点见 docs/porting/moviepilot-metadata-recognition.md。
*/
export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRelease {
if (opts.words?.length) {
// 全局识别词先改标题与副标题(MP MetaInfo 同样先过 WordsMatcher),命中的行记在结果上
const { words, ...rest } = opts;
const w = applyGlobalWords(input, opts.subtitle ?? null, words);
const { subtitle: _s, ...base } = rest;
// 「替换 + 偏移」替换成功、偏移失败时标题已经改了但这一行不算命中(与 MP 一致),所以总按改后的标题解析
const r = parseRelease(w.title, w.subtitle ? { ...base, subtitle: w.subtitle } : base);
if (w.applied.length) {
r.words = { applied: w.applied, original: input };
r.evidence.push("words:applied");
}
return r;
}
const d = emptyDraft(input);
const ev = d.evidence;
let s = input.trim();
@@ -68,6 +68,10 @@ export function mergeParsed(
variantTags: [...primary.variantTags],
evidence: [...primary.evidence],
};
if (fallback.words) {
const applied = [...new Set([...(primary.words?.applied ?? []), ...fallback.words.applied])];
r.words = { applied, original: primary.words?.original ?? primary.raw };
}
const used: string[] = [];
// 目录只有日期(`…Collection.20240501.FRDS` 这类收藏/打包日期)不算剧集信号,不参与类型合并
const dateOnly =
@@ -1,3 +1,5 @@
import type { CompiledGlobalWord } from "../identify-words/apply.ts";
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
export const PARSER_VERSION = 2;
@@ -128,6 +130,8 @@ export interface ParsedRelease {
confidence: Confidence;
/** 命中的规则名 */
evidence: string[];
/** 命中的全局识别词(原文)与套用前的标题;没命中时没有这个字段 */
words?: { applied: string[]; original: string };
}
export interface ParseOptions {
@@ -139,4 +143,6 @@ export interface ParseOptions {
episodeOffset?: string;
/** 强制视为文件(无扩展名时) */
isFile?: boolean;
/** 全局识别词:解析前先套到标题与副标题上(docs/design/identify-words.md) */
words?: readonly CompiledGlobalWord[];
}
+23 -10
View File
@@ -254,7 +254,7 @@ describe("resolveEpisodes 规则", () => {
expect(map(ep(27, 28)).canonical).toEqual({ season: 2, episodes: ep(2) });
expect(map(ep(29)).canonical).toEqual({ season: 2, episodes: ep(3) });
expect(map(ep(26, 29)).canonical).toEqual({ season: 2, episodes: ep(1, 3) });
expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleFailed: "t1" });
expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleId: "t1" });
});
it("special 规则 → 返回特别篇标记,不校验", () => {
@@ -264,20 +264,33 @@ describe("resolveEpisodes 规则", () => {
expect(r).toMatchObject({ method: "rule", ruleId: "sp", special: "extra", canonical: null });
});
it("规则结果越界 → unresolved + ruleFailed,不回退到确定性换算", () => {
it("规则结果越界 → 待确认,记命中的规则行;不回退到确定性换算,也没有停用一说", () => {
// 原号 S02E13 本可按季内绝对集号换算成 S02E01,但有规则时只认规则
const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), {
rules: [rule({ id: "bad", expression: "EP+20" })],
});
expect(r.method).toBe("unresolved");
expect(r.ruleFailed).toBe("bad");
expect(r.ruleId).toBeUndefined();
const bad = rule({ id: "bad", expression: "EP+20", line: 3, text: "S02 >> EP+20" });
const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), { rules: [bad] });
expect(r).toMatchObject({ method: "unresolved", ruleId: "bad", ruleLine: 3, ruleText: "S02 >> EP+20" });
expect(r.evidence).toContain("rule:validate_fail");
expect(r.canonical).toBeNull();
expect("ruleFailed" in r).toBe(false);
// 同一条规则对另一条资源照样生效(不会因为上一条对不上就被停用)
const ok = run({ season: 2, episodes: ep(1) }, table([12, 30]), { rules: [bad] });
expect(ok).toMatchObject({ method: "rule", canonical: { season: 2, episodes: ep(21) } });
});
it("表达式非法 → ruleFailed", () => {
it("表达式非法 → 待确认,记规则", () => {
const r = run({ season: 1, episodes: ep(1) }, table([12]), { rules: [rule({ id: "x", expression: "EP-$" })] });
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
expect(r).toMatchObject({ method: "unresolved", ruleId: "x" });
});
it("发布组繁简视为同一个:綠茶字幕組 的规则命中 绿茶字幕组 的资源", () => {
const r = run(
{ season: 3, episodes: ep(2), releaseGroup: { name: "绿茶字幕组", source: "bracket" } },
table([24, 24, 24]),
{
rules: [rule({ id: "g", season: 3, releaseGroup: "綠茶字幕組", expression: "EP", targetSeason: 1 })],
},
);
expect(r).toMatchObject({ method: "rule", canonical: { season: 1, episodes: ep(2) } });
});
});
+175
View File
@@ -0,0 +1,175 @@
import { calculateEpisodeOffset } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import {
applyGlobalWords,
compileGlobalWords,
parseRelease,
parseReleasePath,
smartNumber,
toChineseNumber,
} from "../src/index.ts";
// 全局识别词套用(移植 MP WordsMatcher)。MP 用例出处:tests/test_metainfo.py、tests/test_rust_accel.py。
const words = (...lines: string[]) => compileGlobalWords(lines.join("\n"));
const parse = (title: string, w: string[], subtitle?: string) =>
parseRelease(title, { words: words(...w), ...(subtitle ? { subtitle } : {}) });
describe("MP 识别词用例", () => {
it("复杂识别词先替换后偏移(test_custom_words_replace_then_episode_offset)", () => {
const w = "旧名 => 新名 && 第 <> 集 >> EP+1";
const r = parse("旧名 第03集", [w]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 4, end: 4 });
expect(r.words?.applied).toEqual([w]);
expect(r.words?.original).toBe("旧名 第03集");
});
it("标题没有集数时只偏移副标题里的集数,季数不动(test_custom_words_episode_offset_applies_to_subtitle)", () => {
const w = "BLEACH Thousand-Year Blood War S04 => BLEACH 2004 S02 && S02 <> 1080p >> EP+40";
const sub = "死神 千年血战篇 -祸进谭- 第四季 第8集 ...";
const out = applyGlobalWords(
"BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB",
sub,
words(w),
);
expect(out.subtitle).toBe("死神 千年血战篇 -祸进谭- 第四季 第48集 ...");
expect(out.applied).toEqual([w]);
const r = parse("BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB", [w], sub);
expect(r.season).toBe(2);
expect(r.episodes).toEqual({ start: 48, end: 48 });
});
it("替换里带 {[tmdbid]} 并跨季偏移(test_get_torrent_episodes_applies_custom_words)", () => {
const w = "A.Will.Eternal.S04 => 一念永恒{[tmdbid=107371;type=tv]}S01 && S01 <> 2160p >> EP+165";
const r = parse("A.Will.Eternal.S04E05.2026.2160p.WEB-DL.mkv", [w]);
expect(r.episodes).toEqual({ start: 170, end: 170 });
expect(r.season).toBe(1);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "107371" });
});
it("偏移表达式支持乘法与分组引用 \\1(test_custom_words_episode_offset_supports_multiplication_expression)", () => {
const w =
"Ha.Ha.Ha.Ha.Ha.2026.S06E([0-1][0-9]).Part1 => 哈哈哈哈哈 (2020){[tmdbid=112732;type=tv]} S06E\\1.Part1 && S06 <> .Part1 >> 2*EP-1";
const r = parse("Ha.Ha.Ha.Ha.Ha.2026.S06E03.Part1", [w]);
expect(r.title).toBe("哈哈哈哈哈");
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "112732" });
expect(r.season).toBe(6);
expect(r.episodes).toEqual({ start: 5, end: 5 });
expect(r.words?.applied).toEqual([w]);
});
it("EP 可以出现多次(test_custom_words_episode_offset_supports_repeated_ep_expression)", () => {
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> EP+EP-1"]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 5, end: 5 });
});
it("2EP 不当成乘法:替换照做、偏移不做、这一行不算命中(test_custom_words_episode_offset_rejects_implicit_ep_expression)", () => {
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> 2EP"]);
expect(r.title).toBe("新名");
expect(r.episodes).toEqual({ start: 3, end: 3 });
expect(r.words).toBeUndefined();
});
it("{[…;g=…;s=1]} 指定作品与季(test_custom_words_support_episode_group_parameter)", () => {
const w = "Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}";
const r = parse("Bakemonogatari 01", [w]);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "46195" });
expect(r.season).toBe(1);
expect(r.mediaTypeHint).toBe("tv");
expect(r.words?.applied).toEqual([w]);
});
it("s=0 指定为特别季(test_custom_words_support_special_season_zero_parameter)", () => {
const r = parse("Test Show 01", ["Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}"]);
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "12345" });
expect(r.season).toBe(0);
});
it("替换为空串删掉词(test_metainfo_preserves_original_name_when_custom_words_applied)", () => {
const r = parse("电影测试替换名称 (2024)", ["测试替换 => "]);
expect(r.title).toBe("电影名称");
expect(r.year).toBe(2024);
});
it("记录命中的识别词(test_custom_words_apply_words_recording)", () => {
const r = parse("电影替换词.2024.mkv", ["替换词 => 新词"]);
expect(r.words?.applied).toEqual(["替换词 => 新词"]);
});
it("路径识别同样套用识别词(test_metainfopath_with_custom_words)", () => {
const r = parseReleasePath("/movies/电影测试替换名称 (2024)/电影测试替换名称.mkv", {
words: words("测试替换 => "),
});
expect(r.title).toBe("电影名称");
expect(r.year).toBe(2024);
expect(r.words?.applied).toEqual(["测试替换 => "]);
});
});
describe("识别词行为细节", () => {
it("屏蔽词:整行当正则删掉", () => {
expect(applyGlobalWords("Show.S01E01.国语中字.1080p", null, words("国语中字")).title).toBe("Show.S01E01..1080p");
});
it("注释、空行跳过;正则写错的行跳过,不影响后面的行", () => {
const out = applyGlobalWords("A B C", null, words("# A => X", "", "([ => Y", "B => Z"));
expect(out.title).toBe("A Z C");
expect(out.applied).toEqual(["B => Z"]);
});
it("按行序处理:前一行的结果交给后一行", () => {
expect(applyGlobalWords("abc", null, words("a => b", "bb => X")).title).toBe("Xc");
});
it("Python 写法:命名分组 (?P<n>…)、\\g<n>、(?i)", () => {
const out = applyGlobalWords("Foo.S01E02", null, words("(?i)foo\\.S(?P<s>\\d+) => Bar.S\\g<s>"));
expect(out.title).toBe("Bar.S01E02");
});
it("只偏移前后定位词之间的数字;前导零保留宽度;向前偏移", () => {
expect(applyGlobalWords("[Grp] Show - 25 [1080p]", null, words("Show - <> \\[1080p >> EP-12")).title).toBe(
"[Grp] Show - 13 [1080p]",
);
expect(applyGlobalWords("Show E05 end", null, words("Show E <> end >> EP+10")).title).toBe("Show E15 end");
});
it("中文集数偏移后仍写中文", () => {
expect(applyGlobalWords("第十二集", null, words("第 <> 集 >> EP+1")).title).toBe("第十三集");
});
it("定位词不在标题里就不动", () => {
const out = applyGlobalWords("Other 05", null, words("Show <> end >> EP+1"));
expect(out).toEqual({ title: "Other 05", subtitle: null, applied: [] });
});
it("中文数字互转", () => {
expect(smartNumber("十二")).toBe(12);
expect(smartNumber("一二")).toBe(12);
expect(smartNumber("08")).toBe(8);
expect(toChineseNumber(13)).toBe("十三");
expect(toChineseNumber(105)).toBe("一百零五");
expect(toChineseNumber(-3)).toBe("负三");
});
});
describe("集号算术(MP calculate_episode_offset)", () => {
it("白名单运算", () => {
expect(calculateEpisodeOffset("EP+1", 3)).toBe(4);
expect(calculateEpisodeOffset("2*EP-1", 3)).toBe(5);
expect(calculateEpisodeOffset("EP+EP-1", 3)).toBe(5);
expect(calculateEpisodeOffset("(EP+1)//2", 5)).toBe(3);
expect(calculateEpisodeOffset("EP/2", 5)).toBe(2);
expect(calculateEpisodeOffset("-EP+20", 5)).toBe(15);
expect(calculateEpisodeOffset("EP%4", 10)).toBe(2);
expect(calculateEpisodeOffset("-7%3", 0)).toBe(2);
expect(calculateEpisodeOffset("5", 3)).toBe(5);
});
it("拒绝省略运算符、乘方、名字与非法字符", () => {
for (const bad of ["2EP", "EP(1)", "EP**2", "EPX+1", "ep+1", "EP+1;", "EP/0", "EP+"]) {
expect(() => calculateEpisodeOffset(bad, 1), bad).toThrow();
}
});
});