@pls-compile-owo/fisheye 0.3.0 → 0.7.1

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/resume.mjs ADDED
@@ -0,0 +1,378 @@
1
+ // 断点续跑:manifest 是唯一真相源。
2
+ //
3
+ // 长视频最贵的两步是「下载」和「抽帧」,而它们都能「重来一半」:
4
+ // · 下载:cache 里的 mp4 还在 → 跳过
5
+ // · 抽帧:out/ 里的拼图还在 → 已抽的图不用重抽,只补缺的
6
+ // · 精读/字幕带同理,用 stages 记录状态即可
7
+ //
8
+ // 不接 CLI、不接 auto:这里只提供纯函数 + 原子写回,由 fisheye.mjs/stages.mjs
9
+ // 在阶段边界调用。接口故意做成「state 进、state 出」,方便链式调用和单测。
10
+ //
11
+ // manifest 的拼图数组兼容两种写法(读时归一化,写回时不改字段名,不破坏旧消费方):
12
+ // 新:state.grids = [{ file, from, to, grid }]
13
+ // 旧:state.chunks = [{ sheet, from, to, grid }] ← stageGrid 目前写的是这个
14
+ // 也可能叫 images / 字符串数组。
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+
18
+ // 记住 state 是从哪个目录、哪个 bvid 读出来的(Symbol 键不会被 JSON.stringify 写进 manifest)。
19
+ // 这样 recordStage(state, ...) 就能自己原子写回,调用方不用到处传 outDir/bvid。
20
+ const SRC = Symbol('fisheye.manifest.source')
21
+
22
+ // 挂在 state 上的「来历」:不可枚举,deepEqual / JSON.stringify 都不会被它干扰
23
+ function setSource(state, outDir, bvid) {
24
+ Object.defineProperty(state, SRC, { value: { outDir, bvid }, enumerable: false, writable: true, configurable: true })
25
+ }
26
+
27
+ // 阶段名 → 中文;未知阶段原样显示
28
+ const STAGE_LABEL = {
29
+ download: '下载',
30
+ grids: '抽帧',
31
+ subs: '字幕带',
32
+ refine: '精读',
33
+ audio: '音频转写',
34
+ danmaku: '弹幕',
35
+ ocr: 'OCR',
36
+ }
37
+
38
+ const STATUS_LABEL = {
39
+ done: '已完成',
40
+ running: '进行中',
41
+ failed: '失败',
42
+ skipped: '已跳过',
43
+ pending: '未开始',
44
+ }
45
+
46
+ const stageLabel = (name) => STAGE_LABEL[name] ?? name
47
+
48
+ /** `out/<bvid>-manifest.json` */
49
+ export function manifestPath(outDir, bvid) {
50
+ return path.join(outDir, `${bvid}-manifest.json`)
51
+ }
52
+
53
+ /**
54
+ * 读 manifest。不存在 / JSON 坏 / 不是对象 → null(坏文件不该拦住续跑流程,调用方自行决定重跑)。
55
+ * @returns {object|null}
56
+ */
57
+ export function loadManifestState(outDir, bvid) {
58
+ let raw
59
+ try { raw = fs.readFileSync(manifestPath(outDir, bvid), 'utf8') } catch { return null }
60
+ let state
61
+ try {
62
+ state = JSON.parse(raw.replace(/^\uFEFF/, '')) // 兼容 PowerShell/记事本写的 BOM
63
+ } catch { return null }
64
+ if (!state || typeof state !== 'object' || Array.isArray(state)) return null
65
+ setSource(state, outDir, bvid)
66
+ return state
67
+ }
68
+
69
+ /**
70
+ * 原子写回:同目录临时文件 → rename。
71
+ * Windows 上 fs.renameSync 是替换语义(MoveFileEx),目标已存在也能覆盖;
72
+ * 写一半断电时正式文件仍是旧的完整版本,不会留下半截 JSON。
73
+ * @returns {string} manifest 路径
74
+ */
75
+ export function saveManifest(outDir, bvid, state) {
76
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
77
+ throw new TypeError('saveManifest: state 必须是对象')
78
+ }
79
+ if (!bvid) throw new TypeError('saveManifest: bvid 不能为空')
80
+ fs.mkdirSync(outDir, { recursive: true })
81
+ const file = manifestPath(outDir, bvid)
82
+ const tmp = path.join(outDir, `.${bvid}-manifest.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`)
83
+ try {
84
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8')
85
+ fs.renameSync(tmp, file)
86
+ } catch (e) {
87
+ try { fs.unlinkSync(tmp) } catch {}
88
+ throw e
89
+ }
90
+ setSource(state, outDir, bvid)
91
+ return file
92
+ }
93
+
94
+ // ---- manifest 内部结构的归一化 ----
95
+
96
+ const num = (v) => {
97
+ const n = Number(v)
98
+ return Number.isFinite(n) ? n : null
99
+ }
100
+
101
+ /** '01:05' / '1:05:30' / 65 → 秒;非法 → null(不走 util.parseTime:那条路会 process.exit) */
102
+ function parseClock(v) {
103
+ const n = num(v)
104
+ if (n !== null) return n
105
+ const parts = String(v ?? '').trim().split(':').map((s) => Number(s))
106
+ if (parts.length < 2 || parts.length > 3 || parts.some((x) => !Number.isFinite(x) || x < 0)) return null
107
+ return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2]
108
+ }
109
+
110
+ /** 拼图数组:grids → images → chunks(不做拷贝,只返回引用) */
111
+ function gridsOf(state) {
112
+ for (const key of ['grids', 'images', 'chunks']) {
113
+ if (Array.isArray(state?.[key])) return state[key]
114
+ }
115
+ return []
116
+ }
117
+
118
+ /** 单张拼图的文件路径 */
119
+ function fileOf(g) {
120
+ if (typeof g === 'string') return g
121
+ if (!g || typeof g !== 'object') return null
122
+ return g.file ?? g.sheet ?? g.path ?? g.image ?? g.sheetPath ?? g.sheetFile ?? null
123
+ }
124
+
125
+ /** 单张拼图的时间范围 {from,to};无法解析 → null */
126
+ function rangeOf(g) {
127
+ if (!g || typeof g !== 'object') return null
128
+ const from = parseClock(g.from ?? g.start)
129
+ const to = parseClock(g.to ?? g.end)
130
+ if (from !== null && to !== null) return { from, to }
131
+ if (typeof g.range === 'string') {
132
+ // manifest 里 range 是 '00:00-05:00' 这种展示字符串
133
+ const m = /^\s*([\d:.]+)\s*-\s*([\d:.]+)\s*$/.exec(g.range)
134
+ if (m) {
135
+ const a = parseClock(m[1])
136
+ const b = parseClock(m[2])
137
+ if (a !== null && b !== null) return { from: a, to: b }
138
+ }
139
+ }
140
+ return null
141
+ }
142
+
143
+ function existsNonEmpty(file) {
144
+ try {
145
+ const st = fs.statSync(file)
146
+ return st.isFile() && st.size > 0
147
+ } catch { return false }
148
+ }
149
+
150
+ function fmtSec(sec) {
151
+ const s = Number(sec)
152
+ if (!Number.isFinite(s) || s < 0) return '?'
153
+ const h = Math.floor(s / 3600)
154
+ const m = Math.floor((s % 3600) / 60)
155
+ const ss = Math.floor(s % 60)
156
+ const pad = (n) => String(n).padStart(2, '0')
157
+ return h ? `${h}:${pad(m)}:${pad(ss)}` : `${pad(m)}:${pad(ss)}`
158
+ }
159
+
160
+ /** 区间补齐默认值:from 缺省 0,to 缺省「片尾」。dur=Infinity 表示时长未知 */
161
+ function normRange(obj, dur) {
162
+ const fromRaw = obj?.from
163
+ const toRaw = obj?.to
164
+ const from = fromRaw === undefined || fromRaw === null ? 0 : num(fromRaw)
165
+ const to = toRaw === undefined || toRaw === null ? dur : num(toRaw)
166
+ return {
167
+ from: from !== null ? from : 0,
168
+ to: to !== null ? to : dur,
169
+ }
170
+ }
171
+
172
+ function sameRange(a, b) {
173
+ const close = (x, y) => (Number.isFinite(x) && Number.isFinite(y) ? Math.abs(x - y) <= 1 : x === y)
174
+ return close(a.from, b.from) && close(a.to, b.to)
175
+ }
176
+
177
+ const rangeText = (r) => `${fmtSec(r.from)}-${Number.isFinite(r.to) ? fmtSec(r.to) : '片尾'}`
178
+
179
+ function intersects(g, r) {
180
+ const gr = rangeOf(g)
181
+ if (!gr) return true // 旧数据看不出范围时不排除,宁可多算一张「可复用」
182
+ const toA = Number.isFinite(r.to) ? r.to : Infinity
183
+ const toB = Number.isFinite(gr.to) ? gr.to : Infinity
184
+ return Math.max(gr.from, r.from) < Math.min(toA, toB)
185
+ }
186
+
187
+ const pick = (...vals) => {
188
+ for (const v of vals) if (v !== undefined && v !== null) return v
189
+ return null
190
+ }
191
+
192
+ /**
193
+ * 判断旧 manifest 能不能复用。
194
+ *
195
+ * @param {object|null} state loadManifestState 的返回值
196
+ * @param {{plan?: {budget?:number, density?:number, from?:number, to?:number}}} opts
197
+ * @returns {{resumable: boolean, reason: string, doneGrids: number}}
198
+ *
199
+ * 判定顺序(先便宜的先判):
200
+ * 1. manifest 存在且是对象
201
+ * 2. 计划参数一致:只比调用方明确给出的 budget / density;
202
+ * manifest 没记录就判不可复用(宁可重抽,也别用不同密度的图冒充本次结果)
203
+ * 3. 时间区间一致(容差 1s,manifest 里存的是 toFixed(1))
204
+ * 4. 拼图文件还在:一张都用不了 → 不可复用;部分缺失 → 可复用、只补缺的
205
+ */
206
+ export function canResume(state, { plan } = {}) {
207
+ const no = (reason, doneGrids = 0) => ({ resumable: false, reason, doneGrids })
208
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
209
+ return no('没有可复用的 manifest(这个视频还没跑过)')
210
+ }
211
+
212
+ const given = plan && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
213
+ const rec = state.plan && typeof state.plan === 'object' && !Array.isArray(state.plan) ? state.plan : {}
214
+ // 老 manifest 没有 plan 对象时,兼容直接写在顶层的字段
215
+ const recorded = {
216
+ budget: pick(rec.budget, state.budget),
217
+ density: pick(rec.density, state.density),
218
+ from: pick(rec.from, state.range?.from),
219
+ to: pick(rec.to, state.range?.to),
220
+ }
221
+
222
+ // 1) budget / density:undefined / null 表示「调用方不关心这个维度」
223
+ for (const key of ['budget', 'density']) {
224
+ const want = given[key]
225
+ if (want === undefined || want === null) continue
226
+ const had = recorded[key]
227
+ if (had === null) {
228
+ return no(`manifest 里没有记录 ${key},无法确认计划参数是否一致;要复用请重跑或手动补 plan`)
229
+ }
230
+ if (Number(want) !== Number(had)) {
231
+ return no(`计划参数变了:${key} ${had} → ${want}(旧拼图的时间/密度和新计划对不上,抽帧不能跳过)`)
232
+ }
233
+ }
234
+
235
+ // 2) 时间区间
236
+ const hasPlanRange = given.from !== undefined || given.to !== undefined
237
+ const dur = Number.isFinite(Number(state.duration)) ? Number(state.duration) : Infinity
238
+ let wantRange = null
239
+ if (hasPlanRange) {
240
+ wantRange = normRange(given, dur)
241
+ const hadRange = normRange(recorded, dur)
242
+ if (!sameRange(wantRange, hadRange)) {
243
+ return no(`时间区间变了:${rangeText(hadRange)} → ${rangeText(wantRange)}(时间轴不同,旧拼图拼不进这次结果)`)
244
+ }
245
+ }
246
+
247
+ // 3) 拼图文件
248
+ const all = gridsOf(state)
249
+ if (!all.length) return no('manifest 里没有拼图记录(grids/images/chunks 为空),没有可跳过的东西')
250
+ const scoped = wantRange ? all.filter((g) => intersects(g, wantRange)) : all
251
+ if (!scoped.length) return no('计划区间内没有已记录的拼图')
252
+
253
+ const paths = scoped.map(fileOf)
254
+ const usable = paths.filter((f) => f && existsNonEmpty(f)).length
255
+ const missing = scoped.length - usable
256
+ if (!usable) {
257
+ return no(`拼图文件全部缺失(0/${scoped.length}),抽帧需要从头来`, 0)
258
+ }
259
+ if (missing > 0) {
260
+ return {
261
+ resumable: true,
262
+ reason: `可复用 ${usable}/${scoped.length} 张拼图,补抽 ${missing} 张即可`,
263
+ doneGrids: usable,
264
+ }
265
+ }
266
+ return {
267
+ resumable: true,
268
+ reason: `拼图齐全(${usable}/${scoped.length}),可跳过抽帧`,
269
+ doneGrids: usable,
270
+ }
271
+ }
272
+
273
+ function persist(state) {
274
+ const src = state[SRC] ?? (state.outDir && state.bvid ? { outDir: state.outDir, bvid: state.bvid } : null)
275
+ if (!src?.outDir || !src?.bvid) return false
276
+ saveManifest(src.outDir, src.bvid, state)
277
+ return true
278
+ }
279
+
280
+ /**
281
+ * 记录阶段进度,并在能定位到 manifest 时原子写回。
282
+ * @param {object} state
283
+ * @param {string} stage 'download' | 'grids' | 'subs' | 'refine' | ...
284
+ * @param {object|string} [info] 对象(会合并进阶段记录)或字符串状态('done' / 'running')
285
+ * @returns {object} 同一个 state(方便链式调用)
286
+ */
287
+ export function recordStage(state, stage, info = {}) {
288
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
289
+ throw new TypeError('recordStage: state 必须是对象')
290
+ }
291
+ const name = String(stage ?? '').trim()
292
+ if (!name) throw new TypeError('recordStage: stage 必须是非空字符串')
293
+
294
+ const patch = info === null || info === undefined
295
+ ? {}
296
+ : typeof info === 'string'
297
+ ? { status: info }
298
+ : typeof info === 'object'
299
+ ? { ...info }
300
+ : {}
301
+
302
+ if (!state.stages || typeof state.stages !== 'object' || Array.isArray(state.stages)) state.stages = {}
303
+ const prev = state.stages[name] && typeof state.stages[name] === 'object' && !Array.isArray(state.stages[name])
304
+ ? state.stages[name]
305
+ : {}
306
+ const status = String(patch.status ?? prev.status ?? 'running')
307
+ const now = new Date().toISOString()
308
+ const next = { ...prev, ...patch, status, at: now }
309
+ if (status === 'done') next.doneAt = now
310
+ state.stages[name] = next
311
+ state.updatedAt = now
312
+ persist(state)
313
+ return state
314
+ }
315
+
316
+ /** 标记阶段完成(等价于 recordStage(state, stage, { ...info, status: 'done' })) */
317
+ export function markDone(state, stage, info = {}) {
318
+ const extra = info && typeof info === 'object' && !Array.isArray(info) ? info : {}
319
+ return recordStage(state, stage, { ...extra, status: 'done' })
320
+ }
321
+
322
+ /**
323
+ * 人类可读摘要:哪些阶段到哪了、拼图还差几张、什么可以跳过。
324
+ * @returns {string} 多行文本(没有 manifest 时返回一句说明)
325
+ */
326
+ export function resumeSummary(state) {
327
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
328
+ return '没有 manifest:还没有可续跑的进度'
329
+ }
330
+ const dur = Number.isFinite(Number(state.duration)) ? Number(state.duration) : Infinity
331
+ const lines = []
332
+
333
+ const head = [state.bvid ?? '未知视频']
334
+ if (state.title) head.push(String(state.title))
335
+ if (state.range) head.push(`区间 ${rangeText(normRange(state.range, dur))}`)
336
+ lines.push(head.join(' · '))
337
+
338
+ const entries = state.stages && typeof state.stages === 'object' && !Array.isArray(state.stages)
339
+ ? Object.entries(state.stages)
340
+ : []
341
+ if (entries.length) {
342
+ const parts = entries.map(([k, v]) => {
343
+ const status = typeof v === 'string' ? v : (v?.status ?? 'unknown')
344
+ return `${stageLabel(k)} ${STATUS_LABEL[status] ?? status}`
345
+ })
346
+ lines.push(`阶段:${parts.join(' · ')}`)
347
+ }
348
+
349
+ const grids = gridsOf(state)
350
+ const files = grids.map(fileOf).filter(Boolean)
351
+ let allGridsOk = false
352
+ if (files.length) {
353
+ const ok = files.filter(existsNonEmpty).length
354
+ allGridsOk = ok === files.length
355
+ lines.push(`拼图:${ok}/${files.length} 张可用${allGridsOk ? '(可跳过抽帧)' : `(缺 ${files.length - ok} 张,需补抽)`}`)
356
+ }
357
+
358
+ const plan = state.plan && typeof state.plan === 'object' && !Array.isArray(state.plan) ? state.plan : {}
359
+ const bits = []
360
+ const budget = pick(plan.budget, state.budget)
361
+ const density = pick(plan.density, state.density)
362
+ if (budget !== null) bits.push(`budget ${budget}`)
363
+ if (density !== null) bits.push(`density ${density}s`)
364
+ if (bits.length) lines.push(`计划:${bits.join(' · ')}`)
365
+
366
+ const skippable = []
367
+ for (const [k, v] of entries) {
368
+ const status = typeof v === 'string' ? v : v?.status
369
+ if (status === 'done') skippable.push(stageLabel(k))
370
+ }
371
+ if (allGridsOk && !skippable.includes(stageLabel('grids'))) skippable.push(stageLabel('grids'))
372
+ lines.push(`可跳过:${skippable.length ? skippable.join('、') : '无'}`)
373
+
374
+ const stamp = state.updatedAt ?? state.createdAt
375
+ if (stamp) lines.push(`更新:${stamp}`)
376
+
377
+ return lines.join('\n')
378
+ }
package/lib/retry.mjs ADDED
@@ -0,0 +1,209 @@
1
+ // 带「重试 + 指数退避」的 fetch 封装(零依赖,只用 Node >= 18 内置能力)
2
+ //
3
+ // 背景:lib/bili.mjs 里的 B站请求都是幂等 GET,但原来是裸 fetch——网络抖动 / 429 限流 /
4
+ // 网关 5xx 一次失败就让整条命令直接挂掉(getView 抛错、ensureMp4 失败、字幕静默降级)。
5
+ // 这里统一加一层重试,调用点只把 fetch 换成 fetchWithRetry,不改各函数的签名和返回值。
6
+ //
7
+ // 策略:
8
+ // · 只重试可安全重复的失败:网络错误 / 超时 / HTTP 5xx / 429。
9
+ // · 其余 4xx(鉴权失效、参数错、资源不存在)是确定性失败,重试没有意义,直接把 Response 交给调用方。
10
+ // · 退避 delay = baseDelayMs * 2^attempt + 随机 jitter;429/503 若带 Retry-After 头则优先听它的
11
+ // (仍受 MAX_BACKOFF_MS 上限约束,避免被服务端要求等几小时)。
12
+ // · 每次尝试都新建独立的超时 signal:AbortSignal.timeout() 触发后不可复用,跨 attempt 复用会让
13
+ // 第二次重试瞬间失败。外部 AbortController 的 signal 则跨 attempt 保持生效(用于用户主动取消)。
14
+ // · 返回原生 Response,调用方照常 .json() / .text() / .arrayBuffer() 消费。
15
+
16
+ const DEFAULT_RETRIES = 2
17
+ const DEFAULT_BASE_DELAY_MS = 500
18
+ const MAX_BACKOFF_MS = 30_000
19
+
20
+ // undici 的网络错误会包一层 TypeError('fetch failed'),真正的 code 在 err.cause 里
21
+ const NETWORK_ERROR_RE = /fetch failed|network error|ECONNRESET|ECONNREFUSED|ECONNABORTED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EPIPE|UND_ERR|socket hang up|terminated|other side closed|connection (?:reset|closed|refused)|getaddrinfo/i
22
+
23
+ /** 5xx 和 429 可以重试;其余状态码(含 3xx/4xx)不重试 */
24
+ export function isRetryableStatus(status) {
25
+ return status === 429 || (status >= 500 && status <= 599)
26
+ }
27
+
28
+ /** 判断 signal 是否因「超时」而 abort(AbortSignal.timeout 的 reason 是 TimeoutError) */
29
+ function isTimeoutAbort(signal) {
30
+ const reason = signal?.reason
31
+ if (!reason) return false
32
+ return reason.name === 'TimeoutError' || reason.code === 23 || /timeout/i.test(String(reason.message ?? ''))
33
+ }
34
+
35
+ /** 判断错误是否为可重试的瞬时网络错误(超时单独由 signal 判定,这里也兼容直接抛出的 TimeoutError) */
36
+ export function isTransientNetworkError(err) {
37
+ if (!err) return false
38
+ const name = String(err.name ?? '')
39
+ if (name === 'TimeoutError') return true
40
+ if (name === 'AbortError') return false // 外部取消由调用方判定,这里不盲目重试
41
+ const msg = [
42
+ err.message,
43
+ err.cause?.message,
44
+ err.cause?.code,
45
+ err.code,
46
+ ].filter(Boolean).join(' ')
47
+ return NETWORK_ERROR_RE.test(msg)
48
+ }
49
+
50
+ /** 组合外部取消信号与本次尝试的超时信号(Node 18.17+ 用 AbortSignal.any,更早版本手动转发) */
51
+ function combineSignals(external, timeoutSignal) {
52
+ if (!external) return timeoutSignal
53
+ if (!timeoutSignal) return external
54
+ if (typeof AbortSignal.any === 'function') {
55
+ try { return AbortSignal.any([external, timeoutSignal]) } catch { /* 参数异常时走下面的回退 */ }
56
+ }
57
+ const ctrl = new AbortController()
58
+ const forward = (src) => () => {
59
+ cleanup()
60
+ ctrl.abort(src.reason ?? new DOMException('This operation was aborted', 'AbortError'))
61
+ }
62
+ const onExternal = forward(external)
63
+ const onTimeout = forward(timeoutSignal)
64
+ function cleanup() {
65
+ external.removeEventListener('abort', onExternal)
66
+ timeoutSignal.removeEventListener('abort', onTimeout)
67
+ }
68
+ if (external.aborted) { onExternal(); return ctrl.signal }
69
+ if (timeoutSignal.aborted) { onTimeout(); return ctrl.signal }
70
+ external.addEventListener('abort', onExternal, { once: true })
71
+ timeoutSignal.addEventListener('abort', onTimeout, { once: true })
72
+ return ctrl.signal
73
+ }
74
+
75
+ /** 可被外部 signal 打断的 sleep(重试等待期间用户取消要立刻退出) */
76
+ function sleep(ms, signal) {
77
+ return new Promise((resolve, reject) => {
78
+ const abortReason = () => signal?.reason instanceof Error
79
+ ? signal.reason
80
+ : Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })
81
+ if (signal?.aborted) { reject(abortReason()); return }
82
+ const timer = setTimeout(() => {
83
+ signal?.removeEventListener('abort', onAbort)
84
+ resolve()
85
+ }, ms)
86
+ function onAbort() {
87
+ clearTimeout(timer)
88
+ reject(abortReason())
89
+ }
90
+ signal?.addEventListener('abort', onAbort, { once: true })
91
+ })
92
+ }
93
+
94
+ /** 解析 Retry-After(秒数或 HTTP 日期),拿不到返回 null */
95
+ function retryAfterMs(res) {
96
+ try {
97
+ const raw = res?.headers?.get?.('retry-after')
98
+ if (!raw) return null
99
+ const secs = Number(raw)
100
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000)
101
+ const at = Date.parse(raw)
102
+ if (Number.isFinite(at)) return Math.max(0, at - Date.now())
103
+ } catch { /* 头不可读就当没有 */ }
104
+ return null
105
+ }
106
+
107
+ /** 指数退避:base * 2^attempt + 随机 jitter;有 Retry-After 时取更大值但封顶 MAX_BACKOFF_MS */
108
+ function backoffDelay(attempt, baseDelayMs, hintMs) {
109
+ const exp = baseDelayMs * 2 ** attempt
110
+ const jitter = Math.random() * baseDelayMs
111
+ let delay = Math.round(exp + jitter)
112
+ if (hintMs != null) delay = Math.max(delay, Math.round(hintMs))
113
+ return Math.min(delay, MAX_BACKOFF_MS)
114
+ }
115
+
116
+ function shortUrl(url) {
117
+ try {
118
+ const u = new URL(url)
119
+ return `${u.host}${u.pathname}`.slice(0, 72)
120
+ } catch {
121
+ return String(url).slice(0, 72)
122
+ }
123
+ }
124
+
125
+ function defaultOnRetry(info) {
126
+ console.warn(`[重试] ${info.label} ${info.reason},${info.delay}ms 后重试(${info.attempt + 1}/${info.maxRetries})`)
127
+ }
128
+
129
+ /**
130
+ * 带重试与指数退避的 fetch。
131
+ *
132
+ * @param {string | URL} url 请求地址
133
+ * @param {RequestInit} [options] 原生 fetch 选项;options.signal 会被视为「外部取消信号」(跨重试复用)
134
+ * @param {object} [retryOptions]
135
+ * @param {number} [retryOptions.retries=2] 额外重试次数(总尝试 = retries + 1)
136
+ * @param {number} [retryOptions.baseDelayMs=500] 指数退避基数(第 n 次重试等待 base*2^n + jitter)
137
+ * @param {number} [retryOptions.timeoutMs=0] 单次尝试超时(毫秒);>0 时每次尝试新建 AbortSignal.timeout
138
+ * @param {boolean} [retryOptions.quiet=false] 为 true 时不打印重试日志
139
+ * @param {Function | null} [retryOptions.onRetry] 自定义重试回调(传 null 关闭日志)
140
+ * @returns {Promise<Response>} 原生 Response;最后一次尝试即使是 5xx/429 也原样返回,交给调用方处理
141
+ */
142
+ export async function fetchWithRetry(url, options = {}, {
143
+ retries = DEFAULT_RETRIES,
144
+ baseDelayMs = DEFAULT_BASE_DELAY_MS,
145
+ timeoutMs = 0,
146
+ quiet = false,
147
+ onRetry,
148
+ } = {}) {
149
+ const maxRetries = Math.max(0, Math.floor(Number(retries) || 0))
150
+ const base = Math.max(0, Number(baseDelayMs) || 0)
151
+ const notify = onRetry !== undefined ? onRetry : (quiet ? null : defaultOnRetry)
152
+ const label = shortUrl(url)
153
+
154
+ // 调用方可能传 AbortSignal.timeout()(一次性)或 AbortController.signal(可持续取消)
155
+ let externalSignal = options.signal ?? null
156
+ let attemptTimeoutMs = Math.max(0, Number(timeoutMs) || 0)
157
+
158
+ if (externalSignal?.aborted) {
159
+ // 已经取消的请求没必要再发;若它同时是超时信号,也不该浪费一次尝试
160
+ throw externalSignal.reason instanceof Error
161
+ ? externalSignal.reason
162
+ : Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })
163
+ }
164
+
165
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
166
+ const startedAt = Date.now()
167
+ const timeoutSignal = attemptTimeoutMs > 0 ? AbortSignal.timeout(attemptTimeoutMs) : null
168
+ const signal = combineSignals(externalSignal, timeoutSignal)
169
+
170
+ try {
171
+ const res = await fetch(url, signal ? { ...options, signal } : options)
172
+
173
+ if (!isRetryableStatus(res.status) || attempt >= maxRetries) return res
174
+
175
+ // 丢弃 5xx/429 的响应体,避免连接/流泄漏后再发下一次
176
+ try { await res.body?.cancel?.() } catch { /* 忽略 */ }
177
+ const hint = res.status === 429 || res.status === 503 ? retryAfterMs(res) : null
178
+ const delay = backoffDelay(attempt, base, hint)
179
+ notify?.({ url, label, attempt, maxRetries, status: res.status, reason: `HTTP ${res.status}`, delay })
180
+ await sleep(delay, externalSignal)
181
+ continue
182
+ } catch (err) {
183
+ const cancelledExternally = externalSignal?.aborted === true
184
+ const timedOutByCaller = cancelledExternally && isTimeoutAbort(externalSignal)
185
+
186
+ // 用户/上层主动取消:立即结束,不重试
187
+ if (cancelledExternally && !timedOutByCaller) throw err
188
+
189
+ // 调用方传的是 AbortSignal.timeout():它已经触发且不可复用,记录实际耗时作为后续尝试的超时
190
+ if (timedOutByCaller) {
191
+ if (!attemptTimeoutMs) attemptTimeoutMs = Math.max(1, Date.now() - startedAt)
192
+ externalSignal = null
193
+ }
194
+
195
+ const timedOut = timedOutByCaller || timeoutSignal?.aborted === true
196
+ const transient = timedOut || isTransientNetworkError(err)
197
+ if (attempt >= maxRetries || !transient) throw err
198
+
199
+ const delay = backoffDelay(attempt, base)
200
+ notify?.({ url, label, attempt, maxRetries, status: null, reason: timedOut ? '请求超时' : `网络错误:${String(err.message).slice(0, 60)}`, delay })
201
+ await sleep(delay, externalSignal)
202
+ }
203
+ }
204
+
205
+ // 逻辑上不可达(循环内要么 return 要么 throw)
206
+ throw new Error(`fetchWithRetry: ${label} 重试耗尽`)
207
+ }
208
+
209
+ export { DEFAULT_RETRIES, DEFAULT_BASE_DELAY_MS, MAX_BACKOFF_MS }