@cloud411716/fancy-webnovel 0.1.76 → 0.1.78

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 (125) hide show
  1. package/infra.js +26 -21
  2. package/package.json +1 -1
  3. package/plugins/fancy-bootstrap/index.js +4 -2
  4. package/plugins/fancy-scan/index.js +1 -2
  5. package/plugins/fancy-scan/scripts/run-scan.js +22 -44
  6. package/plugins/fancy-scan/scripts/scraper-registry.md +3 -5
  7. package/plugins/fancy-scan/scripts/scrapers/cdp-utils.cjs +7 -5
  8. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.cjs +282 -253
  9. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.cjs +193 -253
  10. package/plugins/fancy-scan/scripts/scrapers/node_modules/.package-lock.json +20 -0
  11. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/LICENSE +202 -0
  12. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/NOTICE +5 -0
  13. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/README.md +3 -0
  14. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  15. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/install_media_pack.ps1 +5 -0
  16. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/install_webkit_wsl.ps1 +33 -0
  17. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh +42 -0
  18. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh +13 -0
  19. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 +24 -0
  20. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh +42 -0
  21. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh +12 -0
  22. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 +24 -0
  23. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh +48 -0
  24. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh +11 -0
  25. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 +23 -0
  26. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh +48 -0
  27. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh +11 -0
  28. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 +23 -0
  29. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh +48 -0
  30. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh +11 -0
  31. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 +24 -0
  32. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/browsers.json +75 -0
  33. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/cli.js +21 -0
  34. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/index.d.ts +17 -0
  35. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/index.js +17 -0
  36. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/index.mjs +28 -0
  37. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/bootstrap.js +88 -0
  38. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/coreBundle.js +74830 -0
  39. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/entry/cliDaemon.js +5 -0
  40. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/entry/dashboardApp.js +3 -0
  41. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/entry/mcp.js +10 -0
  42. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/entry/oopBrowserDownload.js +3 -0
  43. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/package.js +50 -0
  44. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/server/chromium/appIcon.png +0 -0
  45. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/server/electron/loader.js +118 -0
  46. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/serverRegistry.js +7347 -0
  47. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/serverRegistry.js.LICENSE +354 -0
  48. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js +141 -0
  49. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/cli.js +6 -0
  50. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/help.json +708 -0
  51. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/minimist.js +128 -0
  52. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/output.js +343 -0
  53. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/program.js +404 -0
  54. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/registry.js +176 -0
  55. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/cli-client/session.js +258 -0
  56. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/dashboard/appIcon.png +0 -0
  57. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md +420 -0
  58. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md +23 -0
  59. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md +39 -0
  60. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md +87 -0
  61. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md +241 -0
  62. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md +225 -0
  63. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md +275 -0
  64. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md +433 -0
  65. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md +139 -0
  66. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md +143 -0
  67. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +143 -0
  68. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
  69. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +85 -0
  70. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +67 -0
  71. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +75 -0
  72. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md +171 -0
  73. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/utils/extension.js +101 -0
  74. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/tools/utils/socketConnection.js +108 -0
  75. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
  76. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
  77. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf +0 -0
  78. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg +1 -0
  79. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg +1 -0
  80. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg +1 -0
  81. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/assets/index-CyWAfh-p.js +11 -0
  82. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/assets/index-DhC616m4.css +1 -0
  83. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg +1 -0
  84. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/index.html +29 -0
  85. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg +24 -0
  86. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/htmlReport/index.html +16 -0
  87. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -0
  88. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/htmlReport/report.js +32 -0
  89. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
  90. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CwYYHbcZ.js +32 -0
  91. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf +0 -0
  92. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/recorder/assets/index-DYjdXIbE.js +129 -0
  93. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/recorder/assets/index-l_lX622x.css +1 -0
  94. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/recorder/index.html +29 -0
  95. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg +9 -0
  96. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-rXmQmLUY.js +32 -0
  97. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-B-dXF5JN.js +181 -0
  98. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
  99. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-BuZfJS5v.js +7 -0
  100. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
  101. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf +0 -0
  102. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.BLFoOugd.css +1 -0
  103. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
  104. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/index.KZ4wOW1K.js +1 -0
  105. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/index.html +44 -0
  106. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest +16 -0
  107. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg +9 -0
  108. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
  109. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +10 -0
  110. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +4 -0
  111. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/uiMode.C7UW1sC9.css +1 -0
  112. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/uiMode.Dzuouizj.js +5 -0
  113. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +18 -0
  114. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
  115. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  116. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  117. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/lib/xdg-open +1267 -0
  118. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/package.json +34 -0
  119. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/types/protocol.d.ts +24842 -0
  120. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/types/structs.d.ts +52 -0
  121. package/plugins/fancy-scan/scripts/scrapers/node_modules/playwright-core/types/types.d.ts +26188 -0
  122. package/plugins/fancy-scan/scripts/scrapers/package-lock.json +27 -0
  123. package/plugins/fancy-scan/scripts/scrapers/package.json +9 -0
  124. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.cjs +200 -297
  125. package/plugins/fancy-scan/scripts/scrapers/zhihu-rank-scraper.cjs +80 -80
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * 番茄小说排行榜采集脚本
4
4
  *
