koishi-plugin-kkk 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # 更新日志
2
2
 
3
+ ## 3.6.0
4
+
5
+ > 下载稳定性:**大文件不会再被 60 秒总超时掐死**,坏掉的 B站 PCDN 直链会自动换镜像。
6
+
7
+ ### 下载:超时改成「空闲超时」,坏源自动换源
8
+
9
+ 用户实测:解析一个 279.9 MB 的 B站视频时进度卡在 `0.0 MB/s`,
10
+ 日志报 `下载失败: 连接被重置 (ECONNRESET): aborted`,三次重试全部如此。
11
+
12
+ 查下来是两件事叠在一起:
13
+
14
+ - **直链落在 PCDN 节点上**:`xy120x240x109x22xy.mcdn.bilivideo.cn:8082` 这类
15
+ `*.mcdn.bilivideo.cn` 边缘节点 TCP 连得上、响应头也正常,就是 60 秒只吐 118 KB;
16
+ 而旧代码只用 `dash.video[0].base_url`,`backup_url` 里的 `upos-*` 镜像一个都没用上。
17
+ - **下载器是「总时长 60 秒」的超时**:定时器从请求开始算、全程不重置,于是任何需要
18
+ 超过 60 秒的文件(按 1.5 MB/s 算就是 90 MB 以上)**永远下不完**。到点被自己 `abort`
19
+ 之后,Node 把这件事报成 `Error: aborted`(code `ECONNRESET`),日志里就成了
20
+ 「连接被重置」—— 把「服务器风控」这条错路指给了所有人。
21
+
22
+ 现在的行为:
23
+
24
+ - 超时是**空闲超时**:每收到一块数据就重新计时,只有真的没数据才算超时;
25
+ 另有 30 分钟的单次尝试兜底,涓流源不会挂着跑一整天。
26
+ - 有备用地址时,**热身 20 秒后速度仍低于 64 KB/s 就直接换源**(已经传了几 MB 再断的除外,
27
+ 那种留着进度续传更划算);B站视频 / 音频 / 推送解析都会带上 `backup_url` 镜像,
28
+ 且非 PCDN 的地址排在前面。
29
+ - 自己掐断的(空闲超时 / 换源)**不再伪装成 ECONNRESET**:日志写「下载中断: 下载超时
30
+ (60 秒内没有收到新数据)」,也不会再误触发「服务器断流自动降速」、建议你降 maxSpeed。
31
+ - 进度条速度改成**按两次回调之间的增量**计算(以前是累计字节 ÷ 从第一次尝试起的总耗时,
32
+ 断点续传时分子还会被重置),不会再出现「明明在跑却显示 `0.0 MB/s 剩余: 661min`」。
33
+
34
+ 新增冒烟测试 `scripts/smoke-download-source-fallback.cjs`:本地起「坏源 / 涓流源 / 健康慢源」
35
+ 三种服务,验证换源、错误文案,以及「总耗时远超 timeout 的健康下载」都能跑通。
36
+
3
37
  ## 3.5.0
4
38
 
5
39
  > 这一版三件事:**B站互动视频能玩了**(能一路选剧情、还能出剧情图)、**合并转发里能单独控制流程图**,
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "3.5.0",
3
- "buildTime": "2026-09-24T04:07:48.461Z",
4
- "buildTimestamp": 1790222868462,
2
+ "version": "3.6.0",
3
+ "buildTime": "2026-09-25T02:51:07.813Z",
4
+ "buildTimestamp": 1790304667814,
5
5
  "name": "koishi-plugin-kkk",
6
6
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
7
7
  "homepage": "https://github.com/maimai993/koishi-plugin-kkk#readme",
@@ -444,15 +444,25 @@ const downloadFile = async (videoUrl, opt) => {
444
444
  autoReduceRatio: uploadConfig.downloadAutoReduce ? 0.6 : 1, // 降速比例
445
445
  minSpeed: (uploadConfig.downloadMinSpeed ?? 1) * 1024 * 1024 // MB/s -> bytes/s
446
446
  };
