dsh-scrape 0.0.1 → 0.1.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/lib/index.mjs ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 公开 API:插件、CLI 和别的程序都从这里(或直接 import 对应子模块)取能力。
3
+ * 拆成小文件是为了「想只用一段就只 import 一段」,不是为了好看。
4
+ */
5
+
6
+ export { scrape, scrapeHtml, scrapeMany, detectSpa, resolveRobots, warning, VERSION, TOOL_NAME, DEFAULTS, LIMITATIONS } from './scrape.mjs'
7
+ export { parseHtml, tokenizeHtml, parseAttrs, renderInline, detectSpaSignals, collapseWhitespace, stripInline, noiseKindOf, resolveUrl } from './html.mjs'
8
+ export { selectMain, blockLength, linkRatio, isBoilerplate, bestRun, EXTRACT_VERSION, MIN_MAIN_CHARS } from './extract.mjs'
9
+ export { renderMarkdown, textLinesToMarkdown, blockToMarkdown, lineRef, summaryLine } from './markdown.mjs'
10
+ export { parseRobots, checkAllowance, matchingGroups, pathMatches, describeAllowance, pathOf, originOf, BOT_TOKEN } from './robots.mjs'
11
+ export { fetchPage, fetchOptional, decodeBuffer, sniffCharset, charsetFromContentType, assertFetchableUrl, DEFAULT_USER_AGENT } from './net.mjs'
12
+ export { createThrottle, hostKey, DEFAULT_MIN_INTERVAL_MS } from './limit.mjs'
13
+ export { decodeEntities, hasEntities, NAMED_ENTITIES } from './entities.mjs'
14
+ export { ScrapeError, classifyFetchError, httpStatusError, describeError } from './errors.mjs'
15
+ export { renderText, renderJson, renderRemoved, humanBytes, padDisplay } from './format.mjs'
package/lib/limit.mjs ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 抓取限速:同一个域名「不并发、不猛打」。
3
+ *
4
+ * 规则只有两条,但都是硬规则:
5
+ * 1) **同域串行**——上一个请求的响应体读完之前,不发下一个(不并发);
6
+ * 2) **同域最小间隔**——两次请求的发出时间至少隔 `minIntervalMs`(默认 500ms);
7
+ * 如果 robots.txt 声明了 `Crawl-delay`,取两者更大者。
8
+ *
9
+ * 不同域名互不影响(各走各的队列),所以批量抓多个站点不会被串成一条长队。
10
+ * 这是「礼貌抓取」的工程底线:并发猛打是被封的最快方式,也会烧掉用户的 IP 招牌。
11
+ */
12
+
13
+ /** 默认同域最小间隔(毫秒)。 */
14
+ export const DEFAULT_MIN_INTERVAL_MS = 500
15
+
16
+ /**
17
+ * 睡一会儿。
18
+ * @param {number} ms 毫秒
19
+ * @returns {Promise<void>} 完成后解析
20
+ */
21
+ function sleep(ms) {
22
+ return new Promise((resolve) => setTimeout(resolve, ms))
23
+ }
24
+
25
+ /**
26
+ * 取 URL 的主机键(同主机不同端口视为同一站点,因为限速关心的是对方服务器)。
27
+ * @param {string} url 任意 URL
28
+ * @returns {string} 主机名(小写)
29
+ */
30
+ export function hostKey(url) {
31
+ try {
32
+ return new URL(url).hostname.toLowerCase()
33
+ } catch {
34
+ return String(url)
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 造一个与实例绑定的限速器。
40
+ * @param {{minIntervalMs?:number,maxPerHost?:number}} [options] 选项
41
+ * @returns {{acquire:(url:string)=>Promise<{release:()=>void}>,stats:()=>object,minIntervalMs:number,setHostInterval:(host:string,ms:number)=>void}} 限速器
42
+ */
43
+ export function createThrottle(options = {}) {
44
+ const defaultInterval = Math.max(0, options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS)
45
+ /** @type {Map<string,{tail:Promise<void>,lastStart:number,requests:number,waitedMs:number,interval:number}>} */
46
+ const hosts = new Map()
47
+
48
+ /**
49
+ * 取(或建)某主机的队列状态。
50
+ * @param {string} host 主机键
51
+ * @returns {object} 队列状态
52
+ */
53
+ const stateOf = (host) => {
54
+ let state = hosts.get(host)
55
+ if (!state) {
56
+ state = { tail: Promise.resolve(), lastStart: 0, requests: 0, waitedMs: 0, interval: defaultInterval }
57
+ hosts.set(host, state)
58
+ }
59
+ return state
60
+ }
61
+
62
+ return {
63
+ minIntervalMs: defaultInterval,
64
+
65
+ /**
66
+ * 为 robots.txt 的 Crawl-delay 抬高某主机的间隔(只升不降)。
67
+ * @param {string} host 主机键
68
+ * @param {number} ms 间隔毫秒
69
+ * @returns {void}
70
+ */
71
+ setHostInterval(host, ms) {
72
+ if (!Number.isFinite(ms) || ms <= 0) return
73
+ const state = stateOf(host)
74
+ state.interval = Math.max(state.interval, Math.round(ms))
75
+ },
76
+
77
+ /**
78
+ * 排队拿到一次请求许可。用完后必须 `release()`(放在 finally 里)。
79
+ * @param {string} url 目标 URL
80
+ * @returns {Promise<{release:()=>void}>} 许可凭据
81
+ */
82
+ async acquire(url) {
83
+ const host = hostKey(url)
84
+ const state = stateOf(host)
85
+ const previous = state.tail
86
+ let finish
87
+ state.tail = new Promise((resolve) => { finish = resolve })
88
+
89
+ await previous
90
+ const wait = state.lastStart + state.interval - Date.now()
91
+ if (wait > 0) {
92
+ state.waitedMs += wait
93
+ await sleep(wait)
94
+ }
95
+ state.lastStart = Date.now()
96
+ state.requests += 1
97
+
98
+ let released = false
99
+ return {
100
+ release() {
101
+ if (released) return
102
+ released = true
103
+ finish()
104
+ },
105
+ }
106
+ },
107
+
108
+ /**
109
+ * 每个主机抓了几次、一共等了多久(用于报告与自测)。
110
+ * @returns {object} 统计
111
+ */
112
+ stats() {
113
+ const out = {}
114
+ for (const [host, state] of hosts) {
115
+ out[host] = { requests: state.requests, waitedMs: state.waitedMs, intervalMs: state.interval }
116
+ }
117
+ return out
118
+ },
119
+ }
120
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 把结构化块渲染成 Markdown,并让每一块都能回指源行号。
3
+ *
4
+ * 行号标注用 HTML 注释(`<!-- L42 -->`):渲染出来看不见,但被下游模型读到、
5
+ * 被 grep 到,于是「这句话来自原页第 42 行」是可验证的,而不是凭记忆说的。
6
+ * 想要一份没有注释的干净 Markdown,把 `lineRefs` 关掉即可。
7
+ */
8
+
9
+ import { stripInline } from './html.mjs'
10
+
11
+ /**
12
+ * 生成一条行号标注。
13
+ * @param {number} start 起始行(1 基)
14
+ * @param {number} end 结束行(1 基,等于起始行时省略)
15
+ * @returns {string} 注释行
16
+ */
17
+ export function lineRef(start, end) {
18
+ const from = Math.max(1, Number(start) || 1)
19
+ const to = Math.max(from, Number(end) || from)
20
+ return to > from ? `<!-- L${from}-${to} -->` : `<!-- L${from} -->`
21
+ }
22
+
23
+ /**
24
+ * 转义表格单元格里的竖线(否则会把表格结构冲掉)。
25
+ * @param {string} text 单元格文本
26
+ * @returns {string} 转义后的文本
27
+ */
28
+ function escapeCell(text) {
29
+ return String(text ?? '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
30
+ }
31
+
32
+ /**
33
+ * 单块 → Markdown 行数组。
34
+ * @param {object} block 块
35
+ * @returns {string[]} Markdown 行
36
+ */
37
+ export function blockToMarkdown(block) {
38
+ switch (block.kind) {
39
+ case 'heading':
40
+ return [`${'#'.repeat(Math.max(1, Math.min(6, block.level ?? 2)))} ${block.text.trim()}`]
41
+ case 'paragraph':
42
+ return [block.text.trim()]
43
+ case 'listItem': {
44
+ const indent = ' '.repeat(Math.max(0, (block.depth ?? 1) - 1))
45
+ const marker = block.ordered ? `${block.index ?? 1}. ` : '- '
46
+ return [`${indent}${marker}${block.text.trim()}`]
47
+ }
48
+ case 'quote':
49
+ return block.text.trim().split('\n').map((line) => `> ${line.trim()}`)
50
+ case 'code': {
51
+ const fence = block.text.includes('```') ? '````' : '```'
52
+ const lang = block.lang ?? ''
53
+ return [`${fence}${lang}`, block.text, fence]
54
+ }
55
+ case 'rule':
56
+ return ['---']
57
+ case 'table': {
58
+ const header = block.header ?? []
59
+ const rows = block.rows ?? []
60
+ if (header.length === 0) return []
61
+ if (rows.length === 0) {
62
+ // 只有表头没有数据行:Markdown 表格装不下,退化成一行文字,信息不丢
63
+ return [`${header.map(escapeCell).filter(Boolean).join(' · ')}`]
64
+ }
65
+ const width = Math.max(header.length, ...rows.map((row) => row.length))
66
+ const pad = (cells) => {
67
+ const out = cells.map(escapeCell)
68
+ while (out.length < width) out.push('')
69
+ return `| ${out.join(' | ')} |`
70
+ }
71
+ const lines = [pad(header), `| ${new Array(width).fill('---').join(' | ')} |`]
72
+ for (const row of rows) lines.push(pad(row))
73
+ return lines
74
+ }
75
+ default:
76
+ return block.text ? [block.text.trim()] : []
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 块列表 → Markdown 文档。
82
+ * @param {Array<object>} blocks 块(按文档顺序)
83
+ * @param {{lineRefs?:boolean,maxChars?:number}} [options] 选项
84
+ * @returns {{markdown:string,truncated:boolean,chars:number,blockCount:number,lines:Array<{line:number,endLine:number,kind:string}>}} 渲染结果
85
+ */
86
+ export function renderMarkdown(blocks, options = {}) {
87
+ const lineRefs = options.lineRefs !== false
88
+ const maxChars = options.maxChars ?? 0
89
+ const lines = []
90
+ const map = []
91
+
92
+ for (const block of blocks) {
93
+ // 空白块直接跳过,别在正文里制造连续空行
94
+ const needsText = block.kind !== 'table' && block.kind !== 'code' && block.kind !== 'rule'
95
+ if (needsText && !String(block.text ?? '').trim()) continue
96
+ const rendered = blockToMarkdown(block)
97
+ if (rendered.length === 0 || rendered.every((line) => line.trim() === '')) continue
98
+ if (lines.length > 0) lines.push('')
99
+ if (lineRefs) lines.push(lineRef(block.line, block.endLine))
100
+ lines.push(...rendered)
101
+ map.push({ line: block.line, endLine: block.endLine, kind: block.kind })
102
+ }
103
+
104
+ let markdown = lines.join('\n').trim()
105
+ let truncated = false
106
+ if (maxChars > 0 && markdown.length > maxChars) {
107
+ markdown = `${markdown.slice(0, maxChars).trimEnd()}\n\n<!-- 已截断:只保留前 ${maxChars} 字,完整内容请提高 maxChars -->`
108
+ truncated = true
109
+ }
110
+
111
+ return { markdown, truncated, chars: markdown.length, blockCount: map.length, lines: map }
112
+ }
113
+
114
+ /**
115
+ * 没有可用块时(例如纯文本响应、或极碎的页面)用行号对齐的可见文本兜底。
116
+ * @param {string[]} textLines 行号对齐的可见文本
117
+ * @param {{lineRefs?:boolean,from?:number,to?:number}} [options] 选项
118
+ * @returns {{markdown:string,chars:number,blockCount:number,lines:Array<object>}} 渲染结果
119
+ */
120
+ export function textLinesToMarkdown(textLines, options = {}) {
121
+ const lineRefs = options.lineRefs !== false
122
+ const from = options.from ?? 0
123
+ const to = options.to ?? textLines.length
124
+ const out = []
125
+ const map = []
126
+ let paragraph = []
127
+
128
+ const flush = () => {
129
+ if (paragraph.length === 0) return
130
+ const text = paragraph.map((item) => item.text).join(' ').trim()
131
+ const start = paragraph[0].line
132
+ const end = paragraph[paragraph.length - 1].line
133
+ paragraph = []
134
+ if (!text) return
135
+ if (lineRefs) out.push(lineRef(start, end))
136
+ out.push(text)
137
+ out.push('')
138
+ map.push({ line: start, endLine: end, kind: 'text' })
139
+ }
140
+
141
+ for (let i = from; i < Math.min(to, textLines.length); i++) {
142
+ const text = String(textLines[i] ?? '').trim()
143
+ if (!text) { flush(); continue }
144
+ paragraph.push({ line: i + 1, text })
145
+ }
146
+ flush()
147
+
148
+ const markdown = out.join('\n').trim()
149
+ return { markdown, chars: markdown.length, blockCount: map.length, lines: map }
150
+ }
151
+
152
+ /**
153
+ * 一行摘要(给工具卡片与 CLI 首行)。
154
+ * @param {object} result scrape 结果
155
+ * @returns {string} 摘要
156
+ */
157
+ export function summaryLine(result) {
158
+ const title = result.title ? stripInline(result.title).slice(0, 60) : '(无标题)'
159
+ const chars = result.content?.chars ?? 0
160
+ return `${title} · ${chars} 字 · 置信度 ${result.content?.confidence ?? 0} · ${result.provenance?.httpStatus ?? '-'} ${result.provenance?.finalUrl ?? ''}`.trim()
161
+ }
package/lib/net.mjs ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * 网络层:抓一个 URL 的原始字节,并把「重定向链、字符集、截断」这些抓取事实记下来。
3
+ *
4
+ * 三条设计取舍:
5
+ * 1) **手动跟随重定向**(`redirect: 'manual'`)——这样才能把每一跳记进 provenance,
6
+ * 也才能自己控制跳数与跨站跳转;undici 的自动跟随只留最终 URL,证据会丢。
7
+ * 2) **自己解码字符集**——`response.text()` 永远按 UTF-8 解,中文老站(GBK/GB18030/Big5)
8
+ * 会变成乱码。这里读字节、从响应头或 `<meta charset>` 认字符集,再交给 `TextDecoder`。
9
+ * 3) **按字节上限截断**——边读边数,超过 maxBytes 就掐掉并标记 `truncated`,
10
+ * 避免一个 200MB 的页面把 agent 的上下文炸掉。
11
+ */
12
+
13
+ import { classifyFetchError, httpStatusError, ScrapeError } from './errors.mjs'
14
+
15
+ /** 默认 UA:说明自己是谁、能做什么,站点管理员看得懂。 */
16
+ export const DEFAULT_USER_AGENT =
17
+ 'dsh-scrape/0.1.0 (+https://github.com/dongsheng123132/dsh-scrape; robots-aware AI fetcher)'
18
+
19
+ /** 判为重定向的状态码。 */
20
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308])
21
+
22
+ /** 不响应字符集检测的二进制类型:抓到也当字节,不猜编码。 */
23
+ const TEXT_HINT = /^(text\/|application\/(json|xml|xhtml\+xml|javascript|rss\+xml|atom\+xml|ld\+json))/i
24
+
25
+ /**
26
+ * 校验 URL 能抓(http/https 且能解析)。
27
+ * @param {string} url 原始 URL
28
+ * @returns {URL} 解析后的 URL
29
+ */
30
+ export function assertFetchableUrl(url) {
31
+ let target
32
+ try {
33
+ target = new URL(String(url))
34
+ } catch (error) {
35
+ throw new ScrapeError('invalid_url', `URL 不合法:${url}`, {
36
+ url: String(url), cause: error, hint: '要写全,例如 https://example.com/page',
37
+ })
38
+ }
39
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
40
+ throw new ScrapeError('invalid_url', `只支持 http/https:${target.protocol}//${target.host}`, {
41
+ url: String(url), hint: '不做 file:// ftp://;本地文件请用 --file 或 html 输入',
42
+ })
43
+ }
44
+ return target
45
+ }
46
+
47
+ /**
48
+ * 从 Content-Type 里取字符集。
49
+ * @param {string} contentType 响应头 Content-Type
50
+ * @returns {string} 小写字符集名,取不到返回空串
51
+ */
52
+ export function charsetFromContentType(contentType) {
53
+ const match = /charset\s*=\s*"?([\w.:-]+)"?/i.exec(String(contentType ?? ''))
54
+ return match ? match[1].toLowerCase() : ''
55
+ }
56
+
57
+ /**
58
+ * 从 HTML 头部字节里嗅 `<meta charset>` / `<meta http-equiv>`。
59
+ * 只看前 2048 字节(规范建议),够覆盖绝大多数页面。
60
+ * @param {Buffer} buffer 原始字节
61
+ * @returns {string} 小写字符集名,嗅不到返回空串
62
+ */
63
+ export function sniffCharset(buffer) {
64
+ const head = buffer.subarray(0, 2048).toString('latin1')
65
+ const direct = /<meta[^>]+charset\s*=\s*["']?\s*([\w.:-]+)/i.exec(head)
66
+ if (direct) return direct[1].toLowerCase()
67
+ const httpEquiv = /<meta[^>]+http-equiv\s*=\s*["']?content-type["']?[^>]*content\s*=\s*["'][^"']*charset=([\w.:-]+)/i.exec(head)
68
+ return httpEquiv ? httpEquiv[1].toLowerCase() : ''
69
+ }
70
+
71
+ /**
72
+ * 用指定字符集解码字节;字符集不认识就退回 UTF-8,并把这件事说出来。
73
+ * @param {Buffer} buffer 原始字节
74
+ * @param {string} label 字符集名
75
+ * @returns {{text:string,charset:string,fallback:boolean,error?:string}} 解码结果
76
+ */
77
+ export function decodeBuffer(buffer, label) {
78
+ const wanted = String(label ?? '').trim().toLowerCase()
79
+ const candidates = wanted ? [wanted] : []
80
+ candidates.push('utf-8')
81
+ let lastError = ''
82
+ for (const candidate of candidates) {
83
+ try {
84
+ const decoder = new TextDecoder(candidate, { fatal: false })
85
+ return { text: decoder.decode(buffer), charset: candidate, fallback: candidate !== wanted && wanted !== '', ...(lastError ? { error: lastError } : {}) }
86
+ } catch (error) {
87
+ lastError = error instanceof Error ? error.message : String(error)
88
+ }
89
+ }
90
+ // 兜底:Node 里 TextDecoder('utf-8') 一定存在,走不到这里
91
+ return { text: buffer.toString('utf8'), charset: 'utf-8', fallback: true, error: lastError }
92
+ }
93
+
94
+ /**
95
+ * 边读边截断,拿到不超过 maxBytes 的字节。
96
+ * @param {ReadableStream<Uint8Array>|null} body 响应体流
97
+ * @param {number} maxBytes 字节上限
98
+ * @returns {Promise<{buffer:Buffer,truncated:boolean}>} 字节与截断标记
99
+ */
100
+ async function readCapped(body, maxBytes) {
101
+ if (!body || typeof body.getReader !== 'function') {
102
+ return { buffer: Buffer.alloc(0), truncated: false }
103
+ }
104
+ const reader = body.getReader()
105
+ const chunks = []
106
+ let total = 0
107
+ let truncated = false
108
+ for (;;) {
109
+ const { done, value } = await reader.read()
110
+ if (done) break
111
+ if (!value || value.length === 0) continue
112
+ const remaining = maxBytes - total
113
+ if (value.length > remaining) {
114
+ chunks.push(Buffer.from(value.subarray(0, remaining)))
115
+ total += remaining
116
+ truncated = true
117
+ try { await reader.cancel() } catch { /* 取消失败不影响已读字节 */ }
118
+ break
119
+ }
120
+ chunks.push(Buffer.from(value))
121
+ total += value.length
122
+ if (total >= maxBytes) {
123
+ // 刚好读满上限:再探一次才知道后面还有没有,别把「正好 maxBytes」误报成截断
124
+ const next = await reader.read()
125
+ if (next.done) break
126
+ truncated = true
127
+ try { await reader.cancel() } catch { /* 忽略 */ }
128
+ break
129
+ }
130
+ }
131
+ return { buffer: Buffer.concat(chunks, total), truncated }
132
+ }
133
+
134
+ /**
135
+ * 抓一个 URL(含手动跟随重定向、字符集解码、按字节截断)。
136
+ *
137
+ * @param {string} url 目标 URL
138
+ * @param {object} [options] 选项
139
+ * @param {number} [options.timeoutMs=15000] 单跳超时(毫秒,整个请求含读体)
140
+ * @param {number} [options.maxBytes=2000000] 响应体字节上限
141
+ * @param {number} [options.maxRedirects=5] 最多跟几跳
142
+ * @param {number[]} [options.acceptStatus] 额外接受的状态码(默认 2xx)
143
+ * @param {string} [options.userAgent] UA
144
+ * @param {AbortSignal} [options.signal] 外部取消信号
145
+ * @param {{acquire:(url:string)=>Promise<void>}} [options.throttle] 同域限速器
146
+ * @returns {Promise<object>} 抓取结果(含 redirects / charset / buffer / text / truncated)
147
+ */
148
+ export async function fetchPage(url, options = {}) {
149
+ const timeoutMs = options.timeoutMs ?? 15000
150
+ const maxBytes = Math.max(64, options.maxBytes ?? 2_000_000)
151
+ const maxRedirects = Math.max(0, options.maxRedirects ?? 5)
152
+ const acceptStatus = options.acceptStatus ?? []
153
+ const userAgent = options.userAgent ?? DEFAULT_USER_AGENT
154
+ const startedAt = Date.now()
155
+
156
+ let current = assertFetchableUrl(url)
157
+ const requestedUrl = current.toString()
158
+ const redirects = []
159
+
160
+ for (let hop = 0; hop <= maxRedirects; hop++) {
161
+ // 同域限速:拿到许可才发请求,读完响应体才放行下一个。
162
+ // try/finally 保证任何异常路径都会放行,否则这个域名的队列会被永久堵死。
163
+ const lease = options.throttle ? await options.throttle.acquire(current.toString()) : null
164
+ let released = false
165
+ const release = () => {
166
+ if (released) return
167
+ released = true
168
+ lease?.release()
169
+ }
170
+
171
+ try {
172
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
173
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal
174
+
175
+ let response
176
+ try {
177
+ response = await fetch(current, {
178
+ redirect: 'manual',
179
+ signal,
180
+ headers: {
181
+ 'user-agent': userAgent,
182
+ accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.8,*/*;q=0.5',
183
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
184
+ },
185
+ })
186
+ } catch (error) {
187
+ throw classifyFetchError(error, current.toString())
188
+ }
189
+
190
+ if (REDIRECT_STATUS.has(response.status)) {
191
+ const location = response.headers.get('location')
192
+ try { await response.body?.cancel() } catch { /* 忽略:只为放掉连接 */ }
193
+ if (!location) {
194
+ // 3xx 但没有 Location:按最终响应处理,不要假装跟过去了
195
+ return await finish(response, current.toString(), { requestedUrl, redirects, startedAt, maxBytes })
196
+ }
197
+ let next
198
+ try {
199
+ next = new URL(location, current).toString()
200
+ } catch (error) {
201
+ throw new ScrapeError('bad_redirect', `重定向的 Location 不合法:${location}`, {
202
+ url: current.toString(), cause: error, details: { location },
203
+ hint: '站点给了相对/畸形地址;先用浏览器确认最终落点',
204
+ })
205
+ }
206
+ redirects.push({ from: current.toString(), to: next, status: response.status })
207
+ if (hop === maxRedirects) {
208
+ throw new ScrapeError('too_many_redirects', `重定向超过 ${maxRedirects} 跳:${requestedUrl}`, {
209
+ url: requestedUrl, details: { redirects }, hint: '可能是死循环;先确认最终地址再抓',
210
+ })
211
+ }
212
+ current = assertFetchableUrl(next)
213
+ continue
214
+ }
215
+
216
+ if (!response.ok && !acceptStatus.includes(response.status)) {
217
+ try { await response.body?.cancel() } catch { /* 忽略 */ }
218
+ throw httpStatusError(current.toString(), response.status, response.statusText)
219
+ }
220
+
221
+ return await finish(response, current.toString(), { requestedUrl, redirects, startedAt, maxBytes })
222
+ } catch (error) {
223
+ // 已经分类过的(ScrapeError)直接抛;读响应体失败这种底层错误在这里补分类
224
+ if (error instanceof ScrapeError) throw error
225
+ throw classifyFetchError(error, current.toString())
226
+ } finally {
227
+ release()
228
+ }
229
+ }
230
+
231
+ throw new ScrapeError('too_many_redirects', `重定向过多:${requestedUrl}`, {
232
+ url: requestedUrl, details: { redirects }, hint: '可能是死循环;先确认最终地址再抓',
233
+ })
234
+ }
235
+
236
+ /**
237
+ * 收尾:读体、解码、组装 provenance。
238
+ * @param {Response} response fetch 响应
239
+ * @param {string} finalUrl 最终 URL
240
+ * @param {object} meta 上下文(requestedUrl/redirects/startedAt/maxBytes)
241
+ * @returns {Promise<object>} 抓取结果
242
+ */
243
+ async function finish(response, finalUrl, meta) {
244
+ const contentType = response.headers.get('content-type') ?? ''
245
+ const { buffer, truncated } = await readCapped(response.body, meta.maxBytes)
246
+ const headerCharset = charsetFromContentType(contentType)
247
+ const sniffed = headerCharset || sniffCharset(buffer)
248
+ const decoded = decodeBuffer(buffer, sniffed)
249
+
250
+ return {
251
+ requestedUrl: meta.requestedUrl,
252
+ finalUrl: response.url && response.url !== finalUrl ? response.url : finalUrl,
253
+ redirects: meta.redirects,
254
+ status: response.status,
255
+ statusText: response.statusText ?? '',
256
+ ok: response.ok,
257
+ contentType,
258
+ charset: decoded.charset,
259
+ charsetDeclared: sniffed || '',
260
+ charsetFallback: decoded.fallback,
261
+ buffer,
262
+ text: decoded.text,
263
+ bytes: buffer.length,
264
+ truncated,
265
+ isText: TEXT_HINT.test(contentType) || contentType === '',
266
+ elapsedMs: Date.now() - meta.startedAt,
267
+ }
268
+ }
269
+
270
+ /**
271
+ * 抓一个「补充文件」并允许 404(robots.txt 用)。
272
+ * 失败不抛错,返回 null 由调用方决定怎么说明。
273
+ * @param {string} url 文件 URL
274
+ * @param {object} [options] 同 fetchPage
275
+ * @returns {Promise<object|null>} 抓取结果;网络/状态失败返回 null
276
+ */
277
+ export async function fetchOptional(url, options = {}) {
278
+ try {
279
+ return await fetchPage(url, { maxBytes: 256 * 1024, ...options, acceptStatus: [200, 204, 400, 401, 403, 404, 410, 429, 500, 502, 503] })
280
+ } catch {
281
+ return null
282
+ }
283
+ }