@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,14 +2,12 @@
2
2
  /**
3
3
  * 晋江文学城排行榜采集脚本
4
4
  *
5
- * 配合 browser-cdp skill 使用。先启动 Chrome CDP 环境,再运行本脚本。
5
+ * 使用 playwright-core 自己管理浏览器。
6
6
  * 采集策略:
7
7
  * 1) topten.php 列表页(纯文本,频道名直接出现,书名/作者交替行)解出频道分组。
8
8
  * 2) 从书名 anchor 取 novelid,逐本进 onebook.php 详情页补采核心指标
9
9
  * (收藏数/营养液/积分/字数/状态),满足规范对晋江的硬性要求。
10
- * 晋江页面为 gb18030 编码:详情页用 fetch+arrayBuffer+TextDecoder('gb18030') 解码
11
- * (同步 XHR 的 responseText 会按 UTF-8 解码导致中文乱码)。
12
- * 详情采集默认开启但有上限(每频道前 N + 总量上限),用 --list-only 可只采列表。
10
+ * 晋江页面为 gb18030 编码:详情页用 fetch+arrayBuffer+TextDecoder('gb18030') 解码。
13
11
  *
14
12
  * 用法:
15
13
  * node jjwxc-rank-scraper.js --type 12 # 收入金榜(默认含详情)
@@ -17,219 +15,196 @@
17
15
  * node jjwxc-rank-scraper.js --type 12 --detail-limit 60 # 详情总量上限 60
18
16
  * node jjwxc-rank-scraper.js --type 12 --list-only # 只采列表(快,无核心指标)
19
17
  * node jjwxc-rank-scraper.js --type all # 全部榜单
20
- *
21
- * 前置:
22
- * node {SKILL_DIR}/browser-cdp/scripts/setup-cdp-chrome.js 9222
18
+ * node jjwxc-rank-scraper.js --login-wait 60 # 等待手动登录
23
19
  */
24
20
 
25
21
  const fs = require("fs");
26
22
  const path = require("path");
27
- const { ab, sleep, evalJSONBase64, getArg, localDateStamp, runCli } = require("./cdp-utils.cjs");
23
+ const { chromium } = require("playwright-core");
24
+ const { getArg, localDateStamp, runCli } = require("./cdp-utils.cjs");
28
25
 
29
26
  const BASE_URL = "https://www.jjwxc.net/topten.php";
27
+ const DETAIL_BASE = "https://www.jjwxc.net/onebook.php";
30
28
 
31
29
  const RANK_TYPES = [
32
30
  { id: "12", label: "收入金榜" },
33
- { id: "7", label: "月榜" },
34
- { id: "8", label: "季度榜" },
31
+ { id: "7", label: "月榜" },
32
+ { id: "8", label: "季度榜" },
35
33
  { id: "14", label: "完结金榜" },
36
34
  { id: "15", label: "新手金榜" },
37
35
  { id: "17", label: "千字金榜" },
38
36
  ];
39
37
 
40
- // 详情请求批大小(async fetch 并发,整批控制在 ab() 20s 超时内)
41
38
  const DETAIL_CHUNK = 6;
42
39
 
43
- /** 连通性 + 页面就绪自检 */
44
- function probePage(port) {
45
- return evalJSONBase64(
46
- port,
47
- "JSON.stringify({host:location.host,len:(document.body&&document.body.innerText||'').length})"
48
- );
49
- }
50
-
51
40
  // ---------------------------------------------------------------------------
52
- // 列表页提取
41
+ // 工具函数
53
42
  // ---------------------------------------------------------------------------
54
43
 
