@cloud411716/fancy-webnovel 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud411716/fancy-webnovel",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -199,9 +199,10 @@ function scraperArgs(platform, length, outDir) {
199
199
  extra: ['--channel', 'all', '--top', '20', '--outdir', outDir],
200
200
  };
201
201
  case 'jinjiang':
202
+ // 月榜(orderstr=5) + 新手金榜(orderstr=17),--type all 扫全部
202
203
  return {
203
204
  script: 'jjwxc-rank-scraper.cjs',
204
- extra: ['--type', '12', '--outdir', outDir],
205
+ extra: ['--type', '5,17', '--outdir', outDir],
205
206
  };
206
207
  case 'zhihu':
207
208
  return {
@@ -59,12 +59,13 @@
59
59
 
60
60
  | 榜单 | orderstr |
61
61
  |------|----------|
62
- | 收入金榜 | 12 |
63
- | 月榜 | 7 |
62
+ | 月榜 | 5 |
63
+ | 总分榜 | 7 |
64
64
  | 季度榜 | 8 |
65
+ | 收入金榜 | 12 |
65
66
  | 完结金榜 | 14 |
66
- | 新手金榜 | 15 |
67
- | 千字金榜 | 17 |
67
+ | 勤奋指数榜 | 15 |
68
+ | 新手金榜 | 17 |
68
69
 
69
70
  ### 输出模板
70
71
 
@@ -27,12 +27,13 @@ const BASE_URL = "https://www.jjwxc.net/topten.php";
27
27
  const DETAIL_BASE = "https://www.jjwxc.net/onebook.php";
28
28
 
29
29
  const RANK_TYPES = [
30
- { id: "12", label: "收入金榜" },
31
- { id: "7", label: "月榜" },
30
+ { id: "5", label: "月榜" },
31
+ { id: "7", label: "总分榜" },
32
32
  { id: "8", label: "季度榜" },
33
+ { id: "12", label: "收入金榜" },
33
34
  { id: "14", label: "完结金榜" },
34
- { id: "15", label: "新手金榜" },
35
- { id: "17", label: "千字金榜" },
35
+ { id: "15", label: "勤奋指数榜" },
36
+ { id: "17", label: "新手金榜" },
36
37
  ];
37
38
 
38
39
  const DETAIL_CHUNK = 6;
@@ -294,13 +295,15 @@ const LIST_ONLY = args.includes("--list-only");
294
295
  const LOGIN_WAIT = parseInt(getArg(args, "--login-wait") || "0", 10);
295
296
 
296
297
  async function main() {
297
- if (RANKTYPE !== "all" && !RANK_TYPES.some(r => r.id === RANKTYPE)) {
298
- throw new Error(`未知 --type: ${RANKTYPE}`);
298
+ const rankTypeIds = (RANKTYPE || "12").split(",").map(t => t.trim());
299
+ const invalid = rankTypeIds.find(id => id !== "all" && !RANK_TYPES.some(r => r.id === id));
300
+ if (invalid) {
301
+ throw new Error(`未知 --type: ${invalid}`);
299
302
  }
300
303
  if (CHANNEL !== "0") {
301
304
  throw new Error(`未知 --channel: ${CHANNEL}(当前仅支持 0=全站)`);
302
305
  }
303
- const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map(r => r.id) : [RANKTYPE];
306
+ const rankTypes = RANKTYPE === "all" ? RANK_TYPES.map(r => r.id) : rankTypeIds;
304
307
  let written = 0, failed = 0, partial = false;
305
308
  const partialReasons = [];
306
309
 
@@ -95,6 +95,52 @@ function summarizeQuality(books, rawCount) {
95
95
  return { linked, heated, problems, quality: problems.length ? "[存在问题]" : "[OK]" };
96
96
  }
97
97
 
98
+ // 遍历 JSON 对象/数组,查找含 bookId + title/author 的数组
99
+ function findBookArrays(obj, depth = 0) {
100
+ if (!obj || depth > 8) return [];
101
+ if (Array.isArray(obj)) {
102
+ for (const item of obj) {
103
+ if (item && typeof item === "object" && (item.bookId || item.novel_id || item.id)) {
104
+ if (item.title || item.bookName || item.author || item.authorName) {
105
+ return obj;
106
+ }
107
+ }
108
+ }
109
+ for (const item of obj) {
110
+ const found = findBookArrays(item, depth + 1);
111
+ if (found.length) return found;
112
+ }
113
+ return [];
114
+ }
115
+ if (typeof obj === "object") {
116
+ for (const key of Object.keys(obj)) {
117
+ const found = findBookArrays(obj[key], depth + 1);
118
+ if (found.length) return found;
119
+ }
120
+ }
121
+ return [];
122
+ }
123
+
124
+ // 从 xhrBookMap 中为一本 innerText 书匹配明文数据
125
+ function findXhrMatch(book, xhrBookMap) {
126
+ // 先用归一化书名直接匹配
127
+ const norm = s => (s || "").replace(/\s+/g, "");
128
+ for (const [id, data] of xhrBookMap) {
129
+ if (norm(data.title) === norm(book.title)) {
130
+ return { ...data, id };
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function findIdByTitle(title, xhrBookMap) {
137
+ const norm = s => (s || "").replace(/\s+/g, "");
138
+ for (const [id, data] of xhrBookMap) {
139
+ if (norm(data.title) === norm(title)) return id;
140
+ }
141
+ return null;
142
+ }
143
+
98
144
  // ---------------------------------------------------------------------------
99
145
  // 采集
100
146
  // ---------------------------------------------------------------------------
@@ -119,7 +165,32 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
119
165
  await cdp.send('Browser.setWindowBounds', { windowId, bounds: { state: 'maximized' } });
120
166
  } catch (_) {}
121
167
 
122
- let books, urls, rawCount;
168
+ // 拦截 XHR 响应,从 JSON 直接拿 bookId + 明文书名/作者,绕过字体加密
169
+ const xhrBookMap = new Map();
170
+ page.on("response", async (resp) => {
171
+ try {
172
+ const respUrl = resp.url();
173
+ const ct = (resp.headers()["content-type"] || "").toLowerCase();
174
+ if (!ct.includes("json") && !respUrl.includes("/api/")) return;
175
+ if (/\.(css|woff|jpg|png|ico)/.test(respUrl)) return;
176
+ const body = await resp.text();
177
+ if (!body || body.length < 100) return;
178
+ let parsed;
179
+ try { parsed = JSON.parse(body); } catch { return; }
180
+ // 遍历 JSON 结构,找含 bookId + title/author 的数组
181
+ const found = findBookArrays(parsed);
182
+ for (const b of found) {
183
+ if (!b.bookId && !b.novel_id && !b.id) continue;
184
+ const id = String(b.bookId || b.novel_id || b.id);
185
+ xhrBookMap.set(id, {
186
+ title: b.title || b.bookName || b.name || "",
187
+ author: b.author || b.authorName || "",
188
+ });
189
+ }
190
+ } catch (_) {}
191
+ });
192
+
193
+ let books, rawCount;
123
194
  try {
124
195
  await page.goto(url, { waitUntil: "networkidle" });
125
196
  await page.waitForTimeout(3000);
@@ -207,22 +278,26 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
207
278
  rawCount = rawBooks.length;
208
279
  books = rawBooks.filter(isUsableBook);
209
280
 
210
- // 提取作品页链接
211
- urls = await page.evaluate(() => {
212
- var byId = {};
213
- var order = [];
214
- Array.from(document.querySelectorAll("a")).forEach(a => {
215
- var h = (a.getAttribute("href") || a.href || "").match(/\/(?:shuku|book)\/([0-9]+)/);
216
- if (!h) return;
217
- var id = h[1];
218
- var t = (a.innerText || a.textContent || "").replace(/\s+/g, " ").trim();
219
- if (!byId[id]) { byId[id] = ""; order.push(id); }
220
- if (t && !/^[0-9]+$/.test(t) && !/^(最近更新、最新章节、最新)/.test(t)) {
221
- if (t.length > byId[id].length) byId[id] = t;
281
+ // 用 XHR 数据覆盖明文书名/作者,绕过字体加密
282
+ if (xhrBookMap.size > 0) {
283
+ let updated = 0;
284
+ for (const b of books) {
285
+ // 尝试从 XHR map 中找匹配的明文数据(优先用 bookId,其次用书名归一匹配)
286
+ const xhrEntry = findXhrMatch(b, xhrBookMap);
287
+ if (xhrEntry) {
288
+ if (xhrEntry.title && xhrEntry.title !== b.title) {
289
+ b.title = xhrEntry.title;
290
+ updated++;
291
+ }
292
+ if (xhrEntry.author) b.author = xhrEntry.author;
293
+ // 用 XHR 数据里的 id 补 URL(如果有的话)
294
+ const id = xhrEntry.id || findIdByTitle(b.title, xhrBookMap);
295
+ if (id) b.url = `https://www.qimao.com/shuku/${id}/`;
222
296
  }
223
- });
224
- return order.map(id => ({ bookId: id, title: byId[id], url: "https://www.qimao.com/shuku/" + id + "/" }));
225
- });
297
+ }
298
+ console.log(` ✓ XHR 明文更新 ${updated}/${books.length} 本`);
299
+ }
300
+
226
301
  } finally {
227
302
  await browser.close();
228
303
  }
@@ -232,13 +307,6 @@ async function scrapeRank(channelId, rankTypeId, periodId) {
232
307
  return null;
233
308
  }
234
309
 
235
- // 按书名匹配 URL
236
- const norm = s => (s || "").replace(/\s+/g, "");
237
- for (const b of books) {
238
- const matched = urls.find(u => norm(u.title) === norm(b.title));
239
- if (matched) b.url = matched.url;
240
- }
241
-
242
310
  const summary = summarizeQuality(books, rawCount);
243
311
  console.log(` ✓ 提取 ${books.length} 本(链接 ${summary.linked}/${books.length},热度 ${summary.heated}/${books.length})`);
244
312