@pls-compile-owo/fisheye 0.2.1 → 0.4.2
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 +60 -0
- package/README.en.md +4 -0
- package/README.md +33 -5
- package/fisheye.mjs +18 -1437
- package/lib/audio.mjs +645 -0
- package/lib/bili.mjs +195 -0
- package/lib/budget.mjs +117 -0
- package/lib/danmaku.mjs +75 -0
- package/lib/env.mjs +61 -0
- package/lib/frame.mjs +192 -0
- package/lib/i18n.mjs +131 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +878 -0
- package/lib/util.mjs +78 -0
- package/package.json +6 -2
- package/test/danmaku.test.mjs +70 -0
- package/test/util.test.mjs +71 -0
package/lib/i18n.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 国际化:CLI 文案按语言取用。语言来自环境变量 FISHEYE_LANG
|
|
2
|
+
// 'zh'(默认)/ 'en';也接受 zh-CN、en-US 这类写法(取第一段)。
|
|
3
|
+
// 零依赖:只读 process.env,不引任何模块。
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LANG = 'zh'
|
|
6
|
+
const SUPPORTED = new Set(['zh', 'en'])
|
|
7
|
+
|
|
8
|
+
/** 当前语言:FISHEYE_LANG 不合法或没设时回落到中文 */
|
|
9
|
+
export function getLang() {
|
|
10
|
+
const raw = String(process.env.FISHEYE_LANG ?? '').trim().toLowerCase()
|
|
11
|
+
if (!raw) return DEFAULT_LANG
|
|
12
|
+
const base = raw.split(/[-_]/)[0]
|
|
13
|
+
return SUPPORTED.has(base) ? base : DEFAULT_LANG
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 文案表。占位符写法 {name},由 t() 的 vars 替换;
|
|
17
|
+
// 找不到当前语言的 key 时回落到中文,再找不到就原样返回 key(方便发现漏翻)。
|
|
18
|
+
const MESSAGES = {
|
|
19
|
+
zh: {
|
|
20
|
+
// —— 用法帮助 ——
|
|
21
|
+
usage: `用法:
|
|
22
|
+
node fisheye.mjs auto <BV> [--budget 20000] [--density 6] ← 推荐:自动规划并铺网
|
|
23
|
+
node fisheye.mjs refine <BV> --template ← 生成精读决策模板
|
|
24
|
+
node fisheye.mjs refine <BV> --decisions <file.json> ← 按模型判断精读
|
|
25
|
+
node fisheye.mjs refine <BV> ← 几何兜底(零 token)
|
|
26
|
+
node fisheye.mjs grid <BV> [--chunk 60] [--grid 3x3]
|
|
27
|
+
node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
|
|
28
|
+
node fisheye.mjs grab <BV> <t1,t2,t3> ← 手动指定时间点
|
|
29
|
+
node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t] ← 看本地视频(mp4/mkv/...)
|
|
30
|
+
node fisheye.mjs selftest [--deep] ← 自检(依赖/凭证/API/缓存/磁盘)`,
|
|
31
|
+
|
|
32
|
+
// —— 依赖缺失 ——
|
|
33
|
+
'err.ffmpeg.missing': `找不到 ffmpeg。
|
|
34
|
+
|
|
35
|
+
安装方式(任选其一):
|
|
36
|
+
1. 装到 PATH:https://ffmpeg.org/download.html (Windows 用 gyan.dev 的 build)
|
|
37
|
+
2. 放项目里:把 ffmpeg 可执行文件放到 {binDir}/
|
|
38
|
+
3. 设环境变量:FFMPEG=<ffmpeg 完整路径>
|
|
39
|
+
4. 写配置:{cfgFile} 里加 {"ffmpeg": "<完整路径>"}`,
|
|
40
|
+
|
|
41
|
+
'err.cred.missing': `找不到 B站登录态文件。
|
|
42
|
+
|
|
43
|
+
创建 {credPath},内容:
|
|
44
|
+
{
|
|
45
|
+
"sessdata": "<B站 SESSDATA>",
|
|
46
|
+
"bili_jct": "<bili_jct>",
|
|
47
|
+
"buvid3": "<buvid3>"
|
|
48
|
+
}
|
|
49
|
+
获取方法:浏览器登录 B站 → F12 → Application → Cookies → bilibili.com,
|
|
50
|
+
复制这三个值。或用环境变量 BILI_CRED=<文件路径> 指向已有文件。
|
|
51
|
+
|
|
52
|
+
(也可以只跑 selftest 先看环境是否就绪:node fisheye.mjs selftest)`,
|
|
53
|
+
|
|
54
|
+
// —— 命令 / 参数错误 ——
|
|
55
|
+
'err.unknownStage': '未知阶段: {stage}',
|
|
56
|
+
'err.bvFormat': '[错误] BV 号格式不对:{raw}(应为 BV + 10 位字母数字,如 BV1ziWP6qENy)',
|
|
57
|
+
'err.local.usage': '用法: node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t]',
|
|
58
|
+
'err.fatal': '\n错误:{message}',
|
|
59
|
+
'err.local.failed': '[错误] {message}',
|
|
60
|
+
|
|
61
|
+
// —— util 参数校验 ——
|
|
62
|
+
'err.argPrefix': '参数错误:',
|
|
63
|
+
'err.num.notNumber': '--{name} 需要数字,收到:{value}',
|
|
64
|
+
'err.num.min': '--{name} 不能小于 {min}(收到 {n})',
|
|
65
|
+
'err.num.max': '--{name} 不能大于 {max}(收到 {n})',
|
|
66
|
+
'err.grid.format': '--grid 格式应为 NxM(如 3x3,行列 1~9),收到:{value}',
|
|
67
|
+
'err.time.format': '--{name} 时间格式应为 330 / 5:30 / 1:05:30,收到:{value}',
|
|
68
|
+
'err.time.invalid': '--{name} 时间格式非法:{value}',
|
|
69
|
+
'err.page.range': '--p 分P序号应为 1~200 的整数,收到:{value}'
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
en: {
|
|
73
|
+
usage: `Usage:
|
|
74
|
+
node fisheye.mjs auto <BV> [--budget 20000] [--density 6] # recommended: auto-plan and build grids
|
|
75
|
+
node fisheye.mjs refine <BV> --template # generate a refine decision template
|
|
76
|
+
node fisheye.mjs refine <BV> --decisions <file.json> # refine using model decisions
|
|
77
|
+
node fisheye.mjs refine <BV> # geometric fallback (zero tokens)
|
|
78
|
+
node fisheye.mjs grid <BV> [--chunk 60] [--grid 3x3]
|
|
79
|
+
node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
|
|
80
|
+
node fisheye.mjs grab <BV> <t1,t2,t3> # pick timestamps manually
|
|
81
|
+
node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t] # analyze a local video (mp4/mkv/...)
|
|
82
|
+
node fisheye.mjs selftest [--deep] # self-check (deps/credentials/API/cache/disk)`,
|
|
83
|
+
|
|
84
|
+
'err.ffmpeg.missing': `ffmpeg not found.
|
|
85
|
+
|
|
86
|
+
Install it in one of these ways:
|
|
87
|
+
1. Put it on PATH: https://ffmpeg.org/download.html (on Windows use a gyan.dev build)
|
|
88
|
+
2. Drop it into the project: place the ffmpeg executable in {binDir}/
|
|
89
|
+
3. Set an env var: FFMPEG=<full path to ffmpeg>
|
|
90
|
+
4. Use the config file: add {"ffmpeg": "<full path>"} to {cfgFile}`,
|
|
91
|
+
|
|
92
|
+
'err.cred.missing': `Bilibili credentials not found.
|
|
93
|
+
|
|
94
|
+
Create {credPath} containing:
|
|
95
|
+
{
|
|
96
|
+
"sessdata": "<Bilibili SESSDATA>",
|
|
97
|
+
"bili_jct": "<bili_jct>",
|
|
98
|
+
"buvid3": "<buvid3>"
|
|
99
|
+
}
|
|
100
|
+
How to get them: log in to Bilibili in your browser → F12 → Application → Cookies → bilibili.com,
|
|
101
|
+
then copy those three values. Or set BILI_CRED=<file path> to point at an existing file.
|
|
102
|
+
|
|
103
|
+
(You can also run selftest first to check whether the environment is ready: node fisheye.mjs selftest)`,
|
|
104
|
+
|
|
105
|
+
'err.unknownStage': 'Unknown stage: {stage}',
|
|
106
|
+
'err.bvFormat': '[error] Invalid BV id: {raw} (expected BV + 10 alphanumeric chars, e.g. BV1ziWP6qENy)',
|
|
107
|
+
'err.local.usage': 'Usage: node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t]',
|
|
108
|
+
'err.fatal': '\nError: {message}',
|
|
109
|
+
'err.local.failed': '[error] {message}',
|
|
110
|
+
|
|
111
|
+
'err.argPrefix': 'Argument error: ',
|
|
112
|
+
'err.num.notNumber': '--{name} must be a number, got: {value}',
|
|
113
|
+
'err.num.min': '--{name} must be >= {min} (got {n})',
|
|
114
|
+
'err.num.max': '--{name} must be <= {max} (got {n})',
|
|
115
|
+
'err.grid.format': '--grid must be NxM (e.g. 3x3, rows/cols 1-9), got: {value}',
|
|
116
|
+
'err.time.format': '--{name} must be a time like 330 / 5:30 / 1:05:30, got: {value}',
|
|
117
|
+
'err.time.invalid': '--{name} invalid time: {value}',
|
|
118
|
+
'err.page.range': '--p (page number) must be an integer from 1 to 200, got: {value}'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 取当前语言的一条文案,替换 {name} 占位符。
|
|
124
|
+
* 未提供的占位符保持原样,未知 key 返回 key 本身。
|
|
125
|
+
*/
|
|
126
|
+
export function t(key, vars = {}) {
|
|
127
|
+
const entry = MESSAGES[getLang()]?.[key] ?? MESSAGES[DEFAULT_LANG][key]
|
|
128
|
+
if (entry === undefined) return key
|
|
129
|
+
return entry.replace(/\{(\w+)\}/g, (match, name) =>
|
|
130
|
+
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match)
|
|
131
|
+
}
|
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 }
|