@cloud411716/fancy-webnovel 1.0.45 → 2.2.0

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 (134) hide show
  1. package/client.js +183 -0
  2. package/cordis.patch.yml +0 -14
  3. package/index.js +21 -37
  4. package/lib/chinese-num.js +111 -0
  5. package/lib/deployed-state.js +141 -0
  6. package/lib/error-codes.js +104 -0
  7. package/lib/infra.js +569 -0
  8. package/lib/llm-wiki.js +109 -0
  9. package/lib/safe-rm.js +94 -0
  10. package/lib/scraper-utils.js +165 -0
  11. package/package.json +37 -5
  12. package/plugins/fancy-analyze/command-input.js +65 -0
  13. package/plugins/fancy-analyze/index.js +973 -0
  14. package/plugins/fancy-analyze/schema-analyze.md +379 -0
  15. package/plugins/fancy-analyze/schema-write.md +178 -0
  16. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  17. package/plugins/fancy-analyze/summary-progress.js +431 -0
  18. package/plugins/fancy-analyze/templates.js +881 -0
  19. package/plugins/fancy-bootstrap/index.js +168 -0
  20. package/plugins/fancy-bootstrap/scripts/init.js +143 -0
  21. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  22. package/plugins/fancy-bootstrap/templates.js +54 -0
  23. package/plugins/fancy-import/import-core.js +1118 -0
  24. package/plugins/fancy-import/import-driver.js +581 -0
  25. package/plugins/fancy-import/index.js +730 -0
  26. package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
  27. package/plugins/fancy-import/templates.js +49 -0
  28. package/plugins/fancy-scan/index.js +319 -0
  29. package/plugins/fancy-scan/scripts/run-scan.js +520 -0
  30. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
  31. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
  32. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
  33. package/plugins/fancy-scan/templates.js +295 -0
  34. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  35. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  36. package/plugins/fancy-story-builder/flesh-output.js +155 -0
  37. package/plugins/fancy-story-builder/index.js +3152 -0
  38. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
  39. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
  40. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
  41. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
  42. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  43. package/plugins/fancy-story-builder/purpose-template.md +107 -0
  44. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  45. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
  46. package/plugins/fancy-story-builder/references/glossary.md +585 -0
  47. package/plugins/fancy-story-builder/references/index.md +490 -0
  48. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  49. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  50. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  51. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  52. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  53. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  54. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  55. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  56. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  57. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  58. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  59. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  60. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  61. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  62. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  63. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  64. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  65. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  66. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
  67. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  68. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
  69. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  70. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
  71. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  72. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
  73. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  74. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
  75. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  76. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  77. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  78. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  79. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
  80. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  81. package/plugins/fancy-story-builder/schema-setting.md +324 -0
  82. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  83. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  84. package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
  85. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  86. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  87. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
  88. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  89. package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
  90. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  91. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  92. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  93. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
  94. package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
  95. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  96. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
  97. package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
  98. package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
  99. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  100. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  101. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  102. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  103. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  104. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
  105. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  106. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  107. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  108. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +66 -0
  109. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +76 -0
  110. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +72 -0
  111. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +37 -0
  112. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +53 -0
  113. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +40 -0
  114. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +79 -0
  115. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +132 -0
  116. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +133 -0
  117. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +160 -0
  118. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  119. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +134 -0
  120. package/plugins/fancy-story-builder/templates.js +1248 -0
  121. package/plugins/fancy-story-builder/wiki-references.js +204 -0
  122. package/plugins/fancy-story-builder/writing-driver.js +1061 -0
  123. package/plugins/fancy-topic/index.js +704 -0
  124. package/plugins/fancy-topic/scripts/select-topic.js +274 -0
  125. package/plugins/fancy-topic/templates.js +373 -0
  126. package/commands/bootstrap/index.js +0 -160
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -275
  129. package/commands/scan/platforms/fanqie.js +0 -146
  130. package/commands/scan/platforms/jinjiang.js +0 -143
  131. package/commands/scan/platforms/qidian.js +0 -102
  132. package/commands/scan/platforms/qimao.js +0 -127
  133. package/commands/scan/scraper.js +0 -195
  134. package/infra.js +0 -160
