@whispering233/ai-editor-server 0.0.51 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/client-dist/assets/{index-Dim9IXQC.css → index-DBshOA8W.css} +1 -1
  2. package/client-dist/assets/index-DnBMudDZ.js +624 -0
  3. package/client-dist/index.html +2 -2
  4. package/dist/decompose/batching.d.ts +20 -0
  5. package/dist/decompose/batching.d.ts.map +1 -0
  6. package/dist/decompose/batching.js +29 -0
  7. package/dist/decompose/batching.js.map +1 -0
  8. package/dist/decompose/extract.d.ts +44 -0
  9. package/dist/decompose/extract.d.ts.map +1 -0
  10. package/dist/decompose/extract.js +246 -0
  11. package/dist/decompose/extract.js.map +1 -0
  12. package/dist/decompose/job.d.ts +91 -0
  13. package/dist/decompose/job.d.ts.map +1 -0
  14. package/dist/decompose/job.js +289 -0
  15. package/dist/decompose/job.js.map +1 -0
  16. package/dist/decompose/llm.d.ts +68 -0
  17. package/dist/decompose/llm.d.ts.map +1 -0
  18. package/dist/decompose/llm.js +180 -0
  19. package/dist/decompose/llm.js.map +1 -0
  20. package/dist/decompose/merge-write.d.ts +75 -0
  21. package/dist/decompose/merge-write.d.ts.map +1 -0
  22. package/dist/decompose/merge-write.js +670 -0
  23. package/dist/decompose/merge-write.js.map +1 -0
  24. package/dist/decompose/merge.d.ts +156 -0
  25. package/dist/decompose/merge.d.ts.map +1 -0
  26. package/dist/decompose/merge.js +266 -0
  27. package/dist/decompose/merge.js.map +1 -0
  28. package/dist/decompose/report.d.ts +57 -0
  29. package/dist/decompose/report.d.ts.map +1 -0
  30. package/dist/decompose/report.js +127 -0
  31. package/dist/decompose/report.js.map +1 -0
  32. package/dist/decompose/runner.d.ts +143 -0
  33. package/dist/decompose/runner.d.ts.map +1 -0
  34. package/dist/decompose/runner.js +520 -0
  35. package/dist/decompose/runner.js.map +1 -0
  36. package/dist/decompose/split.d.ts +89 -0
  37. package/dist/decompose/split.d.ts.map +1 -0
  38. package/dist/decompose/split.js +556 -0
  39. package/dist/decompose/split.js.map +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +4 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/middleware/error.d.ts +1 -1
  44. package/dist/middleware/error.d.ts.map +1 -1
  45. package/dist/middleware/error.js +9 -0
  46. package/dist/middleware/error.js.map +1 -1
  47. package/dist/middleware/project.d.ts.map +1 -1
  48. package/dist/middleware/project.js +14 -0
  49. package/dist/middleware/project.js.map +1 -1
  50. package/dist/routes/chat.d.ts.map +1 -1
  51. package/dist/routes/chat.js +28 -3
  52. package/dist/routes/chat.js.map +1 -1
  53. package/dist/routes/decompose.d.ts +26 -0
  54. package/dist/routes/decompose.d.ts.map +1 -0
  55. package/dist/routes/decompose.js +453 -0
  56. package/dist/routes/decompose.js.map +1 -0
  57. package/dist/routes/project.d.ts +2 -0
  58. package/dist/routes/project.d.ts.map +1 -1
  59. package/dist/routes/project.js +4 -0
  60. package/dist/routes/project.js.map +1 -1
  61. package/package.json +5 -5
  62. package/client-dist/assets/index-BTOnSWMB.js +0 -621