5
- * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
5
+ * 使用 playwright-core 自己管理浏览器生命周期。
6
6
  * 采集策略:从榜单页 __INITIAL_STATE__ 取结构化列表,再逐本请求详情页解码真实
7
7
  * 书名/作者/简介/题材/标签(番茄列表页有字体反爬,详情页 HTML 里是明文)。
8
8
  * 输出 Markdown 格式匹配 scan-output-format.md 规范。
@@ -10,170 +10,197 @@
10
10
  * 用法:
11
11
  * node fanqie-rank-scraper.js --channel 1 --type 2 # 男频阅读榜
12
12
  * node fanqie-rank-scraper.js --channel 0 --type 1 # 女频新书榜
13
- * node fanqie-rank-scraper.js --channel 1 --type 2 --outdir ./ # 指定输出目录
14
13
  * node fanqie-rank-scraper.js --channel all # 全部采集
15
14
  * node fanqie-rank-scraper.js --channel 1 --top 15 # 每题材只取前 15 本
16
- *
17
- * 前置:
18
- * node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
15
+ * node fanqie-rank-scraper.js --login-wait 60 # 打开浏览器后等待用户登录(秒)
16
+ * node fanqie-rank-scraper.js --outdir ./ # 指定输出目录
19
17
  */
20
18
 
21
19
  const fs = require("fs");
22
20
  const path = require("path");
23
- const { ab, sleep, evalJSONBase64, scrollLoad, getArg, localDateStamp, runCli } = require("./cdp-utils.cjs");
21
+ const { chromium } = require("playwright-core");
22
+ const { getArg, localDateStamp, runCli } = require("./cdp-utils.cjs");
24
23
 
25
- // 一次详情请求的并发批大小。番茄详情页用同步 XHR 拉取,批太大会撞上
26
- // cdp-utils 里 ab() 的 20s 超时;超时会显式失败,这里分批是为了避免整个题材被中断。
24
+ // 一次详情请求的并发批大小
27
25
  const DETAIL_CHUNK = 5;
28
26
 
29
27
  // ---------------------------------------------------------------------------
30
- // 页面提取
28
+ // 工具函数
31
29
  // ---------------------------------------------------------------------------
32
30
 
