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 +34 -0
- package/lib/build-metadata.json +3 -3
- package/lib/karin/module/utils/Base.js +30 -8
- package/lib/karin/module/utils/Network/Downloader.js +159 -13
- package/lib/karin/module/utils/Network/Network.js +5 -1
- package/lib/karin/platform/bilibili/bilibili.js +55 -6
- package/lib/karin/platform/bilibili/push.js +12 -5
- package/package.json +1 -1
- package/scripts/smoke-download-source-fallback.cjs +166 -0
- package/src/karin/module/utils/Base.ts +39 -9
- package/src/karin/module/utils/Network/Downloader.ts +164 -14
- package/src/karin/module/utils/Network/Network.ts +6 -2
- package/src/karin/platform/bilibili/bilibili.ts +68 -14
- package/src/karin/platform/bilibili/push.ts +12 -6
- package/src/karin/types/netrorks/NetworksConfigType.ts +5 -0
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站互动视频能玩了**(能一路选剧情、还能出剧情图)、**合并转发里能单独控制流程图**,
|
package/lib/build-metadata.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.
|
|
3
|
-
"buildTime": "2026-09-
|
|
4
|
-
"buildTimestamp":
|
|
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
|
|
472
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
+
clearTimers();
|
|
392
499
|
if (intervalId)
|
|
393
500
|
clearInterval(intervalId);
|
|
394
501
|
// 失败/中断也要清掉,不然「查询下载进度」会一直卡在旧任务上
|
|
395
502
|
clearDownloadProgress(this.filepath);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
765
|
-
? await (0, module_1.downloadFile)(
|
|
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
|
@@ -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
|
|
557
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
+
clearTimers()
|
|
460
570
|
if (intervalId) clearInterval(intervalId)
|
|
461
571
|
// 失败/中断也要清掉,不然「查询下载进度」会一直卡在旧任务上
|
|
462
572
|
clearDownloadProgress(this.filepath)
|
|
463
573
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
1524
|
-
'
|
|
1525
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
|
1555
|
-
this.Type === 'one_video' ? playUrlData.data?.dash?.audio?.[0]
|
|
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
|
-
|
|
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 =
|
|
911
|
-
? await downloadFile(
|
|
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
|
|