@@ -0,0 +1,89 @@
1
+ /** 标题行长度上限(strip 后):实测样本的合法标题都远短于此,超限候选为 0 ⇒ 只保留长度护栏 */
2
+ export declare const SPLIT_TITLE_MAX_CHARS = 40;
3
+ /** 相邻重复标题合并的最大间距(实测形态 = 裸行标题紧跟缩进的同名行) */
4
+ export declare const SPLIT_DUP_MERGE_CHARS = 60;
5
+ /** 微段修复阈值(同时是退化路径的中位段长下限) */
6
+ export declare const SPLIT_MIN_SEGMENT_CHARS = 200;
7
+ /** 判「有章节结构」所需的最少候选数 */
8
+ export declare const SPLIT_MIN_CHAPTERS = 5;
9
+ /** 超长块判定系数:段长 > 该系数 × 中位段长 ⇒ 回扫补漏 */
10
+ export declare const SPLIT_LONG_BLOCK_FACTOR = 3;
11
+ /** 目录页判定:连续命中数下限 */
12
+ export declare const SPLIT_TOC_RUN_MIN = 6;
13
+ /** 目录页判定:单节长度上限 */
14
+ export declare const SPLIT_TOC_SEGMENT_CHARS = 120;
15
+ /** 退化等分的每份目标字数 */
16
+ export declare const SPLIT_FALLBACK_TARGET_CHARS = 4000;
17
+ /** 分卷时卷标记数上限(超出 ⇒ 单卷「全书」兜底) */
18
+ export declare const SPLIT_MAX_VOLUMES = 30;
19
+ /** 编码探测结果(api/120-api-decompose.md §analyze 的 encoding) */
20
+ export type NovelEncoding = "utf-8" | "utf-8-bom" | "utf-16le" | "utf-16be" | "gb18030";
21
+ /** 警告码(与 api/120-api-decompose.md 的 warnings 表逐字一致) */
22
+ export declare const SPLIT_WARNING_CODES: readonly ["NUMBERING_RESTART", "LONG_BLOCK", "DUPLICATE_MERGED", "TOC_DROPPED", "FALLBACK_EQUAL_SPLIT"];
23
+ export type SplitWarningCode = (typeof SPLIT_WARNING_CODES)[number];
24
+ export interface SplitWarning {
25
+ code: SplitWarningCode;
26
+ message: string;
27
+ }
28
+ /** 章(文件位置序);charCount = 该段原文(含标题行)的字数——**近似计数,不是切片长**(§3.4) */
29
+ export interface SplitChapter {
30
+ index: number;
31
+ title: string;
32
+ charCount: number;
33
+ volumeIndex: number;
34
+ }
35
+ /**
36
+ * 章原文切片:归一化文本上的真实偏移 `[start, end)`(含标题行)。
37
+ * 消费方要裁章文本必须用它,**不得拿 `charCount` 当偏移量**(近似计数:正常路径 trim 掉分隔换行、
38
+ * 退化路径不含空行分隔符 ⇒ 当偏移量会错位或丢字符,§3.4)。
39
+ */
40
+ export interface SplitChapterSlice {
41
+ index: number;
42
+ title: string;
43
+ start: number;
44
+ end: number;
45
+ }
46
+ /** 切分结果 + 切片几何(`text` = 归一化文本,切片即其上的偏移) */
47
+ export interface SplitWithSlices {
48
+ result: SplitResult;
49
+ text: string;
50
+ slices: SplitChapterSlice[];
51
+ }
52
+ export interface SplitVolume {
53
+ index: number;
54
+ title: string;
55
+ }
56
+ export interface SplitStats {
57
+ min: number;
58
+ median: number;
59
+ max: number;
60
+ }
61
+ export interface SplitResult {
62
+ encoding: NovelEncoding;
63
+ totalChars: number;
64
+ chapters: SplitChapter[];
65
+ volumes: SplitVolume[];
66
+ stats: SplitStats;
67
+ warnings: SplitWarning[];
68
+ }
69
+ /**
70
+ * 字节层:BOM 探测(UTF-8 / UTF-16LE / UTF-16BE)→ 严格 UTF-8 解码(fatal)→ 失败回退 GB18030。
71
+ * 实测样本是 GB18030(`file` 判为 ISO-8859),按 UTF-8 直读必乱码。TextDecoder 原生支持,不引入依赖。
72
+ */
73
+ export declare function decodeNovel(bytes: Uint8Array): {
74
+ encoding: NovelEncoding;
75
+ text: string;
76
+ };
77
+ /** 文本层:换行归一(CRLF / CR / NEL / U+2028 / U+2029 → \n)、去 BOM、行尾空白清理 */
78
+ export declare function normalizeText(raw: string): string;
79
+ /** 章字数分布(min / median / max;偶数章取中位均值 → median 可能是 x.5)。
80
+ * 预览两端点(analyze 从切分结果、plan 从库内章)**同源**:两处各算一遍必然漂移。 */
81
+ export declare function statsOf(counts: number[]): SplitStats;
82
+ /**
83
+ * 切分小说原始字节 + **章原文切片**:解码 → 归一化 → 候选扫描 → 聚合校验与修复 → 退化等分(纯函数,无 IO)。
84
+ * `slices` 与 `result.chapters` 同序同源(同一份块几何)——S1 导入正文按切片裁文本(§3.4:不得用 charCount)。
85
+ */
86
+ export declare function splitNovelWithSlices(bytes: Uint8Array): SplitWithSlices;
87
+ /** 切分小说原始字节(切分结果本身;切片见 `splitNovelWithSlices`——纯函数,无 IO) */
88
+ export declare function splitNovel(bytes: Uint8Array): SplitResult;
89
+ //# sourceMappingURL=split.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"split.d.ts","sourceRoot":"","sources":["../../src/decompose/split.ts"],"names":[],"mappings":"AAMA,wDAAwD;AACxD,eAAO,MAAM,qBAAqB,KAAK,CAAC;AACxC,yCAAyC;AACzC,eAAO,MAAM,qBAAqB,KAAK,CAAC;AACxC,6BAA6B;AAC7B,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,uBAAuB;AACvB,eAAO,MAAM,kBAAkB,IAAI,CAAC;AACpC,qCAAqC;AACrC,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,oBAAoB;AACpB,eAAO,MAAM,iBAAiB,IAAI,CAAC;AACnC,mBAAmB;AACnB,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,kBAAkB;AAClB,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAChD,+BAA+B;AAC/B,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,2DAA2D;AAC3D,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,WAAW,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAAC;AAExF,uDAAuD;AACvD,eAAO,MAAM,mBAAmB,yGAMtB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,gBAAgB,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED,2CAA2C;AAC3C,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,WAAW,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,iBAAiB,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,aAAa,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,YAAY,EAAE,CAAC;CAC1B;AAuBD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG;IAAE,QAAQ,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CASxF;AAED,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQjD;AA+cD;sDACsD;AACtD,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,UAAU,CAMpD;AA2ED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,UAAU,GAAG,eAAe,CAsBvE;AAED,4DAA4D;AAC5D,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAEzD"}
@@ -0,0 +1,556 @@
1
+ // 章节切分(拆解 S0 解析层)。
2
+ // 契约:docs/design/60-decompose.md §3——四层 = 归一化(编码探测 + 换行归一)→ 候选扫描 →
3
+ // 聚合校验与修复(规则融合 + 结构性评分 / 相邻重复合并 / 微段修复 / 超长块回扫 / 卷首目录页启发式)→ 退化等分。
4
+ // 纯函数、无 IO:字节入口 splitNovel 接受 Uint8Array,其余全部在字符串上工作。
5
+ // 阈值单一定义在本模块并导出;散文与注释只引用常量名,不复述数字。
6
+ /** 标题行长度上限(strip 后):实测样本的合法标题都远短于此,超限候选为 0 ⇒ 只保留长度护栏 */
7
+ export const SPLIT_TITLE_MAX_CHARS = 40;
8
+ /** 相邻重复标题合并的最大间距(实测形态 = 裸行标题紧跟缩进的同名行) */
9
+ export const SPLIT_DUP_MERGE_CHARS = 60;
10
+ /** 微段修复阈值(同时是退化路径的中位段长下限) */
11
+ export const SPLIT_MIN_SEGMENT_CHARS = 200;
12
+ /** 判「有章节结构」所需的最少候选数 */
13
+ export const SPLIT_MIN_CHAPTERS = 5;
14
+ /** 超长块判定系数:段长 > 该系数 × 中位段长 ⇒ 回扫补漏 */
15
+ export const SPLIT_LONG_BLOCK_FACTOR = 3;
16
+ /** 目录页判定:连续命中数下限 */
17
+ export const SPLIT_TOC_RUN_MIN = 6;
18
+ /** 目录页判定:单节长度上限 */
19
+ export const SPLIT_TOC_SEGMENT_CHARS = 120;
20
+ /** 退化等分的每份目标字数 */
21
+ export const SPLIT_FALLBACK_TARGET_CHARS = 4000;
22
+ /** 分卷时卷标记数上限(超出 ⇒ 单卷「全书」兜底) */
23
+ export const SPLIT_MAX_VOLUMES = 30;
24
+ /** 警告码(与 api/120-api-decompose.md 的 warnings 表逐字一致) */
25
+ export const SPLIT_WARNING_CODES = [
26
+ "NUMBERING_RESTART",
27
+ "LONG_BLOCK",
28
+ "DUPLICATE_MERGED",
29
+ "TOC_DROPPED",
30
+ "FALLBACK_EQUAL_SPLIT",
31
+ ];
32
+ /** 单卷兜底(root 仅接纳卷、章只能挂卷 ⇒ 无卷标记时也必须有一卷) */
33
+ const SINGLE_VOLUME = { index: 0, title: "全书" };
34
+ /** 首个章标记之前的内容成章(作者自述/简介/水印,参与抽取) */
35
+ const PREAMBLE_TITLE = "前言";
36
+ /** 评分维度数:序号连续性 / 长度均匀度 / 数量合理性 / marker 一致性 / 覆盖率(等权) */
37
+ const SCORE_DIMENSIONS = 5;
38
+ // ── 第一层 · 归一化 ────────────────────────────────────────────────────────────
39
+ const BOM_UTF8 = [0xef, 0xbb, 0xbf];
40
+ const BOM_UTF16LE = [0xff, 0xfe];
41
+ const BOM_UTF16BE = [0xfe, 0xff];
42
+ function startsWithBytes(bytes, prefix) {
43
+ return prefix.every((byte, position) => bytes[position] === byte);
44
+ }
45
+ function decode(bytes, encoding, fatal = false) {
46
+ return new TextDecoder(encoding, { fatal }).decode(bytes);
47
+ }
48
+ /**
49
+ * 字节层:BOM 探测(UTF-8 / UTF-16LE / UTF-16BE)→ 严格 UTF-8 解码(fatal)→ 失败回退 GB18030。
50
+ * 实测样本是 GB18030(`file` 判为 ISO-8859),按 UTF-8 直读必乱码。TextDecoder 原生支持,不引入依赖。
51
+ */
52
+ export function decodeNovel(bytes) {
53
+ if (startsWithBytes(bytes, BOM_UTF8))
54
+ return { encoding: "utf-8-bom", text: decode(bytes, "utf-8") };
55
+ if (startsWithBytes(bytes, BOM_UTF16LE))
56
+ return { encoding: "utf-16le", text: decode(bytes, "utf-16le") };
57
+ if (startsWithBytes(bytes, BOM_UTF16BE))
58
+ return { encoding: "utf-16be", text: decode(bytes, "utf-16be") };
59
+ try {
60
+ return { encoding: "utf-8", text: decode(bytes, "utf-8", true) };
61
+ }
62
+ catch {
63
+ return { encoding: "gb18030", text: decode(bytes, "gb18030") };
64
+ }
65
+ }
66
+ /** 文本层:换行归一(CRLF / CR / NEL / U+2028 / U+2029 → \n)、去 BOM、行尾空白清理 */
67
+ export function normalizeText(raw) {
68
+ return raw
69
+ .replace(/^\uFEFF/u, "")
70
+ .replace(/\r\n|[\r\u0085\u2028\u2029]/gu, "\n")
71
+ .split("\n")
72
+ .map((line) => line.replace(/[ \t\u3000]+$/u, ""))
73
+ .join("\n")
74
+ .trim();
75
+ }
76
+ /** 编号数字:阿拉伯数字 + 中文数字(含「两」「〇」) */
77
+ const NUMBER_TOKEN = "[0-9〇零一二三四五六七八九十百千两]+";
78
+ function chapterRule(id, source, flags = "") {
79
+ return { id, source, line: new RegExp(`^\\s*${source}`, flags), inline: new RegExp(source, `${flags}g`) };
80
+ }
81
+ /** 模式表按优先级:章/回/节 → 卷(单独扫描)→ 前置关键词 → 英文章节 */
82
+ const CHAPTER_RULES = [
83
+ chapterRule("zhang", `第\\s*(${NUMBER_TOKEN})\\s*章`),
84
+ chapterRule("hui", `第\\s*(${NUMBER_TOKEN})\\s*回`),
85
+ chapterRule("jie", `第\\s*(${NUMBER_TOKEN})\\s*节`),
86
+ chapterRule("keyword", "(序章|楔子|前言|引子|尾声|终章|后记|番外|外传|附录)"),
87
+ chapterRule("english", "((?:chapter|section)\\s+(?:[0-9]+|[ivxlcdm]+))", "i"),
88
+ // 「数字编号式」(`1. 标题`) 有意不作规则:实测命中全是正文列表项(`1,同行者:…`),0 真阳——不要顺手加上
89
+ ];
90
+ const VOLUME_LINE_PATTERN = new RegExp(`^\\s*第\\s*(${NUMBER_TOKEN})\\s*(卷|部|篇)`);
91
+ /**
92
+ * 计数词歧义排除:「第三部分 / 第三节课 / 第三部队 / 第三集合」这类「第 N + 计数词」不是标记。
93
+ * 只在后续字紧跟标记字(无间隔)时排除,正常标题(`第一章 相遇`)不受影响。
94
+ */
95
+ const AMBIGUOUS_FOLLOWER_CHARS = new Set(["分", "课", "队", "合"]);
96
+ const CHINESE_DIGITS = {
97
+ 〇: 0, 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9,
98
+ };
99
+ const CHINESE_UNITS = { 十: 10, 百: 100, 千: 1000 };
100
+ /** 编号解析:仅用于提示与评分,永不参与排序 */
101
+ function parseNumberToken(token) {
102
+ if (/^[0-9]+$/.test(token))
103
+ return Number(token);
104
+ let total = 0;
105
+ let digit = null;
106
+ for (const char of token) {
107
+ const plain = CHINESE_DIGITS[char];
108
+ if (plain !== undefined) {
109
+ digit = plain;
110
+ continue;
111
+ }
112
+ const unit = CHINESE_UNITS[char];
113
+ if (unit === undefined)
114
+ return null;
115
+ total += (digit ?? 1) * unit;
116
+ digit = null;
117
+ }
118
+ return total + (digit ?? 0);
119
+ }
120
+ function indexText(text) {
121
+ const lines = text.split("\n");
122
+ const starts = [];
123
+ let offset = 0;
124
+ for (const line of lines) {
125
+ starts.push(offset);
126
+ offset += line.length + 1;
127
+ }
128
+ return { text, lines, starts };
129
+ }
130
+ /** 分隔符(编号与标题之间)+ 前导全角空格 */
131
+ const TITLE_SEPARATOR = /^[\s\u3000::、,.,。\-—]+/u;
132
+ /** 标题清洗:去编号前缀与前导全角空格;剥离后为空 → 保留原文行(如「第三十四章」) */
133
+ function cleanTitle(line, markerEnd) {
134
+ const rest = line.slice(markerEnd).replace(TITLE_SEPARATOR, "").trim();
135
+ return rest.length > 0 ? rest : line.trim();
136
+ }
137
+ function matchChapterRule(line, spec) {
138
+ const match = spec.line.exec(line);
139
+ if (!match)
140
+ return null;
141
+ const end = match[0].length;
142
+ const numbered = spec.id !== "keyword" && spec.id !== "english";
143
+ if (numbered && AMBIGUOUS_FOLLOWER_CHARS.has(line[end] ?? ""))
144
+ return null;
145
+ const token = spec.id === "keyword" ? null : match[1];
146
+ return { number: token === null ? null : parseNumberToken(token), end };
147
+ }
148
+ /**
149
+ * 逐行扫描:只考虑 strip 后长度 ≤ SPLIT_TITLE_MAX_CHARS 的行(实测校准:不加标点护栏——合法标题以标点结尾极常见;
150
+ * 也不引入全角缩进判据——正文普遍缩进,重复标题改由相邻重复合并处理)。
151
+ * 同行既匹配卷又匹配章 → 按卷处理(卷优先)。
152
+ */
153
+ function scanMarkers(index) {
154
+ const candidates = [];
155
+ const volumes = [];
156
+ index.lines.forEach((line, lineIndex) => {
157
+ const trimmed = line.trim();
158
+ if (trimmed.length === 0 || trimmed.length > SPLIT_TITLE_MAX_CHARS)
159
+ return;
160
+ const start = index.starts[lineIndex];
161
+ const key = trimmed.replace(/\s/gu, "");
162
+ const volume = VOLUME_LINE_PATTERN.exec(line);
163
+ if (volume && !AMBIGUOUS_FOLLOWER_CHARS.has(line[volume[0].length] ?? "")) {
164
+ volumes.push({ start, title: cleanTitle(line, volume[0].length) });
165
+ return;
166
+ }
167
+ for (const spec of CHAPTER_RULES) {
168
+ const match = matchChapterRule(line, spec);
169
+ if (!match)
170
+ continue;
171
+ candidates.push({ rule: spec.id, number: match.number, lineIndex, start, title: cleanTitle(line, match.end), key });
172
+ return;
173
+ }
174
+ });
175
+ return { candidates, volumes };
176
+ }
177
+ // ── 第三层 · 规则融合 + 结构性评分 ────────────────────────────────────────────
178
+ function clamp01(value) {
179
+ return Math.min(1, Math.max(0, value));
180
+ }
181
+ /** 序号连续性:编号已知的相邻候选里严格递增的比例 */
182
+ function numberingContinuity(candidates) {
183
+ const numbers = candidates.map((candidate) => candidate.number).filter((number) => number !== null);
184
+ if (numbers.length < 2)
185
+ return 0;
186
+ let increasing = 0;
187
+ for (let position = 1; position < numbers.length; position++) {
188
+ if (numbers[position] > numbers[position - 1])
189
+ increasing++;
190
+ }
191
+ return increasing / (numbers.length - 1);
192
+ }
193
+ /** 长度均匀度:1 - 变异系数(段长越均匀越接近 1) */
194
+ function lengthUniformity(lengths) {
195
+ const mean = lengths.reduce((sum, length) => sum + length, 0) / lengths.length;
196
+ if (mean === 0)
197
+ return 0;
198
+ const variance = lengths.reduce((sum, length) => sum + (length - mean) ** 2, 0) / lengths.length;
199
+ return clamp01(1 - Math.sqrt(variance) / mean);
200
+ }
201
+ function scoreCandidates(own, allCount, index, totalChars) {
202
+ const lengths = segmentLengths(index, own);
203
+ const coverage = lengths.reduce((sum, length) => sum + length, 0) / totalChars;
204
+ return (numberingContinuity(own) +
205
+ lengthUniformity(lengths) +
206
+ // 数量合理性:达到 SPLIT_MIN_CHAPTERS 即饱和
207
+ clamp01(own.length / SPLIT_MIN_CHAPTERS) +
208
+ // marker 一致性:该规则命中占全部章标记命中的比例(中文计数词混排 ⇒ 低分)
209
+ own.length / allCount +
210
+ clamp01(coverage)) / SCORE_DIMENSIONS;
211
+ }
212
+ /**
213
+ * 规则融合第一步:章/回/节/关键词/英文各自切一遍,按结构性评分选最优主规则。
214
+ * 中文小说的计数词不固定(这本用「章」、那本用「回」),单条规则调参必然在某类书上翻车。
215
+ * 同分取模式表先者(严格大于才替换)。
216
+ */
217
+ function pickChapterRule(candidates, index, totalChars) {
218
+ const scores = CHAPTER_RULES.map((spec) => {
219
+ const own = candidates.filter((candidate) => candidate.rule === spec.id);
220
+ return { spec, score: own.length === 0 ? 0 : scoreCandidates(own, candidates.length, index, totalChars) };
221
+ });
222
+ return scores.reduce((best, current) => (current.score > best.score ? current : best), scores[0]).spec;
223
+ }
224
+ // ── 段几何 ───────────────────────────────────────────────────────────────────
225
+ function segmentEnd(index, candidates, position) {
226
+ return position + 1 < candidates.length ? candidates[position + 1].start : index.text.length;
227
+ }
228
+ function segmentLength(index, from, to) {
229
+ return index.text.slice(from, to).trim().length;
230
+ }
231
+ function segmentLengths(index, candidates) {
232
+ return candidates.map((candidate, position) => segmentLength(index, candidate.start, segmentEnd(index, candidates, position)));
233
+ }
234
+ // ── 第三层 · 聚合校验与修复 ───────────────────────────────────────────────────
235
+ /** 相邻重复合并:相邻候选归一化后相同且间距 < SPLIT_DUP_MERGE_CHARS → 合并(取后者为起点) */
236
+ function mergeAdjacentDuplicates(index, candidates) {
237
+ const kept = [];
238
+ let merged = 0;
239
+ for (const candidate of candidates) {
240
+ const previous = kept[kept.length - 1];
241
+ if (previous && previous.key === candidate.key && segmentLength(index, previous.start, candidate.start) < SPLIT_DUP_MERGE_CHARS) {
242
+ kept[kept.length - 1] = candidate;
243
+ merged++;
244
+ continue;
245
+ }
246
+ kept.push(candidate);
247
+ }
248
+ return { kept, merged };
249
+ }
250
+ /** 卷首目录页启发式:连续 ≥ SPLIT_TOC_RUN_MIN 个命中且每节 < SPLIT_TOC_SEGMENT_CHARS → 判目录页丢弃 */
251
+ function dropTocRuns(index, candidates) {
252
+ const lengths = segmentLengths(index, candidates);
253
+ const dropped = new Set();
254
+ let runStart = 0;
255
+ while (runStart < candidates.length) {
256
+ let runEnd = runStart;
257
+ while (runEnd < candidates.length && lengths[runEnd] < SPLIT_TOC_SEGMENT_CHARS)
258
+ runEnd++;
259
+ if (runEnd - runStart >= SPLIT_TOC_RUN_MIN) {
260
+ for (let position = runStart; position < runEnd; position++)
261
+ dropped.add(position);
262
+ }
263
+ runStart = Math.max(runEnd, runStart + 1);
264
+ }
265
+ return { kept: candidates.filter((_, position) => !dropped.has(position)), dropped: dropped.size };
266
+ }
267
+ /**
268
+ * 微段修复:间距 < SPLIT_MIN_SEGMENT_CHARS → 并入前一段(该候选判误命中丢弃,其文本随前一段),
269
+ * 并入后仍过短 → 继续上溯同类候选一并丢弃。段尾到文件末同理。文本不丢:只丢切点。
270
+ */
271
+ function repairMicroSegments(index, candidates) {
272
+ const kept = [];
273
+ for (const candidate of candidates) {
274
+ while (kept.length > 0 && segmentLength(index, kept[kept.length - 1].start, candidate.start) < SPLIT_MIN_SEGMENT_CHARS) {
275
+ kept.pop();
276
+ }
277
+ kept.push(candidate);
278
+ }
279
+ while (kept.length > 0 && segmentLength(index, kept[kept.length - 1].start, index.text.length) < SPLIT_MIN_SEGMENT_CHARS) {
280
+ kept.pop();
281
+ }
282
+ return kept;
283
+ }
284
+ function buildBlocks(index, candidates) {
285
+ return candidates.map((candidate, position) => ({
286
+ title: candidate.title,
287
+ start: candidate.start,
288
+ end: segmentEnd(index, candidates, position),
289
+ }));
290
+ }
291
+ function blockLength(index, block) {
292
+ return segmentLength(index, block.start, block.end);
293
+ }
294
+ /**
295
+ * 超长块回扫:段长 > SPLIT_LONG_BLOCK_FACTOR × 中位段长 → 用更宽松的规则(允许行内出现、任意缩进)回扫补漏。
296
+ * 补不到不静默处理:降级为预览里的「疑似合并章」提示。
297
+ */
298
+ function splitLongBlock(index, block, spec) {
299
+ const firstBreak = index.text.indexOf("\n", block.start);
300
+ if (firstBreak === -1 || firstBreak >= block.end)
301
+ return [block];
302
+ const bodyStart = firstBreak + 1;
303
+ const cuts = [];
304
+ for (const match of index.text.slice(bodyStart, block.end).matchAll(spec.inline)) {
305
+ const markerStart = bodyStart + (match.index ?? 0);
306
+ if (AMBIGUOUS_FOLLOWER_CHARS.has(index.text[markerStart + match[0].length] ?? ""))
307
+ continue;
308
+ const lineStart = index.text.lastIndexOf("\n", markerStart) + 1;
309
+ const breakAfter = index.text.indexOf("\n", markerStart);
310
+ const lineEnd = breakAfter === -1 || breakAfter > block.end ? block.end : breakAfter;
311
+ const title = cleanTitle(index.text.slice(lineStart, lineEnd), markerStart - lineStart + match[0].length);
312
+ cuts.push({ title, start: markerStart, end: block.end });
313
+ }
314
+ if (cuts.length === 0)
315
+ return [block];
316
+ return [
317
+ { ...block, end: cuts[0].start },
318
+ ...cuts.map((cut, position) => ({ ...cut, end: cuts[position + 1]?.start ?? block.end })),
319
+ ];
320
+ }
321
+ function rescanLongBlocks(index, blocks, spec,
322
+ /** 章号位移:警告里的章号 = 该章在返回 chapters 里的 index(「前言」章插到首位时为 1) */
323
+ chapterOffset) {
324
+ if (blocks.length === 0)
325
+ return { blocks, warnings: [] };
326
+ const median = statsOf(blocks.map((block) => blockLength(index, block))).median;
327
+ const threshold = SPLIT_LONG_BLOCK_FACTOR * median;
328
+ const warnings = [];
329
+ const output = [];
330
+ blocks.forEach((block, position) => {
331
+ if (blockLength(index, block) <= threshold) {
332
+ output.push(block);
333
+ return;
334
+ }
335
+ // 关键词规则不做行内回扫:关键词可出现在正文里(「前言不搭后语」),错拆比提示更糟
336
+ const found = spec.id === "keyword" ? [block] : splitLongBlock(index, block, spec);
337
+ if (found.length > 1) {
338
+ output.push(...found);
339
+ return;
340
+ }
341
+ warnings.push({
342
+ code: "LONG_BLOCK",
343
+ message: `第 ${position + chapterOffset + 1} 章「${block.title}」疑似合并章:长度超过中位段长的 ${SPLIT_LONG_BLOCK_FACTOR} 倍且块内未找到章标记`,
344
+ });
345
+ output.push(block);
346
+ });
347
+ return { blocks: output, warnings };
348
+ }
349
+ /** 编号校验只产提示(非递增点计数);编号永不参与排序——章序 = 文件位置序 */
350
+ function countNumberingRestarts(candidates) {
351
+ let restarts = 0;
352
+ let previous = null;
353
+ for (const candidate of candidates) {
354
+ if (candidate.number === null)
355
+ continue;
356
+ if (previous !== null && candidate.number <= previous)
357
+ restarts++;
358
+ previous = candidate.number;
359
+ }
360
+ return restarts;
361
+ }
362
+ /** 段落边界(空行)优先于换行:拆出装箱单元(计数与位置都来自同一遍扫描,不再分头算) */
363
+ function fallbackBlocks(text) {
364
+ const paragraphs = [];
365
+ const separator = /\n{2,}/g;
366
+ let cursor = 0;
367
+ for (let match = separator.exec(text); match !== null; match = separator.exec(text)) {
368
+ paragraphs.push({ length: match.index - cursor, start: cursor, end: match.index });
369
+ cursor = match.index + match[0].length;
370
+ }
371
+ paragraphs.push({ length: text.length - cursor, start: cursor, end: text.length });
372
+ const blocks = [];
373
+ for (const paragraph of paragraphs) {
374
+ if (paragraph.length <= SPLIT_FALLBACK_TARGET_CHARS) {
375
+ blocks.push(paragraph);
376
+ continue;
377
+ }
378
+ // 超长段落再按行拆:行位置在段落内按行长 + 1 个换行符累加
379
+ let lineStart = paragraph.start;
380
+ for (const line of text.slice(paragraph.start, paragraph.end).split("\n")) {
381
+ blocks.push({ length: line.length, start: lineStart, end: lineStart + line.length });
382
+ lineStart += line.length + 1;
383
+ }
384
+ }
385
+ return blocks;
386
+ }
387
+ /**
388
+ * 退化等分的每份(段落边界优先于换行;贪心装箱到 SPLIT_FALLBACK_TARGET_CHARS):
389
+ * `length` = 计数(charCount,与旧实现逐字同口径),`start`/`end` = 真实切片位置。
390
+ * 两者在退化路径上**本来就不等**(计数不含空行分隔符,§3.4)——切片按位置、计数按旧口径。
391
+ */
392
+ function fallbackParts(text) {
393
+ const parts = [];
394
+ let size = 0;
395
+ let start = 0;
396
+ let end = 0;
397
+ for (const block of fallbackBlocks(text)) {
398
+ if (size >= SPLIT_FALLBACK_TARGET_CHARS) {
399
+ parts.push({ length: size, start, end });
400
+ size = 0;
401
+ }
402
+ // 分隔换行只存在于块之间:每份的首块不补换行,否则末尾会多出 1 个并不存在的字符(charCount > totalChars)
403
+ if (size === 0) {
404
+ start = block.start;
405
+ size = block.length;
406
+ }
407
+ else {
408
+ size += block.length + 1;
409
+ }
410
+ end = block.end;
411
+ }
412
+ if (size > 0)
413
+ parts.push({ length: size, start, end });
414
+ // 切片首尾相接:每份的 `end` 取下一份的 `start`(块之间的空行分隔符归前一份——
415
+ // 与正常路径「切片到下一章起点」同口径),否则份与份之间的分隔符会落在所有切片之外。
416
+ for (let position = 0; position < parts.length - 1; position++) {
417
+ parts[position].end = parts[position + 1].start;
418
+ }
419
+ return parts;
420
+ }
421
+ function fallbackEqualSplit(text, encoding) {
422
+ const parts = fallbackParts(text);
423
+ const blocks = parts.map((part, position) => ({
424
+ title: `第${position + 1}部分`,
425
+ start: part.start,
426
+ end: part.end,
427
+ }));
428
+ const chapters = parts.map((part, position) => ({
429
+ index: position + 1,
430
+ title: blocks[position].title,
431
+ charCount: part.length,
432
+ volumeIndex: 0,
433
+ }));
434
+ return {
435
+ result: {
436
+ encoding,
437
+ totalChars: text.length,
438
+ chapters,
439
+ volumes: [SINGLE_VOLUME],
440
+ stats: statsOf(chapters.map((chapter) => chapter.charCount)),
441
+ warnings: [
442
+ {
443
+ code: "FALLBACK_EQUAL_SPLIT",
444
+ message: `未检测到章节结构,已按字数等分(每份约 ${SPLIT_FALLBACK_TARGET_CHARS} 字)`,
445
+ },
446
+ ],
447
+ },
448
+ blocks,
449
+ };
450
+ }
451
+ // ── 汇总 ─────────────────────────────────────────────────────────────────────
452
+ /** 章字数分布(min / median / max;偶数章取中位均值 → median 可能是 x.5)。
453
+ * 预览两端点(analyze 从切分结果、plan 从库内章)**同源**:两处各算一遍必然漂移。 */
454
+ export function statsOf(counts) {
455
+ if (counts.length === 0)
456
+ return { min: 0, median: 0, max: 0 };
457
+ const sorted = [...counts].sort((left, right) => left - right);
458
+ const middle = sorted.length >> 1;
459
+ const median = sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
460
+ return { min: sorted[0], median, max: sorted[sorted.length - 1] };
461
+ }
462
+ /** 卷判定:卷标记数 ∈ [2, SPLIT_MAX_VOLUMES] → 分卷,否则单卷「全书」 */
463
+ function buildVolumes(markers) {
464
+ if (markers.length < 2 || markers.length > SPLIT_MAX_VOLUMES)
465
+ return [SINGLE_VOLUME];
466
+ return markers.map((marker, position) => ({ index: position, title: marker.title }));
467
+ }
468
+ function volumeIndexOf(start, volumes, markers) {
469
+ if (volumes.length <= 1)
470
+ return 0;
471
+ return Math.max(markers.filter((marker) => marker.start <= start).length - 1, 0);
472
+ }
473
+ function splitMarkedText(index, markers, encoding) {
474
+ const text = index.text;
475
+ const spec = pickChapterRule(markers.candidates, index, text.length);
476
+ // 规则融合第二步:关键词标记(序章/楔子/…)不参与计数词竞争,确定性并入主规则
477
+ const selected = markers.candidates.filter((candidate) => candidate.rule === spec.id || candidate.rule === "keyword");
478
+ const warnings = [];
479
+ const deduped = mergeAdjacentDuplicates(index, selected);
480
+ if (deduped.merged > 0) {
481
+ warnings.push({ code: "DUPLICATE_MERGED", message: `相邻重复标题合并 ${deduped.merged} 处` });
482
+ }
483
+ // 目录页判定必须早于微段修复:目录条目本来就短,晚判会被当误命中并掉,TOC_DROPPED 永不触发
484
+ const withoutToc = dropTocRuns(index, deduped.kept);
485
+ if (withoutToc.dropped > 0) {
486
+ warnings.push({ code: "TOC_DROPPED", message: `丢弃了 ${withoutToc.dropped} 段作为目录页` });
487
+ }
488
+ const repaired = repairMicroSegments(index, withoutToc.kept);
489
+ // 首个块起点在回扫前后不变(回扫只改块的 end)⇒ 提前判「前言」章,供回扫警告预留章号位移
490
+ const firstStart = repaired[0]?.start ?? 0;
491
+ const withPreamble = firstStart > 0 && segmentLength(index, 0, firstStart) > 0;
492
+ const rescanned = rescanLongBlocks(index, buildBlocks(index, repaired), spec, withPreamble ? 1 : 0);
493
+ warnings.push(...rescanned.warnings);
494
+ const lengths = rescanned.blocks.map((block) => blockLength(index, block));
495
+ if (rescanned.blocks.length < SPLIT_MIN_CHAPTERS || statsOf(lengths).median < SPLIT_MIN_SEGMENT_CHARS) {
496
+ return fallbackEqualSplit(text, encoding);
497
+ }
498
+ const restarts = countNumberingRestarts(repaired);
499
+ if (restarts > 0) {
500
+ warnings.push({ code: "NUMBERING_RESTART", message: `检测到 ${restarts} 处编号重启,疑似多卷` });
501
+ }
502
+ const volumes = buildVolumes(markers.volumes);
503
+ const blocks = withPreamble
504
+ ? [{ title: PREAMBLE_TITLE, start: 0, end: firstStart }, ...rescanned.blocks]
505
+ : rescanned.blocks;
506
+ const chapters = blocks.map((block, position) => ({
507
+ index: position + 1,
508
+ title: block.title,
509
+ charCount: blockLength(index, block),
510
+ volumeIndex: volumeIndexOf(block.start, volumes, markers.volumes),
511
+ }));
512
+ return {
513
+ result: {
514
+ encoding,
515
+ totalChars: text.length,
516
+ chapters,
517
+ volumes,
518
+ stats: statsOf(chapters.map((chapter) => chapter.charCount)),
519
+ warnings,
520
+ },
521
+ blocks,
522
+ };
523
+ }
524
+ // ── 入口 ─────────────────────────────────────────────────────────────────────
525
+ /**
526
+ * 切分小说原始字节 + **章原文切片**:解码 → 归一化 → 候选扫描 → 聚合校验与修复 → 退化等分(纯函数,无 IO)。
527
+ * `slices` 与 `result.chapters` 同序同源(同一份块几何)——S1 导入正文按切片裁文本(§3.4:不得用 charCount)。
528
+ */
529
+ export function splitNovelWithSlices(bytes) {
530
+ const { encoding, text: decoded } = decodeNovel(bytes);
531
+ const text = normalizeText(decoded);
532
+ const index = indexText(text);
533
+ if (text.length === 0) {
534
+ return {
535
+ result: { encoding, totalChars: 0, chapters: [], volumes: [SINGLE_VOLUME], stats: { min: 0, median: 0, max: 0 }, warnings: [] },
536
+ text,
537
+ slices: [],
538
+ };
539
+ }
540
+ const { result, blocks } = splitMarkedText(index, scanMarkers(index), encoding);
541
+ return {
542
+ result,
543
+ text,
544
+ slices: blocks.map((block, position) => ({
545
+ index: position + 1,
546
+ title: block.title,
547
+ start: block.start,
548
+ end: block.end,
549
+ })),
550
+ };
551
+ }
552
+ /** 切分小说原始字节(切分结果本身;切片见 `splitNovelWithSlices`——纯函数,无 IO) */
553
+ export function splitNovel(bytes) {
554
+ return splitNovelWithSlices(bytes).result;
555
+ }
556
+ //# sourceMappingURL=split.js.map