33
- /** 连通性 + 页面就绪自检 */
34
- function probePage(port) {
35
- return evalJSONBase64(
36
- port,
37
- "JSON.stringify({host:location.host,hasState:!!window.__INITIAL_STATE__})"
38
- );
31
+ function sleep(ms) {
32
+ // 同步等待:Atomics.wait 在主线程阻塞,不占用事件循环
33
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
39
34
  }
40
35
 
41
- /** 构建:提取侧边菜单品类链接的浏览器 JS */
42
- function buildCategoriesJS(prefix) {
43
- return `JSON.stringify((function(){
44
- var prefix=${JSON.stringify(prefix)};
45
- var out=[];var seen={};
46
- Array.from(document.querySelectorAll('a')).forEach(function(a){
47
- var href=a.getAttribute('href')||'';
48
- if(href.indexOf(prefix)===-1)return;
49
- var name=(a.innerText||a.textContent||'').trim();
50
- if(!name)return;
51
- if(seen[href])return;seen[href]=1;
52
- out.push({name:name,href:href});
53
- });
54
- return out;
55
- })())`;
36
+ // ---------------------------------------------------------------------------
37
+ // 页面提取
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** 连通性 + 页面就绪自检 */
41
+ async function probePage(page) {
42
+ return page.evaluate(() => ({
43
+ host: location.host,
44
+ hasState: !!window.__INITIAL_STATE__,
45
+ }));
56
46
  }
57
47
 
58
48
  /** 提取侧边菜单品类链接 */
59
- function extractCategories(port, channel, type) {
49
+ async function extractCategories(page, channel, type) {
60
50
  const prefix = `/rank/${channel}_${type}_`;
61
- return evalJSONBase64(port, buildCategoriesJS(prefix)) || [];
51
+ const cats = await page.evaluate((p) => {
52
+ const out = [];
53
+ const seen = {};
54
+ document.querySelectorAll("a").forEach((a) => {
55
+ const href = a.getAttribute("href") || "";
56
+ if (href.indexOf(p) === -1) return;
57
+ const name = (a.innerText || a.textContent || "").trim();
58
+ if (!name) return;
59
+ if (seen[href]) return;
60
+ seen[href] = 1;
61
+ out.push({ name, href });
62
+ });
63
+ return out;
64
+ }, prefix);
65
+ return cats;
62
66
  }
63
67
 
64
- /**
65
- * __INITIAL_STATE__ 提取当前品类页的作品列表。
66
- * 多路径尝试 + 深度兜底扫描,并把字段名归一,避免站点改 state 结构就全盘失败。
67
- */
68
- function buildBookListJS() {
69
- return `JSON.stringify((function(){
70
- var s=window.__INITIAL_STATE__||{};
71
- var cands=[
72
- s.rank&&s.rank.book_list, s.rank&&s.rank.bookList, s.rank&&s.rank.rankList,
73
- s.rankData&&s.rankData.book_list, s.page&&s.page.book_list
68
+ /** 从 __INITIAL_STATE__ 提取当前品类页的作品列表 */
69
+ async function extractBookList(page) {
70
+ return page.evaluate(() => {
71
+ const s = window.__INITIAL_STATE__ || {};
72
+ const cands = [
73
+ s.rank && s.rank.book_list,
74
+ s.rank && s.rank.bookList,
75
+ s.rank && s.rank.rankList,
76
+ s.rankData && s.rankData.book_list,
77
+ s.page && s.page.book_list,
74
78
  ];
75
- var list=null;
76
- for(var i=0;i<cands.length;i++){ if(Array.isArray(cands[i])&&cands[i].length){list=cands[i];break;} }
77
- if(!list){
78
- var found=null;
79
- (function walk(o,d){
80
- if(found||!o||d>6)return;
81
- if(Array.isArray(o)){
82
- if(o.length&&o[0]&&typeof o[0]==='object'&&(o[0].bookId||o[0].book_id)){found=o;return;}
83
- for(var j=0;j<o.length&&!found;j++)walk(o[j],d+1);return;
79
+ var list = null;
80
+ for (var i = 0; i < cands.length; i++) {
81
+ if (Array.isArray(cands[i]) && cands[i].length) {
82
+ list = cands[i];
83
+ break;
84
+ }
85
+ }
86
+ if (!list) {
87
+ var found = null;
88
+ (function walk(o, d) {
89
+ if (found || !o || d > 6) return;
90
+ if (Array.isArray(o)) {
91
+ if (o.length && o[0] && typeof o[0] === "object" && (o[0].bookId || o[0].book_id)) {
92
+ found = o;
93
+ return;
94
+ }
95
+ for (var j = 0; j < o.length && !found; j++) walk(o[j], d + 1);
96
+ return;
97
+ }
98
+ if (typeof o === "object") {
99
+ for (var k in o) {
100
+ if (found) break;
101
+ try {
102
+ walk(o[k], d + 1);
103
+ } catch (e) {}
104
+ }
84
105
  }
85
- if(typeof o==='object'){ for(var k in o){ if(found)break; try{walk(o[k],d+1)}catch(e){} } }
86
- })(s,0);
87
- list=found||[];
106
+ })(s, 0);
107
+ list = found || [];
88
108
  }
89
- return list.map(function(b){return {
90
- bookId:String(b.bookId||b.book_id||''),
91
- read_count:b.read_count||b.readCount||b.read||'',
92
- wordNumber:b.wordNumber||b.word_number||b.wordCount||'',
93
- creationStatus:(b.creationStatus!=null?b.creationStatus:(b.creation_status!=null?b.creation_status:b.status)),
94
- lastChapterTitle:b.lastChapterTitle||b.last_chapter_title||b.lastChapter||'',
95
- category:b.category||b.categoryName||b.category_name||''
96
- };}).filter(function(b){return b.bookId;});
97
- })())`;
109
+ return list.map((b) => ({
110
+ bookId: String(b.bookId || b.book_id || ""),
111
+ read_count: b.read_count || b.readCount || b.read || "",
112
+ wordNumber: b.wordNumber || b.word_number || b.wordCount || "",
113
+ creationStatus:
114
+ b.creationStatus != null
115
+ ? b.creationStatus
116
+ : b.creation_status != null
117
+ ? b.creation_status
118
+ : b.status,
119
+ lastChapterTitle: b.lastChapterTitle || b.last_chapter_title || b.lastChapter || "",
120
+ category: b.category || b.categoryName || b.category_name || "",
121
+ })).filter((b) => b.bookId);
122
+ });
98
123
  }
99
124
 
100
- function extractBookList(port) {
101
- const list = evalJSONBase64(port, buildBookListJS());
102
- return Array.isArray(list) ? list : [];
103
- }
104
-
105
- /**
106
- * 批量解码详情:逐本同步 XHR 请求 /page/{id},多策略解析明文字段。
107
- * 番茄列表页书名/作者被字体反爬,详情页 HTML 内嵌 JSON 与 <title> 是明文。
108
- * 字段名以真实 SSR(__INITIAL_STATE__) 为准:bookName/author/abstract 明文,
109
- * 题材在 categoryV2(转义 JSON 数组的首个 Name),番茄 SSR 不含数字评分。
110
- * 返回 { id: {title, author, desc, category, tags} }。
111
- */
112
- function buildDetailJS(ids) {
113
- return `JSON.stringify((function(){
114
- var ids=${JSON.stringify(ids)};
115
- var map={};
116
- function pick(h,res){for(var i=0;i<res.length;i++){var m=h.match(res[i]);if(m&&m[1])return m[1].trim();}return '';}
117
- for(var k=0;k<ids.length;k++){
118
- var id=ids[k];
119
- try{
120
- var x=new XMLHttpRequest();
121
- x.open('GET','/page/'+id,false);
125
+ /** 批量解码详情:逐本同步 XHR 请求 /page/{id} */
126
+ async function fetchDetailsChunk(page, ids) {
127
+ return page.evaluate((ids) => {
128
+ var map = {};
129
+ function pick(h, res) {
130
+ for (var i = 0; i < res.length; i++) {
131
+ var m = h.match(res[i]);
132
+ if (m && m[1]) return m[1].trim();
133
+ }
134
+ return "";
135
+ }
136
+ for (var k = 0; k < ids.length; k++) {
137
+ var id = ids[k];
138
+ try {
139
+ var x = new XMLHttpRequest();
140
+ x.open("GET", "/page/" + id, false);
122
141
  x.send();
123
- var h=x.responseText||'';
124
- var title=pick(h,[
125
- /"bookName"\\s*:\\s*"([^"]+)"/,
142
+ var h = x.responseText || "";
143
+ var title = pick(h, [
144
+ /"bookName"\s*:\s*"([^"]+)"/,
126
145
  /<title>([^<]*?)(?:完整版|最新章节|在线阅读|_番茄小说|-番茄小说|_番茄|-番茄)/,
127
146
  /<meta[^>]+property="og:title"[^>]+content="([^"]+)"/,
128
- /<title>([^<|_]{1,40})/
129
- ]);
130
- var author=pick(h,[
131
- /"author"\\s*:\\s*"([^"]+)"/,
132
- /"authorName"\\s*:\\s*"([^"]+)"/,
133
- /<meta[^>]+property="og:novel:author"[^>]+content="([^"]+)"/
147
+ /<title>([^<|_]{1,40})/,
134
148
  ]);
135
- // abstract(真实简介)优先;meta description 是平台模板("番茄小说提供..."),
136
- // 且常带 data-rh 属性,故用宽松属性匹配兜底。
137
- var abs=pick(h,[/"abstract"\\s*:\\s*"([^"]{6,}?)"/]);
138
- var desc=abs||pick(h,[
139
- /<meta[^>]+name="description"[^>]+content="([^"]+)"/,
140
- /<meta[^>]+property="og:description"[^>]+content="([^"]+)"/
149
+ var author = pick(h, [
150
+ /"author"\s*:\s*"([^"]+)"/,
151
+ /"authorName"\s*:\s*"([^"]+)"/,
152
+ /<meta[^>]+property="og:novel:author"[^>]+content="([^"]+)"/,
141
153
  ]);
142
- // 题材:category 常为空字符串,真实题材在 categoryV2(转义 JSON)首个 Name。
143
- var category=pick(h,[
144
- /"categoryV2":"\\[\\{[\\s\\S]*?\\\\"Name\\\\":\\\\"([^"\\\\]+)/,
145
- /"category"\\s*:\\s*"([^"]{1,20})"/,
146
- /<meta[^>]+property="og:novel:category"[^>]+content="([^"]+)"/
154
+ var abs = pick(h, [/"abstract"\s*:\s*"([^"]{6,}?)"/]);
155
+ var desc =
156
+ abs ||
157
+ pick(h, [
158
+ /<meta[^>]+name="description"[^>]+content="([^"]+)"/,
159
+ /<meta[^>]+property="og:description"[^>]+content="([^"]+)"/,
160
+ ]);
161
+ var category = pick(h, [
162
+ /"categoryV2":"\\[\{[\\s\\S]*?\\"Name\\":\\"([^"\\]+)/,
163
+ /"category"\s*:\s*"([^"]{1,20})"/,
164
+ /<meta[^>]+property="og:novel:category"[^>]+content="([^"]+)"/,
147
165
  ]);
148
- // 标签:番茄简介开头常带【tag+tag+...】,是题材细分的真实信号。
149
- var tags='';
150
- var bm=(abs||desc||'').match(/[【\\[]([^】\\]]{2,40})[】\\]]/);
151
- if(bm){tags=bm[1].split(/[+、,\\/\\s]+/).filter(Boolean).slice(0,6).join('、');}
152
- map[id]={title:title,author:author,desc:desc,category:category,tags:tags};
153
- }catch(e){
154
- map[id]={title:'',author:'',desc:'',category:'',tags:'',err:String(e&&e.message||e)};
166
+ var tags = "";
167
+ var bm = (abs || desc || "").match(/【\[]([^】\]]{2,40})[】\]]/);
168
+ if (bm) {
169
+ tags = bm[1]
170
+ .split(/[+、,\/\s]+/)
171
+ .filter(Boolean)
172
+ .slice(0, 6)
173
+ .join("、");
174
+ }
175
+ map[id] = { title, author, desc, category, tags };
176
+ } catch (e) {
177
+ map[id] = { title: "", author: "", desc: "", category: "", tags: "", err: String(e && e.message || e) };
155
178
  }
156
179
  }
157
180
  return map;
158
- })())`;
159
- }
160
-
161
- function fetchDetailsChunk(port, ids) {
162
- return evalJSONBase64(port, buildDetailJS(ids)) || {};
181
+ }, ids);
163
182
  }
