@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/CHANGELOG.md +69 -0
- package/README.md +62 -1
- package/fisheye.mjs +88 -39
- package/lib/audio.mjs +646 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +86 -30
- package/lib/budget.mjs +117 -0
- package/lib/env.mjs +1 -0
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +147 -0
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +317 -32
- package/lib/util.mjs +9 -8
- package/lib/ytdlp.mjs +393 -0
- package/mcp-video-server.js +261 -0
- package/package.json +3 -2
- package/test/audio.test.mjs +23 -0
- package/test/batch.test.mjs +212 -0
- package/test/bili.test.mjs +70 -0
- package/test/budget.test.mjs +63 -0
- package/test/env.test.mjs +35 -0
- package/test/frame.test.mjs +35 -0
- package/test/i18n.test.mjs +45 -0
- package/test/ocr-sample.png +0 -0
- package/test/ocr.test.mjs +89 -0
- package/test/progress.test.mjs +139 -0
- package/test/resume.test.mjs +232 -0
- package/test/retry.test.mjs +69 -0
- package/test/ytdlp.test.mjs +179 -0
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 }
|