mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
fix: 初始化脚本小宽带下载超时
This commit is contained in:
+238
-95
@@ -13,11 +13,13 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import compressing from 'compressing';
|
||||
import { logger } from '../src/utils/logger.js';
|
||||
import { select, input } from '@inquirer/prompts';
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.join(__dirname, '..');
|
||||
@@ -144,135 +146,276 @@ function validateABI(abi) {
|
||||
* 下载文件(带进度,流式,支持重试)
|
||||
* @param {string} url - 下载地址
|
||||
* @param {string} destPath - 目标文件路径
|
||||
* @param {string|null} proxyUrl - 代理 URL
|
||||
* @param {string|null} proxyUrl - 代理 URL(支持 http:// 和 socks5://)
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
*/
|
||||
async function downloadFile(url, destPath, proxyUrl = null, maxRetries = 3) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
// 如果是 SOCKS5 代理,先转换为本地 HTTP 代理
|
||||
let effectiveProxyUrl = proxyUrl;
|
||||
let anonymizedProxy = null;
|
||||
|
||||
if (proxyUrl && proxyUrl.startsWith('socks5://')) {
|
||||
try {
|
||||
if (attempt > 1) {
|
||||
logger.info('初始化', `第 ${attempt}/${maxRetries} 次尝试下载...`);
|
||||
// 删除之前失败的文件
|
||||
try {
|
||||
if (fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
} catch (e) { }
|
||||
} else {
|
||||
logger.info('初始化', `开始下载: ${url}`);
|
||||
}
|
||||
|
||||
await downloadFileOnce(url, destPath, proxyUrl);
|
||||
return destPath;
|
||||
logger.info('初始化', `检测到 SOCKS5 代理,正在转换为 HTTP 代理...`);
|
||||
anonymizedProxy = await anonymizeProxy(proxyUrl);
|
||||
effectiveProxyUrl = anonymizedProxy;
|
||||
logger.info('初始化', `SOCKS5 代理已转换: ${anonymizedProxy}`);
|
||||
} catch (error) {
|
||||
logger.error('初始化', `下载失败 (尝试 ${attempt}/${maxRetries}): ${error.message}`);
|
||||
logger.error('初始化', `SOCKS5 代理转换失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 1) {
|
||||
logger.info('初始化', `第 ${attempt}/${maxRetries} 次尝试下载...`);
|
||||
// 删除之前失败的文件
|
||||
try {
|
||||
if (fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
} catch (e) { }
|
||||
} else {
|
||||
logger.info('初始化', `开始下载: ${url}`);
|
||||
}
|
||||
|
||||
await downloadFileOnce(url, destPath, effectiveProxyUrl);
|
||||
return destPath;
|
||||
} catch (error) {
|
||||
logger.error('初始化', `下载失败 (尝试 ${attempt}/${maxRetries}): ${error.message}`);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 等待后重试(递增延迟)
|
||||
const delay = attempt * 2000;
|
||||
logger.info('初始化', `${delay / 1000} 秒后重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
// 等待后重试(递增延迟)
|
||||
const delay = attempt * 2000;
|
||||
logger.info('初始化', `${delay / 1000} 秒后重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
} finally {
|
||||
// 清理 SOCKS5 代理资源
|
||||
if (anonymizedProxy) {
|
||||
try {
|
||||
await closeAnonymizedProxy(anonymizedProxy, true);
|
||||
logger.debug('初始化', '已关闭临时代理桥接');
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次下载尝试(内部函数)
|
||||
* 使用 Node.js 原生 http/https 模块,手动管理超时
|
||||
* 只有在指定时间内没有任何数据传输才会触发超时
|
||||
*/
|
||||
async function downloadFileOnce(url, destPath, proxyUrl = null) {
|
||||
const IDLE_TIMEOUT = 180000; // 3 分钟无数据传输才超时
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
http2: false,
|
||||
timeout: {
|
||||
request: 900000, // 总请求超时 15 分钟
|
||||
read: 180000 // 两次数据接收间隔超时 3 分钟
|
||||
},
|
||||
retry: {
|
||||
limit: 0
|
||||
},
|
||||
headerGeneratorOptions: {
|
||||
browsers: [],
|
||||
devices: [],
|
||||
locales: [],
|
||||
operatingSystems: []
|
||||
},
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
|
||||
let requestOptions = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'user-agent': 'Wget/1.21.4 (linux-gnu)',
|
||||
'accept': '*/*',
|
||||
'accept-encoding': 'identity',
|
||||
'connection': 'keep-alive'
|
||||
'User-Agent': 'Wget/1.21.4 (linux-gnu)',
|
||||
'Accept': '*/*',
|
||||
'Accept-Encoding': 'identity',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
};
|
||||
|
||||
if (proxyUrl) {
|
||||
options.proxyUrl = proxyUrl;
|
||||
// 如果有 HTTP 代理(注意:这里只支持 HTTP 代理,SOCKS 需要额外处理)
|
||||
let httpModule = isHttps ? https : http;
|
||||
if (proxyUrl && proxyUrl.startsWith('http')) {
|
||||
const proxyUrlObj = new URL(proxyUrl);
|
||||
// 使用 CONNECT 隧道代理
|
||||
requestOptions = {
|
||||
hostname: proxyUrlObj.hostname,
|
||||
port: proxyUrlObj.port || 80,
|
||||
method: 'CONNECT',
|
||||
path: `${urlObj.hostname}:${urlObj.port || (isHttps ? 443 : 80)}`,
|
||||
headers: {
|
||||
'Host': `${urlObj.hostname}:${urlObj.port || (isHttps ? 443 : 80)}`
|
||||
}
|
||||
};
|
||||
if (proxyUrlObj.username) {
|
||||
const auth = Buffer.from(`${proxyUrlObj.username}:${proxyUrlObj.password || ''}`).toString('base64');
|
||||
requestOptions.headers['Proxy-Authorization'] = `Basic ${auth}`;
|
||||
}
|
||||
httpModule = http; // 代理连接始终是 HTTP
|
||||
}
|
||||
|
||||
const downloadStream = gotScraping.stream(url, options);
|
||||
const fileStream = fs.createWriteStream(destPath);
|
||||
|
||||
let downloadedSize = 0;
|
||||
let totalSize = 0;
|
||||
let lastLogTime = Date.now();
|
||||
let finished = false;
|
||||
let idleTimer = null;
|
||||
let req = null;
|
||||
|
||||
downloadStream.on('response', (response) => {
|
||||
totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
if (totalSize > 0) {
|
||||
logger.info('初始化', `文件大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (!finished) {
|
||||
const error = new Error(`下载超时: ${IDLE_TIMEOUT / 1000} 秒内没有收到任何数据`);
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
}, IDLE_TIMEOUT);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
finished = true;
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (req) {
|
||||
try { req.destroy(); } catch (e) { }
|
||||
}
|
||||
});
|
||||
|
||||
downloadStream.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
|
||||
// 每秒更新一次进度
|
||||
const now = Date.now();
|
||||
if (totalSize > 0 && now - lastLogTime > 1000) {
|
||||
const percent = ((downloadedSize / totalSize) * 100).toFixed(1);
|
||||
const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
|
||||
logger.info('初始化', `下载进度: ${percent}% (${downloadedMB}MB / ${totalMB}MB)`);
|
||||
lastLogTime = now;
|
||||
}
|
||||
});
|
||||
|
||||
downloadStream.on('error', (error) => {
|
||||
fileStream.close();
|
||||
try {
|
||||
fs.unlinkSync(destPath);
|
||||
} catch (e) { }
|
||||
reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
fileStream.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
const handleResponse = (res) => {
|
||||
resetIdleTimer();
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
const finalSize = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
|
||||
// 验证下载完整性
|
||||
if (totalSize > 0 && downloadedSize !== totalSize) {
|
||||
const errorMsg = `下载不完整: 预期 ${(totalSize / 1024 / 1024).toFixed(2)} MB, 实际 ${finalSize} MB`;
|
||||
logger.error('初始化', errorMsg);
|
||||
|
||||
// 清理损坏的文件
|
||||
try {
|
||||
fs.unlinkSync(destPath);
|
||||
} catch (e) { }
|
||||
|
||||
reject(new Error(errorMsg));
|
||||
// 处理重定向
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
cleanup();
|
||||
try { fs.unlinkSync(destPath); } catch (e) { }
|
||||
logger.info('初始化', `重定向到: ${res.headers.location}`);
|
||||
// 递归调用处理重定向
|
||||
downloadFileOnce(res.headers.location, destPath, proxyUrl)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('初始化', `下载完成: ${finalSize} MB`);
|
||||
resolve(destPath);
|
||||
});
|
||||
if (res.statusCode !== 200) {
|
||||
cleanup();
|
||||
try { fs.unlinkSync(destPath); } catch (e) { }
|
||||
reject(new Error(`HTTP 错误: ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
downloadStream.pipe(fileStream);
|
||||
totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||
if (totalSize > 0) {
|
||||
logger.info('初始化', `文件大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
}
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
resetIdleTimer();
|
||||
downloadedSize += chunk.length;
|
||||
|
||||
const now = Date.now();
|
||||
if (totalSize > 0 && now - lastLogTime > 100) { // 100ms 更新一次,更流畅
|
||||
const percent = ((downloadedSize / totalSize) * 100).toFixed(1);
|
||||
const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
|
||||
// 使用 \r 回到行首,实现单行刷新
|
||||
process.stdout.write(`\r下载进度: ${percent}% (${downloadedMB}MB / ${totalMB}MB) `);
|
||||
lastLogTime = now;
|
||||
}
|
||||
});
|
||||
|
||||
res.on('error', (error) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
try { fs.unlinkSync(destPath); } catch (e) { }
|
||||
reject(error);
|
||||
});
|
||||
|
||||
res.pipe(fileStream);
|
||||
|
||||
fileStream.on('error', (error) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
|
||||
const finalSize = (downloadedSize / 1024 / 1024).toFixed(2);
|
||||
|
||||
if (totalSize > 0 && downloadedSize !== totalSize) {
|
||||
process.stdout.write('\n'); // 换行,避免与进度条混在一起
|
||||
const errorMsg = `下载不完整: 预期 ${(totalSize / 1024 / 1024).toFixed(2)} MB, 实际 ${finalSize} MB`;
|
||||
logger.error('初始化', errorMsg);
|
||||
try { fs.unlinkSync(destPath); } catch (e) { }
|
||||
reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('\n'); // 换行,结束进度条
|
||||
logger.info('初始化', `下载完成: ${finalSize} MB`);
|
||||
resolve(destPath);
|
||||
});
|
||||
};
|
||||
|
||||
resetIdleTimer();
|
||||
|
||||
// 如果使用代理,先建立隧道
|
||||
if (proxyUrl && proxyUrl.startsWith('http')) {
|
||||
req = http.request(requestOptions);
|
||||
req.on('connect', (res, socket) => {
|
||||
if (res.statusCode !== 200) {
|
||||
cleanup();
|
||||
reject(new Error(`代理连接失败: ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 通过隧道发起真正的 HTTPS 请求
|
||||
const tunnelOptions = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Wget/1.21.4 (linux-gnu)',
|
||||
'Accept': '*/*',
|
||||
'Accept-Encoding': 'identity',
|
||||
'Connection': 'keep-alive',
|
||||
'Host': urlObj.host
|
||||
},
|
||||
socket: socket,
|
||||
agent: false
|
||||
};
|
||||
|
||||
const tunnelReq = (isHttps ? https : http).request(tunnelOptions, handleResponse);
|
||||
tunnelReq.on('error', (error) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
try { fs.unlinkSync(destPath); } catch (e) { }
|
||||
reject(error);
|
||||
});
|
||||
tunnelReq.end();
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
try { fs.unlinkSync(destPath); } catch (e) { }
|
||||
reject(error);
|
||||
});
|
||||
req.end();
|
||||
} else {
|
||||
// 直连模式
|
||||
req = httpModule.request(requestOptions, handleResponse);
|
||||
req.on('error', (error) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
try { fs.unlinkSync(destPath); } catch (e) { }
|
||||
reject(error);
|
||||
});
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user