package/lib/safe-rm.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 沙箱下 `rm` 挂死的一次性提示(bundle 级,所有子插件共用)。
3
+ *
4
+ * ## 为什么要有这段提示
5
+ *
6
+ * 实测(2026-10-23,DSH web + Landlock 沙箱):
7
+ *
8
+ * 1. 本机的 `/bin/rm` 和 `/usr/bin/rm` 都被替换成了 trash-cli 的 `trash-put`
9
+ * (`ls -l /bin/rm` → `/usr/bin/trash-put`)—— 删除不是真删,是移入回收站,
10
+ * 同时要在 `~/.local/share/Trash/info/` 写一份 `.trashinfo` 元数据。
11
+ * 2. DSH 的 bash 跑在 Landlock 沙箱里,可写白名单只有
12
+ * `/dev/null`、`/tmp` 和会话工作目录
13
+ * (`@deepseek-ai/dsh-sandbox-local/lib/index.js` 的 `landlockProfileArgs`)。
14
+ * 回收站 `~/.local/share/Trash` 不在其中。
15
+ * 3. trash-cli 写 `.trashinfo` 拿到 EACCES 后**不会报错退出**,而是换一个
16
+ * 递增后缀继续重试(`trashcli/put/janitor_tools/info_file_persister.py`
17
+ * 的 `while True` + `NeedsMoreAttempts`,只对 ENAMETOOLONG 特判)。
18
+ * → `rm` 永不返回 → 用 `&&` 串起来的整条命令挂到超时(默认 600 秒)。
19
+ *
20
+ * 复现(`landlock-run --ro / --rw /dev/null --rw /tmp --rw <cwd> -- rm -f <f>`):
21
+ * rc=124(被 timeout 杀);同一环境下 `node -e "fs.unlinkSync(...)"` rc=0。
22
+ *
23
+ * 把 `XDG_DATA_HOME` 指到 `/tmp`,回收站就落在沙箱允许写的目录里,`rm` 立刻返回。
24
+ *
25
+ * ## 为什么放在 bundle 入口,而不是各子插件自己的提示词
26
+ *
27
+ * fancy-webnovel 下每个子插件都可能让模型去删临时文件(骨架构建、拆书、
28
+ * 拆解结果清理……),逐个加提示词必然漏,而且以后新增子插件还会再漏。
29
+ * 这里注册成 DSH 的 `systemPrompt` section:**一次注册,对整个 bundle 的
30
+ * 所有会话、所有子插件生效**,模型不需要知道当前是哪个子插件。
31
+ *
32
+ * ## 顺带澄清一件事:这段文本不是 400 的元凶
33
+ *
34
+ * 曾经出现过 provider 报
35
+ * 400 messages: Validation error: message content cannot be empty
36
+ * 且报错里**含有本段文本**,很容易被误判成"是这段提示词导致的"。
37
+ * 不是。真实原因是插件往会话里塞了一条**空 content 的 user/message**:
38
+ * 它抢走了 transcript 的第一条位置,于是系统提示词(含本段文本)被降级成
39
+ * user 轮,跟空消息一起出现在报错里 —— 本段文本出现在报错里恰恰是**症状**。
40
+ * 详见 tests/cases/conversation-view.test.js 与 lib/infra.js 的文件头说明。
41
+ *
42
+ * @module @cloud411716/fancy-webnovel/safe-rm
43
+ */
44
+
45
+ /** section 名。DSH 里 section 按名去重,重名会抛错。 */
46
+ export const SAFE_RM_SECTION_NAME = 'fancy-webnovel:safe-rm';
47
+
48
+ /**
49
+ * 排序位。DSH 的 `systemPrompt.section()` 要求 order 是有限数字;
50
+ * 外部贡献可以用任意有限值,这里紧跟在 `tool:bash`(1000)之后,
51
+ * 让模型在读完 bash 工具说明后紧接着看到这条约束。
52
+ */
53
+ export const SAFE_RM_SECTION_ORDER = 1005;
54
+
55
+ /** section 正文。措辞要短、要把"为什么"讲透,否则模型会当成风格偏好而忽略。 */
56
+ export const SAFE_RM_SECTION_TEXT = [
57
+ '本机的 rm 是 trash-cli 的 trash-put:删除即移入回收站,并非真删。',
58
+ 'DSH 的文件沙箱(workspace-write)只允许写 /dev/null、/tmp 和会话工作目录,',
59
+ '回收站目录 ~/.local/share/Trash 不在其中;trash-cli 写 .trashinfo 失败后会不断换名重试,',
60
+ 'rm 永不返回,整条命令会挂到超时(默认 600 秒)。',
61
+ '删除任何文件或目录(含临时文件)时,一律给 rm 加 XDG_DATA_HOME=/tmp 前缀,',
62
+ '让回收站落在沙箱允许写的 /tmp 下,rm 才会立刻返回:',
63
+ 'XDG_DATA_HOME=/tmp rm -rf <路径>',
64
+ ].join('\n');
65
+
66
+ /**
67
+ * 注册这条提示。
68
+ *
69
+ * 用 `ctx.inject(['systemPrompt'], ...)` 惰性注册而不是直接摸 `ctx.systemPrompt`:
70
+ * 精简部署(没装 systemPrompt 服务)和单元测试的假 ctx 都不会因此炸,
71
+ * 而且 HMR 重载时 cordis 会自动 dispose / 重建。
72
+ *
73
+ * @param {object} ctx - 插件上下文
74
+ * @returns {boolean} 是否真的注册了(false = 环境里没有 systemPrompt)
75
+ */
76
+ export function registerSafeRmPrompt(ctx) {
77
+ // 没有 inject 的假 ctx(单元测试用的最小 mock)直接跳过 ——
78
+ // 一条提示词不该把整个 bundle 拖挂。
79
+ if (!ctx || typeof ctx.inject !== 'function') return false;
80
+ try {
81
+ // 不 await:systemPrompt 还没就绪时这只是挂一个待启动的子插件,
82
+ // 不能让 bundle 的加载卡在这儿。
83
+ ctx.inject(['systemPrompt'], (scope) => {
84
+ scope.systemPrompt.section({
85
+ name: SAFE_RM_SECTION_NAME,
86
+ order: SAFE_RM_SECTION_ORDER,
87
+ text: SAFE_RM_SECTION_TEXT,
88
+ });
89
+ });
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * scraper-utils.js — 采集脚本公共工具
3
+ *
4
+ * 提供各平台 scraper 公用的纯函数,无外部依赖。
5
+ */
6
+
7
+ export function getArg(args, name) {
8
+ const i = args.indexOf(name);
9
+ if (i >= 0) return i + 1 < args.length ? args[i + 1] : null;
10
+ const prefix = `${name}=`;
11
+ const inline = args.find((arg) => String(arg).startsWith(prefix));
12
+ // 显式空值(--name=)返回 '',让调用方决定兜底
13
+ return inline === undefined ? null : String(inline).slice(prefix.length);
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // 扫描方式(取数路径)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * 番茄的两种扫描方式。
22
+ *
23
+ * api —— 书名/作者/简介取自番茄自家明文接口 /api/book/info。
24
+ * 实测该接口返回与榜单页完全相同的文本(明文),零识别误差,
25
+ * 整榜耗时约为 OCR 路径的 1/5。
26
+ * ocr —— 渲染后截图 + tesseract 识别。不依赖番茄内部接口,接口失效时
27
+ * 仍能采到,但存在认字误差(实测简介字符错误率 8%~17%)。
28
+ *
29
+ * 这是插件层(templates/index)、编排层(run-scan)与 scraper 层之间的契约,
30
+ * 三处必须用同一套 id —— 不能各写各的字面量。
31
+ */
32
+ export const SCAN_MODE_API = 'api';
33
+ export const SCAN_MODE_OCR = 'ocr';
34
+ export const SCAN_MODES = [SCAN_MODE_API, SCAN_MODE_OCR];
35
+ export const DEFAULT_SCAN_MODE = SCAN_MODE_API;
36
+
37
+ /**
38
+ * 把外部传入的扫描方式规整成合法值。
39
+ *
40
+ * 非法值 / 缺失一律回落到默认(api):不能把未知值透传给 scraper ——
41
+ * scraper 遇到不认识的 mode 若既不截图也不取接口,会产出"只有排名没有
42
+ * 任何文本"的空采集,而这种失败在 receipt 上看不出来。
43
+ */
44
+ export function normalizeScanMode(raw) {
45
+ const s = String(raw == null ? '' : raw).trim().toLowerCase();
46
+ return SCAN_MODES.includes(s) ? s : DEFAULT_SCAN_MODE;
47
+ }
48
+
49
+ /**
50
+ * 归一化作品简介文本。
51
+ *
52
+ * 起点移动端详情页的 bookInfo.desc 是"文本 + 内嵌 HTML"的混合体
53
+ * (实测 25 本:56% 用 <br> 分行、36% 用 \n 分段、段首带全角缩进 U+3000),
54
+ * 部分作品末尾带【书籍标签:…】这类标签块(实测新书榜 20 本中 1 本)。
55
+ * 这里统一转成纯文本:
56
+ * - <br> 转成换行(行分隔)
57
+ * - 段落标签转成空行(段分隔)
58
+ * - 其余 HTML 标签去掉,常见实体反转义
59
+ * - 去掉行首全角缩进与行尾空白,压缩多余空行
60
+ */
61
+ export function normalizeBookIntro(raw) {
62
+ if (raw === null || raw === undefined) return '';
63
+ return String(raw)
64
+ .replace(/<br\s*\/?>/gi, '\n')
65
+ .replace(/<\/?(p|div|li|h[1-6])\b[^>]*>/gi, '\n\n')
66
+ .replace(/<[^>]+>/g, '')
67
+ .replace(/&nbsp;/gi, ' ')
68
+ .replace(/&amp;/gi, '&')
69
+ .replace(/&lt;/gi, '<')
70
+ .replace(/&gt;/gi, '>')
71
+ .replace(/&quot;/gi, '"')
72
+ .replace(/&#39;|&apos;/gi, "'")
73
+ .replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d)))
74
+ .replace(/[ \t ]+$/gm, '')
75
+ .replace(/^[ \t ]+/gm, '')
76
+ .replace(/\n{3,}/g, '\n\n')
77
+ .trim();
78
+ }
79
+
80
+ /**
81
+ * 本地日期戳 YYYYMMDD(本地时区,非 UTC)。
82
+ *
83
+ * 全仓唯一的日期实现:run-scan.js 与 fancy-scan/index.js 都用它,
84
+ * 避免同一份 topic_decision_<date>.md 在不同时区口径下算出两个名字。
85
+ */
86
+ export function localDateStamp() {
87
+ const d = new Date();
88
+ return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
89
+ }
90
+
91
+ /**
92
+ * @typedef {Object} CliOutcome
93
+ * @property {number} planned 计划处理的项数
94
+ * @property {number} written 成功处理的项数
95
+ * @property {number} failed 失败的项数
96
+ * @property {boolean} partial 是否部分成功
97
+ * @property {string[]} partialReasons 失败原因列表
98
+ * @property {string[]} [files] 生成的文件路径
99
+ */
100
+
101
+ /**
102
+ * CLI 收尾:在所有 scraper 共享的 finally 关闭 browser 资源,
103
+ * 然后用结构化 JSON 写出结果。保证进程不会因为孤儿 chromium 堆积。
104
+ *
105
+ * @param {Promise<number|CliOutcome>} promise - main() 返回的 promise
106
+ * @param {string} label - scraper 名称
107
+ * @param {Object} [opts]
108
+ * @param {() => Promise<void>} [opts.beforeExit] - 退出前的清理 hook(关 browser 等)
109
+ */
110
+ export function runCli(promise, label, opts = {}) {
111
+ const { beforeExit } = opts;
112
+ Promise.resolve()
113
+ .then(() => promise)
114
+ .then(async (result) => {
115
+ const outcome = Number.isInteger(result)
116
+ ? { planned: result, written: result, failed: 0, partial: false, partialReasons: [] }
117
+ : result;
118
+
119
+ if (!outcome || !Number.isInteger(outcome.written)) {
120
+ throw new Error("no output was written");
121
+ }
122
+ if (outcome.written < 1) {
123
+ throw new Error("no output was written");
124
+ }
125
+
126
+ // 写结构化 receipt 到 stdout
127
+ console.log(JSON.stringify(outcome));
128
+
129
+ const failed = Number.isInteger(outcome.failed) ? outcome.failed : 0;
130
+ if (outcome.partial || failed > 0) {
131
+ console.error(
132
+ `${label} partial: wrote ${outcome.written}/${outcome.planned || outcome.written + failed}`
133
+ );
134
+ }
135
+
136
+ // 先清理再退出(关键:避免进程被强制 kill 导致孤儿 chromium)
137
+ if (typeof beforeExit === 'function') {
138
+ try {
139
+ await Promise.race([
140
+ beforeExit(),
141
+ new Promise((_, reject) => setTimeout(() => reject(new Error('cleanup timeout')), 5000)),
142
+ ]);
143
+ } catch (e) {
144
+ console.error(`${label} cleanup warning: ${e.message}`);
145
+ }
146
+ }
147
+ process.exit(outcome.partial || failed > 0 ? 2 : 0);
148
+ })
149
+ .catch(async (error) => {
150
+ const msg = error && error.message ? error.message : String(error);
151
+ // 关键:即使 msg === "no output was written" 也要写出错误,否则 OOM 时一无所知
152
+ console.error(`${label} failed: ${msg}`);
153
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
154
+ // 清理资源
155
+ if (typeof beforeExit === 'function') {
156
+ try {
157
+ await Promise.race([
158
+ beforeExit(),
159
+ new Promise((_, reject) => setTimeout(() => reject(new Error('cleanup timeout')), 5000)),
160
+ ]);
161
+ } catch (_) { /* 兜底 */ }
162
+ }
163
+ process.exit(1);
164
+ });
165
+ }
package/package.json CHANGED
@@ -1,23 +1,55 @@
1
1
  {
2
2
  "name": "@cloud411716/fancy-webnovel",
3
- "version": "1.0.45",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
7
- ".": "./index.js"
7
+ ".": "./index.js",
8
+ "./client": "./client.js"
9
+ },
10
+ "imports": {
11
+ "#infra": "./lib/infra.js",
12
+ "#scraper-utils": "./lib/scraper-utils.js",
13
+ "#error-codes": "./lib/error-codes.js",
14
+ "#llm-wiki": "./lib/llm-wiki.js",
15
+ "#deployed-state": "./lib/deployed-state.js",
16
+ "#chinese-num": "./lib/chinese-num.js",
17
+ "#story-builder/*": "./plugins/fancy-story-builder/*"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ },
22
+ "scripts": {
23
+ "test": "node tests/run.js"
8
24
  },
9
25
  "files": [
10
26
  "index.js",
11
- "infra.js",
27
+ "lib/",
28
+ "templates.js",
12
29
  "cordis.patch.yml",
13
- "commands/"
30
+ "README.md",
31
+ "plugins/",
32
+ "client.js"
14
33
  ],
15
34
  "dependencies": {
16
- "playwright-core": "1.48.0"
35
+ "graphology": "^0.26.0",
36
+ "playwright-core": "^1.48.0",
37
+ "tesseract.js": "^7.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@deepseek-ai/dsh-llm": {
44
+ "optional": true
45
+ }
17
46
  },
18
47
  "dsh": {
19
48
  "bundle": {
20
49
  "patch": "./cordis.patch.yml"
50
+ },
51
+ "client": {
52
+ "platform": "web"
21
53
  }
22
54
  }
23
55
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * fancy-analyze/command-input.js —— 命令输入的归一化与子命令识别。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 用户在输入框里粘进来的内容常带零宽字符:U+200B(零宽空格)最常见,
7
+ * 还有 U+200C/U+200D(零宽连接符)、U+2060(单词连接符)、U+FEFF(BOM)、
8
+ * U+00A0(不换行空格)。
9
+ *
10
+ * 这些字符 `trim()` 不会去掉(它们不是空白字符),所以精确比较必然落空。
11
+ * 实测用户输入过 `summary-status␣summary`(中间粘了 U+200B),
12
+ * 旧代码 `lowerInput === 'summary-status'` 判不出来,
13
+ * 于是这条命令**掉进了"普通模式"(按书名拆书)** ——
14
+ * 而拆书会调 llm_wiki 的 `sources/ingest` 接口,那条路**有破坏性**。
15
+ *
16
+ * 子命令判断失败时默认掉进一个有破坏性的分支,是很糟的默认行为。
17
+ * 所以这里做两件事:
18
+ * 1. 剥掉不可见字符;
19
+ * 2. 用**前缀匹配**认子命令 —— 用户手滑多打了几个字,意图明显是那个
20
+ * 子命令,不该被当成书名去拆书。真正认不出来的仍然按书名处理
21
+ * (那才是默认用法)。
22
+ */
23
+
24
+ /** 零宽空格 / 零宽连接符 / 单词连接符 / BOM / 不换行空格。 */
25
+ const INVISIBLE_CHARS = /[\u200B-\u200D\u2060\u00A0\uFEFF]/g;
26
+
27
+ /**
28
+ * 剥掉命令输入里的不可见字符,并去首尾空白。
29
+ *
30
+ * @param {string} input - 原始输入
31
+ * @returns {string} 归一化后的输入
32
+ */
33
+ export function normalizeCommandInput(input) {
34
+ if (typeof input !== 'string') return '';
35
+ return input.replace(INVISIBLE_CHARS, '').trim();
36
+ }
37
+
38
+ /**
39
+ * 可识别的子命令。顺序有意义:前缀匹配时长的排前面,
40
+ * 否则 `summary` 会先于 `summary-status` 命中。
41
+ */
42
+ const SUBCOMMANDS = ['summary-status', 'summary', 'resummary', 'status', 'fix'];
43
+
44
+ /**
45
+ * 认子命令:归一化后按前缀匹配。
46
+ *
47
+ * @param {string} normalized - 已归一化的命令输入(小写)
48
+ * @returns {string|null} 子命令名;不是子命令时返回 null
49
+ */
50
+ export function matchSubcommand(normalized) {
51
+ if (typeof normalized !== 'string' || normalized.length === 0) return null;
52
+ // 空格当连字符:`summary status` 与 `summary-status` 是同一个命令
53
+ const flat = normalized.replace(/\s+/g, '-');
54
+ for (const name of SUBCOMMANDS) {
55
+ if (flat === name) return name;
56
+ }
57
+ // 前缀匹配是**刻意设计**(手滑多打的字也认):
58
+ // "summary-statussummary" 要能认出是 summary-status。
59
+ // 2026-10-27 复核后确认:这是有意的容错,不改
60
+ // (收紧成"必须带分隔符"会把这份容错一起收掉)。
61
+ for (const name of SUBCOMMANDS) {
62
+ if (flat.startsWith(name)) return name;
63
+ }
64
+ return null;
65
+ }