@pls-compile-owo/fisheye 0.3.0 → 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/lib/bili.mjs CHANGED
@@ -6,6 +6,8 @@ import crypto from 'node:crypto'
6
6
  import { execFileSync, spawnSync } from 'node:child_process'
7
7
  import { FFMPEG, CACHE, WS, CRED, OUT } from './env.mjs'
8
8
  import { fmt } from './util.mjs'
9
+ // 所有 B站请求都走 fetchWithRetry:网络抖动 / 429 / 5xx 时自动重试(指数退避),幂等 GET 才重试
10
+ import { fetchWithRetry } from './retry.mjs'
9
11
 
10
12
  export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
11
13
 
@@ -26,8 +28,8 @@ export const H = {
26
28
  // 时长格式化:超过 1 小时显示 01:05:30,否则 MM:SS
27
29
 
28
30
  export async function getView(bvid) {
29
- const r = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
30
- { headers: H, signal: AbortSignal.timeout(15000) })
31
+ const r = await fetchWithRetry(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
32
+ { headers: H }, { timeoutMs: 15000 })
31
33
  const v = await r.json()
32
34
  if (v.code !== 0) throw new Error(`view code=${v.code} ${v.message}`)
33
35
  return v.data
@@ -48,8 +50,8 @@ export const clean = (v) => String(v).replace(/[!'()*]/g, '')
48
50
 
49
51
  export async function getOfficialSubs(bvid, cid, range = null) {
50
52
  try {
51
- const nav = await (await fetch('https://api.bilibili.com/x/web-interface/nav',
52
- { headers: H, signal: AbortSignal.timeout(15000) })).json()
53
+ const nav = await (await fetchWithRetry('https://api.bilibili.com/x/web-interface/nav',
54
+ { headers: H }, { timeoutMs: 15000 })).json()
53
55
  const raw = keyOf(nav.data?.wbi_img?.img_url ?? '') + keyOf(nav.data?.wbi_img?.sub_url ?? '')
54
56
  if (raw.length < 64) return null
55
57
  const mixin = MIXIN_TAB.map((i) => raw[i]).join('').slice(0, 32)
@@ -59,8 +61,8 @@ export async function getOfficialSubs(bvid, cid, range = null) {
59
61
  .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(clean(k === 'wts' ? wts : params[k]))}`).join('&')
60
62
  return `${q}&w_rid=${crypto.createHash('md5').update(q + mixin).digest('hex')}`
61
63
  }
62
- const p = await (await fetch(`https://api.bilibili.com/x/player/wbi/v2?${wbi({ bvid, cid })}`,
63
- { headers: H, signal: AbortSignal.timeout(15000) })).json()
64
+ const p = await (await fetchWithRetry(`https://api.bilibili.com/x/player/wbi/v2?${wbi({ bvid, cid })}`,
65
+ { headers: H }, { timeoutMs: 15000 })).json()
64
66
  const subs = p?.data?.subtitle?.subtitles ?? []
65
67
  if (!subs.length) return null
66
68
  const pick = subs.find((s) => s.lan === 'ai-zh') ?? subs.find((s) => s.lan === 'zh-CN')
@@ -68,10 +70,9 @@ export async function getOfficialSubs(bvid, cid, range = null) {
68
70
  if (!pick?.subtitle_url) return null
69
71
 
70
72
  const url = String(pick.subtitle_url).startsWith('http') ? pick.subtitle_url : 'https:' + pick.subtitle_url
71
- const r = await fetch(url, {
73
+ const r = await fetchWithRetry(url, {
72
74
  headers: { 'user-agent': UA, referer: `https://www.bilibili.com/video/${bvid}/` },
73
- signal: AbortSignal.timeout(20000),
74
- })
75
+ }, { timeoutMs: 20000 })
75
76
  if (!r.ok) return null
76
77
  let body = (JSON.parse(await r.text()).body ?? [])
77
78
  if (!body.length) return null
@@ -79,7 +80,8 @@ export async function getOfficialSubs(bvid, cid, range = null) {
79
80
  if (range) body = body.filter((l) => Number(l.from) >= range.from && Number(l.from) <= range.to)
80
81
  if (!body.length) return null
81
82
 
82
- const txt = path.join(OUT, `${bvid}-official-subs.txt`)
83
+ fs.mkdirSync(OUT, { recursive: true })
84
+ const txt = path.join(OUT, `${bvid}-official-subs.txt`)
83
85
  const plain = path.join(OUT, `${bvid}-official-subs-plain.txt`)
84
86
  fs.writeFileSync(txt, body.map((l) => `[${fmt(l.from)}] ${l.content}`).join('\n'), 'utf8')
85
87
  fs.writeFileSync(plain, body.map((l) => l.content).join('\n'), 'utf8')
@@ -122,6 +124,7 @@ export function looksLikeMp4(p) {
122
124
  }
123
125
 
124
126
  export async function ensureMp4(bvid, pageNo = 1) {
127
+ fs.mkdirSync(CACHE, { recursive: true })
125
128
  const p = path.join(CACHE, pageNo > 1 ? `${bvid}-p${pageNo}.mp4` : `${bvid}.mp4`)
126
129
  if (looksLikeMp4(p)) return p
127
130
 
@@ -132,18 +135,17 @@ export async function ensureMp4(bvid, pageNo = 1) {
132
135
  const duration = page.duration ?? data.duration
133
136
  const title = page.part && data.pages.length > 1 ? `${data.title} · P${pageNo} ${page.part}` : data.title
134
137
 
135
- const pu = await (await fetch(
138
+ const pu = await (await fetchWithRetry(
136
139
  `https://api.bilibili.com/x/player/playurl?bvid=${bvid}&cid=${cid}&qn=80&fnval=1&platform=pc&high_quality=1`,
137
- { headers: H, signal: AbortSignal.timeout(15000) })).json()
140
+ { headers: H }, { timeoutMs: 15000 })).json()
138
141
  const url = pu.data?.durl?.[0]?.url
139
142
  if (!url) throw new Error(`没拿到直链 code=${pu.code}`)
140
143
 
141
144
  // 下载到 .part 再 rename:网络中断时只会留下 .part,不会把半个 mp4 伪装成缓存
142
145
  // (旧实现直接写最终路径,中断后下次运行会拿着残缺文件当缓存用)
143
- const r = await fetch(url, {
146
+ const r = await fetchWithRetry(url, {
144
147
  headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/', range: 'bytes=0-' },
145
- signal: AbortSignal.timeout(300000),
146
- })
148
+ }, { timeoutMs: 300000 })
147
149
  if (!r.ok) throw new Error(`下载失败 HTTP ${r.status}`)
148
150
  const buf = Buffer.from(await r.arrayBuffer())
149
151
  if (buf.length < 1024) throw new Error('下载内容过短,可能被中断(.part 已丢弃)')
@@ -161,10 +163,9 @@ export async function ensureMp4(bvid, pageNo = 1) {
161
163
 
162
164
  export async function getDanmaku(cid, range = null) {
163
165
  try {
164
- const r = await fetch(`https://comment.bilibili.com/${cid}.xml`, {
166
+ const r = await fetchWithRetry(`https://comment.bilibili.com/${cid}.xml`, {
165
167
  headers: { 'user-agent': UA, referer: 'https://www.bilibili.com/' },
166
- signal: AbortSignal.timeout(20000),
167
- })
168
+ }, { timeoutMs: 20000 })
168
169
  if (!r.ok) return null
169
170
  const xml = await r.text()
170
171
  const list = []
package/lib/budget.mjs ADDED
@@ -0,0 +1,117 @@
1
+ // lib/budget.mjs —— token 预算估算(零依赖,Node ≥18)
2
+ //
3
+ // 为什么单独拆一个模块:
4
+ // fisheye 的整个「抽多少帧 / 拼几张图」决策都建立在「一张图 ≈ 多少 token」上。
5
+ // 旧版把这个值写死在 stages.mjs(COST = { grid: 1200, subs: 1000, fine: 1300 }),
6
+ // 但不同模型的图像 tokenizer 差异很大——同一张 1920x1080 拼图,
7
+ // Gemini 对单图按固定档位计(约 258 token 起),GPT-4o high detail 按 512px 瓦片计(约 1.1k~2.1k),
8
+ // Qwen-VL 系列走动态分辨率(28px patch,token 更多)。固定系数会系统性高估或低估 20~30%,
9
+ // 直接让「预算只够几张」算错,最后要么超支、要么白白变稀。
10
+ //
11
+ // 估算公式:
12
+ // estimateTokens(kind, count) = round(tokensPerUnit(kind) * count)
13
+ // tokensPerUnit(kind) = round(基准(kind) * 模型系数) // 单张/单帧成本
14
+ //
15
+ // 基准值(环境变量可覆盖,单位 token;覆盖值须为正数,非法值忽略并告警一次):
16
+ // grid = 1200 FISHEYE_TOKENS_PER_GRID
17
+ // 3x3 拼图(9 格 × 640px,约 1920x1080)。stageAuto 还会按实际画面面积再修正,
18
+ // 所以这里只是 16:9 的基准值。
19
+ // subs = 1000 FISHEYE_TOKENS_PER_SUBS
20
+ // 烧录字幕带(1xN 行,只裁画面里的字幕条,信息密度高但仍是图)。
21
+ // fine = 1300 FISHEYE_TOKENS_PER_FINE
22
+ // 精读用单帧原图(不压成 640 格,通常比拼图里的单格更贵一点)。
23
+ //
24
+ // 模型系数(FISHEYE_TOKEN_MODEL,默认 1.0 = 基准,保持向后兼容):
25
+ // gpt 1.0 GPT-4o / 4.1 一类按 512px 瓦片计费的基准
26
+ // qwen 1.35 Qwen2-VL / 2.5-VL 动态分辨率,同图 token 明显更多(保守取 1.35)
27
+ // gemini 0.8 Gemini 单图固定档位计费,token 更少;取 0.8 作为保守下限(不按 258 的极端值算)
28
+ // 也可直接写数字(如 1.2),会被收敛到 [0.25, 4] 区间
29
+ //
30
+ // 兼容性:三个环境变量都不设、FISHEYE_TOKEN_MODEL 也不设时,
31
+ // estimateTokens('grid'|'subs'|'fine', n) 与旧版 COST.* × n 完全一致。
32
+
33
+ // 默认单张成本(token)。导出 COST 是为了兼容旧代码里 `import { COST } from './stages.mjs'` 的用法。
34
+ const DEFAULTS = Object.freeze({ grid: 1200, subs: 1000, fine: 1300 })
35
+
36
+ export const COST = DEFAULTS
37
+
38
+ // 模型系数表:只描述「相对基准模型,同一张图的 token 大致贵/便宜多少」。
39
+ // 数值来自公开的计费规则量级(图像 500~4000 token/图),不是精确 tokenizer 复刻;
40
+ // 目的是把 20~30% 的系统性偏差收窄,而不是追求逐像素精确。
41
+ export const MODEL_FACTORS = Object.freeze({
42
+ gpt: 1,
43
+ qwen: 1.35,
44
+ gemini: 0.8,
45
+ })
46
+
47
+ const ENV_PER_UNIT = Object.freeze({
48
+ grid: 'FISHEYE_TOKENS_PER_GRID',
49
+ subs: 'FISHEYE_TOKENS_PER_SUBS',
50
+ fine: 'FISHEYE_TOKENS_PER_FINE',
51
+ })
52
+
53
+ const FACTOR_MIN = 0.25
54
+ const FACTOR_MAX = 4
55
+
56
+ // 同一个错误只提示一次,避免在每个拼图的日志里刷屏
57
+ const warned = new Set()
58
+ function warnOnce(key, message) {
59
+ if (warned.has(key)) return
60
+ warned.add(key)
61
+ console.warn(`[预算] ${message}`)
62
+ }
63
+
64
+ function toPositiveNumber(raw) {
65
+ if (raw === undefined || raw === null) return null
66
+ const text = String(raw).trim()
67
+ if (!text) return null
68
+ const n = Number(text)
69
+ return Number.isFinite(n) && n > 0 ? n : null
70
+ }
71
+
72
+ /** 解析模型系数;无法识别/非法时回退 1.0(并告警一次),保证环境变量写错不会瘫痪 CLI */
73
+ export function modelFactor(raw = process.env.FISHEYE_TOKEN_MODEL) {
74
+ if (raw === undefined || raw === null || String(raw).trim() === '') return 1
75
+ const key = String(raw).trim().toLowerCase()
76
+ if (Object.prototype.hasOwnProperty.call(MODEL_FACTORS, key)) return MODEL_FACTORS[key]
77
+ const n = toPositiveNumber(key)
78
+ if (n !== null) {
79
+ const clamped = Math.min(FACTOR_MAX, Math.max(FACTOR_MIN, n))
80
+ if (clamped !== n) warnOnce(`factor:${n}`, `FISHEYE_TOKEN_MODEL=${raw} 超出合理范围,已收敛为 ×${clamped}`)
81
+ return clamped
82
+ }
83
+ warnOnce(`model:${key}`, `FISHEYE_TOKEN_MODEL=${raw} 不认识(可用 gpt / qwen / gemini,或 0.5~2 的数字),按 ×1 估算`)
84
+ return 1
85
+ }
86
+
87
+ /** 单张(单帧)成本:基准 × 模型系数,四舍五入到整数 token */
88
+ export function tokensPerUnit(kind) {
89
+ const base = DEFAULTS[kind]
90
+ if (base === undefined) {
91
+ throw new Error(`tokensPerUnit:未知 kind「${kind}」(支持 ${Object.keys(DEFAULTS).join(' / ')})`)
92
+ }
93
+ const envVar = ENV_PER_UNIT[kind]
94
+ const raw = process.env[envVar]
95
+ let unit = base
96
+ if (raw !== undefined && String(raw).trim() !== '') {
97
+ const n = toPositiveNumber(raw)
98
+ if (n === null) warnOnce(`env:${envVar}`, `${envVar}=${raw} 不是正数,忽略,仍用默认 ${base}`)
99
+ else unit = n
100
+ }
101
+ return Math.round(unit * modelFactor())
102
+ }
103
+
104
+ /**
105
+ * 估算 count 个单位的 token 消耗。
106
+ * @param {'grid'|'subs'|'fine'} kind 单位类型:拼图 / 烧录字幕带 / 精读单帧
107
+ * @param {number} [count=1] 数量;非有限数或 ≤0 视为 0(空列表不花钱)
108
+ * @returns {number} 整数 token
109
+ */
110
+ export function estimateTokens(kind, count = 1) {
111
+ if (DEFAULTS[kind] === undefined) {
112
+ throw new Error(`estimateTokens:未知 kind「${kind}」(支持 ${Object.keys(DEFAULTS).join(' / ')})`)
113
+ }
114
+ const n = count === undefined || count === null ? 1 : Number(count)
115
+ if (!Number.isFinite(n) || n <= 0) return 0
116
+ return Math.round(tokensPerUnit(kind) * n)
117
+ }
package/lib/env.mjs CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
+ import { execFileSync } from 'node:child_process'
6
7
 
7
8
  export const PKG_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
8
9
  export const WS = process.env.FISHEYE_HOME || PKG_DIR
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 }