@cloud411716/fancy-webnovel 0.1.44 → 0.1.46

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud411716/fancy-webnovel",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -13,7 +13,8 @@
13
13
  */
14
14
 
15
15
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
16
- import { join } from 'path';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // 平台配置
@@ -168,104 +169,74 @@ function renderQidianMarkdown(rankLabel, url, books) {
168
169
  }
169
170
 
170
171
  // ---------------------------------------------------------------------------
171
- // 占位符采集 浏览器平台
172
+ // Browser scraper spawner(调用 scripts/scrapers/ 下的 CJS 脚本)
172
173
  // ---------------------------------------------------------------------------
173
174
 
174
- function scrapeBrowserStub(platform, length, outDir) {
175
- // 这些平台需要 browser_* 工具,由 LLM 调用
176
- // 这里生成指令文件,供 LLM 读取执行
177
- const today = todayStr();
178
- const pcn = PLATFORM_CN[platform] || platform;
179
- const lenStr = length === 'long' ? '长篇' : '短篇';
180
- const instrFile = join(outDir, `_browser_instructions_${platform}_${length}_${today}.json`);
181
- const instructions = getBrowserInstructions(platform, length);
182
-
183
- writeFileSync(instrFile, JSON.stringify(instructions, null, 2), 'utf-8');
184
- console.error(` ℹ ${pcn}(${lenStr})需要 browser_* 工具,已生成指令文件:${instrFile}`);
185
- console.error(` ℹ 请让 LLM 读取并执行该指令文件中的 browser 操作`);
186
- return { file: instrFile, instructions };
175
+ function spawnScraper(platform, length, outDir) {
176
+ return new Promise(async (resolve) => {
177
+ const { spawn } = await import('child_process');
178
+ const args = scraperArgs(platform, length, outDir);
179
+ // 先尝试 9222 端口(Chrome CDP),没有 Chrome 则尝试 9223
180
+ const ports = ['9222', '9223'];
181
+ let portIdx = 0;
182
+
183
+ function tryPort(port) {
184
+ const scraperScript = join(dirname(fileURLToPath(import.meta.url)), 'scrapers', args.script);
185
+ const fullArgs = ['--port', port, ...args.extra];
186
+ const p = spawn('node', [scraperScript, ...fullArgs], {
187
+ timeout: 120000,
188
+ stdio: ['ignore', 'pipe', 'pipe'],
189
+ });
190
+ let out = '', err = '';
191
+ p.stdout.on('data', d => { out += d.toString(); });
192
+ p.stderr.on('data', d => { err += d.toString(); });
193
+ p.on('close', (code) => {
194
+ if (code === 0 && out.trim()) {
195
+ resolve({ ok: true, output: out, stderr: err });
196
+ } else if (portIdx < ports.length - 1) {
197
+ portIdx++;
198
+ tryPort(ports[portIdx]);
199
+ } else {
200
+ resolve({ ok: false, output: out, error: err || `exit ${code}` });
201
+ }
202
+ });
203
+ p.on('error', (e) => {
204
+ if (portIdx < ports.length - 1) { portIdx++; tryPort(ports[portIdx]); }
205
+ else resolve({ ok: false, output: '', error: e.message });
206
+ });
207
+ }
208
+ tryPort(ports[portIdx]);
209
+ });
187
210
  }
188
211
 
189
- function getBrowserInstructions(platform, length) {
190
- // 返回给 LLM 的操作指令
191
- const today = todayStr();
192
- const pcn = PLATFORM_CN[platform] || platform;
193
- const lenStr = length === 'long' ? '长篇' : '短篇';
194
-
195
- if (platform === 'fanqie') {
196
- return {
197
- platform, length, pcn, lenStr, date: today,
198
- action: 'scrape_fanqie',
199
- steps: [
200
- { tool: 'browser_navigate', url: 'https://fanqienovel.com/rankings?channel=1&type=2', label: '番茄男频阅读榜' },
201
- { tool: 'sleep', seconds: 3 },
202
- { tool: 'scroll', times: 3 },
203
- { tool: 'extract', description: '提取页面书籍列表:排名、书名、作者、在读数、题材' },
204
- ],
205
- outputFile: `扫榜结果/番茄男频阅读榜_${today}.md`,
206
- format: '见 scan-output-format.md',
207
- };
208
- }
209
-
210
- if (platform === 'jinjiang') {
211
- return {
212
- platform, length, pcn, lenStr, date: today,
213
- action: 'scrape_jinjiang',
214
- steps: [
215
- { tool: 'browser_navigate', url: 'https://www.jjwxc.net/topten.php?orderstr=12&t=0', label: '晋江金榜' },
216
- { tool: 'sleep', seconds: 3 },
217
- { tool: 'extract', description: '提取页面书籍列表:排名、书名、作者、收藏数' },
218
- ],
219
- outputFile: `扫榜结果/晋江金榜_${today}.md`,
220
- format: '见 scan-output-format.md',
221
- };
222
- }
223
-
224
- if (platform === 'zhihu') {
225
- const subType = length === 'short' ? '短篇4榜' : '长篇榜';
226
- return {
227
- platform, length, pcn, lenStr, date: today,
228
- action: 'scrape_zhihu',
229
- steps: [
230
- { tool: 'browser_navigate', url: 'https://www.zhihu.com/fiore/h5/vip-web', label: `知乎${subType}` },
231
- { tool: 'sleep', seconds: 3 },
232
- { tool: 'extract', description: '提取页面书籍列表' },
233
- ],
234
- outputFile: `扫榜结果/知乎${subType}_${today}.md`,
235
- format: '见 scan-output-format.md',
236
- };
212
+ function scraperArgs(platform, length) {
213
+ switch (platform) {
214
+ case 'fanqie':
215
+ return {
216
+ script: 'fanqie-rank-scraper.js',
217
+ extra: ['--channel', 'all', '--top', '20'],
218
+ };
219
+ case 'jinjiang':
220
+ return {
221
+ script: 'jjwxc-rank-scraper.js',
222
+ extra: ['--type', '12'],
223
+ };
224
+ case 'zhihu':
225
+ return {
226
+ script: 'zhihu-rank-scraper.js',
227
+ extra: ['--length', length === 'short' ? 'short' : 'long'],
228
+ };
229
+ case 'qimao':
230
+ return {
231
+ script: 'qimao-rank-scraper.js',
232
+ extra: ['--channel', length === 'short' ? 'female' : 'male', '--type', 'hot'],
233
+ };
234
+ case 'dianzhong':
235
+ // 点众没有 scraper,降级为 browser stub
236
+ return { script: null, extra: [] };
237
+ default:
238
+ return { script: null, extra: [] };
237
239
  }
238
-
239
- if (platform === 'dianzhong') {
240
- return {
241
- platform, length, pcn, lenStr, date: today,
242
- action: 'scrape_dianzhong',
243
- steps: [
244
- { tool: 'browser_navigate', url: 'https://www.tiyi.cn/', label: '点众短篇榜' },
245
- { tool: 'sleep', seconds: 3 },
246
- { tool: 'extract', description: '提取页面书籍列表' },
247
- ],
248
- outputFile: `扫榜结果/点众短篇榜_${today}.md`,
249
- format: '见 scan-output-format.md',
250
- };
251
- }
252
-
253
- if (platform === 'qimao') {
254
- const gender = length === 'long' ? '男频' : '女频';
255
- return {
256
- platform, length, pcn, lenStr, date: today,
257
- action: 'scrape_qimao',
258
- steps: [
259
- { tool: 'browser_navigate', url: 'https://www.qimao.com/paihang', label: `七猫${gender}大热榜` },
260
- { tool: 'sleep', seconds: 3 },
261
- { tool: 'extract', description: '提取页面书籍列表:排名、书名、作者、热度' },
262
- ],
263
- outputFile: `扫榜结果/七猫${gender}大热榜_${today}.md`,
264
- format: '见 scan-output-format.md',
265
- };
266
- }
267
-
268
- return { error: `未知平台: ${platform}` };
269
240
  }
270
241
 
271
242
  // ---------------------------------------------------------------------------
@@ -414,16 +385,30 @@ async function main() {
414
385
  }
415
386
  scanFiles = scrapeFiles.map(f => f.file);
416
387
  } else {
417
- // 其他平台:browser_* 工具(LLM 读取指令文件后执行)
418
- console.error(`→ ${PLATFORM_CN[platform]}(browser_* 工具)...`);
419
- const { file, instructions } = scrapeBrowserStub(platform, length, scanDir);
420
- scrapeFiles.push({ file });
421
- scanFiles.push(file);
422
-
423
- // 输出指令供 LLM 读取
424
- console.error('\n=== 浏览器采集指令 ===');
425
- console.error(JSON.stringify(instructions, null, 2));
426
- console.error('=== 浏览器采集指令 END ===\n');
388
+ // 其他平台:spawn scraper 进程
389
+ const cfg = scraperArgs(platform, length);
390
+ if (!cfg.script) {
391
+ console.error(` ❌ 平台 ${platform} 暂不支持采集`);
392
+ process.exit(1);
393
+ }
394
+ console.error(`→ 采集 ${PLATFORM_CN[platform]}(Chrome CDP)...`);
395
+ const result = await spawnScraper(platform, length, scanDir);
396
+ if (!result.ok) {
397
+ console.error(` ❌ ${PLATFORM_CN[platform]} 采集失败:${result.error}`);
398
+ process.exit(1);
399
+ }
400
+ // scraper 输出落在 stdout(JSON 摘要),找到它
401
+ const lines = result.output.split('\n');
402
+ const writtenFiles = [];
403
+ for (const line of lines) {
404
+ const m = line.match(/已保存:\s*(.+)/);
405
+ if (m) writtenFiles.push(m[1].trim());
406
+ }
407
+ if (!writtenFiles.length) {
408
+ console.error(' ❌ 未找到任何输出文件');
409
+ process.exit(1);
410
+ }
411
+ scanFiles.push(...writtenFiles);
427
412
  }
428
413
 
429
414
  // Step 6: 收集原始数据
@@ -0,0 +1,242 @@
1
+ /**
2
+ * CDP 工具函数 — 各平台采集脚本的公共依赖
3
+ *
4
+ * 使用方式:
5
+ * const { ab, sleep, evalJSON, evalJSONBase64, scrollLoad, getArg, safeStr, localDateStamp } = require("./cdp-utils");
6
+ *
7
+ * 前置:
8
+ * node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
9
+ */
10
+
11
+ const { execFileSync } = require("child_process");
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ /**
16
+ * On Windows `agent-browser` is an npm shim (agent-browser.cmd/.ps1) that
17
+ * forwards to the real target — the native agent-browser-win32-*.exe or a
18
+ * bundled Node CLI. Node refuses to execFile the `.cmd` without a shell
19
+ * (CVE-2024-27980), and routing the argv array through a shell mangles it: the
20
+ * `.cmd`'s `%*` is re-tokenized by cmd.exe (splitting on spaces, breaking on
21
+ * & | ^), and calling the shim by bare name from powershell.exe collapses the
22
+ * whole array into a single space-joined argument. The exact locus differs by
23
+ * runtime, so instead of hardening any one shell path we bypass shells entirely:
24
+ * read the `.cmd` shim, recover the real program plus its fixed leading args,
25
+ * and execFile that target directly with the argv array — verbatim, no shell.
26
+ */
27
+ function resolveWindowsAgentBrowser(argv) {
28
+ const dirs = String(process.env.PATH || "").split(path.delimiter);
29
+ let cmdPath = null;
30
+ for (const dir of dirs) {
31
+ if (!dir) continue;
32
+ const candidate = path.join(dir, "agent-browser.cmd");
33
+ if (fs.existsSync(candidate)) {
34
+ cmdPath = candidate;
35
+ break;
36
+ }
37
+ }
38
+ if (!cmdPath) return { file: "agent-browser", args: argv };
39
+ const dir = path.dirname(cmdPath);
40
+ const forwardLine =
41
+ fs
42
+ .readFileSync(cmdPath, "utf8")
43
+ .split(/\r?\n/)
44
+ .find((line) => line.includes("%*")) || "";
45
+ const tokens = [...forwardLine.matchAll(/"([^"]*)"/g)]
46
+ .map((m) => m[1])
47
+ .map((t) =>
48
+ t
49
+ .replace(/%~dp0/gi, () => dir + path.sep)
50
+ .replace(/%dp0%/gi, () => dir + path.sep)
51
+ );
52
+ const jsIndex = tokens.findIndex((t) => /\.[cm]?js$/i.test(t));
53
+ if (jsIndex >= 0) {
54
+ return { file: process.execPath, args: [...tokens.slice(jsIndex), ...argv] };
55
+ }
56
+ if (tokens.length > 0) {
57
+ return { file: tokens[0], args: [...tokens.slice(1), ...argv] };
58
+ }
59
+ return { file: "agent-browser", args: argv };
60
+ }
61
+
62
+ /**
63
+ * Build a shell-free invocation. POSIX runs the native `agent-browser` binary
64
+ * directly; Windows resolves the npm `.cmd` shim to that native target so the
65
+ * argument array is passed verbatim, never routed through cmd.exe/PowerShell.
66
+ */
67
+ function buildAgentBrowserInvocation(port, args, platform = process.platform) {
68
+ const argv = ["--cdp", String(port), ...args.map(String)];
69
+ if (platform !== "win32") {
70
+ return { file: "agent-browser", args: argv };
71
+ }
72
+ return resolveWindowsAgentBrowser(argv);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // agent-browser 工具函数
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * 调用 agent-browser CLI
81
+ * @param {number} port - CDP 端口
82
+ * @param {...string} args - agent-browser 参数
83
+ * @returns {string} stdout(trim 后)
84
+ */
85
+ function ab(port, ...args) {
86
+ const invocation = buildAgentBrowserInvocation(port, args);
87
+ try {
88
+ return execFileSync(
89
+ invocation.file,
90
+ invocation.args,
91
+ {
92
+ encoding: "utf-8",
93
+ timeout: 20000,
94
+ stdio: ["pipe", "pipe", "pipe"],
95
+ windowsHide: true,
96
+ }
97
+ ).trim();
98
+ } catch (error) {
99
+ const stderr = error && error.stderr ? String(error.stderr).trim() : "";
100
+ const stdout = error && error.stdout ? String(error.stdout).trim() : "";
101
+ const detail = stderr || stdout || (error && error.message) || "unknown error";
102
+ throw new Error(`agent-browser failed: ${detail}`, { cause: error });
103
+ }
104
+ }
105
+
106
+ /** 等待 ms 毫秒(跨平台,不依赖系统 sleep 命令) */
107
+ function sleep(ms) {
108
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
109
+ }
110
+
111
+ function parseJSONResult(raw) {
112
+ if (!raw || raw === "ERR") {
113
+ throw new Error("agent-browser returned no JSON result");
114
+ }
115
+ try {
116
+ let parsed = JSON.parse(raw);
117
+ if (typeof parsed === "string") {
118
+ try { parsed = JSON.parse(parsed); } catch {}
119
+ }
120
+ return parsed;
121
+ } catch (error) {
122
+ throw new Error(`agent-browser returned invalid JSON: ${String(raw).slice(0, 160)}`, {
123
+ cause: error,
124
+ });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 在浏览器内执行 JS,并解析 JSON 返回值。
130
+ * 一律走 base64(-b):正文提取用的 JS 常含引号、反斜杠等,作为命令行参数时在 Windows 上
131
+ * 无法逐字透传(.cmd 的 %* 与 PowerShell 都会二次解析)。base64 让参数只含 [A-Za-z0-9+/=],
132
+ * 和各采集脚本已在用的 evalJSONBase64 走同一条安全通道。
133
+ */
134
+ function evalJSON(port, js) {
135
+ return evalJSONBase64(port, js);
136
+ }
137
+
138
+ /**
139
+ * 通过 agent-browser 的 base64 参数执行复杂 JS,避免命令行转义和参数边界问题。
140
+ */
141
+ function evalJSONBase64(port, js) {
142
+ const encoded = Buffer.from(String(js), "utf8").toString("base64");
143
+ return parseJSONResult(ab(port, "eval", "-b", encoded));
144
+ }
145
+
146
+ /**
147
+ * 安全地将值插入浏览器 eval 字符串。
148
+ * 使用 JSON.stringify 确保值不会因特殊字符(引号、反斜杠等)破坏 eval 字符串。
149
+ * @param {*} val - 要插入的值
150
+ * @returns {string} JSON 字符串表示(含引号)
151
+ */
152
+ function safeStr(val) {
153
+ return JSON.stringify(String(val));
154
+ }
155
+
156
+ /**
157
+ * 滚动页面加载更多内容
158
+ * @param {number} port - CDP 端口
159
+ * @param {number} times - 滚动次数
160
+ * @param {number} [interval=1000] - 每次滚动间隔(ms)
161
+ */
162
+ function scrollLoad(port, times, interval = 1000) {
163
+ for (let i = 0; i < times; i++) {
164
+ ab(port, "eval", "window.scrollBy(0, window.innerHeight)");
165
+ sleep(interval);
166
+ }
167
+ }
168
+
169
+ /** 解析 --xxx 参数 */
170
+ function getArg(args, name) {
171
+ const i = args.indexOf(name);
172
+ if (i >= 0) return i + 1 < args.length ? args[i + 1] : null;
173
+ const prefix = `${name}=`;
174
+ const inline = args.find((arg) => String(arg).startsWith(prefix));
175
+ return inline === undefined ? null : String(inline).slice(prefix.length);
176
+ }
177
+
178
+ /**
179
+ * 输出文件名用的日期戳(YYYYMMDD),一律取**本地日历日**。
180
+ * 不能用 new Date().toISOString().slice(0,10):那是 UTC 日期,比 UTC+8 晚 8 小时。
181
+ * 文件名是各采集脚本唯一的去重键(一个榜单一天一份),北京时间 00:00-08:00 之间的采集
182
+ * 会退回「昨天」的文件名,静默覆盖前一晚采到的同名报告,且这份数据被标成前一天。
183
+ * @param {Date} [date] - 默认当前时间
184
+ * @returns {string} YYYYMMDD
185
+ */
186
+ function localDateStamp(date) {
187
+ const d = date instanceof Date ? date : new Date();
188
+ const y = String(d.getFullYear()).padStart(4, "0");
189
+ const m = String(d.getMonth() + 1).padStart(2, "0");
190
+ const day = String(d.getDate()).padStart(2, "0");
191
+ return `${y}${m}${day}`;
192
+ }
193
+
194
+ /**
195
+ * Run a scraper entrypoint and turn empty/partial output into machine-readable
196
+ * CLI status. Legacy entrypoints may return an integer; multi-target scrapers
197
+ * return {planned,written,failed,partial,partialReasons}.
198
+ */
199
+ function runCli(main, label) {
200
+ Promise.resolve()
201
+ .then(main)
202
+ .then((result) => {
203
+ const outcome = Number.isInteger(result)
204
+ ? { planned: result, written: result, failed: 0, partial: false, partialReasons: [] }
205
+ : result;
206
+ if (!outcome || !Number.isInteger(outcome.written) || outcome.written < 1) {
207
+ throw new Error("no output was written");
208
+ }
209
+ const failed = Number.isInteger(outcome.failed) ? outcome.failed : 0;
210
+ const planned = Number.isInteger(outcome.planned)
211
+ ? outcome.planned
212
+ : outcome.written + failed;
213
+ const reasons = Array.isArray(outcome.partialReasons)
214
+ ? outcome.partialReasons.filter(Boolean).map(String)
215
+ : [];
216
+ if (outcome.partial || failed > 0) {
217
+ const details = [`wrote ${outcome.written}/${planned}`];
218
+ if (failed > 0) details.push(`failed ${failed}`);
219
+ details.push(...reasons);
220
+ console.error(`${label} partial: ${details.join("; ")}`);
221
+ process.exitCode = 2;
222
+ }
223
+ })
224
+ .catch((error) => {
225
+ const message = error && error.message ? error.message : String(error);
226
+ console.error(`${label} failed: ${message}`);
227
+ process.exitCode = 1;
228
+ });
229
+ }
230
+
231
+ module.exports = {
232
+ ab,
233
+ sleep,
234
+ evalJSON,
235
+ evalJSONBase64,
236
+ buildAgentBrowserInvocation,
237
+ safeStr,
238
+ scrollLoad,
239
+ getArg,
240
+ localDateStamp,
241
+ runCli,
242
+ };