@cloud411716/fancy-webnovel 1.0.46 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +427 -0
  238. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  239. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  240. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  241. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +168 -0
  242. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +184 -0
  243. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  244. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +131 -0
  245. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +85 -0
  246. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +103 -0
  247. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +68 -0
  248. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +142 -0
  249. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +292 -0
  250. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +266 -0
  251. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +336 -0
  252. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  253. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 晋江文学城排行榜采集脚本
4
+ *
5
+ * 使用 playwright-core 直接控制浏览器采集。
6
+ * 采集策略:
7
+ * 1) topten.php 列表页(纯文本,频道名直接出现,书名/作者交替行)解出频道分组。
8
+ * 2) 从书名 anchor 取 novelid,逐本进 onebook.php 详情页补采核心指标
9
+ * (收藏数/营养液/积分/字数/状态),满足规范对晋江的硬性要求。
10
+ * 晋江页面为 gb18030 编码:详情页用 fetch+arrayBuffer+TextDecoder('gb18030') 解码。
11
+ * 详情采集默认开启但有上限(每频道前 N + 总量上限),用 --list-only 可只采列表。
12
+ *
13
+ * 用法:
14
+ * node jjwxc-rank-scraper.js --type 12 # 收入金榜
15
+ * node jjwxc-rank-scraper.js --type 12 --top 15 # 每频道补采前 15 本
16
+ * node jjwxc-rank-scraper.js --type 12 --detail-limit 60 # 详情总量上限 60
17
+ * node jjwxc-rank-scraper.js --type 12 --list-only # 只采列表
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import { atomicWriteFile } from '#atomic-write';
22
+ import path from 'path';
23
+ import { pathToFileURL } from 'url';
24
+ import { chromium } from 'playwright-core';
25
+ import { getArg, localDateStamp, runCli, renderIntroLines, sanitizeBookFields, stripThousands } from '#scraper-utils';
26
+
27
+ const BASE_URL = "https://www.jjwxc.net/topten.php";
28
+
29
+ const RANK_TYPES = [
30
+ { id: "5", label: "月榜" },
31
+ { id: "7", label: "总分榜" },
32
+ { id: "4", label: "季度榜" },
33
+ { id: "3", label: "新晋作者榜" },
34
+ ];
35
+
36
+ const DETAIL_CHUNK = 6;
37
+ const DETAIL_LIMIT = 300;
38
+ const TOP = 10;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // 详情页解析(Node 侧纯函数,可被单元测试直接调用)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * HTML 片段 → 纯文本。
46
+ *
47
+ * 晋江的"文案"是多段的,段间是 `<br>`(不是 `<p>`),要转成换行才能读。
48
+ * 页面里还有 `&nbsp;`(晋江排版大量用它),不清掉会到处是空白。
49
+ */
50
+ export function htmlToText(html) {
51
+ return String(html || '')
52
+ .replace(/<br\s*\/?>/gi, '\n')
53
+ .replace(/<\/p>/gi, '\n\n')
54
+ .replace(/<[^>]+>/g, '')
55
+ .replace(/&nbsp;/gi, ' ')
56
+ .replace(/&amp;/gi, '&')
57
+ .replace(/&lt;/gi, '<')
58
+ .replace(/&gt;/gi, '>')
59
+ .replace(/&quot;/gi, '"')
60
+ .replace(/&#39;/gi, "'")
61
+ .replace(/\r\n?/g, '\n')
62
+ .replace(/[ \t]+\n/g, '\n')
63
+ .replace(/\n{3,}/g, '\n\n')
64
+ .trim();
65
+ }
66
+
67
+ /**
68
+ * 从详情页 HTML 里解析**简介**和**内容标签**。
69
+ *
70
+ * 简介:`<div id="novelintro" itemprop="description">…</div>`
71
+ * 文案有多段,段间是 `<br>`(见 htmlToText)。
72
+ *
73
+ * 内容标签:`<a href="//www.jjwxc.net/bookbase.php?bq=32">宫廷侯爵</a>`
74
+ * —— 整页里只有这些链接指向 bookbase。实测 12/12 本,
75
+ * **bq 链接数 == 标签数**(3~6 个/本),不会混进别的东西。
76
+ *
77
+ * 标签是**平台官方内容标签**,题材与元素套路混在一起
78
+ * (如 都市 / 破镜重圆 / 天之骄子 / 高岭之花 / 暗恋),
79
+ * 且**没有"主分类"概念** —— 这点与番茄不同(番茄第一个标签是主分类)。
80
+ */
81
+ export function parseNovelDetail(html) {
82
+ const out = { intro: '', tags: [] };
83
+ const m = String(html || '').match(/<div[^>]*id="novelintro"[^>]*>([\s\S]*?)<\/div>/i);
84
+ if (m) out.intro = htmlToText(m[1]);
85
+
86
+ const tags = [];
87
+ const re = /<a[^>]*href=["'][^"']*bookbase\.php\?bq=\d+[^"']*["'][^>]*>([^<]{1,30})<\/a>/gi;
88
+ let t;
89
+ while ((t = re.exec(String(html || ''))) !== null) {
90
+ const name = htmlToText(t[1]);
91
+ if (name && !tags.includes(name)) tags.push(name);
92
+ }
93
+ out.tags = tags;
94
+ return out;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // 命令行参数解析
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const args = process.argv.slice(2);
102
+ const RANKTYPE = getArg(args, "--type") || "12";
103
+ const LIST_ONLY = args.includes("--list-only");
104
+ const OUTDIR = getArg(args, "--outdir") || '.';
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // 列表页提取(使用 Playwright)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * 提取晋江榜单数据(从表格提取)
112
+ */
113
+ async function extractRankData(page) {
114
+ return await page.evaluate(() => {
115
+ var result = { channels: [{ name: '全站', books: [] }] };
116
+
117
+ // 从表格提取书籍数据
118
+ var tables = document.querySelectorAll('table');
119
+ for (var t = 0; t < tables.length; t++) {
120
+ var table = tables[t];
121
+ var rows = table.querySelectorAll('tr');
122
+ for (var i = 0; i < rows.length; i++) {
123
+ var row = rows[i];
124
+ var cells = row.querySelectorAll('td');
125
+ if (cells.length < 6) continue;
126
+
127
+ // 检查是否是书籍行(第一列是数字序号)
128
+ var rankCell = cells[0];
129
+ var rankNum = parseInt(rankCell.innerText.trim(), 10);
130
+ if (isNaN(rankNum)) continue;
131
+
132
+ var authorCell = cells[1];
133
+ var titleCell = cells[2];
134
+ var typeCell = cells[3];
135
+ var statusCell = cells[4];
136
+ var wordsCell = cells[5];
137
+ var scoreCell = cells[6] || null;
138
+
139
+ var author = (authorCell.innerText || '').trim();
140
+ var title = (titleCell.innerText || '').trim();
141
+ var type = (typeCell.innerText || '').trim();
142
+ var status = (statusCell.innerText || '').trim();
143
+ var words = (wordsCell.innerText || '').trim();
144
+ // 积分列(第 7 列)可能整列缺失 —— 榜单页偶尔只有 6 列。
145
+ // 之前直接取 cells[6].innerText 会抛 TypeError,
146
+ // page.evaluate 整体 reject → 整榜不落盘。
147
+ // 缺积分的书照样收,积分留空。
148
+ var score = scoreCell ? (scoreCell.innerText || '').trim() : '';
149
+
150
+ // 排除标题行
151
+ if (author === '作者' || title === '作品') continue;
152
+
153
+ // 获取 novelid
154
+ var novelid = '';
155
+ var link = titleCell.querySelector('a');
156
+ if (link) {
157
+ var href = link.getAttribute('href') || '';
158
+ var m = href.match(/novelid=([0-9]+)/);
159
+ if (m) novelid = m[1];
160
+ }
161
+
162
+ result.channels[0].books.push({
163
+ title: title,
164
+ author: author,
165
+ novelid: novelid,
166
+ type: type,
167
+ status: status,
168
+ words: words,
169
+ score: score
170
+ });
171
+ }
172
+ }
173
+ return result;
174
+ });
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // 单本书的字段渲染(纯函数,可被单元测试直接调用)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * 一本书 → markdown 字段行(不含 `### #N 书名` 标题行)。
183
+ *
184
+ * 抽出来是为了能单测这两条格式规则 —— 它们原本埋在 scrapeRank 的
185
+ * async 函数里,测不到:
186
+ *
187
+ * 1. **积分不带千分位逗号**:晋江榜单页写的是 "1,099,899,136",
188
+ * 存成纯数字,下游才好比较/求和,模型抄数字时也不会把逗号抄进去
189
+ * ("327,650,784" 和 "327650784" 在模型的算术里不是一回事);
190
+ * 2. **简介是 `- ` 列表项**:单换行用硬换行保留,空行才是段落分隔;
191
+ * 每段首尾无空白,作品页也单独作为一个列表项。
192
+ *
193
+ * @param {object} b - 一本书
194
+ * @returns {string[]} markdown 行
195
+ */
196
+ export function renderBookLines(raw) {
197
+ const b = sanitizeBookFields(raw);
198
+ const lines = [];
199
+ if (b.author) lines.push(`- **作者:** ${b.author}`);
200
+ if (b.type) lines.push(`- **题材:** ${b.type}`);
201
+ if (b.status) lines.push(`- **状态:** ${b.status}`);
202
+ if (b.words) lines.push(`- **字数:** ${stripThousands(b.words)}`);
203
+ // 积分是 10 亿级的,榜单页必带千分位逗号 —— 写盘前去掉。
204
+ if (b.score) lines.push(`- **积分:** ${stripThousands(b.score)}`);
205
+ if (b.tags && b.tags.length) lines.push(`- **标签:** ${b.tags.join('、')}`);
206
+ // 简介和其他字段一样是 `- ` 列表项;单换行用硬换行,空行才是段落分隔。
207
+ // 之前是「空行 + `**简介**` + 简介整块」—— 没有冒号,且和上下文粘成一段,
208
+ // 而且简介首尾的空白字符原样留在文件里。
209
+ lines.push(...renderIntroLines(b.intro || ''));
210
+ if (b.novelid) lines.push(`- [作品页](https://www.jjwxc.net/onebook.php?novelid=${b.novelid})`);
211
+ return lines;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // 详情页提取(gb18030 + itemprop 微数据)
216
+ // ---------------------------------------------------------------------------
217
+
218
+ async function fetchDetails(page, ids) {
219
+ // 分块抓:一次把 200 本的 HTML 全带过 evaluate 边界会有几十 MB 的
220
+ // 序列化开销,分块后每块才 1MB 上下。
221
+ const map = {};
222
+ for (let i = 0; i < ids.length; i += DETAIL_CHUNK) {
223
+ const chunk = ids.slice(i, i + DETAIL_CHUNK);
224
+ // 浏览器侧只负责"把解码后的原始 HTML 拿回来",
225
+ // **解析放在 Node 侧**(parseNovelDetail)—— 这样解析逻辑能被单元测试直接调用。
226
+ const details = await page.evaluate(async (list) => {
227
+ const results = [];
228
+ for (const id of list) {
229
+ try {
230
+ // redirect: 'error' 防止 onebook.php 重定向到外站造成 SSRF
231
+ const response = await fetch(`/onebook.php?novelid=${id}`, { redirect: 'error' });
232
+ const buffer = await response.arrayBuffer();
233
+ results.push({ id: id, html: new TextDecoder('gb18030').decode(buffer) });
234
+ } catch (e) {
235
+ results.push({ id: id, err: String(e) });
236
+ }
237
+ }
238
+ return results;
239
+ }, chunk);
240
+ for (const d of details) {
241
+ map[d.id] = d.err ? { err: d.err } : parseNovelDetail(d.html);
242
+ }
243
+ }
244
+ return map;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // 采集单个榜单
249
+ // ---------------------------------------------------------------------------
250
+
251
+ async function scrapeRank(browser, rankTypeId, channelId) {
252
+ const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
253
+ if (!rt) {
254
+ console.error(` ⚠ 未知榜单类型: ${rankTypeId}`);
255
+ return null;
256
+ }
257
+
258
+ const url = `${BASE_URL}?orderstr=${rankTypeId}&t=${channelId}`;
259
+ const chLabel = channelId === "0" ? "全站" : `频道${channelId}`;
260
+
261
+ const page = await browser.newPage();
262
+ const partialReasons = [];
263
+
264
+ try {
265
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
266
+
267
+ const data = await extractRankData(page);
268
+ if (!data?.channels?.length) {
269
+ console.error(`❌ 晋江-${rt.label}(${chLabel})采集失败:未解析到榜单(页面结构可能变动或未加载)。请人工打开 ${url} 确认。`);
270
+ return null;
271
+ }
272
+
273
+ let totalBooks = 0;
274
+ data.channels.forEach((ch) => {
275
+ totalBooks += ch.books.length;
276
+ const authors = new Set(ch.books.map((b) => b.author));
277
+ if (ch.books.length >= 5 && authors.size / ch.books.length < 0.2) {
278
+ console.error(` ⚠ ${ch.name}:${ch.books.length} 本只有 ${authors.size} 个唯一作者,可能提取有误`);
279
+ }
280
+ });
281
+ console.error(` ✓ 列表:${data.channels.length} 个频道,共 ${totalBooks} 本`);
282
+ // 解析到 0 本 = 页面结构变了 / 选择器失效 / 没加载出来。
283
+ // extractRankData 永远至少返回一个 `{ name: '全站', books: [] }`,
284
+ // 所以上面的 `!data.channels.length` 守卫**永远不触发** ——
285
+ // 结果是写出一个只有 "## 全站 — 0 本" 的空文件,
286
+ // 上层 `ok: scanFiles.length > 0` 还判定成功,用户拿到空报告。
287
+ if (totalBooks === 0) {
288
+ console.error(`❌ 晋江-${rt.label}(${chLabel})采集失败:页面解析到 0 本书(选择器可能失效或页面未加载)。请人工打开 ${url} 确认。`);
289
+ return null;
290
+ }
291
+
292
+ // 逐本进详情页补**简介**和**内容标签**(列表页没有这两个字段)。
293
+ // 此前 fetchDetails 写好了却从没被调用 —— 晋江产出的 md 里因此一直没有
294
+ // 简介和标签,提示词里"提取简介标签""简介标签"全是空的。
295
+ const detailIds = [];
296
+ for (const ch of data.channels) {
297
+ for (const b of ch.books) {
298
+ if (b.novelid && !detailIds.includes(b.novelid)) detailIds.push(b.novelid);
299
+ }
300
+ }
301
+ const detailIdsToFetch = detailIds.slice(0, DETAIL_LIMIT);
302
+ if (detailIds.length > DETAIL_LIMIT) {
303
+ console.error(` ⚠ 详情页只采前 ${DETAIL_LIMIT} 本(共 ${detailIds.length} 本),其余没有简介和标签`);
304
+ }
305
+ let detailMap = {};
306
+ if (!LIST_ONLY && detailIdsToFetch.length) {
307
+ console.error(` → 采详情页 ${detailIdsToFetch.length} 本(简介 + 内容标签)`);
308
+ detailMap = await fetchDetails(page, detailIdsToFetch);
309
+ const okCount = detailIdsToFetch.filter((id) => detailMap[id] && !detailMap[id].err).length;
310
+ console.error(` ✓ 详情页:${okCount}/${detailIdsToFetch.length} 本取到`);
311
+ }
312
+
313
+ // 构建输出内容
314
+ const now = new Date().toISOString();
315
+ const lines = [
316
+ `# 晋江 · ${rt.label}`,
317
+ "",
318
+ `- 来源:${url}`,
319
+ `- 抓取时间:${now}`,
320
+ `- 频道数:${data.channels.length}`,
321
+ `- 总条目数:${totalBooks}`,
322
+ ...(detailIds.length > DETAIL_LIMIT
323
+ ? [`- 详情覆盖:${DETAIL_LIMIT} / ${detailIds.length}(其余条目没有简介与标签)`]
324
+ : []),
325
+ "",
326
+ "---",
327
+ "",
328
+ ];
329
+
330
+
331
+ for (const ch of data.channels) {
332
+ lines.push(`## ${ch.name} — ${ch.books.length} 本`, "");
333
+ for (let i = 0; i < ch.books.length; i++) {
334
+ const b = ch.books[i];
335
+ const d = b.novelid ? detailMap[b.novelid] : null;
336
+ if (d && !d.err) {
337
+ b.intro = d.intro;
338
+ b.tags = d.tags;
339
+ } else if (d && d.err) {
340
+ partialReasons.push(`详情页取数失败 ${b.novelid}: ${d.err}`);
341
+ }
342
+ lines.push(`### #${i + 1} ${b.title}`);
343
+ lines.push(...renderBookLines(b));
344
+ lines.push("");
345
+ }
346
+ lines.push("---", "");
347
+ }
348
+
349
+ return {
350
+ content: lines.join("\n"),
351
+ partial: partialReasons.length > 0,
352
+ partialReasons,
353
+ };
354
+ } finally {
355
+ await page.close();
356
+ }
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // 主函数
361
+ // ---------------------------------------------------------------------------
362
+
363
+ async function main() {
364
+ const CHANNEL = getArg(args, "--channel") || "0";
365
+
366
+ if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
367
+ throw new Error(`未知 --type: ${RANKTYPE}`);
368
+ }
369
+ if (CHANNEL !== "0") {
370
+ throw new Error(`未知 --channel: ${CHANNEL}(当前仅支持 0=全站)`);
371
+ }
372
+
373
+ const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map((r) => r.id) : [RANKTYPE];
374
+ const channels = [CHANNEL];
375
+
376
+ let written = 0;
377
+ let failed = 0;
378
+ let partial = false;
379
+ const partialReasons = [];
380
+ const files = [];
381
+
382
+ // 启动浏览器
383
+ let browser;
384
+ // 被 SIGTERM 杀时(上游超时或中断)主动关掉浏览器,
385
+ // 否则 chromium 会留在系统里继续占 CPU/内存。
386
+ // close 必须带超时兜底:浏览器正在导航时 close() 可能挂住,
387
+ // 无超时的话进程会卡住不退出(实测过)。
388
+ process.on('SIGTERM', async () => {
389
+ try {
390
+ if (browser) {
391
+ await Promise.race([
392
+ browser.close(),
393
+ new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), 5000)),
394
+ ]);
395
+ }
396
+ } catch (_) {}
397
+ process.exit(143);
398
+ });
399
+ try {
400
+ browser = await chromium.launch({ headless: true, timeout: 30000 });
401
+ } catch (launchErr) {
402
+ console.log(JSON.stringify({ ok: false, error: `晋江采集失败:浏览器启动失败:${launchErr.message}` }));
403
+ process.exit(1);
404
+ }
405
+ try {
406
+ for (const rt of rankTypes) {
407
+ for (const ch of channels) {
408
+ try {
409
+ // rtInfo 必须在**读它的所有地方之前**声明。
410
+ // 之前它声明在 partialReasons 那个 if 的后面,
411
+ // 而上面的 if 会先读 rtInfo → TDZ ReferenceError
412
+ // → 整榜已经采好的 content 被 catch 吞掉、整榜不落盘。
413
+ // 只要有一本详情页失败就触发,等于"详情页偶发失败 = 整个榜单没了"。
414
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
415
+ const result = await scrapeRank(browser, rt, ch);
416
+ if (!result) {
417
+ failed++;
418
+ partialReasons.push(`${rtInfo ? rtInfo.label : rt}: no usable data`);
419
+ continue;
420
+ }
421
+ if (result.partial) {
422
+ partial = true;
423
+ }
424
+ if (result.partialReasons && result.partialReasons.length) {
425
+ for (const r of result.partialReasons) partialReasons.push(`${rtInfo ? rtInfo.label : rt}: ${r}`);
426
+ }
427
+
428
+ const date = localDateStamp();
429
+ const chLabel = ch === "0" ? "全站" : `频道${ch}`;
430
+ const filename = `晋江${rtInfo.label}_${chLabel}_${date}.md`;
431
+ fs.mkdirSync(OUTDIR, { recursive: true });
432
+ const filepath = path.join(OUTDIR, filename);
433
+ atomicWriteFile(filepath, result.content);
434
+ written++;
435
+ files.push(filepath);
436
+ console.error(` ✓ 已保存: ${filepath}`);
437
+ } catch (rankErr) {
438
+ failed++;
439
+ const rtInfo = RANK_TYPES.find((r) => r.id === rt);
440
+ const message = rankErr && rankErr.message ? rankErr.message : String(rankErr);
441
+ partialReasons.push(`${rtInfo ? rtInfo.label : rt}: ${message}`);
442
+ console.error(`❌ 晋江-${rtInfo ? rtInfo.label : rt}采集失败:${message}`);
443
+ }
444
+ }
445
+ }
446
+ } finally {
447
+ await browser.close();
448
+ }
449
+
450
+ return {
451
+ planned: rankTypes.length * channels.length,
452
+ written,
453
+ failed,
454
+ partial: partial || failed > 0,
455
+ partialReasons,
456
+ files,
457
+ };
458
+ }
459
+
460
+
461
+ // import.meta.main 需要 Node >= 22.19(package.json 声明支持 node >=18.0.0)。
462
+ // 旧版本上它是 undefined,必须回落到 argv[1] 比对 —— 否则整个脚本静默空跑:
463
+ // exit 0、无 stdout、没有任何采集文件,调用方只看到"采集失败"。
464
+ const isMain = import.meta.main ??
465
+ (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
466
+
467
+ if (isMain) {
468
+ runCli(main(), "晋江采集");
469
+ }