447
+ /**
448
+ * 速度采样点。速度必须按「两次回调之间的增量」算,不能按「累计字节 ÷ 总耗时」:
449
+ * 后者把重试的退避等待也算进分母,断点续传时分子还会被重置回续传点,
450
+ * 于是明明在正常下载也会显示成 `0.0 MB/s 剩余: 661min`(用户实测反馈)。
451
+ */
452
+ let lastSampleBytes = 0;
453
+ let lastSampleAt = startTime;
454
+ /** 平滑后的速度(byte/s) */
455
+ let smoothSpeed = 0;
447
456
  try {
448
457
  // 使用 networks 类进行文件下载,并通过回调函数实时更新下载进度
449
458
  const { filepath, totalBytes } = await new utils_1.Networks({
450
459
  url: videoUrl,
451
460
  headers: opt.headers ?? utils_1.baseHeaders,
452
461
  filepath: opt.filepath ?? utils_1.Common.tempDri.video + opt.title,
453
- timeout: 60000, // 增加超时时间
462
+ timeout: 60000, // 空闲超时(连续 60s 没有新数据才算超时),传输途中会不断续期
454
463
  maxRetries: 3, // 增加重试次数
455
- throttle: throttleConfig
464
+ throttle: throttleConfig,
465
+ backupUrls: opt.backupUrls
456
466
  }).downloadStream((downloadedBytes, totalBytes) => {
457
467
  // 定义进度条长度及生成进度条字符串的函数
458
468
  const barLength = 45;
@@ -467,14 +477,26 @@ const downloadFile = async (videoUrl, opt) => {
467
477
  // 计算动态 RGB 颜色
468
478
  const red = Math.floor(255 - (255 * progressPercentage) / 100); // 红色分量随进度减少
469
479
  const coloredPercentage = node_karin_1.logger.chalk.rgb(red, 255, 0)(`${progressPercentage.toFixed(1)}%`);
470
- // 计算下载速度(MB/s)
471
- const elapsedTime = (Date.now() - startTime) / 1000;
472
- const speed = downloadedBytes / elapsedTime;
480
+ // 计算下载速度(MB/s):增量法 + 一点平滑,避免数字乱跳
481
+ const sampleAt = Date.now();
482
+ const elapsedSinceSample = (sampleAt - lastSampleAt) / 1000;
483
+ if (elapsedSinceSample >= 0.2) {
484
+ // 断点续传时 downloadedBytes 会往回退,增量按 0 处理
485
+ const instant = Math.max(0, downloadedBytes - lastSampleBytes) / elapsedSinceSample;
486
+ smoothSpeed = smoothSpeed > 0 ? smoothSpeed * 0.6 + instant * 0.4 : instant;
487
+ lastSampleBytes = downloadedBytes;
488
+ lastSampleAt = sampleAt;
489
+ }
490
+ const speed = smoothSpeed;
473
491
  const formattedSpeed = (speed / 1048576).toFixed(1) + ' MB/s';
474
- // 计算剩余时间
492
+ // 计算剩余时间(速度还没出来时不瞎猜)
475
493
  const remainingBytes = totalBytes - downloadedBytes; // 剩余字节数
476
- const remainingTime = remainingBytes / speed; // 剩余时间(秒)
477
- const formattedRemainingTime = remainingTime > 60 ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s` : `${remainingTime.toFixed(0)}s`;
494
+ const remainingTime = speed > 1024 ? remainingBytes / speed : Number.POSITIVE_INFINITY;
495
+ const formattedRemainingTime = !Number.isFinite(remainingTime)
496
+ ? '--'
497
+ : remainingTime > 60
498
+ ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s`
499
+ : `${remainingTime.toFixed(0)}s`;
478
500
  // 计算已下载和总下载的文件大小(MB)
479
501
  const downloadedSizeMB = (downloadedBytes / 1048576).toFixed(1);
480
502
  const totalSizeMB = (totalBytes / 1048576).toFixed(1);
@@ -147,9 +147,40 @@ function listActiveDownloads() {
147
147
  }
148
148
  return result.sort((left, right) => right.at - left.at);
149
149
  }
150
+ /* ------------------------------------------------------------------ *
151
+ * 坏源判定阈值
152
+ * ------------------------------------------------------------------ */
153
+ /**
154
+ * 换源前的热身时间:新源先给这么久证明自己。
155
+ *
156
+ * B站 playurl 经常把直链指向 `*.mcdn.bilivideo.cn` 这类 PCDN 边缘节点:
157
+ * TCP 连得上、响应头也回得来(content-length 都正常),就是几乎不吐数据
158
+ * (实测 118 KB / 60 秒 ≈ 2 KB/s)。在这种源上干等毫无意义。
159
+ */
160
+ const STALL_WARMUP_MS = 20_000;
161
+ /** 热身期过后速度仍低于这个值(byte/s)且还有备用地址时,判定为坏源并换源 */
162
+ const STALL_MIN_SPEED = 64 * 1024;
163
+ /**
164
+ * 重试同一个源之前,本轮至少要传这么多字节,才值得原地重试。
165
+ *
166
+ * 传了几 MB 再断,说明源本身是好的(只是被掐了),留着进度续传最划算;
167
+ * 传了几百 KB 就断,那就是坏源,直接换。
168
+ */
169
+ const SWITCH_IF_UNDER_BYTES = 4 * 1024 * 1024;
170
+ /** 单次尝试的硬上限:既没数据、又没有备用源可换的「涓流」源不能永远挂着 */
171
+ const MAX_ATTEMPT_MS = 30 * 60_000;
172
+ /** 打个码的地址(只留 host,别把带签名的完整直链写进日志) */
173
+ const safeHost = (url) => {
174
+ try {
175
+ return new URL(url).host;
176
+ }
177
+ catch {
178
+ return '(非法地址)';
179
+ }
180
+ };
150
181
  /**
151
182
  * 文件下载器
152
- * 支持断点续传、限速下载、自动重试
183
+ * 支持断点续传、限速下载、自动重试,以及**坏源自动换源**
153
184
  */
154
185
  class Downloader {
155
186
  axiosInstance;
@@ -161,7 +192,20 @@ class Downloader {
161
192
  throttleConfig;
162
193
  currentSpeed;
163
194
  consecutiveResets;
164
- constructor(axiosInstance, url, filepath, headers, timeout, maxRetries, throttleConfig) {
195
+ /** 备用直链:当前源被判定为坏源时按顺序换过去(B站 dash 的 backup_url) */
196
+ backupUrls;
197
+ /**
198
+ * 本次尝试「被自己掐断」的原因:
199
+ * - `timeout`:空闲超时(连续 timeout 毫秒没收到数据)
200
+ * - `slow`:坏源看门狗主动放弃
201
+ *
202
+ * 自己掐断的必须和「服务器重置」区分开 —— Node 把 abort 报成
203
+ * `Error: aborted`(code ECONNRESET),照抄进日志会把排查方向带偏。
204
+ */
205
+ abortReason = null;
206
+ /** 已经换过几次源(仅用于日志) */
207
+ sourceSwitches = 0;
208
+ constructor(axiosInstance, url, filepath, headers, timeout, maxRetries, throttleConfig, backupUrls = []) {
165
209
  this.axiosInstance = axiosInstance;
166
210
  this.url = url;
167
211
  this.filepath = filepath;
@@ -171,6 +215,7 @@ class Downloader {
171
215
  this.throttleConfig = { ...types_1.DEFAULT_THROTTLE_CONFIG, ...throttleConfig };
172
216
  this.currentSpeed = this.throttleConfig.maxSpeed;
173
217
  this.consecutiveResets = 0;
218
+ this.backupUrls = [...backupUrls];
174
219
  }
175
220
  /**
176
221
  * 执行下载
@@ -186,11 +231,51 @@ class Downloader {
186
231
  if (!this.filepath) {
187
232
  throw new Error('未指定文件保存路径: filepath 为空');
188
233
  }
234
+ // 每次尝试都从「没被掐断」开始
235
+ this.abortReason = null;
189
236
  const controller = new AbortController();
190
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
191
237
  let intervalId = null;
192
238
  let throttleStream = null;
193
239
  let writer = null;
240
+ /** 本轮尝试收到的字节数(用于坏源判定,断点续传时只算这一轮新增的) */
241
+ let attemptBytes = 0;
242
+ /** 本轮尝试的开始时间 */
243
+ const attemptStart = Date.now();
244
+ let timeoutId = null;
245
+ let attemptCapId = null;
246
+ const clearTimers = () => {
247
+ if (timeoutId)
248
+ clearTimeout(timeoutId);
249
+ if (attemptCapId)
250
+ clearTimeout(attemptCapId);
251
+ timeoutId = null;
252
+ attemptCapId = null;
253
+ };
254
+ /**
255
+ * 重新计时:**这是空闲超时,不是总时长超时**。
256
+ *
257
+ * 以前这里是一个从头开始算的定时器(`setTimeout(abort, 60000)` 并且全程不再重置),
258
+ * 于是任何需要超过 60 秒的文件(按 1.5 MB/s 算就是 90 MB 以上)都**永远下不完**:
259
+ * 到点就被 abort,而 Node 把「自己 abort」报成 `Error: aborted`(code ECONNRESET),
260
+ * 日志里就成了「下载失败: 连接被重置 (ECONNRESET): aborted」,把所有排查方向都带偏。
261
+ * 现在每收到一块数据就重新计时,只有**真的没数据**才超时。
262
+ */
263
+ const armIdleTimeout = () => {
264
+ if (this.timeout <= 0)
265
+ return;
266
+ if (timeoutId)
267
+ clearTimeout(timeoutId);
268
+ timeoutId = setTimeout(() => {
269
+ this.abortReason = 'timeout';
270
+ controller.abort();
271
+ }, this.timeout);
272
+ };
273
+ armIdleTimeout();
274
+ // 兜底:涓流源(一直有数据但慢得离谱、又没有备用源)不能在这一次尝试里挂几个小时
275
+ attemptCapId = setTimeout(() => {
276
+ this.abortReason = 'timeout';
277
+ controller.abort();
278
+ }, MAX_ATTEMPT_MS);
194
279
  try {
195
280
  // 检查断点续传
196
281
  let startByte = 0;
@@ -234,7 +319,7 @@ class Downloader {
234
319
  currentSpeed: this.throttleConfig.enabled ? (0, helpers_1.formatBytes)(this.currentSpeed) + '/s' : '不限速'
235
320
  });
236
321
  const response = await this.axiosInstance(requestConfig);
237
- clearTimeout(timeoutId);
322
+ // 注意:这里**不**清除定时器 —— 传输阶段的空闲超时还得靠它(每收到数据会重新计时)
238
323
  // 检查 HTTP 状态码
239
324
  // 416 Range Not Satisfiable
240
325
  if (response.status === 416) {
@@ -328,11 +413,35 @@ class Downloader {
328
413
  reportDownloadProgress(this.filepath, downloadedBytes, totalBytes);
329
414
  };
330
415
  const interval = totalBytes > 0 && totalBytes < 10 * 1024 * 1024 ? 1000 : 500;
331
- intervalId = setInterval(printProgress, interval);
416
+ /**
417
+ * 坏源看门狗:有备用地址时,热身期过了速度还低得离谱就直接放弃这个源。
418
+ *
419
+ * 没有备用地址时不介入 —— 慢总比失败强,真正的死源交给空闲超时兜底。
420
+ */
421
+ const checkStall = () => {
422
+ if (this.backupUrls.length === 0)
423
+ return;
424
+ const elapsed = Date.now() - attemptStart;
425
+ if (elapsed < STALL_WARMUP_MS)
426
+ return;
427
+ const speed = attemptBytes / (elapsed / 1000);
428
+ if (speed >= STALL_MIN_SPEED)
429
+ return;
430
+ node_karin_1.logger.warn(`当前下载源速度过低 (${(0, helpers_1.formatBytes)(Math.round(speed))}/s),准备切换备用地址`);
431
+ this.abortReason = 'slow';
432
+ controller.abort();
433
+ };
434
+ intervalId = setInterval(() => {
435
+ printProgress();
436
+ checkStall();
437
+ }, interval);
332
438
  // 创建计数流
333
439
  const counterStream = new node_stream_1.Transform({
334
440
  transform(chunk, encoding, callback) {
335
441
  downloadedBytes += chunk.length;
442
+ attemptBytes += chunk.length;
443
+ // 还有数据在流,说明这个源活着:把空闲超时往后推
444
+ armIdleTimeout();
336
445
  callback(null, chunk);
337
446
  }
338
447
  });
@@ -345,8 +454,6 @@ class Downloader {
345
454
  else {
346
455
  await (0, promises_1.pipeline)(response.data, counterStream, writer);
347
456
  }
348
- if (intervalId)
349
- clearInterval(intervalId);
350
457
  // pipeline 已经等待所有流完成,包括 writer 的 finish 事件
351
458
  node_karin_1.logger.debug('文件下载并写入完成');
352
459
  // 验证文件大小
@@ -388,20 +495,34 @@ class Downloader {
388
495
  };
389
496
  }
390
497
  catch (error) {
391
- clearTimeout(timeoutId);
498
+ clearTimers();
392
499
  if (intervalId)
393
500
  clearInterval(intervalId);
394
501
  // 失败/中断也要清掉,不然「查询下载进度」会一直卡在旧任务上
395
502
  clearDownloadProgress(this.filepath);
396
- const isRecoverable = (0, helpers_1.isRecoverableNetworkError)(error);
397
- const isThrottling = (0, helpers_1.isThrottlingError)(error);
398
- const errorDesc = (0, helpers_1.getErrorDescription)(error);
399
- if (error instanceof axios_1.AxiosError) {
503
+ /**
504
+ * 自己掐断的(空闲超时 / 坏源看门狗)不能按「服务器重置」处理。
505
+ *
506
+ * Node 对 `request.abort()` 抛的是 `Error: aborted`(code ECONNRESET),
507
+ * 直接套 code→文案的映射就会打印成「连接被重置」,既误导排查、
508
+ * 又会让下面的「服务器断流自动降速」误判(把一个坏源当成风控)。
509
+ */
510
+ const idleTimeout = this.abortReason === 'timeout';
511
+ const slowSource = this.abortReason === 'slow';
512
+ const ownAbort = idleTimeout || slowSource;
513
+ const isRecoverable = ownAbort || (0, helpers_1.isRecoverableNetworkError)(error);
514
+ const isThrottling = !ownAbort && (0, helpers_1.isThrottlingError)(error);
515
+ const errorDesc = idleTimeout
516
+ ? `下载超时(${Math.round(this.timeout / 1000)} 秒内没有收到新数据)`
517
+ : slowSource
518
+ ? '下载源速度过低,主动放弃该源'
519
+ : (0, helpers_1.getErrorDescription)(error);
520
+ if (error instanceof axios_1.AxiosError && !ownAbort) {
400
521
  const sanitized = (0, helpers_1.sanitizeHeaders)(this.headers);
401
522
  node_karin_1.logger.error(`请求失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`);
402
523
  }
403
524
  else {
404
- node_karin_1.logger.error(`下载失败: ${errorDesc}`);
525
+ node_karin_1.logger.error(ownAbort ? `下载中断: ${errorDesc}` : `下载失败: ${errorDesc}`);
405
526
  }
406
527
  // 如果是断流错误,自动降速
407
528
  if (isThrottling && this.throttleConfig.enabled) {
@@ -415,6 +536,22 @@ class Downloader {
415
536
  node_karin_1.logger.warn(`已达到最低速度限制 ${(0, helpers_1.formatBytes)(this.throttleConfig.minSpeed)}/s,无法继续降速`);
416
537
  }
417
538
  }
539
+ /**
540
+ * 坏源优先换源。
541
+ *
542
+ * 同一份数据在别的镜像上往往能跑满带宽,而坏源上耗满 maxRetries 次
543
+ * (每次都要再等一个超时)纯属浪费时间;反过来,如果本轮已经传了好几 MB
544
+ * 只是被掐了一下,那就该留在原源续传,别把进度扔掉。
545
+ */
546
+ if ((slowSource || (idleTimeout && attemptBytes < SWITCH_IF_UNDER_BYTES)) && this.backupUrls.length > 0) {
547
+ const next = this.backupUrls.shift();
548
+ this.sourceSwitches++;
549
+ node_karin_1.logger.warn(`当前源不可用,换备用下载源 (${this.sourceSwitches},剩余 ${this.backupUrls.length}): ` +
550
+ `${safeHost(this.url)} -> ${safeHost(next)}`);
551
+ this.url = next;
552
+ await new Promise((resolve) => setTimeout(resolve, 1000));
553
+ return this.download(progressCallback, 0);
554
+ }
418
555
  const nextDelay = (0, helpers_1.calculateBackoffDelay)(retryCount);
419
556
  if (retryCount < this.maxRetries) {
420
557
  // 等待 writer 完全关闭,确保异步写入操作已完成,避免 stat 获取到不准确的文件大小
@@ -453,6 +590,9 @@ class Downloader {
453
590
  if (isThrottling) {
454
591
  node_karin_1.logger.warn('建议: 服务器可能有下载速度限制,请尝试在配置中降低 maxSpeed 参数');
455
592
  }
593
+ if (idleTimeout) {
594
+ node_karin_1.logger.warn('提示: 这条直链所属的 CDN 节点(常见于 *.mcdn.bilivideo.cn 这类 PCDN)很可能已失效,稍后重试或换个画质通常会换到别的节点');
595
+ }
456
596
  }
457
597
  else {
458
598
  try {
@@ -468,6 +608,12 @@ class Downloader {
468
608
  throw new Error(`在 ${this.maxRetries} 次尝试后下载失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`);
469
609
  }
470
610
  }
611
+ finally {
612
+ // 所有出口(成功 / 416 提前返回 / 换源 / 重试 / 抛错)都要把定时器和轮询清干净
613
+ clearTimers();
614
+ if (intervalId)
615
+ clearInterval(intervalId);
616
+ }
471
617
  }
472
618
  /**
473
619
  * 手动设置下载速度
@@ -54,6 +54,7 @@ class Network {
54
54
  filepath;
55
55
  maxRetries;
56
56
  throttleConfig;
57
+ backupUrls;
57
58
  /**
58
59
  * 创建网络请求实例
59
60
  *
@@ -79,6 +80,7 @@ class Network {
79
80
  this.filepath = data.filepath ?? '';
80
81
  this.maxRetries = data.maxRetries ?? 3;
81
82
  this.throttleConfig = data.throttle;
83
+ this.backupUrls = data.backupUrls ?? [];
82
84
  // 创建 axios 实例
83
85
  this.axiosInstance = axios_1.default.create({
84
86
  timeout: this.timeout,
@@ -126,7 +128,9 @@ class Network {
126
128
  * @param retryCount 重试次数(内部使用)
127
129
  */
128
130
  async downloadStream(progressCallback, retryCount = 0) {
129
- const downloader = new Downloader_1.Downloader(this.axiosInstance, this.url, this.filepath, this.headers, this.timeout, this.maxRetries, this.throttleConfig);
131
+ const downloader = new Downloader_1.Downloader(this.axiosInstance, this.url, this.filepath, this.headers, this.timeout, this.maxRetries, this.throttleConfig,
132
+ // 拷贝一份:下载器内部会 shift 掉已经试过的地址
133
+ [...this.backupUrls]);
130
134
  return downloader.download(progressCallback, retryCount);
131
135
  }
132
136
  async getfetch() {
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.extractArticleImages = exports.getvideosize = exports.bilibiliProcessVideos = exports.preferAvcStream = exports.isAvcStream = exports.parseAdditionalCard = exports.generateDecorationCard = exports.cover = exports.TimeFormatter = exports.Bilibili = void 0;
39
+ exports.extractArticleImages = exports.getvideosize = exports.bilibiliProcessVideos = exports.preferAvcStream = exports.isAvcStream = exports.parseAdditionalCard = exports.generateDecorationCard = exports.cover = exports.TimeFormatter = exports.Bilibili = exports.buildDashUrlCandidates = void 0;
40
40
  const node_fs_1 = __importDefault(require("node:fs"));
41
41
  const ImageSlice_1 = require("../../module/utils/ImageSlice");
42
42
  const QqPanel_1 = require("../../module/utils/QqPanel");
@@ -65,6 +65,42 @@ const danmaku_1 = require("../../platform/bilibili/danmaku");
65
65
  const dynamic_text_1 = require("../../platform/bilibili/dynamic-text");
66
66
  const interactive_story_1 = require("../../platform/bilibili/interactive-story");
67
67
  let img;
68
+ /**
69
+ * 一个地址是不是 PCDN 边缘节点。
70
+ *
71
+ * `xy120x240x109x22xy.mcdn.bilivideo.cn:8082/v1/resource/upgcxcode/...` 这种就是:
72
+ * 域名带 mcdn、带非标准端口、路径 `/v1/resource/`。
73
+ * 它们对本机来说常常是「TCP 连得上、响应头也正常,就是不吐数据」(实测 118 KB / 60 秒),
74
+ * 而同一份数据在 `upos-*` 镜像上能跑满带宽。
75
+ */
76
+ const isPcdnUrl = (url) => /\.mcdn\.bilivideo\./i.test(url) || /\.pcdn\./i.test(url) || /\/v1\/resource\//i.test(url);
77
+ /**
78
+ * 把一条 dash 流的直链整理成**按优先级排序**的候选列表。
79
+ *
80
+ * 排序规则(尽量不改变原来的行为,只在必要时动):
81
+ * 1. `base_url` 不是 PCDN 时保持第一 —— 原来的行为不变;
82
+ * 2. `base_url` 是 PCDN 时,把非 PCDN 的备用地址提到前面;
83
+ * 3. 其余地址按接口给的顺序跟在后面。
84
+ *
85
+ * 下载器遇到「连得上但不吐数据」的源会自动换到下一个(见 Downloader 的坏源看门狗),
86
+ * 所以列表顺序就是换源顺序。
87
+ *
88
+ * @param stream dash 里的 video / audio 流对象
89
+ * @returns 去重且已排序的直链列表(可能为空)
90
+ */
91
+ const buildDashUrlCandidates = (stream) => {
92
+ const raw = [stream?.base_url ?? stream?.baseUrl, ...(stream?.backup_url ?? stream?.backupUrl ?? [])];
93
+ const seen = new Set();
94
+ const urls = [];
95
+ for (const url of raw) {
96
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url) || seen.has(url))
97
+ continue;
98
+ seen.add(url);
99
+ urls.push(url);
100
+ }
101
+ return [...urls.filter((url) => !isPcdnUrl(url)), ...urls.filter(isPcdnUrl)];
102
+ };
103
+ exports.buildDashUrlCandidates = buildDashUrlCandidates;
68
104
  /** 评论请求统一使用匿名态,避免账号 Cookie 改变评论热度池结果。 */
69
105
  const bilibiliAnonymousRequestConfig = {
70
106
  headers: {
@@ -1359,15 +1395,24 @@ class Bilibili extends utils_1.Base {
1359
1395
  this.danmakuList = danmakuList;
1360
1396
  switch (this.islogin) {
1361
1397
  case true: {
1362
- node_karin_1.logger.debug('视频 URL:', this.Type === 'one_video' ? playUrlData.data?.dash?.video[0].base_url : playUrlData.result.dash.video[0].base_url);
1398
+ const videoStream = this.Type === 'one_video' ? playUrlData.data?.dash?.video[0] : playUrlData.result.dash.video[0];
1399
+ node_karin_1.logger.debug('视频 URL:', videoStream?.base_url);
1363
1400
  // B站 CDN 需要正确的 Referer
1364
1401
  const downloadHeaders = {
1365
1402
  ...this.headers,
1366
1403
  Referer: 'https://www.bilibili.com'
1367
1404
  };
1368
- const bmp4Raw = await (0, utils_1.downloadFile)(this.Type === 'one_video' ? playUrlData.data?.dash?.video[0].base_url : playUrlData.result.dash.video[0].base_url, {
1405
+ /**
1406
+ * 直链候选:`base_url`(常是 mcdn/PCDN 节点)+ `backup_url` 镜像。
1407
+ * 排在前面的先试,坏源由下载器自动换掉 —— 不然一个死节点就能让整个解析卡死。
1408
+ */
1409
+ const videoUrls = (0, exports.buildDashUrlCandidates)(videoStream);
1410
+ if (videoUrls.length === 0)
1411
+ throw new Error('没有拿到视频流直链(playurl 返回里没有 base_url)');
1412
+ const bmp4Raw = await (0, utils_1.downloadFile)(videoUrls[0], {
1369
1413
  title: `Bil_V_${this.Type === 'one_video' ? infoData && infoData.data.bvid : infoData && infoData.result.season_id}.m4s`,
1370
- headers: downloadHeaders
1414
+ headers: downloadHeaders,
1415
+ backupUrls: videoUrls.slice(1)
1371
1416
  });
1372
1417
  // 修复 m4s 文件为标准 MP4
1373
1418
  const videoPath = utils_1.Common.tempDri.video +
@@ -1379,14 +1424,18 @@ class Bilibili extends utils_1.Base {
1379
1424
  }
1380
1425
  // 删除原始 m4s 文件
1381
1426
  await utils_1.Common.removeFile(bmp4Raw.filepath, true);
1382
- const audioUrl = this.Type === 'one_video' ? playUrlData.data?.dash?.audio?.[0]?.base_url : playUrlData.result.dash.audio?.[0]?.base_url;
1427
+ const audioStream = this.Type === 'one_video' ? playUrlData.data?.dash?.audio?.[0] : playUrlData.result.dash.audio?.[0];
1428
+ /** 音频候选同样带镜像,理由见上面视频那段 */
1429
+ const audioUrls = (0, exports.buildDashUrlCandidates)(audioStream);
1430
+ const audioUrl = audioUrls[0];
1383
1431
  node_karin_1.logger.debug('音频 URL:', audioUrl);
1384
1432
  /** 没有音频流(如纯视频稿件)时为 undefined,此时无从合成,直接发视频流 */
1385
1433
  let bmp3;
1386
1434
  if (audioUrl) {
1387
1435
  const bmp3Raw = await (0, utils_1.downloadFile)(audioUrl, {
1388
1436
  title: `Bil_A_${this.Type === 'one_video' ? infoData && infoData.data.bvid : infoData && infoData.result.season_id}.m4s`,
1389
- headers: downloadHeaders
1437
+ headers: downloadHeaders,
1438
+ backupUrls: audioUrls.slice(1)
1390
1439
  });
1391
1440
  // 修复音频 m4s 文件为 m4a(AAC 音频不能直接转为 MP3 容器)
1392
1441
  const audioPath = utils_1.Common.tempDri.video +
@@ -757,14 +757,21 @@ class Bilibilipush extends module_1.Base {
757
757
  const infoData = await this.amagi.bilibili.fetcher.fetchVideoInfo({
758
758
  bvid: data[dynamicId].Dynamic_Data.modules.module_dynamic.major.archive.bvid
759
759
  });
760
- const mp4File = await (0, module_1.downloadFile)(playUrlData.data?.data?.dash?.video[0].base_url, {
760
+ /** 直链候选:base_url(可能是 mcdn/PCDN 节点)+ backup_url 镜像,坏源由下载器自动换 */
761
+ const videoUrls = (0, bilibili_1.buildDashUrlCandidates)(playUrlData.data?.data?.dash?.video[0]);
762
+ const audioUrls = (0, bilibili_1.buildDashUrlCandidates)(playUrlData.data?.data?.dash?.audio?.[0]);
763
+ if (videoUrls.length === 0)
764
+ break;
765
+ const mp4File = await (0, module_1.downloadFile)(videoUrls[0], {
761
766
  title: `Bil_V_${infoData.data.data.bvid}.mp4`,
762
- headers: bilibiliBaseHeaders
767
+ headers: bilibiliBaseHeaders,
768
+ backupUrls: videoUrls.slice(1)
763
769
  });
764
- const mp3File = audioUrl
765
- ? await (0, module_1.downloadFile)(audioUrl, {
770
+ const mp3File = audioUrls.length > 0
771
+ ? await (0, module_1.downloadFile)(audioUrls[0], {
766
772
  title: `Bil_A_${infoData.data.data.bvid}.mp3`,
767
- headers: bilibiliBaseHeaders
773
+ headers: bilibiliBaseHeaders,
774
+ backupUrls: audioUrls.slice(1)
768
775
  })
769
776
  : undefined;
770
777
  if (mp4File.filepath && (!mp3File || mp3File.filepath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 冒烟测试:下载器遇到「连得上但不吐数据」的坏源时的行为。
3
+ *
4
+ * 背景(用户实测):B站 playurl 把直链指向 `*.mcdn.bilivideo.cn` 这类 PCDN 边缘节点,
5
+ * TCP 连得上、响应头也正常,就是 60 秒只吐 118 KB;而旧实现的定时器是
6
+ * **从请求开始算的 60 秒总超时**,于是任何大文件都永远下不完,
7
+ * 报错还被 Node 报成 `Error: aborted`(code ECONNRESET)→ 日志写成「连接被重置」。
8
+ *
9
+ * 这里用本地 HTTP 服务复现四种场景,全程只依赖本机、不碰外网:
10
+ * 1. 坏源 + 备用镜像:应当自动换源并下完(新增行为)
11
+ * 2. 只有坏源、没有备用:应当报「下载超时」,**不能**再报「连接被重置」
12
+ * 3. 健康但慢的源(总耗时远超 timeout):应当下完(旧的 60 秒总超时会掐死它)
13
+ * 4. 涓流源(一直有数据但速度远低于阈值)+ 备用镜像:应当被看门狗换掉
14
+ *
15
+ * 走的是 lib 产物(真正跑的就是它),不是 src。
16
+ * 用法:node scripts/smoke-download-source-fallback.cjs
17
+ */
18
+ const http = require('node:http')
19
+ const fs = require('node:fs')
20
+ const os = require('node:os')
21
+ const path = require('node:path')
22
+ const axios = require('axios')
23
+
24
+ const { Downloader } = require('../lib/karin/module/utils/Network/Downloader.js')
25
+
26
+ /** 目标文件大小:8 MB(远大于 x64 回退的 256 KB 安全裕量) */
27
+ const TOTAL = 8 * 1024 * 1024
28
+
29
+ let failures = 0
30
+ const check = (name, ok, extra = '') => {
31
+ console.log((ok ? '✅' : '❌') + ' ' + name + (extra ? ' | ' + extra : ''))
32
+ if (!ok) failures++
33
+ }
34
+
35
+ /** 收集日志,用于断言「换源」「超时」这类提示文案 */
36
+ const logs = []
37
+ const rawLog = console.log.bind(console)
38
+ const rawWarn = console.warn.bind(console)
39
+ console.log = (...args) => { logs.push(args.join(' ')); rawLog(...args) }
40
+ console.warn = (...args) => { logs.push(args.join(' ')); rawWarn(...args) }
41
+
42
+ const listen = (handler) => new Promise((resolve) => {
43
+ const server = http.createServer(handler)
44
+ server.listen(0, '127.0.0.1', () => resolve(server))
45
+ })
46
+ const urlOf = (server) => 'http://127.0.0.1:' + server.address().port + '/file'
47
+
48
+ const parseRangeStart = (header) => {
49
+ if (!header) return 0
50
+ const raw = String(header).replace('bytes=', '').split('-')[0]
51
+ const start = Number(raw)
52
+ return Number.isFinite(start) && start > 0 ? start : 0
53
+ }
54
+
55
+ /** 正常源:支持 Range,快速把整份数据写完 */
56
+ const goodHandler = (req, res) => {
57
+ const start = parseRangeStart(req.headers.range)
58
+ const length = TOTAL - start
59
+ res.writeHead(start > 0 ? 206 : 200, {
60
+ 'content-type': 'application/octet-stream',
61
+ 'content-length': String(length),
62
+ ...(start > 0 ? { 'content-range': 'bytes ' + start + '-' + (TOTAL - 1) + '/' + TOTAL } : {})
63
+ })
64
+ const chunk = Buffer.alloc(256 * 1024)
65
+ let sent = start
66
+ const pump = () => {
67
+ while (sent < TOTAL) {
68
+ const size = Math.min(chunk.length, TOTAL - sent)
69
+ sent += size
70
+ if (!res.write(chunk.subarray(0, size))) {
71
+ res.once('drain', pump)
72
+ return
73
+ }
74
+ }
75
+ res.end()
76
+ }
77
+ pump()
78
+ }
79
+
80
+ /** 坏源:发完响应头和 64 KB 就再也不吐数据,且不关连接(mcdn/PCDN 的典型表现) */
81
+ const badHandler = (req, res) => {
82
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(TOTAL) })
83
+ res.write(Buffer.alloc(64 * 1024))
84
+ }
85
+
86
+ /** 涓流源:一直有数据,但只有 ~5 KB/s,远低于坏源看门狗阈值 */
87
+ const trickleHandler = (req, res) => {
88
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(TOTAL) })
89
+ const timer = setInterval(() => { res.write(Buffer.alloc(1024)) }, 200)
90
+ res.on('close', () => clearInterval(timer))
91
+ }
92
+
93
+ /** 健康但慢的源:约 4 秒传完 8 MB(每 200 ms 一块),总耗时远超 timeout */
94
+ const slowHandler = (req, res) => {
95
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(TOTAL) })
96
+ let sent = 0
97
+ const timer = setInterval(() => {
98
+ if (sent >= TOTAL) { clearInterval(timer); res.end(); return }
99
+ const size = Math.min(512 * 1024, TOTAL - sent)
100
+ sent += size
101
+ res.write(Buffer.alloc(size))
102
+ }, 250)
103
+ res.on('close', () => clearInterval(timer))
104
+ }
105
+
106
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kkk-dl-'))
107
+
108
+ const runDownload = async (label, url, { timeout, retries = 3, backups = [] }) => {
109
+ const filepath = path.join(tmp, label + '.bin')
110
+ const instance = axios.create({ timeout, maxRedirects: 5, validateStatus: () => true })
111
+ const downloader = new Downloader(instance, url, filepath, {}, timeout, retries, { enabled: false }, backups)
112
+ const started = Date.now()
113
+ try {
114
+ const result = await downloader.download(() => {})
115
+ return { ok: true, bytes: fs.statSync(result.filepath).size, elapsed: Date.now() - started }
116
+ } catch (error) {
117
+ return { ok: false, message: String(error && error.message), bytes: fs.existsSync(filepath) ? fs.statSync(filepath).size : 0, elapsed: Date.now() - started }
118
+ }
119
+ }
120
+
121
+ const main = async () => {
122
+ const bad = await listen(badHandler)
123
+ const good = await listen(goodHandler)
124
+ const trickle = await listen(trickleHandler)
125
+ const slow = await listen(slowHandler)
126
+
127
+ // 1. 坏源 + 备用镜像 → 自动换源下完
128
+ logs.length = 0
129
+ const switched = await runDownload('switch', urlOf(bad), { timeout: 2000, backups: [urlOf(good)] })
130
+ check('坏源 + 备用镜像:下载成功', switched.ok, JSON.stringify(switched))
131
+ check('坏源 + 备用镜像:文件完整', switched.bytes === TOTAL, switched.bytes + '/' + TOTAL)
132
+ check('坏源 + 备用镜像:日志里有换源提示', logs.some((line) => line.includes('换备用下载源')), logs.filter((l) => l.includes('备用下载源')).join(' / '))
133
+
134
+ // 2. 只有坏源、没有备用 → 报「下载超时」,不报「连接被重置」
135
+ logs.length = 0
136
+ const timedOut = await runDownload('timeout', urlOf(bad), { timeout: 1500, retries: 0 })
137
+ check('无备用源:如预期失败', !timedOut.ok, JSON.stringify(timedOut))
138
+ check('无备用源:报「下载超时」', timedOut.message.includes('下载超时'), timedOut.message.slice(0, 160))
139
+ check('无备用源:不再误报「连接被重置」', !timedOut.message.includes('连接被重置'), timedOut.message.slice(0, 160))
140
+ check('无备用源:超时按空闲判定(约等于 timeout)', timedOut.elapsed < 1500 + 1500, timedOut.elapsed + 'ms')
141
+
142
+ // 3. 健康但慢的源:总耗时远超 timeout 也要下完(旧的 60 秒总超时会掐死它)
143
+ const slowRun = await runDownload('slow', urlOf(slow), { timeout: 1500, retries: 0 })
144
+ check('健康慢源:下载成功', slowRun.ok, JSON.stringify(slowRun))
145
+ check('健康慢源:文件完整', slowRun.bytes === TOTAL, slowRun.bytes + '/' + TOTAL)
146
+ check('健康慢源:确实是「比 timeout 慢」的场景', slowRun.elapsed > 1500 * 2, slowRun.elapsed + 'ms > 2x timeout')
147
+
148
+ // 4. 涓流源 + 备用镜像 → 看门狗换源(热身期 20s,这条最慢)
149
+ logs.length = 0
150
+ const watchdog = await runDownload('watchdog', urlOf(trickle), { timeout: 60000, retries: 1, backups: [urlOf(good)] })
151
+ check('涓流源:被看门狗换掉后下载成功', watchdog.ok, JSON.stringify(watchdog))
152
+ check('涓流源:文件完整', watchdog.bytes === TOTAL, watchdog.bytes + '/' + TOTAL)
153
+ check('涓流源:日志提示速度过低', logs.some((line) => line.includes('速度过低')), logs.filter((l) => l.includes('速度过低')).join(' / '))
154
+
155
+ for (const server of [bad, good, trickle, slow]) server.close()
156
+ fs.rmSync(tmp, { recursive: true, force: true })
157
+
158
+ console.log('')
159
+ console.log(failures ? '失败 ' + failures + ' 项' : '全部通过')
160
+ process.exit(failures ? 1 : 0)
161
+ }
162
+
163
+ main().catch((error) => {
164
+ console.error(error)
165
+ process.exit(1)
166
+ })
@@ -100,6 +100,14 @@ export type downLoadFileOptions = {
100
100
  headers?: (RawAxiosRequestHeaders & MethodsHeaders) | AxiosHeaders
101
101
  /** 文件保存路径 */
102
102
  filepath?: string
103
+ /**
104
+ * 备用直链(按顺序尝试)。
105
+ *
106
+ * B站 dash 的 `backup_url` 就是干这个用的:`base_url` 常常指向
107
+ * `*.mcdn.bilivideo.cn` 这类 PCDN 节点,连得上却不吐数据;
108
+ * 把镜像地址带上,下载器就能在坏源上自动换过去。
109
+ */
110
+ backupUrls?: string[]
103
111
  }
104
112
 
105
113
  /**
@@ -526,15 +534,26 @@ export const downloadFile = async (videoUrl: string, opt: downLoadFileOptions):
526
534
  minSpeed: (uploadConfig.downloadMinSpeed ?? 1) * 1024 * 1024 // MB/s -> bytes/s
527
535
  }
528
536
 
537
+ /**
538
+ * 速度采样点。速度必须按「两次回调之间的增量」算,不能按「累计字节 ÷ 总耗时」:
539
+ * 后者把重试的退避等待也算进分母,断点续传时分子还会被重置回续传点,
540
+ * 于是明明在正常下载也会显示成 `0.0 MB/s 剩余: 661min`(用户实测反馈)。
541
+ */
542
+ let lastSampleBytes = 0
543
+ let lastSampleAt = startTime
544
+ /** 平滑后的速度(byte/s) */
545
+ let smoothSpeed = 0
546
+
529
547
  try {
530
548
  // 使用 networks 类进行文件下载,并通过回调函数实时更新下载进度
531
549
  const { filepath, totalBytes } = await new Networks({
532
550
  url: videoUrl,
533
551
  headers: opt.headers ?? baseHeaders,
534
552
  filepath: opt.filepath ?? Common.tempDri.video + opt.title,
535
- timeout: 60000, // 增加超时时间
553
+ timeout: 60000, // 空闲超时(连续 60s 没有新数据才算超时),传输途中会不断续期
536
554
  maxRetries: 3, // 增加重试次数
537
- throttle: throttleConfig
555
+ throttle: throttleConfig,
556
+ backupUrls: opt.backupUrls
538
557
  }).downloadStream((downloadedBytes, totalBytes) => {
539
558
  // 定义进度条长度及生成进度条字符串的函数
540
559
  const barLength = 45
@@ -552,16 +571,27 @@ export const downloadFile = async (videoUrl: string, opt: downLoadFileOptions):
552
571
  const red = Math.floor(255 - (255 * progressPercentage) / 100) // 红色分量随进度减少
553
572
  const coloredPercentage = logger.chalk.rgb(red, 255, 0)(`${progressPercentage.toFixed(1)}%`)
554
573
 
555
- // 计算下载速度(MB/s)
556
- const elapsedTime = (Date.now() - startTime) / 1000
557
- const speed = downloadedBytes / elapsedTime
574
+ // 计算下载速度(MB/s):增量法 + 一点平滑,避免数字乱跳
575
+ const sampleAt = Date.now()
576
+ const elapsedSinceSample = (sampleAt - lastSampleAt) / 1000
577
+ if (elapsedSinceSample >= 0.2) {
578
+ // 断点续传时 downloadedBytes 会往回退,增量按 0 处理
579
+ const instant = Math.max(0, downloadedBytes - lastSampleBytes) / elapsedSinceSample
580
+ smoothSpeed = smoothSpeed > 0 ? smoothSpeed * 0.6 + instant * 0.4 : instant
581
+ lastSampleBytes = downloadedBytes
582
+ lastSampleAt = sampleAt
583
+ }
584
+ const speed = smoothSpeed
558
585
  const formattedSpeed = (speed / 1048576).toFixed(1) + ' MB/s'
559
586
 
560
- // 计算剩余时间
587
+ // 计算剩余时间(速度还没出来时不瞎猜)
561
588
  const remainingBytes = totalBytes - downloadedBytes // 剩余字节数
562
- const remainingTime = remainingBytes / speed // 剩余时间(秒)
563
- const formattedRemainingTime =
564
- remainingTime > 60 ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s` : `${remainingTime.toFixed(0)}s`
589
+ const remainingTime = speed > 1024 ? remainingBytes / speed : Number.POSITIVE_INFINITY
590
+ const formattedRemainingTime = !Number.isFinite(remainingTime)
591
+ ? '--'
592
+ : remainingTime > 60
593
+ ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s`
594
+ : `${remainingTime.toFixed(0)}s`
565
595
 
566
596
  // 计算已下载和总下载的文件大小(MB)
567
597
  const downloadedSizeMB = (downloadedBytes / 1048576).toFixed(1)
@@ -180,9 +180,45 @@ export function listActiveDownloads (): DownloadProgressEntry[] {
180
180
  return result.sort((left, right) => right.at - left.at)
181
181
  }
182
182
 
183
+ /* ------------------------------------------------------------------ *
184
+ * 坏源判定阈值
185
+ * ------------------------------------------------------------------ */
186
+
187
+ /**
188
+ * 换源前的热身时间:新源先给这么久证明自己。
189
+ *
190
+ * B站 playurl 经常把直链指向 `*.mcdn.bilivideo.cn` 这类 PCDN 边缘节点:
191
+ * TCP 连得上、响应头也回得来(content-length 都正常),就是几乎不吐数据
192
+ * (实测 118 KB / 60 秒 ≈ 2 KB/s)。在这种源上干等毫无意义。
193
+ */
194
+ const STALL_WARMUP_MS = 20_000
195
+
196
+ /** 热身期过后速度仍低于这个值(byte/s)且还有备用地址时,判定为坏源并换源 */
197
+ const STALL_MIN_SPEED = 64 * 1024
198
+
199
+ /**
200
+ * 重试同一个源之前,本轮至少要传这么多字节,才值得原地重试。
201
+ *
202
+ * 传了几 MB 再断,说明源本身是好的(只是被掐了),留着进度续传最划算;
203
+ * 传了几百 KB 就断,那就是坏源,直接换。
204
+ */
205
+ const SWITCH_IF_UNDER_BYTES = 4 * 1024 * 1024
206
+
207
+ /** 单次尝试的硬上限:既没数据、又没有备用源可换的「涓流」源不能永远挂着 */
208
+ const MAX_ATTEMPT_MS = 30 * 60_000
209
+
210
+ /** 打个码的地址(只留 host,别把带签名的完整直链写进日志) */
211
+ const safeHost = (url: string): string => {
212
+ try {
213
+ return new URL(url).host
214
+ } catch {
215
+ return '(非法地址)'
216
+ }
217
+ }
218
+
183
219
  /**
184
220
  * 文件下载器
185
- * 支持断点续传、限速下载、自动重试
221
+ * 支持断点续传、限速下载、自动重试,以及**坏源自动换源**
186
222
  */
187
223
  export class Downloader {
188
224
  private axiosInstance: AxiosInstance
@@ -194,6 +230,19 @@ export class Downloader {
194
230
  private throttleConfig: ThrottleConfig
195
231
  private currentSpeed: number
196
232
  private consecutiveResets: number
233
+ /** 备用直链:当前源被判定为坏源时按顺序换过去(B站 dash 的 backup_url) */
234
+ private backupUrls: string[]
235
+ /**
236
+ * 本次尝试「被自己掐断」的原因:
237
+ * - `timeout`:空闲超时(连续 timeout 毫秒没收到数据)
238
+ * - `slow`:坏源看门狗主动放弃
239
+ *
240
+ * 自己掐断的必须和「服务器重置」区分开 —— Node 把 abort 报成
241
+ * `Error: aborted`(code ECONNRESET),照抄进日志会把排查方向带偏。
242
+ */
243
+ private abortReason: 'timeout' | 'slow' | null = null
244
+ /** 已经换过几次源(仅用于日志) */
245
+ private sourceSwitches = 0
197
246
 
198
247
  constructor(
199
248
  axiosInstance: AxiosInstance,
@@ -202,7 +251,8 @@ export class Downloader {
202
251
  headers: Record<string, string>,
203
252
  timeout: number,
204
253
  maxRetries: number,
205
- throttleConfig?: Partial<ThrottleConfig>
254
+ throttleConfig?: Partial<ThrottleConfig>,
255
+ backupUrls: string[] = []
206
256
  ) {
207
257
  this.axiosInstance = axiosInstance
208
258
  this.url = url
@@ -213,6 +263,7 @@ export class Downloader {
213
263
  this.throttleConfig = { ...DEFAULT_THROTTLE_CONFIG, ...throttleConfig }
214
264
  this.currentSpeed = this.throttleConfig.maxSpeed
215
265
  this.consecutiveResets = 0
266
+ this.backupUrls = [...backupUrls]
216
267
  }
217
268
 
218
269
  /**
@@ -231,11 +282,49 @@ export class Downloader {
231
282
  throw new Error('未指定文件保存路径: filepath 为空')
232
283
  }
233
284
 
285
+ // 每次尝试都从「没被掐断」开始
286
+ this.abortReason = null
287
+
234
288
  const controller = new AbortController()
235
- const timeoutId = setTimeout(() => controller.abort(), this.timeout)
236
289
  let intervalId: NodeJS.Timeout | null = null
237
290
  let throttleStream: ThrottleStream | null = null
238
291
  let writer: fs.WriteStream | null = null
292
+ /** 本轮尝试收到的字节数(用于坏源判定,断点续传时只算这一轮新增的) */
293
+ let attemptBytes = 0
294
+ /** 本轮尝试的开始时间 */
295
+ const attemptStart = Date.now()
296
+
297
+ let timeoutId: NodeJS.Timeout | null = null
298
+ let attemptCapId: NodeJS.Timeout | null = null
299
+ const clearTimers = () => {
300
+ if (timeoutId) clearTimeout(timeoutId)
301
+ if (attemptCapId) clearTimeout(attemptCapId)
302
+ timeoutId = null
303
+ attemptCapId = null
304
+ }
305
+ /**
306
+ * 重新计时:**这是空闲超时,不是总时长超时**。
307
+ *
308
+ * 以前这里是一个从头开始算的定时器(`setTimeout(abort, 60000)` 并且全程不再重置),
309
+ * 于是任何需要超过 60 秒的文件(按 1.5 MB/s 算就是 90 MB 以上)都**永远下不完**:
310
+ * 到点就被 abort,而 Node 把「自己 abort」报成 `Error: aborted`(code ECONNRESET),
311
+ * 日志里就成了「下载失败: 连接被重置 (ECONNRESET): aborted」,把所有排查方向都带偏。
312
+ * 现在每收到一块数据就重新计时,只有**真的没数据**才超时。
313
+ */
314
+ const armIdleTimeout = () => {
315
+ if (this.timeout <= 0) return
316
+ if (timeoutId) clearTimeout(timeoutId)
317
+ timeoutId = setTimeout(() => {
318
+ this.abortReason = 'timeout'
319
+ controller.abort()
320
+ }, this.timeout)
321
+ }
322
+ armIdleTimeout()
323
+ // 兜底:涓流源(一直有数据但慢得离谱、又没有备用源)不能在这一次尝试里挂几个小时
324
+ attemptCapId = setTimeout(() => {
325
+ this.abortReason = 'timeout'
326
+ controller.abort()
327
+ }, MAX_ATTEMPT_MS)
239
328
 
240
329
  try {
241
330
  // 检查断点续传
@@ -282,7 +371,7 @@ export class Downloader {
282
371
  })
283
372
 
284
373
  const response = await this.axiosInstance(requestConfig)
285
- clearTimeout(timeoutId)
374
+ // 注意:这里**不**清除定时器 —— 传输阶段的空闲超时还得靠它(每收到数据会重新计时)
286
375
 
287
376
  // 检查 HTTP 状态码
288
377
  // 416 Range Not Satisfiable
@@ -387,12 +476,35 @@ export class Downloader {
387
476
  }
388
477
 
389
478
  const interval = totalBytes > 0 && totalBytes < 10 * 1024 * 1024 ? 1000 : 500
390
- intervalId = setInterval(printProgress, interval)
479
+
480
+ /**
481
+ * 坏源看门狗:有备用地址时,热身期过了速度还低得离谱就直接放弃这个源。
482
+ *
483
+ * 没有备用地址时不介入 —— 慢总比失败强,真正的死源交给空闲超时兜底。
484
+ */
485
+ const checkStall = () => {
486
+ if (this.backupUrls.length === 0) return
487
+ const elapsed = Date.now() - attemptStart
488
+ if (elapsed < STALL_WARMUP_MS) return
489
+ const speed = attemptBytes / (elapsed / 1000)
490
+ if (speed >= STALL_MIN_SPEED) return
491
+ logger.warn(`当前下载源速度过低 (${formatBytes(Math.round(speed))}/s),准备切换备用地址`)
492
+ this.abortReason = 'slow'
493
+ controller.abort()
494
+ }
495
+
496
+ intervalId = setInterval(() => {
497
+ printProgress()
498
+ checkStall()
499
+ }, interval)
391
500
 
392
501
  // 创建计数流
393
502
  const counterStream = new Transform({
394
503
  transform(chunk, encoding, callback) {
395
504
  downloadedBytes += chunk.length
505
+ attemptBytes += chunk.length
506
+ // 还有数据在流,说明这个源活着:把空闲超时往后推
507
+ armIdleTimeout()
396
508
  callback(null, chunk)
397
509
  }
398
510
  })
@@ -406,8 +518,6 @@ export class Downloader {
406
518
  await pipeline(response.data, counterStream, writer as fs.WriteStream)
407
519
  }
408
520
 
409
- if (intervalId) clearInterval(intervalId)
410
-
411
521
  // pipeline 已经等待所有流完成,包括 writer 的 finish 事件
412
522
  logger.debug('文件下载并写入完成')
413
523
 
@@ -456,20 +566,34 @@ export class Downloader {
456
566
  totalBytes: totalBytes > 0 ? totalBytes : downloadedBytes
457
567
  }
458
568
  } catch (error) {
459
- clearTimeout(timeoutId)
569
+ clearTimers()
460
570
  if (intervalId) clearInterval(intervalId)
461
571
  // 失败/中断也要清掉,不然「查询下载进度」会一直卡在旧任务上
462
572
  clearDownloadProgress(this.filepath)
463
573
 
464
- const isRecoverable = isRecoverableNetworkError(error)
465
- const isThrottling = isThrottlingError(error)
466
- const errorDesc = getErrorDescription(error)
467
-
468
- if (error instanceof AxiosError) {
574
+ /**
575
+ * 自己掐断的(空闲超时 / 坏源看门狗)不能按「服务器重置」处理。
576
+ *
577
+ * Node 对 `request.abort()` 抛的是 `Error: aborted`(code ECONNRESET),
578
+ * 直接套 code→文案的映射就会打印成「连接被重置」,既误导排查、
579
+ * 又会让下面的「服务器断流自动降速」误判(把一个坏源当成风控)。
580
+ */
581
+ const idleTimeout = this.abortReason === 'timeout'
582
+ const slowSource = this.abortReason === 'slow'
583
+ const ownAbort = idleTimeout || slowSource
584
+ const isRecoverable = ownAbort || isRecoverableNetworkError(error)
585
+ const isThrottling = !ownAbort && isThrottlingError(error)
586
+ const errorDesc = idleTimeout
587
+ ? `下载超时(${Math.round(this.timeout / 1000)} 秒内没有收到新数据)`
588
+ : slowSource
589
+ ? '下载源速度过低,主动放弃该源'
590
+ : getErrorDescription(error)
591
+
592
+ if (error instanceof AxiosError && !ownAbort) {
469
593
  const sanitized = sanitizeHeaders(this.headers)
470
594
  logger.error(`请求失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`)
471
595
  } else {
472
- logger.error(`下载失败: ${errorDesc}`)
596
+ logger.error(ownAbort ? `下载中断: ${errorDesc}` : `下载失败: ${errorDesc}`)
473
597
  }
474
598
 
475
599
  // 如果是断流错误,自动降速
@@ -487,6 +611,25 @@ export class Downloader {
487
611
  }
488
612
  }
489
613
 
614
+ /**
615
+ * 坏源优先换源。
616
+ *
617
+ * 同一份数据在别的镜像上往往能跑满带宽,而坏源上耗满 maxRetries 次
618
+ * (每次都要再等一个超时)纯属浪费时间;反过来,如果本轮已经传了好几 MB
619
+ * 只是被掐了一下,那就该留在原源续传,别把进度扔掉。
620
+ */
621
+ if ((slowSource || (idleTimeout && attemptBytes < SWITCH_IF_UNDER_BYTES)) && this.backupUrls.length > 0) {
622
+ const next = this.backupUrls.shift() as string
623
+ this.sourceSwitches++
624
+ logger.warn(
625
+ `当前源不可用,换备用下载源 (${this.sourceSwitches},剩余 ${this.backupUrls.length}): ` +
626
+ `${safeHost(this.url)} -> ${safeHost(next)}`
627
+ )
628
+ this.url = next
629
+ await new Promise((resolve) => setTimeout(resolve, 1000))
630
+ return this.download(progressCallback, 0)
631
+ }
632
+
490
633
  const nextDelay = calculateBackoffDelay(retryCount)
491
634
 
492
635
  if (retryCount < this.maxRetries) {
@@ -528,6 +671,9 @@ export class Downloader {
528
671
  if (isThrottling) {
529
672
  logger.warn('建议: 服务器可能有下载速度限制,请尝试在配置中降低 maxSpeed 参数')
530
673
  }
674
+ if (idleTimeout) {
675
+ logger.warn('提示: 这条直链所属的 CDN 节点(常见于 *.mcdn.bilivideo.cn 这类 PCDN)很可能已失效,稍后重试或换个画质通常会换到别的节点')
676
+ }
531
677
  } else {
532
678
  try {
533
679
  fs.unlinkSync(this.filepath)
@@ -541,6 +687,10 @@ export class Downloader {
541
687
  const sanitized = sanitizeHeaders(this.headers)
542
688
  throw new Error(`在 ${this.maxRetries} 次尝试后下载失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`)
543
689
  }
690
+ } finally {
691
+ // 所有出口(成功 / 416 提前返回 / 换源 / 重试 / 抛错)都要把定时器和轮询清干净
692
+ clearTimers()
693
+ if (intervalId) clearInterval(intervalId)
544
694
  }
545
695
  }
546
696
 
@@ -24,6 +24,7 @@ export class Network {
24
24
  private filepath: string
25
25
  private maxRetries: number
26
26
  private throttleConfig?: Partial<ThrottleConfig>
27
+ private backupUrls: string[]
27
28
 
28
29
  /**
29
30
  * 创建网络请求实例
@@ -35,7 +36,7 @@ export class Network {
35
36
  *
36
37
  * @param data - 配置对象
37
38
  */
38
- constructor(data: NetworksConfigType & { throttle?: Partial<ThrottleConfig> }) {
39
+ constructor(data: NetworksConfigType & { throttle?: Partial<ThrottleConfig>; backupUrls?: string[] }) {
39
40
  this.headers = data.headers ? Object.fromEntries(Object.entries(data.headers).map(([key, value]) => [key, String(value)])) : {}
40
41
 
41
42
  // 合并默认头
@@ -52,6 +53,7 @@ export class Network {
52
53
  this.filepath = data.filepath ?? ''
53
54
  this.maxRetries = data.maxRetries ?? 3
54
55
  this.throttleConfig = data.throttle
56
+ this.backupUrls = data.backupUrls ?? []
55
57
 
56
58
  // 创建 axios 实例
57
59
  this.axiosInstance = axios.create({
@@ -120,7 +122,9 @@ export class Network {
120
122
  this.headers,
121
123
  this.timeout,
122
124
  this.maxRetries,
123
- this.throttleConfig
125
+ this.throttleConfig,
126
+ // 拷贝一份:下载器内部会 shift 掉已经试过的地址
127
+ [...this.backupUrls]
124
128
  )
125
129
 
126
130
  return downloader.download(progressCallback, retryCount)
@@ -81,6 +81,52 @@ import { BilibiliDataTypes } from '@/types'
81
81
  let img: ElementTypes[]
82
82
  type videoDownloadUrlList = BilibiliVideoStreamResponse['data']['dash']['video']
83
83
 
84
+ /**
85
+ * 一条 dash 流里能拿来下载的地址(video / audio 都是这个形状)
86
+ */
87
+ type DashStreamUrls = {
88
+ base_url?: string
89
+ baseUrl?: string
90
+ backup_url?: string[]
91
+ backupUrl?: string[]
92
+ }
93
+
94
+ /**
95
+ * 一个地址是不是 PCDN 边缘节点。
96
+ *
97
+ * `xy120x240x109x22xy.mcdn.bilivideo.cn:8082/v1/resource/upgcxcode/...` 这种就是:
98
+ * 域名带 mcdn、带非标准端口、路径 `/v1/resource/`。
99
+ * 它们对本机来说常常是「TCP 连得上、响应头也正常,就是不吐数据」(实测 118 KB / 60 秒),
100
+ * 而同一份数据在 `upos-*` 镜像上能跑满带宽。
101
+ */
102
+ const isPcdnUrl = (url: string) => /\.mcdn\.bilivideo\./i.test(url) || /\.pcdn\./i.test(url) || /\/v1\/resource\//i.test(url)
103
+
104
+ /**
105
+ * 把一条 dash 流的直链整理成**按优先级排序**的候选列表。
106
+ *
107
+ * 排序规则(尽量不改变原来的行为,只在必要时动):
108
+ * 1. `base_url` 不是 PCDN 时保持第一 —— 原来的行为不变;
109
+ * 2. `base_url` 是 PCDN 时,把非 PCDN 的备用地址提到前面;
110
+ * 3. 其余地址按接口给的顺序跟在后面。
111
+ *
112
+ * 下载器遇到「连得上但不吐数据」的源会自动换到下一个(见 Downloader 的坏源看门狗),
113
+ * 所以列表顺序就是换源顺序。
114
+ *
115
+ * @param stream dash 里的 video / audio 流对象
116
+ * @returns 去重且已排序的直链列表(可能为空)
117
+ */
118
+ export const buildDashUrlCandidates = (stream?: DashStreamUrls): string[] => {
119
+ const raw = [stream?.base_url ?? stream?.baseUrl, ...(stream?.backup_url ?? stream?.backupUrl ?? [])]
120
+ const seen = new Set<string>()
121
+ const urls: string[] = []
122
+ for (const url of raw) {
123
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url) || seen.has(url)) continue
124
+ seen.add(url)
125
+ urls.push(url)
126
+ }
127
+ return [...urls.filter((url) => !isPcdnUrl(url)), ...urls.filter(isPcdnUrl)]
128
+ }
129
+
84
130
  /** 评论请求统一使用匿名态,避免账号 Cookie 改变评论热度池结果。 */
85
131
  const bilibiliAnonymousRequestConfig = {
86
132
  headers: {
@@ -1520,10 +1566,9 @@ export class Bilibili extends Base {
1520
1566
  this.danmakuList = danmakuList
1521
1567
  switch (this.islogin) {
1522
1568
  case true: {
1523
- logger.debug(
1524
- '视频 URL:',
1525
- this.Type === 'one_video' ? playUrlData.data?.dash?.video[0].base_url : playUrlData.result.dash.video[0].base_url
1526
- )
1569
+ const videoStream: DashStreamUrls | undefined =
1570
+ this.Type === 'one_video' ? playUrlData.data?.dash?.video[0] : playUrlData.result.dash.video[0]
1571
+ logger.debug('视频 URL:', videoStream?.base_url)
1527
1572
 
1528
1573
  // B站 CDN 需要正确的 Referer
1529
1574
  const downloadHeaders = {
@@ -1531,13 +1576,18 @@ export class Bilibili extends Base {
1531
1576
  Referer: 'https://www.bilibili.com'
1532
1577
  }
1533
1578
 
1534
- const bmp4Raw = await downloadFile(
1535
- this.Type === 'one_video' ? playUrlData.data?.dash?.video[0].base_url : playUrlData.result.dash.video[0].base_url,
1536
- {
1537
- title: `Bil_V_${this.Type === 'one_video' ? infoData && infoData.data.bvid : infoData && infoData.result.season_id}.m4s`,
1538
- headers: downloadHeaders
1539
- }
1540
- )
1579
+ /**
1580
+ * 直链候选:`base_url`(常是 mcdn/PCDN 节点)+ `backup_url` 镜像。
1581
+ * 排在前面的先试,坏源由下载器自动换掉 —— 不然一个死节点就能让整个解析卡死。
1582
+ */
1583
+ const videoUrls = buildDashUrlCandidates(videoStream)
1584
+ if (videoUrls.length === 0) throw new Error('没有拿到视频流直链(playurl 返回里没有 base_url)')
1585
+
1586
+ const bmp4Raw = await downloadFile(videoUrls[0], {
1587
+ title: `Bil_V_${this.Type === 'one_video' ? infoData && infoData.data.bvid : infoData && infoData.result.season_id}.m4s`,
1588
+ headers: downloadHeaders,
1589
+ backupUrls: videoUrls.slice(1)
1590
+ })
1541
1591
 
1542
1592
  // 修复 m4s 文件为标准 MP4
1543
1593
  const videoPath =
@@ -1551,8 +1601,11 @@ export class Bilibili extends Base {
1551
1601
  // 删除原始 m4s 文件
1552
1602
  await Common.removeFile(bmp4Raw.filepath, true)
1553
1603
 
1554
- const audioUrl =
1555
- this.Type === 'one_video' ? playUrlData.data?.dash?.audio?.[0]?.base_url : playUrlData.result.dash.audio?.[0]?.base_url
1604
+ const audioStream: DashStreamUrls | undefined =
1605
+ this.Type === 'one_video' ? playUrlData.data?.dash?.audio?.[0] : playUrlData.result.dash.audio?.[0]
1606
+ /** 音频候选同样带镜像,理由见上面视频那段 */
1607
+ const audioUrls = buildDashUrlCandidates(audioStream)
1608
+ const audioUrl = audioUrls[0]
1556
1609
  logger.debug('音频 URL:', audioUrl)
1557
1610
 
1558
1611
  /** 没有音频流(如纯视频稿件)时为 undefined,此时无从合成,直接发视频流 */
@@ -1560,7 +1613,8 @@ export class Bilibili extends Base {
1560
1613
  if (audioUrl) {
1561
1614
  const bmp3Raw = await downloadFile(audioUrl, {
1562
1615
  title: `Bil_A_${this.Type === 'one_video' ? infoData && infoData.data.bvid : infoData && infoData.result.season_id}.m4s`,
1563
- headers: downloadHeaders
1616
+ headers: downloadHeaders,
1617
+ backupUrls: audioUrls.slice(1)
1564
1618
  })
1565
1619
 
1566
1620
  // 修复音频 m4s 文件为 m4a(AAC 音频不能直接转为 MP3 容器)
@@ -34,7 +34,7 @@ import {
34
34
  } from '@/module'
35
35
  import { bilibiliFetcher } from '@/module/utils/amagiClient'
36
36
  import { Config } from '@/module/utils/Config'
37
- import { bilibiliProcessVideos, generateDecorationCard, getvideosize, parseAdditionalCard, TimeFormatter } from '@/platform/bilibili'
37
+ import { bilibiliProcessVideos, buildDashUrlCandidates, generateDecorationCard, getvideosize, parseAdditionalCard, TimeFormatter } from '@/platform/bilibili'
38
38
  import {
39
39
  buildBilibiliArticleRichText,
40
40
  buildBilibiliDynamicRichText,
@@ -903,14 +903,20 @@ export class Bilibilipush extends Base {
903
903
  const infoData = await this.amagi.bilibili.fetcher.fetchVideoInfo({
904
904
  bvid: data[dynamicId].Dynamic_Data.modules.module_dynamic.major.archive.bvid
905
905
  })
906
- const mp4File = await downloadFile(playUrlData.data?.data?.dash?.video[0].base_url, {
906
+ /** 直链候选:base_url(可能是 mcdn/PCDN 节点)+ backup_url 镜像,坏源由下载器自动换 */
907
+ const videoUrls = buildDashUrlCandidates(playUrlData.data?.data?.dash?.video[0])
908
+ const audioUrls = buildDashUrlCandidates(playUrlData.data?.data?.dash?.audio?.[0])
909
+ if (videoUrls.length === 0) break
910
+ const mp4File = await downloadFile(videoUrls[0], {
907
911
  title: `Bil_V_${infoData.data.data.bvid}.mp4`,
908
- headers: bilibiliBaseHeaders
912
+ headers: bilibiliBaseHeaders,
913
+ backupUrls: videoUrls.slice(1)
909
914
  })
910
- const mp3File = audioUrl
911
- ? await downloadFile(audioUrl, {
915
+ const mp3File = audioUrls.length > 0
916
+ ? await downloadFile(audioUrls[0], {
912
917
  title: `Bil_A_${infoData.data.data.bvid}.mp3`,
913
- headers: bilibiliBaseHeaders
918
+ headers: bilibiliBaseHeaders,
919
+ backupUrls: audioUrls.slice(1)
914
920
  })
915
921
  : undefined
916
922
 
@@ -58,4 +58,9 @@ export interface NetworksConfigType {
58
58
  * 当下载速度过快导致连接被重置时,会自动降速重试
59
59
  */
60
60
  throttle?: ThrottleConfig
61
+ /**
62
+ * 备用直链:当前直链被判定为坏源(连得上但不吐数据)时,下载器按顺序换过去。
63
+ * B站 dash 的 `backup_url` 就属于这种镜像地址。
64
+ */
65
+ backupUrls?: string[]
61
66
  }