164
183
 
165
- /** 分批解码,避免单次 eval 超时;返回合并后的 map */
166
- function fetchDetails(port, bookIds) {
184
+ /** 分批解码,避免单次 eval 超时 */
185
+ async function fetchDetails(page, bookIds) {
167
186
  const map = {};
168
187
  for (let i = 0; i < bookIds.length; i += DETAIL_CHUNK) {
169
188
  const chunk = bookIds.slice(i, i + DETAIL_CHUNK);
170
- const part = fetchDetailsChunk(port, chunk);
189
+ const part = await fetchDetailsChunk(page, chunk);
171
190
  Object.assign(map, part);
172
191
  sleep(300);
173
192
  }
174
193
  return map;
175
194
  }
176
195
 
196
+ /** 滚动页面加载更多内容 */
197
+ async function scrollLoad(page, times, interval = 1000) {
198
+ for (let i = 0; i < times; i++) {
199
+ await page.evaluate(() => window.scrollBy(0, window.innerHeight));
200
+ sleep(interval);
201
+ }
202
+ }
203
+
177
204
  // ---------------------------------------------------------------------------
178
205
  // 格式化
179
206
  // ---------------------------------------------------------------------------
@@ -201,11 +228,9 @@ function fmtStatus(s) {
201
228
  return s ? String(s) : "未知";
202
229
  }
203
230
 
204
- /** 清洗简介:去平台模板文本 → 折叠空白 → 句末截断 100 字 */
205
231
  function cleanDesc(raw) {
206
232
  if (!raw) return "";
207
233
  let d = String(raw)
208
- // 简介取自 JSON 字符串原文,先还原常见转义(\n \uXXXX \" 等)
209
234
  .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
210
235
  .replace(/\\[nrt]/g, " ")
211
236
  .replace(/\\"/g, '"')
@@ -215,7 +240,7 @@ function cleanDesc(raw) {
215
240
  .trim();
216
241
  if (d.length <= 100) return d;
217
242
  const cut = d.slice(0, 100);
218
- const m = cut.match(/^[\s\S]*[。!?]/);
243
+ const m = cut.match(/[\s\S]*[。!?]/);
219
244
  return (m ? m[0] : cut) + "...";
220
245
  }
221
246
 
@@ -224,7 +249,7 @@ function cleanDesc(raw) {
224
249
  // ---------------------------------------------------------------------------
225
250
 
226
251
  const args = process.argv.slice(2);
227
- const PORT = parseInt(getArg(args, "--port") || "9222", 10);
252
+ const LOGIN_WAIT = parseInt(getArg(args, "--login-wait") || "0", 10);
228
253
  const OUTDIR = getArg(args, "--outdir") || ".";
229
254
  const CHANNEL = getArg(args, "--channel") || "1";
230
255
  const TYPE = getArg(args, "--type") || "2";
@@ -238,155 +263,162 @@ function typeLabel(t) {
238
263
  return t === "2" ? "阅读榜" : "新书榜";
239
264
  }
240
265
 
241
- function scrapeChannel(ch, type) {
266
+ async function scrapeChannel(ch, type) {
242
267
  const chLabel = channelLabel(ch);
243
268
  const tyLabel = typeLabel(type);
244
269
  console.log(`\n→ 采集 ${chLabel}${tyLabel}...`);
245
270
 
246
- // 用已知品类 ID 作为入口,确保菜单只显示当前频道/类型的品类
247
271
  const initCatId = ch === "1" ? "1141" : "1139"; // 男频:西方奇幻 / 女频:古风世情
248
272
  const initUrl = `https://fanqienovel.com/rank/${ch}_${type}_${initCatId}`;
249
- ab(PORT, "open", initUrl);
250
- sleep(3000);
251
-
252
- // 连通性自检:把"静默写出一堆 bookId"变成可操作的报错
253
- const probe = probePage(PORT);
254
- if (!probe) {
255
- console.error(
256
- ` ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${PORT}),且 agent-browser 可用。`
257
- );
258
- return null;
259
- }
260
- if (probe.host && probe.host.indexOf("fanqie") === -1) {
261
- console.error(
262
- ` ✗ 当前页面非番茄(host=${probe.host}),可能被重定向到登录/验证页,已跳过。`
263
- );
264
- return null;
265
- }
266
- if (!probe.hasState) {
267
- console.error(` ⚠ 页面未挂载 __INITIAL_STATE__,将尝试兜底扫描,结果可能不完整。`);
268
- }
269
273
 
270
- let categories = extractCategories(PORT, ch, type);
271
- if (!categories.length) {
272
- // 菜单可能懒加载,滚动后重试一次
273
- scrollLoad(PORT, 2);
274
- sleep(1000);
275
- categories = extractCategories(PORT, ch, type);
276
- }
277
- if (!categories.length) {
278
- // 仍失败:降级为只采当前入口页,至少产出数据而不是空跑
279
- console.log(` ⚠ 未提取到品类菜单,降级为单题材采集(入口页)`);
280
- categories = [{ name: "全部(入口页)", href: `/rank/${ch}_${type}_${initCatId}` }];
281
- } else {
282
- console.log(` 发现 ${categories.length} 个品类`);
283
- }
274
+ const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
275
+ const context = await browser.newContext();
276
+ const page = await context.newPage();
284
277
 
285
- const now = new Date().toISOString();
286
- const lines = [
287
- `# 番茄 · ${chLabel}${tyLabel} · 全 ${categories.length} 题材`,
288
- "",
289
- `- 频道参数:channel=${ch},type=${type}`,
290
- `- 抓取时间:${now}`,
291
- `- 每题材上限 ≈ ${TOP}`,
292
- "",
293
- "---",
294
- "",
295
- ];
296
-
297
- let totalBooks = 0;
298
- let resolvedTitles = 0;
299
- const bodyLines = [];
300
-
301
- for (let ci = 0; ci < categories.length; ci++) {
302
- const cat = categories[ci];
303
- console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
304
-
305
- try {
306
- ab(PORT, "open", `https://fanqienovel.com${cat.href}`);
307
- sleep(2500);
308
- scrollLoad(PORT, 2);
309
-
310
- let books = extractBookList(PORT);
311
- if (!Array.isArray(books) || !books.length) {
312
- bodyLines.push(`## ${cat.name} — 0 本`, "", "---", "");
313
- continue;
314
- }
315
- if (books.length > TOP) books = books.slice(0, TOP);
278
+ let scraped = null;
316
279
 
317
- // 分批解码真实书名/作者/简介/题材/评分/标签
318
- const bookIds = books.map((b) => String(b.bookId));
319
- const details = fetchDetails(PORT, bookIds);
280
+ try {
281
+ // 打开入口页
282
+ await page.goto(initUrl, { waitUntil: "networkidle" });
320
283
 
321
- bodyLines.push(`## ${cat.name} — ${books.length} 本`, "");
284
+ // 等待登录(用户可在此时手动登录)
285
+ if (LOGIN_WAIT > 0) {
286
+ console.log(` ⏳ 等待 ${LOGIN_WAIT}s 供用户登录...`);
287
+ await page.waitForTimeout(LOGIN_WAIT * 1000);
288
+ }
322
289
 
323
- for (let i = 0; i < books.length; i++) {
324
- const b = books[i];
325
- const info = details[String(b.bookId)] || {};
326
- totalBooks++;
327
- const resolved = !!info.title;
328
- if (resolved) resolvedTitles++;
290
+ // 连通性自检
291
+ const probe = await probePage(page);
292
+ if (!probe.host || probe.host.indexOf("fanqie") === -1) {
293
+ console.error(
294
+ ` ✗ 当前页面非番茄(host=${probe.host}),可能被重定向到登录/验证页,已跳过。`
295
+ );
296
+ return null;
297
+ }
298
+ if (!probe.hasState) {
299
+ console.log(` ⚠ 页面未挂载 __INITIAL_STATE__,将尝试兜底扫描,结果可能不完整。`);
300
+ }
329
301
 
330
- const title = info.title || "(标题待解析)";
331
- const author = info.author || "未知";
332
- const category = info.category || b.category || "";
333
- const catSeg = category ? ` · ${category}` : "";
302
+ // 提取品类菜单
303
+ let categories = await extractCategories(page, ch, type);
304
+ if (!categories.length) {
305
+ await scrollLoad(page, 2);
306
+ sleep(1000);
307
+ categories = await extractCategories(page, ch, type);
308
+ }
309
+ if (!categories.length) {
310
+ console.log(` ⚠ 未提取到品类菜单,降级为单题材采集(入口页)`);
311
+ categories = [{ name: "全部(入口页)", href: `/rank/${ch}_${type}_${initCatId}` }];
312
+ } else {
313
+ console.log(` 发现 ${categories.length} 个品类`);
314
+ }
334
315
 
335
- bodyLines.push(`### #${i + 1} ${title}`);
336
- bodyLines.push(
337
- `*${author}${catSeg} · ${fmtStatus(b.creationStatus)} · ${fmtReads(b.read_count)} 在读 · ${fmtWords(b.wordNumber)}字*`
338
- );
339
- if (info.tags) bodyLines.push(`**标签:** ${info.tags}`);
340
- bodyLines.push(`**最新更新:** ${b.lastChapterTitle || "未知"}`);
341
- bodyLines.push(`**bookId:** ${b.bookId}`);
342
- if (info.err) bodyLines.push(`⚠️ 详情:${info.err}`);
343
- bodyLines.push(`[作品页](https://fanqienovel.com/page/${b.bookId})`);
344
- const desc = cleanDesc(info.desc);
345
- if (desc) {
346
- bodyLines.push("");
347
- bodyLines.push("**简介**");
316
+ const now = new Date().toISOString();
317
+ const lines = [
318
+ `# 番茄 · ${chLabel}${tyLabel} · ${categories.length} 题材`,
319
+ "",
320
+ `- 频道参数:channel=${ch},type=${type}`,
321
+ `- 抓取时间:${now}`,
322
+ `- 每题材上限 ≈ ${TOP}`,
323
+ "",
324
+ "---",
325
+ "",
326
+ ];
327
+
328
+ let totalBooks = 0;
329
+ let resolvedTitles = 0;
330
+ const bodyLines = [];
331
+
332
+ for (let ci = 0; ci < categories.length; ci++) {
333
+ const cat = categories[ci];
334
+ console.log(` [${ci + 1}/${categories.length}] ${cat.name}`);
335
+
336
+ try {
337
+ await page.goto(`https://fanqienovel.com${cat.href}`, { waitUntil: "networkidle" });
338
+ await page.waitForTimeout(2500);
339
+ await scrollLoad(page, 2);
340
+
341
+ let books = await extractBookList(page);
342
+ if (!Array.isArray(books) || !books.length) {
343
+ bodyLines.push(`## ${cat.name} — 0 本`, "", "---", "");
344
+ continue;
345
+ }
346
+ if (books.length > TOP) books = books.slice(0, TOP);
347
+
348
+ const bookIds = books.map((b) => String(b.bookId));
349
+ const details = await fetchDetails(page, bookIds);
350
+
351
+ bodyLines.push(`## ${cat.name} — ${books.length} 本`, "");
352
+
353
+ for (let i = 0; i < books.length; i++) {
354
+ const b = books[i];
355
+ const info = details[String(b.bookId)] || {};
356
+ totalBooks++;
357
+ const resolved = !!info.title;
358
+ if (resolved) resolvedTitles++;
359
+
360
+ const title = info.title || "(标题待解析)";
361
+ const author = info.author || "未知";
362
+ const category = info.category || b.category || "";
363
+ const catSeg = category ? ` · ${category}` : "";
364
+
365
+ bodyLines.push(`### #${i + 1} ${title}`);
366
+ bodyLines.push(
367
+ `*${author}${catSeg} · ${fmtStatus(b.creationStatus)} · ${fmtReads(b.read_count)} 在读 · ${fmtWords(b.wordNumber)}字*`
368
+ );
369
+ if (info.tags) bodyLines.push(`**标签:** ${info.tags}`);
370
+ bodyLines.push(`**最新更新:** ${b.lastChapterTitle || "未知"}`);
371
+ bodyLines.push(`**bookId:** ${b.bookId}`);
372
+ if (info.err) bodyLines.push(`⚠️ 详情:${info.err}`);
373
+ bodyLines.push(`[作品页](https://fanqienovel.com/page/${b.bookId})`);
374
+ const desc = cleanDesc(info.desc);
375
+ if (desc) {
376
+ bodyLines.push("");
377
+ bodyLines.push("**简介**");
378
+ bodyLines.push("");
379
+ bodyLines.push(desc);
380
+ }
348
381
  bodyLines.push("");
349
- bodyLines.push(desc);
350
382
  }
351
- bodyLines.push("");
383
+
384
+ bodyLines.push("---", "");
385
+ } catch (catErr) {
386
+ console.error(
387
+ ` [fanqie] 品类 ${cat.name} 处理出错,跳过: ${catErr && catErr.message ? catErr.message : catErr}`
388
+ );
389
+ bodyLines.push(`## ${cat.name} — 采集失败`, "", "---", "");
352
390
  }
391
+ }
392
+
393
+ const ratio = totalBooks ? resolvedTitles / totalBooks : 0;
394
+ const quality =
395
+ totalBooks === 0 ? "[无数据]" : ratio < 0.5 ? "[标题解析异常]" : "[OK]";
396
+ lines.splice(
397
+ 5,
398
+ 0,
399
+ `- 标题解析:成功 ${resolvedTitles} / 共 ${totalBooks}`,
400
+ `- 数据质量:${quality}`
401
+ );
353
402
 
354
- bodyLines.push("---", "");
355
- } catch (catErr) {
403
+ if (totalBooks > 0 && resolvedTitles === 0) {
404
+ console.error(
405
+ ` ✗ ${chLabel}${tyLabel}:${totalBooks} 本全部标题解析失败。请在浏览器内手动打开任一 https://fanqienovel.com/page/{bookId} 确认页面正常。`
406
+ );
407
+ } else if (ratio < 0.5) {
356
408
  console.error(
357
- ` [fanqie] 品类 ${cat.name} 处理出错,跳过: ${catErr && catErr.message ? catErr.message : catErr}`
409
+ ` ${chLabel}${tyLabel}:标题解析率偏低(${resolvedTitles}/${totalBooks}),结果质量已标注。`
358
410
  );
359
- bodyLines.push(`## ${cat.name} — 采集失败`, "", "---", "");
360
411
  }
361
- }
362
412
 
363
- // 质量状态:标题解析比例是番茄采集成败的核心信号
364
- const ratio = totalBooks ? resolvedTitles / totalBooks : 0;
365
- const quality = totalBooks === 0
366
- ? "[无数据]"
367
- : ratio < 0.5
368
- ? "[标题解析异常]"
369
- : "[OK]";
370
- lines.splice(5, 0,
371
- `- 标题解析:成功 ${resolvedTitles} / 共 ${totalBooks}`,
372
- `- 数据质量:${quality}`
373
- );
374
-
375
- if (totalBooks > 0 && resolvedTitles === 0) {
376
- console.error(
377
- ` ✗ ${chLabel}${tyLabel}:${totalBooks} 本全部标题解析失败。多为详情页结构变动或登录/验证拦截,` +
378
- `请在 Chrome 内手动打开任一 https://fanqienovel.com/page/{bookId} 确认页面正常。`
379
- );
380
- } else if (ratio < 0.5) {
381
- console.error(
382
- ` ⚠ ${chLabel}${tyLabel}:标题解析率偏低(${resolvedTitles}/${totalBooks}),结果质量已标注。`
383
- );
413
+ scraped = lines.concat(bodyLines).join("\n");
414
+ } finally {
415
+ await browser.close();
384
416
  }
385
417
 
386
- return lines.concat(bodyLines).join("\n");
418
+ return scraped;
387
419
  }
388
420
 
389
- function main() {
421
+ async function main() {
390
422
  if (!["0", "1", "all"].includes(CHANNEL)) {
391
423
  throw new Error(`未知 --channel: ${CHANNEL}`);
392
424
  }
@@ -400,7 +432,7 @@ function main() {
400
432
  for (const ch of channels) {
401
433
  for (const ty of types) {
402
434
  try {
403
- const content = scrapeChannel(ch, ty);
435
+ const content = await scrapeChannel(ch, ty);
404
436
  if (!content) continue;
405
437
 
406
438
  const date = localDateStamp();
@@ -424,11 +456,8 @@ if (require.main === module) {
424
456
  runCli(main, "番茄采集");
425
457
  }
426
458
 
427
- // 导出纯函数/JS 构建器,供测试在 sandbox 内验证解析逻辑
459
+ // 导出纯函数/JS 构建器
428
460
  module.exports = {
429
- buildCategoriesJS,
430
- buildBookListJS,
431
- buildDetailJS,
432
461
  fmtReads,
433
462
  fmtWords,
434
463
  fmtStatus,