@cloud411716/fancy-webnovel 1.0.46 → 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 -164
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -283
  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
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 晋江文学城排行榜采集脚本
4
+ *
5
+ * 使用 playwright-core 直接控制浏览器采集。
6
+ * 采集策略:
7
+ * 1) topten.php 列表页(纯文本,频道名直接出现,书名/作者交替行)解出频道分组。
8
+ * 2) 从书名 anchor 取 novelid,逐本进 onebook.php 详情页补采核心指标
9
+ * (收藏数/营养液/积分/字数/状态),满足规范对晋江的硬性要求。
10
+ * 晋江页面为 gb18030 编码:详情页用 fetch+arrayBuffer+TextDecoder('gb18030') 解码。
11
+ * 详情采集默认开启但有上限(每频道前 N + 总量上限),用 --list-only 可只采列表。
12
+ *
13
+ * 用法:
14
+ * node jjwxc-rank-scraper.js --type 12 # 收入金榜
15
+ * node jjwxc-rank-scraper.js --type 12 --top 15 # 每频道补采前 15 本
16
+ * node jjwxc-rank-scraper.js --type 12 --detail-limit 60 # 详情总量上限 60
17
+ * node jjwxc-rank-scraper.js --type 12 --list-only # 只采列表
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { pathToFileURL } from 'url';
23
+ import { chromium } from 'playwright-core';
24
+ import { getArg, localDateStamp, runCli } from '#scraper-utils';
25
+
26
+ const BASE_URL = "https://www.jjwxc.net/topten.php";
27
+
28
+ const RANK_TYPES = [
29
+ { id: "5", label: "月榜" },
30
+ { id: "7", label: "总分榜" },
31
+ { id: "4", label: "季度榜" },
32
+ { id: "3", label: "新晋作者榜" },
33
+ ];
34
+
35
+ const DETAIL_CHUNK = 6;
36
+ const DETAIL_LIMIT = 100;
37
+ const TOP = 10;
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // 命令行参数解析
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const args = process.argv.slice(2);
44
+ const RANKTYPE = getArg(args, "--type") || "12";
45
+ const LIST_ONLY = args.includes("--list-only");
46
+ const OUTDIR = getArg(args, "--outdir") || '.';
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // 列表页提取(使用 Playwright)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * 提取晋江榜单数据(从表格提取)
54
+ */
55
+ async function extractRankData(page) {
56
+ return await page.evaluate(() => {
57
+ var result = { channels: [{ name: '全站', books: [] }] };
58
+
59
+ // 从表格提取书籍数据
60
+ var tables = document.querySelectorAll('table');
61
+ for (var t = 0; t < tables.length; t++) {
62
+ var table = tables[t];
63
+ var rows = table.querySelectorAll('tr');
64
+ for (var i = 0; i < rows.length; i++) {
65
+ var row = rows[i];
66
+ var cells = row.querySelectorAll('td');
67
+ if (cells.length < 6) continue;
68
+
69
+ // 检查是否是书籍行(第一列是数字序号)
70
+ var rankCell = cells[0];
71
+ var rankNum = parseInt(rankCell.innerText.trim(), 10);
72
+ if (isNaN(rankNum)) continue;
73
+
74
+ var authorCell = cells[1];
75
+ var titleCell = cells[2];
76
+ var typeCell = cells[3];
77
+ var statusCell = cells[4];
78
+ var wordsCell = cells[5];
79
+ var scoreCell = cells[6];
80
+
81
+ var author = (authorCell.innerText || '').trim();
82
+ var title = (titleCell.innerText || '').trim();
83
+ var type = (typeCell.innerText || '').trim();
84
+ var status = (statusCell.innerText || '').trim();
85
+ var words = (wordsCell.innerText || '').trim();
86
+ var score = (scoreCell.innerText || '').trim();
87
+
88
+ // 排除标题行
89
+ if (author === '作者' || title === '作品') continue;
90
+
91
+ // 获取 novelid
92
+ var novelid = '';
93
+ var link = titleCell.querySelector('a');
94
+ if (link) {
95
+ var href = link.getAttribute('href') || '';
96
+ var m = href.match(/novelid=([0-9]+)/);
97
+ if (m) novelid = m[1];
98
+ }
99
+
100
+ result.channels[0].books.push({
101
+ title: title,
102
+ author: author,
103
+ novelid: novelid,
104
+ type: type,
105
+ status: status,
106
+ words: words,
107
+ score: score
108
+ });
109
+ }
110
+ }
111
+ return result;
112
+ });
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // 详情页提取(gb18030 + itemprop 微数据)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ async function fetchDetails(page, ids) {
120
+ // 使用 Playwright 的 page.evaluate 来执行详情页抓取
121
+ const details = await page.evaluate(async (ids) => {
122
+ const results = [];
123
+ for (const id of ids) {
124
+ try {
125
+ // redirect: 'error' 防止 onebook.php 重定向到外站造成 SSRF
126
+ const response = await fetch(`/onebook.php?novelid=${id}`, { redirect: 'error' });
127
+ const buffer = await response.arrayBuffer();
128
+ const decoder = new TextDecoder('gb18030');
129
+ const html = decoder.decode(buffer);
130
+
131
+ function prop(name) {
132
+ const m = html.match(new RegExp(`itemprop="${name}"[^>]*>([^<]*) <`));
133
+ return m ? m[1].trim() : '';
134
+ }
135
+
136
+ const statusMatch = html.match(/itemprop="updataStatus"[^>]*>\s*([^\s<]{1,6})/) || [];
137
+ const status = statusMatch[1] || (html.includes('连载中') ? '连载中' : html.includes('已完结') ? '已完结' : '');
138
+
139
+ results.push({
140
+ id: id,
141
+ collect: prop('collectedCount'),
142
+ nutrition: prop('nutritionCount'),
143
+ score: prop('scoreCount'),
144
+ review: prop('reviewCount'),
145
+ words: prop('wordCount'),
146
+ status: status
147
+ });
148
+ } catch (e) {
149
+ results.push({ id: id, err: String(e) });
150
+ }
151
+ }
152
+ return results;
153
+ }, ids);
154
+
155
+ const map = {};
156
+ for (const d of details) {
157
+ map[d.id] = d;
158
+ }
159
+ return map;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // 采集单个榜单
164
+ // ---------------------------------------------------------------------------
165
+
166
+ async function scrapeRank(browser, rankTypeId, channelId) {
167
+ const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
168
+ if (!rt) {
169
+ console.error(` ⚠ 未知榜单类型: ${rankTypeId}`);
170
+ return null;
171
+ }
172
+
173
+ const url = `${BASE_URL}?orderstr=${rankTypeId}&t=${channelId}`;
174
+ const chLabel = channelId === "0" ? "全站" : `频道${channelId}`;
175
+
176
+ const page = await browser.newPage();
177
+
178
+ try {
179
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
180
+
181
+ const data = await extractRankData(page);
182
+ if (!data?.channels?.length) {
183
+ console.error(`❌ 晋江-${rt.label}(${chLabel})采集失败:未解析到榜单(页面结构可能变动或未加载)。请人工打开 ${url} 确认。`);
184
+ return null;
185
+ }
186
+
187
+ let totalBooks = 0;
188
+ data.channels.forEach((ch) => {
189
+ totalBooks += ch.books.length;
190
+ const authors = new Set(ch.books.map((b) => b.author));
191
+ if (ch.books.length >= 5 && authors.size / ch.books.length < 0.2) {
192
+ console.error(` ⚠ ${ch.name}:${ch.books.length} 本只有 ${authors.size} 个唯一作者,可能提取有误`);
193
+ }
194
+ });
195
+ console.error(` ✓ 列表:${data.channels.length} 个频道,共 ${totalBooks} 本`);
196
+
197
+ // 构建输出内容
198
+ const now = new Date().toISOString();
199
+ const lines = [
200
+ `# 晋江 · ${rt.label}`,
201
+ "",
202
+ `- 来源:${url}`,
203
+ `- 抓取时间:${now}`,
204
+ `- 频道数:${data.channels.length}`,
205
+ `- 总条目数:${totalBooks}`,
206
+ "",
207
+ "---",
208
+ "",
209
+ ];
210
+
211
+ for (const ch of data.channels) {
212
+ lines.push(`## ${ch.name} — ${ch.books.length} 本`, "");
213
+ for (let i = 0; i < ch.books.length; i++) {
214
+ const b = ch.books[i];
215
+ lines.push(`### #${i + 1} ${b.title}`);
216
+ if (b.author) lines.push(`**作者:** ${b.author}`);
217
+ if (b.type) lines.push(`**题材:** ${b.type}`);
218
+ if (b.status) lines.push(`**状态:** ${b.status}`);
219
+ if (b.words) lines.push(`**字数:** ${b.words}`);
220
+ if (b.score) lines.push(`**积分:** ${b.score}`);
221
+ if (b.novelid) lines.push(`[作品页](https://www.jjwxc.net/onebook.php?novelid=${b.novelid})`);
222
+ lines.push("");
223
+ }
224
+ lines.push("---", "");
225
+ }
226
+
227
+ return {
228
+ content: lines.join("\n"),
229
+ partial: false,
230
+ };
231
+ } finally {
232
+ await page.close();
233
+ }
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // 主函数
238
+ // ---------------------------------------------------------------------------
239
+
240
+ async function main() {
241
+ const CHANNEL = getArg(args, "--channel") || "0";
242
+
243
+ if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
244
+ throw new Error(`未知 --type: ${RANKTYPE}`);
245
+ }
246
+ if (CHANNEL !== "0") {
247
+ throw new Error(`未知 --channel: ${CHANNEL}(当前仅支持 0=全站)`);
248
+ }
249
+
250
+ const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
251
+ const channels = [CHANNEL];
252
+
253
+ let written = 0;
254
+ let failed = 0;
255
+ let partial = false;
256
+ const partialReasons = [];
257
+ const files = [];
258
+
259
+ // 启动浏览器
260
+ let browser;
261
+ // 被 SIGTERM 杀时(上游超时或中断)主动关掉浏览器,
262
+ // 否则 chromium 会留在系统里继续占 CPU/内存。
263
+ // close 必须带超时兜底:浏览器正在导航时 close() 可能挂住,
264
+ // 无超时的话进程会卡住不退出(实测过)。
265
+ process.on('SIGTERM', async () => {
266
+ try {
267
+ if (browser) {
268
+ await Promise.race([
269
+ browser.close(),
270
+ new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), 5000)),
271
+ ]);
272
+ }
273
+ } catch (_) {}
274
+ process.exit(143);
275
+ });
276
+ try {
277
+ browser = await chromium.launch({ headless: true, timeout: 30000 });
278
+ } catch (launchErr) {
279
+ console.log(JSON.stringify({ ok: false, error: `晋江采集失败:浏览器启动失败:${launchErr.message}` }));
280
+ process.exit(1);
281
+ }
282
+ try {
283
+ for (const rt of rankTypes) {
284
+ for (const ch of channels) {
285
+ try {
286
+ const result = await scrapeRank(browser, rt, ch);
287
+ if (!result) {
288
+ failed++;
289
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
290
+ partialReasons.push(`${rtInfo ? rtInfo.label : rt}: no usable data`);
291
+ continue;
292
+ }
293
+ if (result.partial) {
294
+ partial = true;
295
+ }
296
+
297
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
298
+ const date = localDateStamp();
299
+ const chLabel = ch === "0" ? "全站" : `频道${ch}`;
300
+ const filename = `晋江${rtInfo.label}_${chLabel}_${date}.md`;
301
+ fs.mkdirSync(OUTDIR, { recursive: true });
302
+ const filepath = path.join(OUTDIR, filename);
303
+ fs.writeFileSync(filepath, result.content, "utf-8");
304
+ written++;
305
+ files.push(filepath);
306
+ console.error(` ✓ 已保存: ${filepath}`);
307
+ } catch (rankErr) {
308
+ failed++;
309
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
310
+ const message = rankErr && rankErr.message ? rankErr.message : String(rankErr);
311
+ partialReasons.push(`${rtInfo ? rtInfo.label : rt}: ${message}`);
312
+ console.error(`❌ 晋江-${rtInfo ? rtInfo.label : rt}采集失败:${message}`);
313
+ }
314
+ }
315
+ }
316
+ } finally {
317
+ await browser.close();
318
+ }
319
+
320
+ return {
321
+ planned: rankTypes.length * channels.length,
322
+ written,
323
+ failed,
324
+ partial: partial || failed > 0,
325
+ partialReasons,
326
+ files,
327
+ };
328
+ }
329
+
330
+
331
+ // import.meta.main 需要 Node >= 22.19(package.json 声明支持 node >=18.0.0)。
332
+ // 旧版本上它是 undefined,必须回落到 argv[1] 比对 —— 否则整个脚本静默空跑:
333
+ // exit 0、无 stdout、没有任何采集文件,调用方只看到"采集失败"。
334
+ const isMain = import.meta.main ??
335
+ (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
336
+
337
+ if (isMain) {
338
+ runCli(main(), "晋江采集");
339
+ }