@cloud411716/fancy-webnovel 1.0.45 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +183 -0
- package/cordis.patch.yml +0 -14
- package/index.js +21 -37
- package/lib/chinese-num.js +111 -0
- package/lib/deployed-state.js +141 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +569 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/safe-rm.js +94 -0
- package/lib/scraper-utils.js +165 -0
- package/package.json +37 -5
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +973 -0
- package/plugins/fancy-analyze/schema-analyze.md +379 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +431 -0
- package/plugins/fancy-analyze/templates.js +881 -0
- package/plugins/fancy-bootstrap/index.js +168 -0
- package/plugins/fancy-bootstrap/scripts/init.js +143 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +54 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +581 -0
- package/plugins/fancy-import/index.js +730 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
- package/plugins/fancy-import/templates.js +49 -0
- package/plugins/fancy-scan/index.js +319 -0
- package/plugins/fancy-scan/scripts/run-scan.js +520 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
- package/plugins/fancy-scan/templates.js +295 -0
- package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +155 -0
- package/plugins/fancy-story-builder/index.js +3152 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +107 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
- package/plugins/fancy-story-builder/references/glossary.md +585 -0
- package/plugins/fancy-story-builder/references/index.md +490 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.md +324 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
- 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
- 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
- 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
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +66 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +76 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +72 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +37 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +53 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +40 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +79 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +132 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +133 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +160 -0
- 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
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +134 -0
- package/plugins/fancy-story-builder/templates.js +1248 -0
- package/plugins/fancy-story-builder/wiki-references.js +204 -0
- package/plugins/fancy-story-builder/writing-driver.js +1061 -0
- package/plugins/fancy-topic/index.js +704 -0
- package/plugins/fancy-topic/scripts/select-topic.js +274 -0
- package/plugins/fancy-topic/templates.js +373 -0
- package/commands/bootstrap/index.js +0 -160
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -275
- package/commands/scan/platforms/fanqie.js +0 -146
- package/commands/scan/platforms/jinjiang.js +0 -143
- package/commands/scan/platforms/qidian.js +0 -102
- package/commands/scan/platforms/qimao.js +0 -127
- package/commands/scan/scraper.js +0 -195
- package/infra.js +0 -160
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 七猫小说排行榜采集脚本
|
|
4
|
+
*
|
|
5
|
+
* 使用 playwright-core 直接控制浏览器采集。
|
|
6
|
+
* 采集策略:滚动加载后从页面文本解析结构化数据。
|
|
7
|
+
* 输出 Markdown 格式到 --outdir 目录。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* node qimao-rank-scraper.js --channel boy --type hot --period month # 男生大热榜月榜
|
|
11
|
+
* node qimao-rank-scraper.js --channel boy --type hot --period day # 男生大热榜日榜
|
|
12
|
+
* node qimao-rank-scraper.js --channel girl --type new --period month # 女生新书榜月榜
|
|
13
|
+
* node qimao-rank-scraper.js --channel girl --type new # 女生新书榜日榜
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { pathToFileURL } from 'url';
|
|
19
|
+
import { chromium } from 'playwright-core';
|
|
20
|
+
import { getArg, localDateStamp, runCli } from '#scraper-utils';
|
|
21
|
+
|
|
22
|
+
const RANK_URL = "https://www.qimao.com/paihang";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// 命令行参数解析
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const OUTDIR = getArg(args, "--outdir") || ".";
|
|
30
|
+
const CHANNEL = getArg(args, "--channel") || "boy";
|
|
31
|
+
const RANKTYPE = getArg(args, "--type") || "hot";
|
|
32
|
+
const PERIOD = getArg(args, "--period") || "day";
|
|
33
|
+
|
|
34
|
+
const CHANNELS = [
|
|
35
|
+
{ id: "boy", label: "男生", tab: "男生榜", path: "boy" },
|
|
36
|
+
{ id: "girl", label: "女生", tab: "女生榜", path: "girl" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const RANK_TYPES = [
|
|
40
|
+
{ id: "hot", label: "大热", path: "hot" },
|
|
41
|
+
{ id: "new", label: "新书", path: "new" },
|
|
42
|
+
{ id: "finish", label: "完结", path: "over" },
|
|
43
|
+
{ id: "collect", label: "收藏", path: "collect" },
|
|
44
|
+
{ id: "update", label: "更新", path: "update" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const PERIODS = [
|
|
48
|
+
{ id: "day", label: "日榜", path: "date" },
|
|
49
|
+
{ id: "month", label: "月榜", path: "month" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 详情页并发上限。
|
|
54
|
+
*
|
|
55
|
+
* 七猫对密集请求会返回半成品页(页面能打开但内容是空的)。
|
|
56
|
+
* 实测并发 3 / 5 各抓 20 本均 20/20 成功,单轮 15~22 秒;取 4 留余量。
|
|
57
|
+
*/
|
|
58
|
+
const DETAIL_CONCURRENCY = 4;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 哪些榜单类型真的有"月榜"。
|
|
62
|
+
*
|
|
63
|
+
* 实测依据(2026-10-21,逐个请求官网榜单页 + 核对站内导航与书名条目数):
|
|
64
|
+
* 大热榜 / 新书榜 / 完结榜 → 日榜 + 月榜(官网页内有月榜 tab 入口)
|
|
65
|
+
* 收藏榜 / 更新榜 → 只有日榜
|
|
66
|
+
*
|
|
67
|
+
* 收藏榜/更新榜的月榜 URL(/paihang/{频道}/collect/month/ 等)虽然返回 HTTP 200,
|
|
68
|
+
* 但页面只有约 26KB 的壳、**0 条书名链接** —— 是空榜,不是"月榜=日榜同数据"。
|
|
69
|
+
*
|
|
70
|
+
* 之前只有 hot 才展开周期(其它类型的 period 被置 null,rankUrl 回落到日榜),
|
|
71
|
+
* 于是"新书月榜"实际抓的是日榜数据,而产物文件名还不带"月"——
|
|
72
|
+
* 用户全程看不出来。现在按本表决定周期是否生效。
|
|
73
|
+
*
|
|
74
|
+
* 注意:这里用的是 RANK_TYPES 的 **id**(完结榜的 id 是 finish、path 才是 over)。
|
|
75
|
+
*/
|
|
76
|
+
const MONTH_RANK_TYPES = new Set(["hot", "new", "finish"]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 该「榜单类型 × 周期」组合在七猫官网上是否真实存在。
|
|
80
|
+
*
|
|
81
|
+
* 收藏榜/更新榜只有日榜:它们的月榜 URL 返回 HTTP 200,但页面是空壳
|
|
82
|
+
* (约 26KB、0 条书名链接),扫出来必然是"无有效数据"。
|
|
83
|
+
* 提前判掉,别让用户等一次浏览器往返才拿到一句无数据。
|
|
84
|
+
*/
|
|
85
|
+
function rankPeriodExists(rankTypeId, periodId) {
|
|
86
|
+
if (periodId !== "month") return true;
|
|
87
|
+
return MONTH_RANK_TYPES.has(rankTypeId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// 页面操作
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function rankUrl(channelId, rankTypeId, periodId) {
|
|
95
|
+
const channel = CHANNELS.find((item) => item.id === channelId);
|
|
96
|
+
const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
|
|
97
|
+
const period = PERIODS.find((item) => item.id === (periodId || "day"));
|
|
98
|
+
if (!channel || !rankType || !period) return "";
|
|
99
|
+
return `${RANK_URL}/${channel.path}/${rankType.path}/${period.path}/`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 从页面 innerText 解析结构化书籍数据。
|
|
104
|
+
*/
|
|
105
|
+
async function extractBooksFromText(page) {
|
|
106
|
+
return await page.evaluate(() => {
|
|
107
|
+
var text = document.body.innerText || '';
|
|
108
|
+
// 找到榜单数据起始位置
|
|
109
|
+
var start = -1;
|
|
110
|
+
['日榜', '月榜'].forEach(function(m) {
|
|
111
|
+
if (start < 0) start = text.indexOf(m)
|
|
112
|
+
});
|
|
113
|
+
if (start < 0) return [];
|
|
114
|
+
var lines = text.substring(start).split(/\n/);
|
|
115
|
+
var books = [];
|
|
116
|
+
var cur = null;
|
|
117
|
+
var fieldIdx = 0;
|
|
118
|
+
for (var i = 0; i < lines.length; i++) {
|
|
119
|
+
var line = lines[i].trim();
|
|
120
|
+
if (!line) continue;
|
|
121
|
+
// 分页器截断
|
|
122
|
+
if (/^(上一页|下一页|跳转|友情链接[::]?)$/.test(line)) {
|
|
123
|
+
if (cur && cur.title) books.push(cur);
|
|
124
|
+
cur = null;
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
// 排名标记:独立数字 1-99
|
|
128
|
+
if (/^\d{1,2}$/.test(line) && parseInt(line) < 100) {
|
|
129
|
+
if (cur && cur.title) books.push(cur);
|
|
130
|
+
cur = {
|
|
131
|
+
rank: parseInt(line),
|
|
132
|
+
title: '',
|
|
133
|
+
author: '',
|
|
134
|
+
genre: '',
|
|
135
|
+
subGenre: '',
|
|
136
|
+
status: '',
|
|
137
|
+
words: '',
|
|
138
|
+
heat: '',
|
|
139
|
+
update: '',
|
|
140
|
+
desc: ''
|
|
141
|
+
};
|
|
142
|
+
fieldIdx = 0;
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (!cur) continue;
|
|
146
|
+
// 跳过 UI 文字
|
|
147
|
+
if (/^(加入书架|立即阅读|蝉联|榜首)/.test(line)) continue;
|
|
148
|
+
// 热度
|
|
149
|
+
var hm = line.match(/([\d.]+)\s*万\s*热度/);
|
|
150
|
+
if (hm) {
|
|
151
|
+
cur.heat = hm[1] + '万';
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
// 最新更新
|
|
155
|
+
if (line.indexOf('最近更新') === 0) {
|
|
156
|
+
cur.update = line.replace(/^最近更新\s*/, '');
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
// 状态
|
|
160
|
+
if (/^(连载中|已完结)$/.test(line)) {
|
|
161
|
+
cur.status = line;
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
// 字数
|
|
165
|
+
if (/^[\d.]+万字$/.test(line)) {
|
|
166
|
+
cur.words = line;
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
// 按序填充:书名→作者→题材→子分类
|
|
170
|
+
if (fieldIdx === 0) {
|
|
171
|
+
cur.title = line;
|
|
172
|
+
fieldIdx = 1;
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (fieldIdx === 1) {
|
|
176
|
+
cur.author = line;
|
|
177
|
+
fieldIdx = 2;
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
if (fieldIdx === 2) {
|
|
181
|
+
cur.genre = line;
|
|
182
|
+
fieldIdx = 3;
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (fieldIdx === 3) {
|
|
186
|
+
cur.subGenre = line;
|
|
187
|
+
fieldIdx = 4;
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
// 其余为简介
|
|
191
|
+
cur.desc += (cur.desc ? ' ' : '') + line
|
|
192
|
+
}
|
|
193
|
+
if (cur && cur.title) books.push(cur);
|
|
194
|
+
return books
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 从书名链接元素直接提取 href,避免全页遍历+标题匹配。
|
|
200
|
+
* 对每个书籍容器,找到其中的书名链接,取其 href。
|
|
201
|
+
*/
|
|
202
|
+
async function extractBookUrls(page) {
|
|
203
|
+
return await page.evaluate(() => {
|
|
204
|
+
var results = [];
|
|
205
|
+
// 常见书籍列表容器
|
|
206
|
+
var selectors = [
|
|
207
|
+
'.book-list li',
|
|
208
|
+
'.rank-list .rank-item',
|
|
209
|
+
'[class*="book-item"]',
|
|
210
|
+
'[class*="rank-item"]',
|
|
211
|
+
'.bookshelf-list li',
|
|
212
|
+
'ul li',
|
|
213
|
+
];
|
|
214
|
+
var container = null;
|
|
215
|
+
for (var s of selectors) {
|
|
216
|
+
var el = document.querySelector(s);
|
|
217
|
+
if (el) { container = s; break; }
|
|
218
|
+
}
|
|
219
|
+
if (!container) return results;
|
|
220
|
+
|
|
221
|
+
var items = document.querySelectorAll(container);
|
|
222
|
+
items.forEach(function(item) {
|
|
223
|
+
// 找书名链接(常见选择器)
|
|
224
|
+
var titleEl = item.querySelector('a[class*="title"], h3 a, a.title, .book-title a, [class*="title"] a');
|
|
225
|
+
if (!titleEl) {
|
|
226
|
+
// 备选:找包含非 UI 文本的有意义链接
|
|
227
|
+
var links = item.querySelectorAll('a');
|
|
228
|
+
for (var i = 0; i < links.length; i++) {
|
|
229
|
+
var t = (links[i].innerText || links[i].textContent || '').trim();
|
|
230
|
+
if (t && t.length > 2 && t.length < 50 &&
|
|
231
|
+
!/^(上一页|下一页|跳转|加入书架|立即阅读)$/.test(t)) {
|
|
232
|
+
titleEl = links[i];
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!titleEl) return;
|
|
238
|
+
|
|
239
|
+
var title = (titleEl.innerText || titleEl.textContent || '').replace(/\s+/g, '').trim();
|
|
240
|
+
var rawHref = titleEl.getAttribute('href') || '';
|
|
241
|
+
var fullUrl = rawHref.startsWith('http')
|
|
242
|
+
? rawHref
|
|
243
|
+
: 'https://www.qimao.com' + rawHref;
|
|
244
|
+
|
|
245
|
+
if (title) results.push({ title, url: fullUrl });
|
|
246
|
+
});
|
|
247
|
+
return results;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 详情页的"简介"元素。
|
|
253
|
+
*
|
|
254
|
+
* 榜单页只给列表视图的短简介(有的书只有第一句,40 字上下),
|
|
255
|
+
* 详情页的 <p class="intro"> 才是完整简介(实测 38~333 字、分段)。
|
|
256
|
+
*
|
|
257
|
+
* 取 textContent 而不是 innerText:innerText 会受 CSS 裁剪影响,
|
|
258
|
+
* textContent 拿到的始终是完整文本。
|
|
259
|
+
* 兜底读 Nuxt 状态里的 bookIntroData.intro(与页面同源)。
|
|
260
|
+
*/
|
|
261
|
+
async function extractDetailIntro(page) {
|
|
262
|
+
return await page.evaluate(() => {
|
|
263
|
+
var el = document.querySelector('.intro');
|
|
264
|
+
if (el) {
|
|
265
|
+
var t = (el.textContent || '').trim();
|
|
266
|
+
if (t) return t;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
var n = window.__NUXT__;
|
|
270
|
+
var info = (n && n.state && n.state.common && n.state.common.bookRelatedInfo)
|
|
271
|
+
|| (n && n.state && n.state.modules && n.state.modules.common && n.state.modules.common.bookRelatedInfo);
|
|
272
|
+
var t2 = info && info.bookIntroData && info.bookIntroData.intro;
|
|
273
|
+
if (t2) return String(t2).trim();
|
|
274
|
+
} catch (_) {}
|
|
275
|
+
return '';
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 逐本访问详情页,取回完整简介。
|
|
281
|
+
*
|
|
282
|
+
* 与起点(run-scan.js 的 fetchBookDetail)同一套做法:
|
|
283
|
+
* 榜单页拿基础字段,详情页补简介。
|
|
284
|
+
*
|
|
285
|
+
* 并发上限 DETAIL_CONCURRENCY:七猫对密集请求会返回"半成品页"
|
|
286
|
+
* (页面能打开但内容是空的)。实测并发 3/5 各抓 20 本均 20/20 成功、
|
|
287
|
+
* 单轮 15~22 秒,取 4 作为安全值。
|
|
288
|
+
*
|
|
289
|
+
* 单本失败不中断整个榜单:
|
|
290
|
+
* - 失败原因记入 reasons,由 receipt 的 partialReasons 一路带到
|
|
291
|
+
* 插件层的失败清单(用户能看见"哪本没取到");
|
|
292
|
+
* - 同时**保留榜单页的简介**——它至少不是空的,不让用户拿到空简介。
|
|
293
|
+
*/
|
|
294
|
+
async function fetchDetailIntros(browser, books) {
|
|
295
|
+
const reasons = [];
|
|
296
|
+
const targets = books.filter((b) => b.url);
|
|
297
|
+
let cursor = 0;
|
|
298
|
+
|
|
299
|
+
async function worker() {
|
|
300
|
+
while (cursor < targets.length) {
|
|
301
|
+
const book = targets[cursor++];
|
|
302
|
+
let page = null;
|
|
303
|
+
try {
|
|
304
|
+
page = await browser.newPage();
|
|
305
|
+
await page.goto(book.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
306
|
+
const intro = await extractDetailIntro(page);
|
|
307
|
+
if (intro) {
|
|
308
|
+
book.desc = intro;
|
|
309
|
+
} else {
|
|
310
|
+
reasons.push(`${book.title} 详情页没有简介(保留榜单页简介)`);
|
|
311
|
+
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
reasons.push(`${book.title} 简介获取失败: ${err.message}`);
|
|
314
|
+
} finally {
|
|
315
|
+
if (page) {
|
|
316
|
+
try { await page.close(); } catch (_) {}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const workers = Math.min(DETAIL_CONCURRENCY, Math.max(1, targets.length));
|
|
323
|
+
await Promise.all(Array.from({ length: workers }, worker));
|
|
324
|
+
return reasons;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isUsableBook(book) {
|
|
328
|
+
return !!(
|
|
329
|
+
book &&
|
|
330
|
+
Number.isInteger(book.rank) &&
|
|
331
|
+
book.rank > 0 &&
|
|
332
|
+
book.title &&
|
|
333
|
+
book.author &&
|
|
334
|
+
!/^(上一页|下一页|跳转)$/.test(book.title) &&
|
|
335
|
+
!/^(上一页|下一页|跳转|友情链接[::]?)$/.test(book.author)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function cleanDesc(value) {
|
|
340
|
+
// 只压行内空白,**保留换行** —— 详情页的简介是分段的,
|
|
341
|
+
// 压成一行会看不出段落结构。
|
|
342
|
+
return String(value || "")
|
|
343
|
+
.replace(/\s*(?:飙升|上升|下降)\s*\d+\s*名\s*$/g, "")
|
|
344
|
+
.replace(/\s*(?:上一页|下一页)\s*$/g, "")
|
|
345
|
+
.replace(/[ \t ]+/g, " ")
|
|
346
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
347
|
+
.trim();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function summarizeQuality(books, rawCount) {
|
|
351
|
+
const linked = books.filter((book) => book.url).length;
|
|
352
|
+
const heated = books.filter((book) => book.heat).length;
|
|
353
|
+
const fieldCounts = [
|
|
354
|
+
["题材", "genre"],
|
|
355
|
+
["子分类", "subGenre"],
|
|
356
|
+
["状态", "status"],
|
|
357
|
+
["字数", "words"],
|
|
358
|
+
["热度", "heat"],
|
|
359
|
+
["简介", "desc"],
|
|
360
|
+
].map(([label, field]) => ({
|
|
361
|
+
label,
|
|
362
|
+
missing: books.filter((book) => !book[field]).length,
|
|
363
|
+
}));
|
|
364
|
+
const problems = [];
|
|
365
|
+
if (rawCount > books.length) problems.push(`移除无效/UI条目 ${rawCount - books.length} 条`);
|
|
366
|
+
if (linked < books.length) problems.push(`作品页链接缺失 ${books.length - linked} 条`);
|
|
367
|
+
for (const field of fieldCounts) {
|
|
368
|
+
if (field.missing) problems.push(`${field.label}缺失 ${field.missing} 条`);
|
|
369
|
+
}
|
|
370
|
+
if (books.length < 15) problems.push(`[数据稀疏] 实际采集 ${books.length} 条`);
|
|
371
|
+
return {
|
|
372
|
+
linked,
|
|
373
|
+
heated,
|
|
374
|
+
problems,
|
|
375
|
+
quality: problems.length ? "[存在问题]" : "[OK]",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function renderMarkdown(ch, rt, period, url, books, rawCount, now = new Date().toISOString()) {
|
|
380
|
+
const periodLabel = period ? period.label : "";
|
|
381
|
+
const summary = summarizeQuality(books, rawCount);
|
|
382
|
+
const lines = [
|
|
383
|
+
`# 七猫 · ${ch.label} · ${rt.label}${periodLabel}`,
|
|
384
|
+
"",
|
|
385
|
+
`- 数据质量:${summary.quality}`,
|
|
386
|
+
`- 有效条目:${books.length} / ${rawCount}`,
|
|
387
|
+
`- 问题摘要:${summary.problems.length ? summary.problems.join(";") : "无"}`,
|
|
388
|
+
`- 作品页链接:${summary.linked} / ${books.length}`,
|
|
389
|
+
`- 热度命中:${summary.heated} / ${books.length}`,
|
|
390
|
+
`- 来源:${url}`,
|
|
391
|
+
`- 抓取时间:${now}`,
|
|
392
|
+
`- 条目数:${books.length}`,
|
|
393
|
+
"",
|
|
394
|
+
"---",
|
|
395
|
+
"",
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
for (const b of books) {
|
|
399
|
+
try {
|
|
400
|
+
lines.push(`### #${b.rank} ${b.title}`);
|
|
401
|
+
if (b.author) lines.push(`**作者:** ${b.author}`);
|
|
402
|
+
if (b.genre) lines.push(`**题材:** ${b.genre}`);
|
|
403
|
+
if (b.subGenre) lines.push(`**子分类:** ${b.subGenre}`);
|
|
404
|
+
if (b.status) lines.push(`**状态:** ${b.status}`);
|
|
405
|
+
if (b.words) lines.push(`**字数:** ${b.words}`);
|
|
406
|
+
if (b.heat) lines.push(`**热度:** ${b.heat}`);
|
|
407
|
+
if (b.url) lines.push(`[作品页](${b.url})`);
|
|
408
|
+
const desc = cleanDesc(b.desc);
|
|
409
|
+
if (desc) {
|
|
410
|
+
lines.push("");
|
|
411
|
+
lines.push(`**简介**`);
|
|
412
|
+
// 详情页的简介是分段的(单换行分隔),markdown 里要空行才算分段
|
|
413
|
+
const paras = desc.split(/\n+/).map((p) => p.trim()).filter(Boolean);
|
|
414
|
+
lines.push(paras[0]);
|
|
415
|
+
for (const p of paras.slice(1)) lines.push("", p);
|
|
416
|
+
}
|
|
417
|
+
lines.push("", "---", "");
|
|
418
|
+
} catch (bookErr) {
|
|
419
|
+
lines.push("", "---", "");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return lines.join("\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// 主流程
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
async function scrapeRank(browser, channelId, rankTypeId, periodId) {
|
|
431
|
+
const ch = CHANNELS.find((c) => c.id === channelId);
|
|
432
|
+
const rt = RANK_TYPES.find((r) => r.id === rankTypeId);
|
|
433
|
+
const period = periodId ? PERIODS.find((p) => p.id === periodId) : null;
|
|
434
|
+
if (!ch || !rt) {
|
|
435
|
+
console.error(" ⚠ 未知频道或榜单类型");
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const periodLabel = period ? period.label : "";
|
|
440
|
+
const url = rankUrl(channelId, rankTypeId, periodId);
|
|
441
|
+
|
|
442
|
+
const page = await browser.newPage();
|
|
443
|
+
let books = [];
|
|
444
|
+
let rawCount = 0;
|
|
445
|
+
let partialReasons = [];
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
449
|
+
|
|
450
|
+
// 滚动加载更多
|
|
451
|
+
for (let s = 0; s < 5; s++) {
|
|
452
|
+
await page.mouse.wheel(0, 800);
|
|
453
|
+
await page.waitForTimeout(300);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 文本解析获取书籍数据 + DOM 获取链接
|
|
457
|
+
const rawBooks = await extractBooksFromText(page);
|
|
458
|
+
rawCount = rawBooks.length;
|
|
459
|
+
books = rawBooks.filter(isUsableBook);
|
|
460
|
+
const urls = await extractBookUrls(page);
|
|
461
|
+
|
|
462
|
+
// 按标题匹配 URL
|
|
463
|
+
const norm = (s) => (s || "").replace(/\s+/g, "");
|
|
464
|
+
for (const b of books) {
|
|
465
|
+
const matched = urls.find((u) => norm(u.title) === norm(b.title));
|
|
466
|
+
if (matched) b.url = matched.url;
|
|
467
|
+
}
|
|
468
|
+
} catch (err) {
|
|
469
|
+
console.error(`❌ 七猫-${ch.label}${rt.label}${periodLabel}采集失败:${err.message}`);
|
|
470
|
+
await page.close();
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await page.close();
|
|
475
|
+
|
|
476
|
+
if (!books.length) {
|
|
477
|
+
console.error(`❌ 七猫采集失败:无有效数据`);
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 逐本访问详情页补完整简介(榜单页给的只是列表视图的短版)
|
|
482
|
+
partialReasons = await fetchDetailIntros(browser, books);
|
|
483
|
+
if (partialReasons.length) {
|
|
484
|
+
console.error(` ⚠ 详情页简介 ${partialReasons.length} 本未取到(已保留榜单页简介)`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const summary = summarizeQuality(books, rawCount);
|
|
488
|
+
console.error(
|
|
489
|
+
` ✓ 提取 ${books.length} 本(链接 ${summary.linked}/${books.length},热度 ${summary.heated}/${books.length})`
|
|
490
|
+
);
|
|
491
|
+
return {
|
|
492
|
+
content: renderMarkdown(ch, rt, period, url, books, rawCount),
|
|
493
|
+
partialReasons,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function buildTargets(channel, rankType, period) {
|
|
498
|
+
const channels = channel === "all" ? CHANNELS.map((item) => item.id) : [channel];
|
|
499
|
+
const rankTypes = rankType === "all" ? RANK_TYPES.map((item) => item.id) : [rankType];
|
|
500
|
+
const targets = [];
|
|
501
|
+
for (const channelId of channels) {
|
|
502
|
+
for (const rankTypeId of rankTypes) {
|
|
503
|
+
// 显式指定周期就照用;all 则只展开该类型**真实存在**的周期
|
|
504
|
+
// (收藏榜/更新榜没有月榜,不展开,免得扫出一堆空榜)
|
|
505
|
+
const periodIds = period === "all"
|
|
506
|
+
? PERIODS.map((item) => item.id)
|
|
507
|
+
.filter((pid) => rankPeriodExists(rankTypeId, pid))
|
|
508
|
+
: [period];
|
|
509
|
+
for (const periodId of periodIds) {
|
|
510
|
+
targets.push({ channel: channelId, rankType: rankTypeId, period: periodId });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return targets;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function outputFilename(channelId, rankTypeId, periodId, date) {
|
|
518
|
+
const channel = CHANNELS.find((item) => item.id === channelId);
|
|
519
|
+
const rankType = RANK_TYPES.find((item) => item.id === rankTypeId);
|
|
520
|
+
const period = periodId ? PERIODS.find((item) => item.id === periodId) : null;
|
|
521
|
+
return `七猫${channel.label}${rankType.label}${period ? period.label : ""}_${date}.md`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function main() {
|
|
525
|
+
if (CHANNEL !== "all" && !CHANNELS.some((channel) => channel.id === CHANNEL)) {
|
|
526
|
+
throw new Error(`未知 --channel: ${CHANNEL}`);
|
|
527
|
+
}
|
|
528
|
+
if (RANKTYPE !== "all" && !RANK_TYPES.some((rank) => rank.id === RANKTYPE)) {
|
|
529
|
+
throw new Error(`未知 --type: ${RANKTYPE}`);
|
|
530
|
+
}
|
|
531
|
+
if (PERIOD !== "all" && !PERIODS.some((p) => p.id === PERIOD)) {
|
|
532
|
+
throw new Error(`未知 --period: ${PERIOD}`);
|
|
533
|
+
}
|
|
534
|
+
// 收藏榜/更新榜没有月榜:直接说清楚,别悄悄回落到日榜
|
|
535
|
+
// (悄悄回落 = 用户以为扫的是月榜,拿到的却是日榜,文件名还不带"月")
|
|
536
|
+
if (RANKTYPE !== "all" && !rankPeriodExists(RANKTYPE, PERIOD)) {
|
|
537
|
+
const rt = RANK_TYPES.find((rank) => rank.id === RANKTYPE);
|
|
538
|
+
throw new Error(`七猫${rt ? rt.label : RANKTYPE}榜没有月榜(只有日榜),请改用 --period day`);
|
|
539
|
+
}
|
|
540
|
+
const targets = buildTargets(CHANNEL, RANKTYPE, PERIOD);
|
|
541
|
+
let written = 0;
|
|
542
|
+
let failed = 0;
|
|
543
|
+
const files = [];
|
|
544
|
+
const partialReasons = [];
|
|
545
|
+
|
|
546
|
+
let browser;
|
|
547
|
+
// 被 SIGTERM 杀时(上游超时或中断)主动关掉浏览器,
|
|
548
|
+
// 否则 chromium 会留在系统里继续占 CPU/内存。
|
|
549
|
+
// close 必须带超时兜底:浏览器正在导航时 close() 可能挂住,
|
|
550
|
+
// 无超时的话进程会卡住不退出(实测过)。
|
|
551
|
+
process.on('SIGTERM', async () => {
|
|
552
|
+
try {
|
|
553
|
+
if (browser) {
|
|
554
|
+
await Promise.race([
|
|
555
|
+
browser.close(),
|
|
556
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), 5000)),
|
|
557
|
+
]);
|
|
558
|
+
}
|
|
559
|
+
} catch (_) {}
|
|
560
|
+
process.exit(143);
|
|
561
|
+
});
|
|
562
|
+
try {
|
|
563
|
+
browser = await chromium.launch({ headless: true, timeout: 30000 });
|
|
564
|
+
} catch (launchErr) {
|
|
565
|
+
console.log(JSON.stringify({ ok: false, error: `七猫采集失败:浏览器启动失败:${launchErr.message}` }));
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
for (const target of targets) {
|
|
570
|
+
const result = await scrapeRank(browser, target.channel, target.rankType, target.period);
|
|
571
|
+
if (!result) {
|
|
572
|
+
failed++;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const date = localDateStamp();
|
|
577
|
+
const filename = outputFilename(target.channel, target.rankType, target.period, date);
|
|
578
|
+
fs.mkdirSync(OUTDIR, { recursive: true });
|
|
579
|
+
const filepath = path.join(OUTDIR, filename);
|
|
580
|
+
fs.writeFileSync(filepath, result.content, "utf-8");
|
|
581
|
+
written++;
|
|
582
|
+
files.push(filepath);
|
|
583
|
+
// 详情页没取到简介的书:原因进 partialReasons,一路带到插件层的失败清单
|
|
584
|
+
if (Array.isArray(result.partialReasons)) partialReasons.push(...result.partialReasons);
|
|
585
|
+
console.error(` ✓ 已保存: ${filepath}`);
|
|
586
|
+
}
|
|
587
|
+
} finally {
|
|
588
|
+
await browser.close();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
planned: targets.length,
|
|
593
|
+
written,
|
|
594
|
+
failed,
|
|
595
|
+
partial: failed > 0 || partialReasons.length > 0,
|
|
596
|
+
partialReasons,
|
|
597
|
+
files,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// import.meta.main 需要 Node >= 22.19(package.json 声明支持 node >=18.0.0)。
|
|
602
|
+
// 旧版本上它是 undefined,必须回落到 argv[1] 比对 —— 否则整个脚本静默空跑:
|
|
603
|
+
// exit 0、无 stdout、没有任何采集文件,调用方只看到"采集失败"。
|
|
604
|
+
const isMain = import.meta.main ??
|
|
605
|
+
(process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
|
|
606
|
+
|
|
607
|
+
if (isMain) {
|
|
608
|
+
runCli(main(), "七猫采集");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export { extractBooksFromText, isUsableBook, cleanDesc };
|