dsh-baleen 0.1.0 → 0.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/AGENTS.md +4 -0
- package/README.md +18 -3
- package/client.js +509 -31
- package/index.js +153 -3
- package/lib/adapters/index.js +5 -4
- package/lib/adapters/xhs.js +173 -0
- package/lib/dedup.js +16 -1
- package/lib/fulltext.js +222 -0
- package/lib/jobs.js +4 -0
- package/lib/mcp/xhs-client.js +206 -0
- package/lib/normalize.js +4 -2
- package/lib/preference.js +23 -2
- package/lib/runner.js +53 -2
- package/lib/store.js +11 -0
- package/package.json +1 -1
- package/src/client/_footer.txt +173 -4
- package/src/client/css.js +48 -10
- package/src/client/format.js +15 -0
- package/src/client/sidebar-tab.js +56 -0
- package/src/client/view-api.js +10 -0
- package/src/client/views/article.js +37 -3
- package/src/client/views/drawer.js +100 -7
- package/src/client/views/player-tab.js +33 -0
- package/src/client/views/settings.js +27 -3
- package/src/client/views/today.js +5 -2
- package/src/client/views/video.js +9 -5
- package/tools/build-client.mjs +2 -1
- package/tools/client-loader.mjs +4 -3
- package/tools/preview.mjs +19 -2
package/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import { openStore, migrate, counts, listItems, listSources, lastRun, getCursor, markFeedback, listFolders, createFolder, renameFolder, deleteFolder, setItemFolders, foldersOfItem, itemsWithFolders, candidates, latestDigests, exportItems, wipeLibrary } from './lib/store.js'
|
|
4
|
+
import { openStore, migrate, counts, listItems, listSources, lastRun, getCursor, markFeedback, listFolders, createFolder, renameFolder, deleteFolder, setItemFolders, foldersOfItem, itemsWithFolders, candidates, latestDigests, exportItems, wipeLibrary, refreshItem, setMeta } from './lib/store.js'
|
|
5
5
|
import { loadPreference, ensurePreferenceFile, bumpTuningFile, writePreference, readPreferenceRaw, clearTuningFile, validate } from './lib/preference.js'
|
|
6
6
|
import { runOnce, syncSources } from './lib/runner.js'
|
|
7
|
+
import { fetchFulltext } from './lib/fulltext.js'
|
|
8
|
+
import { classifyError } from './lib/pool.js'
|
|
9
|
+
import { stopXhsMcp, getXhsMcp } from './lib/mcp/xhs-client.js'
|
|
7
10
|
import { createScheduler } from './lib/scheduler.js'
|
|
8
11
|
import { makeLogger, redact } from './lib/diag.js'
|
|
9
12
|
import { ensureHome } from './lib/paths.js'
|
|
@@ -24,7 +27,7 @@ const ALLOWED_ACTIONS = { dismiss: -0.1, save: 0.1 }
|
|
|
24
27
|
* tuning 只走 /feedback 与 /tuning-reset,version 没有升级路径可写。
|
|
25
28
|
* yt_dlp_path / network 这类进阶键留给手编文件,页签不碰。
|
|
26
29
|
*/
|
|
27
|
-
const PREF_SECTIONS = new Set(['sources', 'follow', 'themes', 'redlines', 'schedule', 'limits', 'charter', 'require_theme_hit', 'inject_prompt'])
|
|
30
|
+
const PREF_SECTIONS = new Set(['sources', 'follow', 'themes', 'redlines', 'schedule', 'limits', 'charter', 'require_theme_hit', 'inject_prompt', 'sidebar_takeover', 'sidebar_crop', 'fulltext'])
|
|
28
31
|
|
|
29
32
|
/** 夹操作是白名单而不是 if 链:新加动作时漏了枚举就直接 400,不会悄悄走进某个分支 */
|
|
30
33
|
const FOLDER_ACTIONS = new Set(['create', 'rename', 'delete'])
|
|
@@ -32,6 +35,32 @@ const FOLDER_ACTIONS = new Set(['create', 'rename', 'delete'])
|
|
|
32
35
|
/** store 抛的是短码(name_taken / bad_name / …),这里只负责翻成 HTTP 语义 */
|
|
33
36
|
const FOLDER_ERRORS = { name_taken: 409, name_too_long: 400, bad_name: 400, no_folder: 404, default_folder: 403 }
|
|
34
37
|
|
|
38
|
+
/** 「浏览器打开」的 opener 解析链:① 注入(测试/明确指定);② Electron 的
|
|
39
|
+
* shell.openExternal(apply 若跑在宿主主进程里,createRequire('electron') 拿得到);
|
|
40
|
+
* ③ Windows 兜底 rundll32 走 ShellExecute → 默认浏览器(本机 2026-10-31 实证:
|
|
41
|
+
* Start-Process 同一条 ShellExecute 路能拉起 Edge)。都拿不到返回 null——/open
|
|
42
|
+
* 路由据此如实回 500 open_unavailable,而不是静默 ok。
|
|
43
|
+
* rundll32 的 exit code 不可靠,只当"发得出去";真开不开由系统关联决定。 */
|
|
44
|
+
async function resolveOpenExternal(log) {
|
|
45
|
+
try {
|
|
46
|
+
const { createRequire } = await import('node:module')
|
|
47
|
+
const electron = createRequire(import.meta.url)('electron')
|
|
48
|
+
if (electron && typeof electron.shell?.openExternal === 'function') {
|
|
49
|
+
return (url) => electron.shell.openExternal(url)
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
log?.({ level: 'info', event: 'open_external_opener', detail: `electron 不可用(${String(e?.code ?? e?.message ?? e).slice(0, 40)}),退回系统 rundll32` })
|
|
53
|
+
}
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
return (url) => new Promise((resolve, reject) => {
|
|
56
|
+
const child = spawn('rundll32', ['url.dll,FileProtocolHandler', url], { stdio: 'ignore' })
|
|
57
|
+
child.on('error', reject)
|
|
58
|
+
child.on('spawn', () => resolve())
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
/**
|
|
36
65
|
* /collect 自己的夹错误契约:no_folder 对齐 missing 扫描的 410(folder_gone 同源),
|
|
37
66
|
* 不沿用 /folder 路由的 404。当前实际不可达——missing 扫描在前拦截,能把
|
|
@@ -280,8 +309,14 @@ export function apply(ctx, config = {}) {
|
|
|
280
309
|
const boot = getPref()
|
|
281
310
|
if (!boot.errors.length) syncSources(db, boot.pref)
|
|
282
311
|
// 定时器可注入:测试里必须注入,否则一个 5 分钟的 interval 会把测试进程吊住不退出。
|
|
312
|
+
// runOnce 的正文补抓实现从这一层注入:调度器只会传 {db, pref, now, log, trigger, force},
|
|
313
|
+
// 抓取用的 fetch 是宿主这边的事(config.fetchImpl 是既有的注入口,与工具面同一个)。
|
|
314
|
+
const runOnceWithFulltext = (opts = {}) => runOnce({
|
|
315
|
+
...opts,
|
|
316
|
+
fetchFulltextImpl: (url, o = {}) => fetchFulltext(url, { ...o, fetchImpl: config.fetchImpl ?? globalThis.fetch })
|
|
317
|
+
})
|
|
283
318
|
const scheduler = createScheduler({
|
|
284
|
-
db, getPref, runOnce, log,
|
|
319
|
+
db, getPref, runOnce: runOnceWithFulltext, log,
|
|
285
320
|
now,
|
|
286
321
|
tickMin: Number.isFinite(config.tickMin) ? config.tickMin : 5,
|
|
287
322
|
setInterval: config.setInterval ?? setInterval,
|
|
@@ -292,6 +327,16 @@ export function apply(ctx, config = {}) {
|
|
|
292
327
|
const credFile = path.join(dir, 'credentials', 'bili-cookie.txt')
|
|
293
328
|
const probeYtDlp = makeYtDlpProbe({ spawnImpl: config.spawnImpl ?? spawn, getPref, now })
|
|
294
329
|
|
|
330
|
+
// 「浏览器打开」的出口(终审真修):面板只报条目 id,这里查库取 URL 再交系统浏览器。
|
|
331
|
+
// opener 可注入;config 里显式给 null = 明确没有出口(测试里钉"缺席"行为用),
|
|
332
|
+
// 键缺席才走 Electron → rundll32 的解析链,解析一次并缓存。
|
|
333
|
+
let openerPromise = null
|
|
334
|
+
const getOpener = () => {
|
|
335
|
+
if (Object.hasOwn(config, 'openExternal')) return Promise.resolve(config.openExternal)
|
|
336
|
+
if (!openerPromise) openerPromise = resolveOpenExternal(log)
|
|
337
|
+
return openerPromise
|
|
338
|
+
}
|
|
339
|
+
|
|
295
340
|
function state() {
|
|
296
341
|
const { pref, errors } = getPref()
|
|
297
342
|
const prev = lastRun(db)
|
|
@@ -405,6 +450,67 @@ export function apply(ctx, config = {}) {
|
|
|
405
450
|
return json(res, code, { ok: false, error: code === 500 ? 'internal' : msg })
|
|
406
451
|
}
|
|
407
452
|
}
|
|
453
|
+
if (sub === '/open' && req.method === 'POST') {
|
|
454
|
+
// 外链出口(终审真修):URL 只从库里来,不接受调用方传入——面板报 item_id,
|
|
455
|
+
// 宿主查库取 url。scheme 白名单 http/https 且拒带凭据的 URL(对齐宿主自己
|
|
456
|
+
// openExternal 的守卫);opener 缺席(非 Electron 环境)如实 500 而非静默 ok。
|
|
457
|
+
let body
|
|
458
|
+
try { body = JSON.parse((await readBody(req)) || '{}') || {} } catch { return json(res, 400, { ok: false, error: 'bad_payload' }) }
|
|
459
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || !Number.isInteger(body.item_id)) {
|
|
460
|
+
return json(res, 400, { ok: false, error: 'bad_payload' })
|
|
461
|
+
}
|
|
462
|
+
const row = db.prepare('SELECT url FROM items WHERE id = ? LIMIT 1').get(body.item_id)
|
|
463
|
+
if (!row) return json(res, 404, { ok: false, error: 'no_item' })
|
|
464
|
+
let parsed = null
|
|
465
|
+
try { parsed = new URL(row.url) } catch { return json(res, 400, { ok: false, error: 'unsupported_scheme' }) }
|
|
466
|
+
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password) {
|
|
467
|
+
return json(res, 400, { ok: false, error: 'unsupported_scheme' })
|
|
468
|
+
}
|
|
469
|
+
const opener = await getOpener()
|
|
470
|
+
if (typeof opener !== 'function') {
|
|
471
|
+
log({ level: 'error', event: 'open_external_failed', detail: `item#${body.item_id}: opener 缺席` })
|
|
472
|
+
return json(res, 500, { ok: false, error: 'open_unavailable' })
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
await opener(parsed.href)
|
|
476
|
+
} catch (e) {
|
|
477
|
+
log({ level: 'error', event: 'open_external_failed', detail: `item#${body.item_id}: ${String(e?.message ?? e).slice(0, 60)}` })
|
|
478
|
+
return json(res, 500, { ok: false, error: 'open_failed' })
|
|
479
|
+
}
|
|
480
|
+
log({ level: 'info', event: 'open_external', detail: `item#${body.item_id} → ${parsed.host}` })
|
|
481
|
+
return json(res, 200, { ok: true })
|
|
482
|
+
}
|
|
483
|
+
if (sub === '/xhs-status' && req.method === 'GET') {
|
|
484
|
+
// 小红书登录态探活(面板→宿主,非 agent 工具)。MCP 未配置/起不来都走 200 + 说明
|
|
485
|
+
// 文本——面板据此显示状态行,不该让一次探活把设置页打成红错。
|
|
486
|
+
const pref = getPref()
|
|
487
|
+
if (pref.errors.length || !pref.pref?.xhs?.mcp_path) {
|
|
488
|
+
return json(res, 200, { ok: true, configured: false, loggedIn: false, text: 'xhs.mcp_path 未配置(通道不启用)' })
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const client = await getXhsMcp(pref.pref, log)
|
|
492
|
+
const login = await client.checkLoginStatus()
|
|
493
|
+
return json(res, 200, { ok: true, configured: true, loggedIn: login.loggedIn, username: login.username, text: login.raw })
|
|
494
|
+
} catch (e) {
|
|
495
|
+
return json(res, 200, { ok: true, configured: true, loggedIn: false, text: String(e?.message ?? e).slice(0, 200) })
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (sub === '/xhs-login-qr' && req.method === 'GET') {
|
|
499
|
+
// 扫码登录(面板→宿主):宿主侧调 MCP 的 get_login_qrcode,PNG 以 dataURL 回面板。
|
|
500
|
+
const pref = getPref()
|
|
501
|
+
if (pref.errors.length || !pref.pref?.xhs?.mcp_path) {
|
|
502
|
+
return json(res, 503, { ok: false, error: 'xhs.mcp_path 未配置(通道不启用)' })
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const client = await getXhsMcp(pref.pref, log)
|
|
506
|
+
const r = await client.callTool('get_login_qrcode')
|
|
507
|
+
const img = r.images[0]
|
|
508
|
+
if (!img?.data) return json(res, 503, { ok: false, error: (r.texts.join(' ') || '二维码为空').slice(0, 160) })
|
|
509
|
+
return json(res, 200, { ok: true, image: `data:${img.mimeType ?? 'image/png'};base64,${img.data}`, text: r.texts.join(' ').slice(0, 160) })
|
|
510
|
+
} catch (e) {
|
|
511
|
+
return json(res, 503, { ok: false, error: String(e?.message ?? e).slice(0, 200) })
|
|
512
|
+
}
|
|
513
|
+
}
|
|
408
514
|
if (sub === '/folders' && req.method === 'GET') {
|
|
409
515
|
return json(res, 200, { ok: true, folders: listFolders(db) })
|
|
410
516
|
}
|
|
@@ -436,6 +542,46 @@ export function apply(ctx, config = {}) {
|
|
|
436
542
|
const r = await scheduler.triggerNow()
|
|
437
543
|
return json(res, 200, { ok: !r.skipped, ...r, state: state() })
|
|
438
544
|
}
|
|
545
|
+
if (sub === '/fulltext' && req.method === 'POST') {
|
|
546
|
+
// 面板→宿主:按条目 id 现抓一次正文(不是 agent 工具,不进工具面)。
|
|
547
|
+
// 抓不到一律 502 + error:客户端靠"非 2xx"把按钮状态收回来(view-api.send),
|
|
548
|
+
// 回 200 配 ok:false 会让面板以为成功而白刷新一次。
|
|
549
|
+
let body
|
|
550
|
+
try { body = JSON.parse((await readBody(req)) || '{}') || {} } catch { return json(res, 400, { ok: false, error: 'bad_payload' }) }
|
|
551
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || !Number.isInteger(body.item_id)) {
|
|
552
|
+
return json(res, 400, { ok: false, error: 'bad_payload' })
|
|
553
|
+
}
|
|
554
|
+
const row = db.prepare('SELECT id, url, kind, body_state FROM items WHERE id = ? LIMIT 1').get(body.item_id)
|
|
555
|
+
if (!row) return json(res, 404, { ok: false, error: 'no_item' })
|
|
556
|
+
// 已经有正文(抓到的全文 / 视频字幕)就什么都不做:这条路由只负责补缺,
|
|
557
|
+
// 不是"再抓一次覆盖掉"——重复点击不该产生第二个网络请求。
|
|
558
|
+
if (row.body_state === 'full' || row.body_state === 'subtitle' || row.body_state === 'transcribed') {
|
|
559
|
+
return json(res, 200, { ok: true, body_state: row.body_state, already: true })
|
|
560
|
+
}
|
|
561
|
+
let parsed = null
|
|
562
|
+
try { parsed = new URL(row.url) } catch { return json(res, 400, { ok: false, error: 'unsupported_scheme' }) }
|
|
563
|
+
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password) {
|
|
564
|
+
return json(res, 400, { ok: false, error: 'unsupported_scheme' })
|
|
565
|
+
}
|
|
566
|
+
let got = null
|
|
567
|
+
try {
|
|
568
|
+
got = await fetchFulltext(parsed.href, { fetchImpl: config.fetchImpl ?? globalThis.fetch })
|
|
569
|
+
} catch (e) {
|
|
570
|
+
const cls = classifyError(e)
|
|
571
|
+
log({ level: 'warn', event: 'fulltext_failed', detail: `item#${row.id}: ${cls}` })
|
|
572
|
+
return json(res, 502, { ok: false, error: 'fetch_failed', error_class: cls })
|
|
573
|
+
}
|
|
574
|
+
if (!got || !got.text) {
|
|
575
|
+
log({ level: 'warn', event: 'fulltext_failed', detail: `item#${row.id}: empty` })
|
|
576
|
+
return json(res, 502, { ok: false, error: 'fetch_failed', error_class: 'empty' })
|
|
577
|
+
}
|
|
578
|
+
// 只改正文两列 + fetched_at:不动 status(面板刷新后它还是那条候选)。
|
|
579
|
+
refreshItem(db, row.id, { body_text: got.text, body_state: 'full', fetched_at: now() })
|
|
580
|
+
// 手动抓成功就把自动补抓的失败计数清零,让下一轮愿意再试
|
|
581
|
+
setMeta(db, `fulltext:fail:${row.id}`, 0)
|
|
582
|
+
log({ level: 'info', event: 'fulltext_ok', detail: `item#${row.id}: ${got.chars} 字` })
|
|
583
|
+
return json(res, 200, { ok: true, body_state: 'full', chars: got.chars })
|
|
584
|
+
}
|
|
439
585
|
if (sub === '/feedback' && req.method === 'POST') {
|
|
440
586
|
const body = JSON.parse((await readBody(req)) || '{}') || {}
|
|
441
587
|
const actionDelta = ALLOWED_ACTIONS[body.action]
|
|
@@ -680,6 +826,10 @@ export function apply(ctx, config = {}) {
|
|
|
680
826
|
return () => scheduler.stop(handle)
|
|
681
827
|
}, 'dsh-baleen: scheduler')
|
|
682
828
|
|
|
829
|
+
// xhs 的 MCP 子进程(M6):惰性拉起(配了 xhs 源且首次抓取才 spawn),禁用插件时
|
|
830
|
+
// 跟着停——没拉起过就是空操作。
|
|
831
|
+
ctx.effect(() => () => stopXhsMcp(), 'dsh-baleen: xhs-mcp')
|
|
832
|
+
|
|
683
833
|
log({ level: 'info', event: 'plugin_loaded', detail: `M1 摄取侧就绪(只读路由 ${API_ROUTE})` })
|
|
684
834
|
return baleen
|
|
685
835
|
}
|
package/lib/adapters/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { rssAdapter } from './rss.js'
|
|
2
2
|
import { biliAdapter } from './bili.js'
|
|
3
3
|
import { ytAdapter } from './yt.js'
|
|
4
|
+
import { xhsAdapter } from './xhs.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* 适配器注册表。M1 只有 rss;bili(M4a)与 yt(M4b
|
|
7
|
-
* runner 不需要认识任何具体平台——它只按 source.type 查这张表。
|
|
7
|
+
* 适配器注册表。M1 只有 rss;bili(M4a)与 yt(M4b)已登记;xhs(M6)经官方 MCP
|
|
8
|
+
* 接入——runner 不需要认识任何具体平台——它只按 source.type 查这张表。
|
|
8
9
|
*/
|
|
9
|
-
export const ADAPTERS = { rss: rssAdapter, bili: biliAdapter, yt: ytAdapter }
|
|
10
|
-
export { rssAdapter, biliAdapter, ytAdapter }
|
|
10
|
+
export const ADAPTERS = { rss: rssAdapter, bili: biliAdapter, yt: ytAdapter, xhs: xhsAdapter }
|
|
11
|
+
export { rssAdapter, biliAdapter, ytAdapter, xhsAdapter }
|
|
11
12
|
|
|
12
13
|
export function adapterFor(type) {
|
|
13
14
|
const key = String(type ?? '').toLowerCase()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { getXhsMcp } from '../mcp/xhs-client.js'
|
|
2
|
+
|
|
3
|
+
export const type = 'xhs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* xhs 适配器 —— 小红书搜索关键词 → 笔记列表 → 逐条详情,经官方 MCP(HTTP,见
|
|
7
|
+
* lib/mcp/xhs-client.js)。选型与边界见 docs/v3-xhs-mcp-audit.md(V3)与 M6 计划:
|
|
8
|
+
*
|
|
9
|
+
* 边界(AGENTS.md 适配器红线,与 bili/yt 同款):
|
|
10
|
+
* - 只读:search_feeds / get_feed_detail / check_login_status 全是查询,写工具
|
|
11
|
+
* 在 client 白名单就被拒(红线之下的第二道闸);
|
|
12
|
+
* - 不判断、不写库:产出 RawItem,增量判定交给 putDraft 去重、backlog 交给 gate;
|
|
13
|
+
* - 零重试:本层从不重试,失败上抛交 pool 分类(needs_login / mcp_missing /
|
|
14
|
+
* xhs_risk 各自钉 error_class,游标不推进)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 每轮新条目上限:xhs 的详情是浏览器逐页滚动(比 B 站/yt 的 API 慢一个量级),
|
|
18
|
+
* 真机两轮实证 12 条装不进 90s/180s 预算——降到 6,余量按分批纪律留给下轮。 */
|
|
19
|
+
const MAX_NEW_PER_RUN = 6
|
|
20
|
+
/** 详情并发 2:小红书风控比 B 站敏感,礼貌优先;job 预算 90s 兜得住。 */
|
|
21
|
+
const DETAIL_CONCURRENCY = 2
|
|
22
|
+
|
|
23
|
+
function fail(message, props = {}) {
|
|
24
|
+
return Object.assign(new Error(message), props)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function mapLimit(list, limit, fn) {
|
|
28
|
+
const results = new Array(list.length)
|
|
29
|
+
let next = 0
|
|
30
|
+
let broken = null
|
|
31
|
+
async function worker() {
|
|
32
|
+
while (broken === null) {
|
|
33
|
+
const i = next++
|
|
34
|
+
if (i >= list.length) return
|
|
35
|
+
try {
|
|
36
|
+
results[i] = await fn(list[i], i)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
broken = e
|
|
39
|
+
throw e
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker))
|
|
44
|
+
if (broken !== null) throw broken
|
|
45
|
+
return results
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 互动数是小红书给的展示字符串("26826",偶尔带分隔),能解析就给数字,解析不了给 null 不编数。 */
|
|
49
|
+
function parseCount(s) {
|
|
50
|
+
const n = Number(String(s ?? '').replace(/[,\s]/g, ''))
|
|
51
|
+
return Number.isFinite(n) && n >= 0 ? n : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 笔记详情页地址。xsec_token 是详情接口的入场券(搜索结果自带),随 URL 保全。 */
|
|
55
|
+
export function noteUrl(id, xsecToken) {
|
|
56
|
+
const q = new URLSearchParams({ xsec_token: String(xsecToken ?? ''), xsec_source: 'pc_search' })
|
|
57
|
+
return `https://www.xiaohongshu.com/explore/${encodeURIComponent(String(id))}?${q}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* fetchXhs(source, {…}) → RawItem[]
|
|
62
|
+
* 源主键是 source.query(搜索关键词);needs_login 是一等公民错误类:登录态过期
|
|
63
|
+
* 本轮整源跳过,汇报里明写,不重试不静默。
|
|
64
|
+
*/
|
|
65
|
+
export async function fetchXhs(source, {
|
|
66
|
+
signal,
|
|
67
|
+
now = Date.now,
|
|
68
|
+
pref = null,
|
|
69
|
+
knownIds = null,
|
|
70
|
+
log = () => {},
|
|
71
|
+
mcp = null // 注入缝(测试/替身);缺省走 getXhsMcp(惰性 spawn + 会话缓存)
|
|
72
|
+
} = {}) {
|
|
73
|
+
// 关键词从 source.url 的 query 参数派生:sources 表只回传 url(与 bili 的 space URL
|
|
74
|
+
// 同构),preference 里的 query 字段进不了表。两种写法都收(query 优先)。
|
|
75
|
+
let keyword = String(source?.query ?? '').trim()
|
|
76
|
+
try {
|
|
77
|
+
const fromUrl = new URL(String(source?.url ?? '')).searchParams.get('query')
|
|
78
|
+
if (!keyword && fromUrl && fromUrl.trim()) keyword = fromUrl.trim()
|
|
79
|
+
} catch { /* url 不是合法 URL 时走 query 分支的报错 */ }
|
|
80
|
+
if (!keyword) {
|
|
81
|
+
throw fail('xhs 源解析不出搜索关键词——url 要形如 xiaohongshu.com/search?query=…', { error_class: 'bad_source' })
|
|
82
|
+
}
|
|
83
|
+
mcp = mcp ?? await getXhsMcp(pref, log)
|
|
84
|
+
|
|
85
|
+
const login = await mcp.checkLoginStatus()
|
|
86
|
+
if (!login.loggedIn) {
|
|
87
|
+
throw fail('小红书未登录——本轮跳过;到 设置 → 凭据与环境 重新扫码', { error_class: 'needs_login' })
|
|
88
|
+
}
|
|
89
|
+
if (login.username) log('xhs_login_as', `登录态有效(${login.username})`)
|
|
90
|
+
|
|
91
|
+
const search = await mcp.searchFeeds(keyword)
|
|
92
|
+
const feeds = Array.isArray(search?.feeds) ? search.feeds : []
|
|
93
|
+
if (!feeds.length) {
|
|
94
|
+
throw fail(`xhs 搜索 "${keyword}" 没有返回任何笔记——关键词太冷门或风控拦了,先在浏览器里搜一次核对`, { empty: true })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 已入库跳过(纯内存过滤,发生在任何 detail 请求之前)+ 每轮上限(预算分批)。
|
|
98
|
+
let fresh = feeds.filter((f) => f?.id)
|
|
99
|
+
if (knownIds?.size) {
|
|
100
|
+
const before = fresh.length
|
|
101
|
+
fresh = fresh.filter((f) => !knownIds.has(String(f.id)))
|
|
102
|
+
if (fresh.length < before) log('xhs_skipped_known', `${before - fresh.length} 条已入库,跳过详情`)
|
|
103
|
+
}
|
|
104
|
+
if (fresh.length > MAX_NEW_PER_RUN) {
|
|
105
|
+
const deferred = fresh.length - MAX_NEW_PER_RUN
|
|
106
|
+
fresh = fresh.slice(0, MAX_NEW_PER_RUN)
|
|
107
|
+
log('xhs_deferred', `本轮只处理 ${MAX_NEW_PER_RUN} 条新笔记,${deferred} 条留待下轮`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 详情受限并发 2;单条详情失败只跳过该条(一条风控不该掀翻整源),needs_login 级
|
|
111
|
+
// 的整源错误只可能发生在前面的探测与搜索阶段。
|
|
112
|
+
const mapped = await mapLimit(fresh, DETAIL_CONCURRENCY, async (f) => {
|
|
113
|
+
try {
|
|
114
|
+
const detail = await mcp.getFeedDetail(String(f.id), String(f.xsecToken ?? ''))
|
|
115
|
+
return toItem(detail, f, source, { now, signal })
|
|
116
|
+
} catch (e) {
|
|
117
|
+
log('xhs_item_skipped', `笔记 ${f.id} 详情失败被跳过:${String(e?.message ?? e).slice(0, 80)}`)
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
return mapped.filter(Boolean)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 详情 → RawItem。详情 JSON 的字段名以实机返回为准做防御式读取;detail 不可用时
|
|
126
|
+
* 回落搜索卡片(列表里也有标题/作者/封面,只是没有正文——body_state 会如实降为 summary)。
|
|
127
|
+
*/
|
|
128
|
+
function toItem(detail, card, source, { now, signal }) {
|
|
129
|
+
// 工具的返回可能是 {note: …} / {currentNote: …} / 直接是笔记本体,逐层兜。
|
|
130
|
+
const note = detail?.note ?? detail?.currentNote ?? detail?.data?.note ?? (detail?.id ? detail : null)
|
|
131
|
+
const noteCard = card?.noteCard ?? {}
|
|
132
|
+
const interact = note?.interactInfo ?? noteCard.interactInfo ?? {}
|
|
133
|
+
const user = note?.user ?? noteCard.user ?? {}
|
|
134
|
+
const type = String(note?.type ?? noteCard.type ?? 'normal').toLowerCase()
|
|
135
|
+
const id = String(note?.id ?? card?.id ?? '')
|
|
136
|
+
if (!id) return null
|
|
137
|
+
|
|
138
|
+
const video = note?.video ?? noteCard.video ?? null
|
|
139
|
+
const durationSec = Number(video?.capa?.duration ?? video?.duration ?? NaN)
|
|
140
|
+
const images = Array.isArray(note?.imageList) ? note.imageList : []
|
|
141
|
+
const cover = noteCard.cover?.urlDefault || noteCard.cover?.url || images[0]?.urlDefault || images[0]?.url || null
|
|
142
|
+
|
|
143
|
+
const desc = typeof note?.desc === 'string' ? note.desc : ''
|
|
144
|
+
const title = String(note?.title ?? noteCard.displayTitle ?? '').trim()
|
|
145
|
+
const timeMs = Number(note?.time ?? NaN)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
source_id: source.id,
|
|
149
|
+
external_id: id,
|
|
150
|
+
title: title || '(无标题)',
|
|
151
|
+
url: noteUrl(id, note?.xsecToken ?? card?.xsecToken),
|
|
152
|
+
author: user.nickname ?? user.nickName ?? null,
|
|
153
|
+
author_id: user.userId ?? null,
|
|
154
|
+
// 小红书给不了可靠的发布时间就如实缺省(time_estimated 由 normalize 按缺省置 1)
|
|
155
|
+
published_at: Number.isFinite(timeMs) && timeMs > 0 ? timeMs : null,
|
|
156
|
+
fetched_at: now(),
|
|
157
|
+
thumb: cover,
|
|
158
|
+
// video 笔记 kind 归 video(有 duration 时 normalize 也认);图文归 note——M1 预留的形态位
|
|
159
|
+
note_type: type === 'video' ? 'video' : 'image',
|
|
160
|
+
duration_sec: Number.isFinite(durationSec) && durationSec > 0 ? Math.round(durationSec) : null,
|
|
161
|
+
stats: {
|
|
162
|
+
likes: parseCount(interact.likedCount),
|
|
163
|
+
collects: parseCount(interact.collectedCount),
|
|
164
|
+
comments: parseCount(interact.commentCount)
|
|
165
|
+
},
|
|
166
|
+
// 正文:详情拿得到就是 full;detail 被跳过时 card 只有标题 → summary 由 normalize 判
|
|
167
|
+
body_html: desc ? `<p>${desc.replace(/&/g, '&').replace(/</g, '<').replace(/\n/g, '</p><p>')}</p>` : '',
|
|
168
|
+
summary_html: desc ? `<p>${desc.slice(0, 200)}</p>` : (noteCard.displayTitle ? `<p>${noteCard.displayTitle}</p>` : ''),
|
|
169
|
+
tags: Array.isArray(note?.tagList) ? note.tagList.map((t) => t.name).filter(Boolean) : []
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const xhsAdapter = { type, fetch: fetchXhs }
|
package/lib/dedup.js
CHANGED
|
@@ -4,6 +4,12 @@ import { getItemByKey, findByUrl, findByTitleNorm, insertItem, refreshItem, setS
|
|
|
4
4
|
const WINDOW = 14 * 86400000
|
|
5
5
|
const MIN_TITLE_NORM = 8
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* 「这条已经有可读正文了」的三态。full 是抓到的全文,subtitle/transcribed 是视频的字幕。
|
|
9
|
+
* summary_only / missing / burned_in 都不在此列——它们本来就该被更好的正文覆盖。
|
|
10
|
+
*/
|
|
11
|
+
const HAS_BODY = new Set(['full', 'subtitle', 'transcribed'])
|
|
12
|
+
|
|
7
13
|
/**
|
|
8
14
|
* 去掉空白与标点做比较键。"转载时改个问号/加个空格"是最常见的重复形态,
|
|
9
15
|
* 但短到两三个字的标题("教程")不配参与查重,否则会把一片正常内容误吞掉。
|
|
@@ -49,7 +55,16 @@ export function putDraft(db, draft, now = Date.now) {
|
|
|
49
55
|
if (key.status === 'archived' && !String(key.archived_reason ?? '').startsWith('duplicate')) {
|
|
50
56
|
return { id: key.id, action: 'skip_seen', reason: `archived:${key.archived_reason}` }
|
|
51
57
|
}
|
|
52
|
-
|
|
58
|
+
// 已经抓到过正文的条目,不能被"这一轮又只给了摘要"覆盖回 summary_only:
|
|
59
|
+
// RSS 每轮都只搬 feed 的摘要,正文是我们另外去原文抓的(lib/fulltext.js)。
|
|
60
|
+
// 不挡这一下,补抓就变成每轮重抓一遍——既烧请求,也会让"已补全"这件事在库里
|
|
61
|
+
// 反复丢失。只剔 body 两列,其余(标题、分数、时间、thumb)照旧刷新。
|
|
62
|
+
const patch = { ...row }
|
|
63
|
+
if (HAS_BODY.has(key.body_state) && !HAS_BODY.has(row.body_state)) {
|
|
64
|
+
delete patch.body_text
|
|
65
|
+
delete patch.body_state
|
|
66
|
+
}
|
|
67
|
+
refreshItem(db, key.id, patch, 'new')
|
|
53
68
|
return { id: key.id, action: 'update' }
|
|
54
69
|
}
|
|
55
70
|
|
package/lib/fulltext.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fulltext.js —— 从原文页面抽正文,补「RSS 只给摘要」这个缺口。
|
|
3
|
+
*
|
|
4
|
+
* 为什么手写而不是引库:Readability 那一套是几万行加依赖,而这里只需要
|
|
5
|
+
* 「article/main/body 里挑一个容器、丢掉评论区与推荐区、收 p/h2/h3/li」。
|
|
6
|
+
* 判据是**宁可抽不到,也不要把评论区当正文**:抽出来太短就返回空串,
|
|
7
|
+
* 调用方据此保留原来的摘要——把评论与推荐当正文,比只读摘要更糟。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { stripTags } from './feed.js'
|
|
11
|
+
|
|
12
|
+
export const FULLTEXT_MIN_CHARS = 400
|
|
13
|
+
|
|
14
|
+
// 整块连内容一起丢:这些标签里要么是脚本样式,要么是页面框架,从来不是正文
|
|
15
|
+
const DROP_TAGS = ['script', 'style', 'noscript', 'iframe', 'svg', 'form', 'header', 'footer', 'nav', 'aside']
|
|
16
|
+
// 正文容器内按 class/id 判噪音(评论、分享、相关推荐、订阅、面包屑……)
|
|
17
|
+
const NOISE_RE = /comment|share|related|recommend|sidebar|footer|nav|advert|promo|subscribe|newsletter|cookie|breadcrumb|social|toolbar/i
|
|
18
|
+
// 只认这四类段落标签。div 里也常有正文,但 div 也常有导航和页脚——认它等于把噪音请回来
|
|
19
|
+
const SEG_OPEN_RE = /<(p|h2|h3|li)\b[^>]*>/gi
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 找到 from 之后与 start 处同名开标签配对的闭合标签位置(同名嵌套按深度算)。
|
|
23
|
+
* 正则非贪婪在 `<div class="comments"><div class="comments">…</div></div>` 上会切在内层
|
|
24
|
+
* 闭合处,把外层剩下的正文一起吞掉,所以这里必须数深度。
|
|
25
|
+
*/
|
|
26
|
+
function closeIndex(text, from, tag) {
|
|
27
|
+
const openRe = new RegExp(`<${tag}(?:\\s[^>]*)?>`, 'gi')
|
|
28
|
+
const closeRe = new RegExp(`</${tag}\\s*>`, 'gi')
|
|
29
|
+
openRe.lastIndex = from
|
|
30
|
+
closeRe.lastIndex = from
|
|
31
|
+
let depth = 1
|
|
32
|
+
while (true) {
|
|
33
|
+
const c = closeRe.exec(text)
|
|
34
|
+
if (!c) return -1
|
|
35
|
+
const o = openRe.exec(text)
|
|
36
|
+
if (o && o.index < c.index) {
|
|
37
|
+
depth++
|
|
38
|
+
openRe.lastIndex = o.index + 1
|
|
39
|
+
closeRe.lastIndex = c.index + 1
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
depth--
|
|
43
|
+
if (depth === 0) return c.index
|
|
44
|
+
closeRe.lastIndex = c.index + 1
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 把某个标签的整块(含内容)剪掉。
|
|
50
|
+
* 没闭合时只剪开标签本身、内容留着——脚本样式整块丢是对的,
|
|
51
|
+
* 但一个坏页面不该因为少了一个 </header> 就把全文一起吞掉。
|
|
52
|
+
*/
|
|
53
|
+
function dropBlocks(text, tag) {
|
|
54
|
+
const openRe = new RegExp(`<${tag}(?:\\s[^>]*)?>`, 'gi')
|
|
55
|
+
let out = ''
|
|
56
|
+
let cursor = 0
|
|
57
|
+
let m
|
|
58
|
+
while ((m = openRe.exec(text))) {
|
|
59
|
+
const end = closeIndex(text, m.index + m[0].length, tag)
|
|
60
|
+
if (end < 0) continue
|
|
61
|
+
out += text.slice(cursor, m.index) + ' '
|
|
62
|
+
cursor = end + tag.length + 3
|
|
63
|
+
openRe.lastIndex = cursor
|
|
64
|
+
}
|
|
65
|
+
return out + text.slice(cursor)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 取 class 或 id 的原文(单双引号都认,无引号的值也收) */
|
|
69
|
+
function attrOf(attrs, name) {
|
|
70
|
+
const m = new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(attrs)
|
|
71
|
+
return m ? (m[1] ?? m[2] ?? m[3] ?? '') : ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 容器内按 class/id 丢噪音块:一次只剪一个命中,剪完从同一位置继续,
|
|
76
|
+
* 嵌套的随外层一起消失。先剪外层再扫内层,所以不会出现「父元素文字被连带丢掉」。
|
|
77
|
+
*/
|
|
78
|
+
function dropNoise(text) {
|
|
79
|
+
const re = /<([a-zA-Z][a-zA-Z0-9:-]*)((?:\s[^>]*)?)>/g
|
|
80
|
+
let out = ''
|
|
81
|
+
let cursor = 0
|
|
82
|
+
let m
|
|
83
|
+
while ((m = re.exec(text))) {
|
|
84
|
+
const attrs = m[2] ?? ''
|
|
85
|
+
if (!NOISE_RE.test(`${attrOf(attrs, 'class')} ${attrOf(attrs, 'id')}`)) continue
|
|
86
|
+
const end = closeIndex(text, m.index + m[0].length, m[1])
|
|
87
|
+
out += text.slice(cursor, m.index) + ' '
|
|
88
|
+
if (end < 0) {
|
|
89
|
+
// 自闭合或没闭合(img/br 一类):只剪开标签,后面照常扫
|
|
90
|
+
cursor = m.index + m[0].length
|
|
91
|
+
re.lastIndex = cursor
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
cursor = end + m[1].length + 3
|
|
95
|
+
re.lastIndex = cursor
|
|
96
|
+
}
|
|
97
|
+
return out + text.slice(cursor)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 正文容器:优先 article,其次 main,再次 body;都没闭合就退回全文 */
|
|
101
|
+
function pickContainer(html) {
|
|
102
|
+
for (const tag of ['article', 'main', 'body']) {
|
|
103
|
+
const m = new RegExp(`<${tag}(?:\\s[^>]*)?>`, 'i').exec(html)
|
|
104
|
+
if (!m) continue
|
|
105
|
+
const end = closeIndex(html, m.index + m[0].length, tag)
|
|
106
|
+
if (end < 0) continue
|
|
107
|
+
return html.slice(m.index + m[0].length, end)
|
|
108
|
+
}
|
|
109
|
+
return html
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 收段落文字。闭合的取配对区间;HTML 允许省略 </p>,没闭合的取到下一个段落开标签为止
|
|
114
|
+
* (真实博客里 `<p>甲<p>乙</p>` 这种写法比想象的常见,整段吞掉就等于白抓)。
|
|
115
|
+
*/
|
|
116
|
+
function collectSegments(text, segs) {
|
|
117
|
+
let m
|
|
118
|
+
SEG_OPEN_RE.lastIndex = 0
|
|
119
|
+
while ((m = SEG_OPEN_RE.exec(text))) {
|
|
120
|
+
const tag = m[1].toLowerCase()
|
|
121
|
+
const start = m.index + m[0].length
|
|
122
|
+
const end = closeIndex(text, start, tag)
|
|
123
|
+
if (end < 0) {
|
|
124
|
+
// 注意:exec 落空会把 lastIndex 归零,所以探路用另一枚正则
|
|
125
|
+
const ahead = new RegExp(SEG_OPEN_RE.source, 'gi')
|
|
126
|
+
ahead.lastIndex = start
|
|
127
|
+
const nxt = ahead.exec(text)
|
|
128
|
+
push(stripTags(text.slice(start, nxt ? nxt.index : text.length)), segs)
|
|
129
|
+
if (!nxt) break
|
|
130
|
+
SEG_OPEN_RE.lastIndex = nxt.index
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
push(stripTags(text.slice(start, end)), segs)
|
|
134
|
+
SEG_OPEN_RE.lastIndex = end + tag.length + 3
|
|
135
|
+
}
|
|
136
|
+
return segs
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 空段丢掉,相邻重复段只留一次(分享按钮里常把标题重复一遍) */
|
|
140
|
+
function push(seg, segs) {
|
|
141
|
+
if (!seg) return
|
|
142
|
+
if (seg === segs[segs.length - 1]) return
|
|
143
|
+
segs.push(seg)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* HTML → 纯文本(段落之间两个换行)。
|
|
148
|
+
* 抽出来比 FULLTEXT_MIN_CHARS 短就返回空串:'' 表示「没抽到比摘要更多的东西」,
|
|
149
|
+
* 调用方据此不覆盖已有内容——这是这个模块唯一对外承诺的语义。
|
|
150
|
+
*/
|
|
151
|
+
export function extractFulltext(html, url = '') {
|
|
152
|
+
let text = String(html ?? '').replace(/<!--[\s\S]*?-->/g, ' ')
|
|
153
|
+
for (const tag of DROP_TAGS) text = dropBlocks(text, tag)
|
|
154
|
+
text = dropNoise(pickContainer(text))
|
|
155
|
+
const segs = collectSegments(text, [])
|
|
156
|
+
const out = segs.join('\n\n')
|
|
157
|
+
return out.length >= FULLTEXT_MIN_CHARS ? out : ''
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 抓 url 再抽正文。失败全部上抛,交给 pool 的 classifyError:
|
|
162
|
+
* HTTP 非 2xx → e.status;不是 HTML → e.parse;抽不到东西 → e.empty;
|
|
163
|
+
* 超时 → name='TimeoutError'。三种失败都只该让这条退回摘要,不该让一轮抓取停摆。
|
|
164
|
+
*/
|
|
165
|
+
export async function fetchFulltext(url, { signal, fetchImpl = globalThis.fetch, timeoutMs = 15000 } = {}) {
|
|
166
|
+
const controller = new AbortController()
|
|
167
|
+
const onAbort = () => controller.abort()
|
|
168
|
+
if (signal) {
|
|
169
|
+
if (signal.aborted) controller.abort()
|
|
170
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let timer
|
|
174
|
+
const timeout = new Promise((_, reject) => {
|
|
175
|
+
timer = setTimeout(() => {
|
|
176
|
+
controller.abort()
|
|
177
|
+
const e = new Error(`正文抓取超过 ${timeoutMs}ms 无响应`)
|
|
178
|
+
e.name = 'TimeoutError'
|
|
179
|
+
reject(e)
|
|
180
|
+
}, timeoutMs)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const work = (async () => {
|
|
184
|
+
const res = await fetchImpl(url, {
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
redirect: 'follow',
|
|
187
|
+
headers: {
|
|
188
|
+
'User-Agent': 'baleen/0.1.0 (+local; fulltext)',
|
|
189
|
+
Accept: 'text/html,application/xhtml+xml'
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
if (!res?.ok) {
|
|
193
|
+
const e = new Error(`HTTP ${res?.status ?? '?'}`)
|
|
194
|
+
e.status = res?.status ?? 0
|
|
195
|
+
throw e
|
|
196
|
+
}
|
|
197
|
+
const ctype = String(res.headers?.get?.('content-type') ?? '')
|
|
198
|
+
// xhtml 也是 HTML:Accept 里带了它,回过来就不该被当成"不是网页"
|
|
199
|
+
if (!/text\/html|application\/xhtml\+xml/i.test(ctype)) {
|
|
200
|
+
const e = new Error(`不是 HTML(content-type=${ctype || '缺失'})`)
|
|
201
|
+
e.parse = true
|
|
202
|
+
throw e
|
|
203
|
+
}
|
|
204
|
+
const body = await res.text()
|
|
205
|
+
const text = extractFulltext(body, url)
|
|
206
|
+
if (!text) {
|
|
207
|
+
const e = new Error('页面里抽不出比摘要更多的正文')
|
|
208
|
+
e.empty = true
|
|
209
|
+
throw e
|
|
210
|
+
}
|
|
211
|
+
return { text, chars: text.length }
|
|
212
|
+
})()
|
|
213
|
+
// 超时先赢时 work 的迟到拒绝会变成 unhandledRejection,先挂个空 catch 兜住
|
|
214
|
+
work.catch(() => {})
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
return await Promise.race([work, timeout])
|
|
218
|
+
} finally {
|
|
219
|
+
clearTimeout(timer)
|
|
220
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
221
|
+
}
|
|
222
|
+
}
|