feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
12f8fc59c3
commit
63e94b308e
90 files changed
+6366
-2131
No files matched your search
@@ -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;拿不准就不写,只把模拟通过的规则留作建议。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
/** 写入后事项的状态(资源都解决时已自动关闭) */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`] };
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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";
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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) } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user