55
- /**
56
- * 提取晋江榜单数据(频道分组 + 书名/作者交替),并从书名 anchor 附上 novelid。
57
- */
58
- function extractRankData(port) {
59
- const js =
60
- "JSON.stringify((function(){" +
61
- "var result={channels:[]};" +
62
- "var text=document.body.innerText||'';" +
63
- "var lines=text.split(/\\n/).map(function(l){return l.trim()}).filter(Boolean);" +
64
- // 书名 anchor novelid(排除霸王票"X向《书名》投了Y"这类记录)
65
- "var idMap={};" +
66
- "Array.from(document.querySelectorAll('a')).forEach(function(a){" +
67
- " var hm=(a.getAttribute('href')||'').match(/novelid=([0-9]+)/);if(!hm)return;" +
68
- " var t=(a.innerText||a.textContent||'').trim();" +
69
- " if(!t||t.indexOf('向《')>-1||t.indexOf('投')>-1||t.length>30)return;" +
70
- " if(!idMap[t])idMap[t]=hm[1];" +
71
- "});" +
72
- "var channels=['古代言情','现代言情','古代穿越','现代都市纯爱','现代幻想纯爱','古代纯爱','衍生纯爱','幻想现言','奇幻言情','未来游戏悬疑','百合','无CP','二次元言情','衍生言情','衍生无cp','未来幻想纯爱','原创轻小说','多元'];" +
73
- "var channelSet={};channels.forEach(function(c){channelSet[c]=true});" +
74
- "var curChannel='';" +
75
- "var channelBooks={};" +
76
- "var expectTitle=true;" +
77
- "var pendingTitle='';" +
78
- "for(var i=0;i<lines.length;i++){" +
79
- " var line=lines[i];" +
80
- " if(/上榜天数记录|榜单说明/.test(line)){break}" +
81
- " if(/^(免费强推|vip强推|新晋作者|月榜|季榜|半年榜|长生殿|总分榜|字数榜|收入金榜|霸王票|霸王总榜|勤奋指数|完结金榜|新手金榜|栽培月榜|驻站|完结高分|千字金榜|完结全订榜)$/.test(line)){continue}" +
82
- " if(line.length>30&&line.indexOf('·')>0)continue;" +
83
- " if(channelSet[line]){" +
84
- " if(curChannel&&channelBooks[curChannel])channelBooks[curChannel]._finished=true;" +
85
- " curChannel=line;" +
86
- " if(!channelBooks[curChannel])channelBooks[curChannel]={books:[]};" +
87
- " expectTitle=true;pendingTitle='';continue" +
88
- " }" +
89
- " if(!curChannel)continue;" +
90
- " if(expectTitle){" +
91
- " pendingTitle=line;expectTitle=false" +
92
- " }else{" +
93
- " if(pendingTitle){" +
94
- " channelBooks[curChannel].books.push({title:pendingTitle,author:line,novelid:idMap[pendingTitle]||''})" +
95
- " }" +
96
- " expectTitle=true;pendingTitle=''" +
97
- " }" +
98
- "}" +
99
- "for(var name in channelBooks){" +
100
- " if(channelBooks[name].books.length>0){" +
101
- " result.channels.push({name:name,books:channelBooks[name].books})" +
102
- " }" +
103
- "}" +
104
- "return result" +
105
- "})())";
106
- return evalJSONBase64(port, js);
44
+ function sleep(ms) {
45
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
46
+ }
47
+
48
+ function fmtWan(s, unit) {
49
+ if (s == null || s === "") return "";
50
+ const n = parseInt(String(s).replace(/[^0-9]/g, ""), 10);
51
+ if (isNaN(n)) return "";
52
+ if (n >= 10000) return (n / 10000).toFixed(1) + "万" + (unit || "");
53
+ return n + (unit || "");
107
54
  }
108
55
 
109
56
  // ---------------------------------------------------------------------------
110
- // 详情页提取(gb18030 + itemprop 微数据)
57
+ // 详情页(Node.js fetch + gb18030 解码)
111
58
  // ---------------------------------------------------------------------------
112
59
 
113
- /** 构建:一批 novelid 的详情解码 JS(async fetch + TextDecoder,返回 JSON 字符串) */
114
- function buildDetailJS(ids) {
115
- return `Promise.all(${JSON.stringify(ids)}.map(function(id){
116
- return fetch('/onebook.php?novelid='+id)
117
- .then(function(r){return r.arrayBuffer()})
118
- .then(function(b){
119
- var h=new TextDecoder('gb18030').decode(new Uint8Array(b));
120
- function prop(n){var m=h.match(new RegExp('itemprop="'+n+'"[^>]*>([^<]*)<'));return m?m[1].trim():'';}
121
- var status=(h.match(/itemprop="updataStatus"[^>]*>\\s*([^<\\s]{1,6})/)||[,''])[1]
122
- ||(h.match(/(连载中|已完结|完结)/)||[,''])[1]||'';
123
- return {id:id,collect:prop('collectedCount'),nutrition:prop('nutritionCount'),
124
- score:prop('scoreCount'),review:prop('reviewCount'),words:prop('wordCount'),status:status};
125
- })
126
- .catch(function(e){return {id:id,err:String(e&&e.message||e)}});
127
- })).then(function(arr){var map={};arr.forEach(function(o){map[o.id]=o});return JSON.stringify(map);})`;
60
+ /** 单次详情请求(Node.js fetch,gb18030 解码) */
61
+ async function fetchOneDetail(novelid) {
62
+ try {
63
+ const res = await fetch(`${DETAIL_BASE}?novelid=${novelid}`);
64
+ if (!res.ok) return { id: novelid, err: `HTTP ${res.status}` };
65
+ const buf = await res.arrayBuffer();
66
+ const h = new TextDecoder("gb18030").decode(new Uint8Array(buf));
67
+ function prop(n) {
68
+ const m = h.match(new RegExp(`itemprop="${n}"[^>]*>([^<]*)<`));
69
+ return m ? m[1].trim() : "";
70
+ }
71
+ const status =
72
+ (h.match(/itemprop="updataStatus"[^>]*>\s*([^\s<]{1,6})/) || [, ""])[1] ||
73
+ (h.match(/(连载中|已完结|完结)/) || [, ""])[1] ||
74
+ "";
75
+ return {
76
+ id: novelid,
77
+ collect: prop("collectedCount"),
78
+ nutrition: prop("nutritionCount"),
79
+ score: prop("scoreCount"),
80
+ review: prop("reviewCount"),
81
+ words: prop("wordCount"),
82
+ status,
83
+ };
84
+ } catch (e) {
85
+ return { id: novelid, err: String(e && e.message || e) };
86
+ }
128
87
  }
129
88
 
130
- /**
131
- * 分批解码详情,合并结果。
132
- * 每批单独 try/catch:整批的并发 fetch 贴着 ab() 的 20s 超时线,一次瞬时超时(或
133
- * 返回非 JSON)只该丢这 6 本,不能连坐后面几十本,更不能把已解析好的列表带走。
134
- */
135
- function fetchDetails(port, ids) {
89
+ /** 分批补采详情 */
90
+ async function fetchDetails(ids) {
136
91
  const map = {};
137
92
  let failedChunks = 0;
138
93
  for (let i = 0; i < ids.length; i += DETAIL_CHUNK) {
139
94
  const chunk = ids.slice(i, i + DETAIL_CHUNK);
140
- try {
141
- const part = evalJSONBase64(port, buildDetailJS(chunk)) || {};
142
- Object.assign(map, part);
143
- } catch (chunkErr) {
144
- failedChunks++;
145
- console.error(
146
- ` ⚠ 详情批次 ${Math.floor(i / DETAIL_CHUNK) + 1}(${chunk.length} 本)获取失败,跳过: ${chunkErr.message}`
147
- );
148
- }
95
+ const results = await Promise.all(chunk.map(id => fetchOneDetail(id)));
96
+ for (const r of results) map[r.id] = r;
149
97
  sleep(400);
150
98
  }
151
- if (failedChunks > 0) {
152
- console.error(` ⚠ 共 ${failedChunks} 个详情批次失败,这部分书只有列表数据。`);
153
- }
154
- return { map, failedChunks };
155
- }
156
-
157
- // ---------------------------------------------------------------------------
158
- // 格式化
159
- // ---------------------------------------------------------------------------
160
-
161
- function fmtWan(s, unit) {
162
- if (s == null || s === "") return "";
163
- const n = parseInt(String(s).replace(/[^0-9]/g, ""), 10);
164
- if (isNaN(n)) return "";
165
- if (n >= 10000) return (n / 10000).toFixed(1) + "万" + (unit || "");
166
- return n + (unit || "");
99
+ return map;
167
100
  }
168
101
 
169
102
  // ---------------------------------------------------------------------------
170
103
  // 主流程
171
104
  // ---------------------------------------------------------------------------
172
105
 
173
- const args = process.argv.slice(2);
174
- const PORT = parseInt(getArg(args, "--port") || "9222", 10);
175
- const OUTDIR = getArg(args, "--outdir") || ".";
176
- const RANKTYPE = getArg(args, "--type") || "12";
177
- const CHANNEL = getArg(args, "--channel") || "0";
178
- const TOP = parseInt(getArg(args, "--top") || "10", 10);
179
- const DETAIL_LIMIT = parseInt(getArg(args, "--detail-limit") || "100", 10);
180
- const LIST_ONLY = args.includes("--list-only");
181
-
182
- function scrapeRank(port, rankTypeId, channelId) {
183
- const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
184
- if (!rt) {
185
- console.log(` ⚠ 未知榜单类型: ${rankTypeId}`);
186
- return null;
187
- }
106
+ async function scrapeRank(rankTypeId, channelId) {
107
+ const rt = RANK_TYPES.find(r => r.id === rankTypeId);
108
+ if (!rt) { console.log(` ⚠ 未知榜单类型: ${rankTypeId}`); return null; }
188
109
 
189
110
  const url = `${BASE_URL}?orderstr=${rankTypeId}&t=${channelId}`;
190
111
  const chLabel = channelId === "0" ? "全站" : `频道${channelId}`;
191
112
  console.log(`\n→ 采集 晋江${rt.label}(${chLabel})...`);
192
113
  console.log(` URL: ${url}`);
193
114
 
194
- let data;
115
+ const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
116
+ const context = await browser.newContext();
117
+ const page = await context.newPage();
118
+
119
+ let data = null;
195
120
  try {
196
- ab(port, "open", url);
197
- sleep(4000);
198
-
199
- // 连通性自检:CDP 未起/被重定向时给可操作报错,而非误报"结构已变"
200
- const probe = probePage(port);
201
- if (!probe) {
202
- console.error(
203
- ` ✗ CDP 无响应。请确认已用 browser-cdp 启动 Chrome(端口 ${port}),且 agent-browser 可用。`
204
- );
205
- return null;
206
- }
207
- if (probe.host && probe.host.indexOf("jjwxc") === -1) {
208
- console.error(` ✗ 当前页面非晋江(host=${probe.host}),可能被重定向,已跳过。`);
121
+ await page.goto(url, { waitUntil: "networkidle" });
122
+ await page.waitForTimeout(3000);
123
+
124
+ // 连通性自检
125
+ const host = page.url();
126
+ if (host.indexOf("jjwxc") === -1) {
127
+ console.error(` ✗ 当前页面非晋江(url=${host}),可能被重定向,已跳过。`);
209
128
  return null;
210
129
  }
211
130
 
212
- data = extractRankData(port);
131
+ // 提取列表数据
132
+ data = await page.evaluate(() => {
133
+ var result = { channels: [] };
134
+ var text = document.body.innerText || "";
135
+ var lines = text.split(/\n/).map(function(l) { return l.trim(); }).filter(Boolean);
136
+
137
+ var idMap = {};
138
+ Array.from(document.querySelectorAll("a")).forEach(function(a) {
139
+ var hm = (a.getAttribute("href") || "").match(/novelid=([0-9]+)/);
140
+ if (!hm) return;
141
+ var t = (a.innerText || a.textContent || "").trim();
142
+ if (!t || t.indexOf("向《") > -1 || t.indexOf("投") > -1 || t.length > 30) return;
143
+ if (!idMap[t]) idMap[t] = hm[1];
144
+ });
145
+
146
+ var channels = ["古代言情","现代言情","古代穿越","现代都市纯爱","现代幻想纯爱","古代纯爱","衍生纯爱","幻想现言","奇幻言情","未来游戏悬疑","百合","无CP","二次元言情","衍生言情","衍生无cp","未来幻想纯爱","原创轻小说","多元"];
147
+ var channelSet = {};
148
+ channels.forEach(function(c) { channelSet[c] = true; });
149
+
150
+ var curChannel = "";
151
+ var channelBooks = {};
152
+ var expectTitle = true;
153
+ var pendingTitle = "";
154
+
155
+ for (var i = 0; i < lines.length; i++) {
156
+ var line = lines[i];
157
+ if (/上榜天数记录|榜单说明/.test(line)) { break; }
158
+ if (/^(免费强推|vip强推|新晋作者|月榜|季榜|半年榜|长生殿|总分榜|字数榜|收入金榜|霸王票|霸王总榜|勤奋指数|完结金榜|新手金榜|栽培月榜|驻站|完结高分|千字金榜|完结全订榜)$/.test(line)) { continue; }
159
+ if (line.length > 30 && line.indexOf("·") > 0) continue;
160
+ if (channelSet[line]) {
161
+ if (curChannel && channelBooks[curChannel]) channelBooks[curChannel]._finished = true;
162
+ curChannel = line;
163
+ if (!channelBooks[curChannel]) channelBooks[curChannel] = { books: [] };
164
+ expectTitle = true;
165
+ pendingTitle = "";
166
+ continue;
167
+ }
168
+ if (!curChannel) continue;
169
+ if (expectTitle) {
170
+ pendingTitle = line;
171
+ expectTitle = false;
172
+ } else {
173
+ if (pendingTitle) {
174
+ channelBooks[curChannel].books.push({ title: pendingTitle, author: line, novelid: idMap[pendingTitle] || "" });
175
+ }
176
+ expectTitle = true;
177
+ pendingTitle = "";
178
+ }
179
+ }
180
+
181
+ for (var name in channelBooks) {
182
+ if (channelBooks[name].books.length > 0) {
183
+ result.channels.push({ name: name, books: channelBooks[name].books });
184
+ }
185
+ }
186
+ return result;
187
+ });
188
+
213
189
  if (!data?.channels?.length) {
214
190
  console.error(`[jjwxc] 采集失败:未解析到榜单(页面结构可能变动或未加载)。请人工打开 ${url} 确认。`);
215
191
  return null;
216
192
  }
217
- } catch (err) {
218
- console.error(`[jjwxc] ${rt.label} 页面加载或提取出错: ${err.message}`);
219
- return null;
193
+ } finally {
194
+ await browser.close();
220
195
  }
221
196
 
222
197
  let totalBooks = 0;
223
- data.channels.forEach((ch) => {
198
+ data.channels.forEach(ch => {
224
199
  totalBooks += ch.books.length;
225
- const authors = new Set(ch.books.map((b) => b.author));
200
+ const authors = new Set(ch.books.map(b => b.author));
226
201
  if (ch.books.length >= 5 && authors.size / ch.books.length < 0.2) {
227
202
  console.log(` ⚠ ${ch.name}:${ch.books.length} 本只有 ${authors.size} 个唯一作者,可能提取有误`);
228
203
  }
229
204
  });
230
205
  console.log(` ✓ 列表:${data.channels.length} 个频道,共 ${totalBooks} 本`);
231
206
 
232
- // 选取每频道前 TOP 本(有 novelid 的)补采详情,受 DETAIL_LIMIT 总量约束
207
+ // 补采详情
233
208
  let detailMap = {};
234
209
  let detailPlanned = 0;
235
210
  let detailOk = 0;
@@ -247,32 +222,19 @@ function scrapeRank(port, rankTypeId, channelId) {
247
222
  }
248
223
  detailPlanned = picked.length;
249
224
  if (picked.length) {
250
- console.log(` → 补采详情 ${picked.length} 本(每频道前 ${TOP},上限 ${DETAIL_LIMIT})...`);
251
- // 详情是列表的增补,不是前提:整段失败也要保住已解析好的列表落盘
252
- // (下面的质量门会把 detailOk===0 标成 [详情解析异常/登录态缺失])
253
- try {
254
- const detailResult = fetchDetails(port, picked);
255
- detailMap = detailResult.map;
256
- detailFailedChunks = detailResult.failedChunks;
257
- } catch (detailErr) {
258
- detailMap = {};
259
- detailFailedChunks = Math.max(1, Math.ceil(picked.length / DETAIL_CHUNK));
260
- console.error(` ⚠ 详情补采整体失败,仅保留列表数据: ${detailErr.message}`);
261
- }
262
- detailOk = Object.values(detailMap).filter((d) => d && d.collect).length;
225
+ console.log(` → 补采详情 ${picked.length} 本...`);
226
+ detailMap = await fetchDetails(picked);
227
+ detailFailedChunks = Object.values(detailMap).filter(d => d.err).length;
228
+ detailOk = Object.values(detailMap).filter(d => d && d.collect).length;
263
229
  console.log(` ✓ 详情命中收藏数 ${detailOk}/${picked.length}`);
264
230
  }
265
231
  }
266
232
 
267
- // 质量状态:详情开启时,收藏数命中率是核心信号
268
233
  let quality = "[OK]";
269
- const detailPartial =
270
- !LIST_ONLY &&
271
- detailPlanned > 0 &&
272
- (detailFailedChunks > 0 || detailOk < detailPlanned);
234
+ const detailPartial = !LIST_ONLY && detailPlanned > 0 && (detailFailedChunks > 0 || detailOk < detailPlanned);
273
235
  if (!LIST_ONLY && detailPlanned > 0 && detailOk === 0) {
274
236
  quality = "[详情解析异常/登录态缺失]";
275
- console.error(` ⚠ 详情全部无收藏数:可能页面结构变动或需登录,已在文件头标注。`);
237
+ console.error(` ⚠ 详情全部无收藏数:可能页面结构变动或需登录。`);
276
238
  } else if (detailPartial) {
277
239
  quality = "[部分详情缺失]";
278
240
  console.error(` ⚠ 详情仅命中 ${detailOk}/${detailPlanned},已按部分结果标注。`);
@@ -288,7 +250,7 @@ function scrapeRank(port, rankTypeId, channelId) {
288
250
  `- 抓取时间:${now}`,
289
251
  `- 频道数:${data.channels.length}`,
290
252
  `- 总条目数:${totalBooks}`,
291
- `- 详情采集:${detailOk} / ${detailPlanned}(每频道前 ${TOP},上限 ${DETAIL_LIMIT})`,
253
+ `- 详情采集:${detailOk} / ${detailPlanned}`,
292
254
  `- 数据质量:${quality}`,
293
255
  "",
294
256
  "---",
@@ -296,108 +258,86 @@ function scrapeRank(port, rankTypeId, channelId) {
296
258
  ];
297
259
 
298
260
  for (const ch of data.channels) {
299
- try {
300
- lines.push(`## ${ch.name} ${ch.books.length} 本`, "");
301
- for (let i = 0; i < ch.books.length; i++) {
302
- try {
303
- const b = ch.books[i];
304
- lines.push(`### #${i + 1} ${b.title}`);
305
- const d = b.novelid ? detailMap[b.novelid] : null;
306
- const seg = [b.author || ""];
307
- if (d) {
308
- if (d.collect) seg.push("收藏 " + fmtWan(d.collect));
309
- if (d.nutrition) seg.push("营养液 " + fmtWan(d.nutrition));
310
- if (d.score) seg.push("积分 " + d.score);
311
- if (d.words) seg.push("字数 " + fmtWan(d.words, "字"));
312
- if (d.status) seg.push(d.status);
313
- }
314
- const meta = seg.filter(Boolean).join(" · ");
315
- if (meta) lines.push(`*${meta}*`);
316
- if (b.novelid) lines.push(`[作品页](https://www.jjwxc.net/onebook.php?novelid=${b.novelid})`);
317
- lines.push("");
318
- } catch (bookErr) {
319
- console.error(`[jjwxc] ${rt.label} ${ch.name} 第${i + 1}条处理出错: ${bookErr.message}`);
320
- lines.push("");
321
- }
261
+ lines.push(`## ${ch.name} — ${ch.books.length} 本`, "");
262
+ for (let i = 0; i < ch.books.length; i++) {
263
+ const b = ch.books[i];
264
+ const d = b.novelid ? detailMap[b.novelid] : null;
265
+ const seg = [b.author || ""];
266
+ if (d) {
267
+ if (d.collect) seg.push("收藏 " + fmtWan(d.collect));
268
+ if (d.nutrition) seg.push("营养液 " + fmtWan(d.nutrition));
269
+ if (d.score) seg.push("积分 " + d.score);
270
+ if (d.words) seg.push("字数 " + fmtWan(d.words, "字"));
271
+ if (d.status) seg.push(d.status);
322
272
  }
323
- lines.push("---", "");
324
- } catch (chErr) {
325
- console.error(`[jjwxc] ${rt.label} 频道「${ch.name}」处理出错,跳过: ${chErr.message}`);
273
+ const meta = seg.filter(Boolean).join(" · ");
274
+ if (meta) lines.push(`*${meta}*`);
275
+ if (b.novelid) lines.push(`[作品页](https://www.jjwxc.net/onebook.php?novelid=${b.novelid})`);
276
+ lines.push("");
326
277
  }
278
+ lines.push("---", "");
327
279
  }
328
280
 
329
281
  return {
330
282
  content: lines.join("\n"),
331
283
  partial: detailPartial,
332
- partialReason: detailPartial
333
- ? `${rt.label}: detail ${detailOk}/${detailPlanned}, failed chunks ${detailFailedChunks}`
334
- : "",
284
+ partialReason: detailPartial ? `${rt.label}: detail ${detailOk}/${detailPlanned}` : "",
335
285
  };
336
286
  }
337
287
 
338
- function main() {
339
- if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
288
+ // ---------------------------------------------------------------------------
289
+ // 入口
290
+ // ---------------------------------------------------------------------------
291
+
292
+ const args = process.argv.slice(2);
293
+ const OUTDIR = getArg(args, "--outdir") || ".";
294
+ const RANKTYPE = getArg(args, "--type") || "12";
295
+ const CHANNEL = getArg(args, "--channel") || "0";
296
+ const TOP = parseInt(getArg(args, "--top") || "10", 10);
297
+ const DETAIL_LIMIT = parseInt(getArg(args, "--detail-limit") || "100", 10);
298
+ const LIST_ONLY = args.includes("--list-only");
299
+ const LOGIN_WAIT = parseInt(getArg(args, "--login-wait") || "0", 10);
300
+
301
+ async function main() {
302
+ if (RANKTYPE !== "all" && !RANK_TYPES.some(r => r.id === RANKTYPE)) {
340
303
  throw new Error(`未知 --type: ${RANKTYPE}`);
341
304
  }
342
- // 当前脚本只实现全站榜(t=0);不能把任意数字静默标成“频道 N”。
343
- // 若后续支持分频道,先从页面提取并维护明确 ID 白名单再开放。
344
305
  if (CHANNEL !== "0") {
345
306
  throw new Error(`未知 --channel: ${CHANNEL}(当前仅支持 0=全站)`);
346
307
  }
347
- const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
348
- const channels = [CHANNEL]; // 晋江频道 ID 需从页面获取,默认全站
349
- let written = 0;
350
- let failed = 0;
351
- let partial = false;
308
+ const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map(r => r.id) : [RANKTYPE];
309
+ let written = 0, failed = 0, partial = false;
352
310
  const partialReasons = [];
353
311
 
354
312
  for (const rt of rankTypes) {
355
- for (const ch of channels) {
356
- // per-榜单隔离:一个榜单出错不该掐掉 --type all 后面的榜单(与番茄/刺猬猫一致)
357
- try {
358
- const result = scrapeRank(PORT, rt, ch);
359
- if (!result) {
360
- failed++;
361
- const rtInfo = RANK_TYPES.find((r) => r.id === rt);
362
- partialReasons.push(`${rtInfo ? rtInfo.label : rt}: no usable data`);
363
- continue;
364
- }
365
- if (result.partial) {
366
- partial = true;
367
- if (result.partialReason) partialReasons.push(result.partialReason);
368
- }
369
-
370
- const rtInfo = RANK_TYPES.find((r) => r.id === rt);
371
- const date = localDateStamp();
372
- const chLabel = ch === "0" ? "全站" : `频道${ch}`;
373
- const filename = `晋江${rtInfo.label}_${chLabel}_${date}.md`;
374
- fs.mkdirSync(OUTDIR, { recursive: true });
375
- const filepath = path.join(OUTDIR, filename);
376
- fs.writeFileSync(filepath, result.content, "utf-8");
377
- written++;
378
- console.log(` ✓ 已保存: ${filepath}`);
379
- } catch (rankErr) {
380
- failed++;
381
- const rtInfo = RANK_TYPES.find((r) => r.id === rt);
382
- const message = rankErr && rankErr.message ? rankErr.message : String(rankErr);
383
- partialReasons.push(`${rtInfo ? rtInfo.label : rt}: ${message}`);
384
- console.error(
385
- `[jjwxc] ${rtInfo ? rtInfo.label : rt} 采集失败,跳过: ${message}`
386
- );
313
+ try {
314
+ const result = await scrapeRank(rt, CHANNEL);
315
+ if (!result) { failed++; continue; }
316
+ if (result.partial) {
317
+ partial = true;
318
+ if (result.partialReason) partialReasons.push(result.partialReason);
387
319
  }
320
+ const rtInfo = RANK_TYPES.find(r => r.id === rt);
321
+ const date = localDateStamp();
322
+ const filename = `晋江${rtInfo.label}_全站_${date}.md`;
323
+ fs.mkdirSync(OUTDIR, { recursive: true });
324
+ const filepath = path.join(OUTDIR, filename);
325
+ fs.writeFileSync(filepath, result.content, "utf-8");
326
+ written++;
327
+ console.log(` ✓ 已保存: ${filepath}`);
328
+ } catch (rankErr) {
329
+ failed++;
330
+ const message = rankErr && rankErr.message ? rankErr.message : String(rankErr);
331
+ partialReasons.push(`${rt}: ${message}`);
332
+ console.error(`[jjwxc] ${rt} 采集失败,跳过: ${message}`);
388
333
  }
389
334
  }
390
- return {
391
- planned: rankTypes.length * channels.length,
392
- written,
393
- failed,
394
- partial: partial || failed > 0,
395
- partialReasons,
396
- };
335
+
336
+ return { planned: rankTypes.length, written, failed, partial: partial || failed > 0, partialReasons };
397
337
  }
398
338
 
399
339
  if (require.main === module) {
400
340
  runCli(main, "晋江采集");
401
341
  }
402
342
 
403
- module.exports = { buildDetailJS, fmtWan };
343
+ module.exports = { fmtWan };
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "fancy-scan-scrapers",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "node_modules/playwright-core": {
8
+ "version": "1.62.1",
9
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
10
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
11
+ "license": "Apache-2.0",
12
+ "bin": {
13
+ "playwright-core": "cli.js"
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ }
18
+ }
19
+ }
20
+ }