723 lines
25 KiB
JavaScript
723 lines
25 KiB
JavaScript
/**
|
||
* @file bit_api.js
|
||
* @brief 比特浏览器 Local API 封装
|
||
* @details 基于官方文档 https://doc2.bitbrowser.cn/
|
||
* 所有接口使用 POST 方法,JSON 格式
|
||
*/
|
||
|
||
const axios = require('axios');
|
||
const { getSetting } = require('./database');
|
||
|
||
// ==================== 信号量(限制并发打开浏览器请求) ====================
|
||
|
||
/**
|
||
* 简单信号量,用于限制同时进行的 openBrowser API 请求数
|
||
* 避免比特浏览器因请求过于集中而返回 429
|
||
*/
|
||
class Semaphore {
|
||
constructor(max) {
|
||
this.max = max;
|
||
this.current = 0;
|
||
this.queue = [];
|
||
}
|
||
acquire() {
|
||
return new Promise(resolve => {
|
||
if (this.current < this.max) {
|
||
this.current++;
|
||
resolve();
|
||
} else {
|
||
this.queue.push(resolve);
|
||
}
|
||
});
|
||
}
|
||
release() {
|
||
this.current--;
|
||
if (this.queue.length > 0 && this.current < this.max) {
|
||
this.current++;
|
||
this.queue.shift()();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 动态调整并发上限,并立即释放新增的可用名额。
|
||
* @param {number} max - 新的并发上限
|
||
* @returns {number} 标准化后的并发上限
|
||
*/
|
||
setMax(max) {
|
||
this.max = Math.max(1, Math.min(100, parseInt(max, 10) || 1));
|
||
while (this.current < this.max && this.queue.length > 0) {
|
||
this.current++;
|
||
this.queue.shift()();
|
||
}
|
||
return this.max;
|
||
}
|
||
|
||
/** 返回当前信号量状态,供诊断和测试使用。 */
|
||
getStats() {
|
||
return { max: this.max, current: this.current, queued: this.queue.length };
|
||
}
|
||
}
|
||
|
||
// 初始值仅用于任务配置前;每个 Worker 启动后会同步为用户设置的任务并发数。
|
||
const _openBrowserSem = new Semaphore(2);
|
||
|
||
/**
|
||
* 让比特浏览器打开请求并发跟随任务并发。
|
||
* 每秒启动速度仍由 browser_lifecycle 的 openThrottle 独立控制。
|
||
* @param {number} concurrency - 当前任务并发数
|
||
* @returns {number} 实际采用的打开请求并发数
|
||
*/
|
||
function setOpenBrowserConcurrency(concurrency) {
|
||
return _openBrowserSem.setMax(concurrency);
|
||
}
|
||
|
||
// ==================== API 类 ====================
|
||
|
||
class BitBrowserAPI {
|
||
/**
|
||
* 构造函数
|
||
* @param {string} [baseUrl='http://127.0.0.1:54345'] - 比特浏览器 API 基础 URL
|
||
*/
|
||
constructor(baseUrl = 'http://127.0.0.1:54345') {
|
||
this.baseUrl = baseUrl;
|
||
this.headers = { 'Content-Type': 'application/json' };
|
||
}
|
||
|
||
/**
|
||
* 发送 POST 请求到比特浏览器 API
|
||
* @param {string} endpoint - API 路径(如 '/browser/open')
|
||
* @param {object} [data={}] - 请求体数据
|
||
* @param {number} [timeout=30000] - 请求超时时间(毫秒)
|
||
* @returns {Promise<object>} API 响应数据
|
||
*/
|
||
async request(endpoint, data = {}, timeout = 30000) {
|
||
const url = `${this.baseUrl}${endpoint}`;
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
const response = await axios.post(url, data, {
|
||
headers: this.headers,
|
||
timeout,
|
||
});
|
||
return response.data;
|
||
} catch (e) {
|
||
if (e.response && e.response.status === 429 && attempt < 2) {
|
||
const delay = (attempt + 1) * 1000; // 1s, 2s
|
||
await new Promise(r => setTimeout(r, delay));
|
||
continue;
|
||
}
|
||
if (e.response) {
|
||
return { success: false, msg: `HTTP ${e.response.status}: ${e.response.statusText}` };
|
||
}
|
||
return { success: false, msg: `请求失败: ${e.message}` };
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检测比特浏览器是否在线
|
||
* @returns {Promise<object>} 健康检查响应
|
||
*/
|
||
async healthCheck() {
|
||
return this.request('/health');
|
||
}
|
||
|
||
/**
|
||
* 创建新的浏览器窗口
|
||
* @param {object} [options={}] - 创建选项
|
||
* @param {string} [options.name='new browser'] - 窗口名称
|
||
* @param {string} [options.groupId] - 分组 ID
|
||
* @param {object} [options.browserFingerprint] - 指纹配置
|
||
* @param {number} [options.proxyMethod=2] - 代理方式
|
||
* @param {string} [options.proxyType='noproxy'] - 代理类型
|
||
* @returns {Promise<object>} 创建结果,data.id 为新窗口 ID
|
||
*/
|
||
async createBrowser({ name = 'new browser', groupId, browserFingerprint, proxyMethod = 2, proxyType = 'noproxy', ...rest } = {}) {
|
||
const data = {
|
||
name,
|
||
proxyMethod,
|
||
proxyType,
|
||
browserFingerPrint: browserFingerprint || {},
|
||
...rest,
|
||
};
|
||
if (groupId) data.groupId = groupId;
|
||
return this.request('/browser/update', data);
|
||
}
|
||
|
||
/**
|
||
* 打开指定浏览器窗口
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @param {object} [options={}] - 打开选项
|
||
* @param {string[]} [options.args] - 启动参数
|
||
* @param {boolean} [options.queue=true] - 是否排队等待
|
||
* @param {boolean} [options.ignoreDefaultUrls] - 是否忽略默认标签页
|
||
* @param {string} [options.newPageUrl] - 打开后跳转到的 URL
|
||
* @returns {Promise<object>} 打开结果,data.ws 为 WebSocket 端点
|
||
*/
|
||
async openBrowser(browserId, { args, queue = true, ignoreDefaultUrls, newPageUrl } = {}) {
|
||
const data = { id: browserId, queue };
|
||
if (args) data.args = args;
|
||
if (ignoreDefaultUrls) data.ignoreDefaultUrls = ignoreDefaultUrls;
|
||
if (newPageUrl) data.newPageUrl = newPageUrl;
|
||
return this.request('/browser/open', data, 60000);
|
||
}
|
||
|
||
/**
|
||
* 关闭指定浏览器窗口
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @returns {Promise<object>} 关闭结果
|
||
*/
|
||
async closeBrowser(browserId) {
|
||
return this.request('/browser/close', { id: browserId });
|
||
}
|
||
|
||
/**
|
||
* 删除指定浏览器窗口(含数据)
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @returns {Promise<object>} 删除结果
|
||
*/
|
||
async deleteBrowser(browserId) {
|
||
return this.request('/browser/delete', { id: browserId });
|
||
}
|
||
|
||
/**
|
||
* 批量删除浏览器窗口(最多 100 个)
|
||
* @param {string[]} browserIds - 浏览器窗口 ID 数组
|
||
* @returns {Promise<object>} 删除结果
|
||
*/
|
||
async deleteBrowsers(browserIds) {
|
||
return this.request('/browser/delete/ids', { ids: browserIds.slice(0, 100) });
|
||
}
|
||
|
||
/**
|
||
* 获取浏览器窗口详情
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @returns {Promise<object>} 窗口详情
|
||
*/
|
||
async getBrowserDetail(browserId) {
|
||
return this.request('/browser/detail', { id: browserId });
|
||
}
|
||
|
||
/**
|
||
* 分页列举浏览器窗口
|
||
* @param {object} [options={}] - 查询选项
|
||
* @param {number} [options.page=0] - 页码
|
||
* @param {number} [options.pageSize=10] - 每页数量(最大 100)
|
||
* @param {string} [options.groupId] - 按分组过滤
|
||
* @param {string} [options.name] - 按名称过滤
|
||
* @param {string} [options.sort='desc'] - 排序方向
|
||
* @returns {Promise<object>} 窗口列表
|
||
*/
|
||
async listBrowsers({ page = 0, pageSize = 10, groupId, name, sort = 'desc' } = {}) {
|
||
const data = { page, pageSize: Math.min(pageSize, 100), sort };
|
||
if (groupId) data.groupId = groupId;
|
||
if (name) data.name = name;
|
||
return this.request('/browser/list', data);
|
||
}
|
||
|
||
/**
|
||
* 批量更新浏览器代理设置
|
||
* @param {string[]} browserIds - 浏览器窗口 ID 数组
|
||
* @param {object} [options={}] - 代理配置
|
||
* @param {number} [options.proxyMethod=2] - 代理方式
|
||
* @param {string} [options.proxyType='noproxy'] - 代理类型
|
||
* @returns {Promise<object>} 更新结果
|
||
*/
|
||
async updateBrowserProxy(browserIds, { proxyMethod = 2, proxyType = 'noproxy', ...rest } = {}) {
|
||
return this.request('/browser/proxy/update', {
|
||
ids: browserIds,
|
||
proxyMethod,
|
||
proxyType,
|
||
...rest,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 查询浏览器进程是否存活
|
||
* @param {string[]} browserIds - 浏览器窗口 ID 数组
|
||
* @returns {Promise<object>} 进程状态映射,data[id] 为进程 PID 或 0
|
||
*/
|
||
async getAliveBrowserPids(browserIds) {
|
||
return this.request('/browser/pids/alive', { ids: browserIds });
|
||
}
|
||
}
|
||
|
||
// ==================== 全局实例(单例) ====================
|
||
|
||
let _apiInstance = null;
|
||
|
||
/**
|
||
* 从数据库设置中读取比特浏览器端口号
|
||
* @returns {number} 端口号,默认 54345
|
||
*/
|
||
function getBitBrowserPort() {
|
||
try {
|
||
const portStr = getSetting('bit_browser_port', '54345');
|
||
return parseInt(portStr, 10) || 54345;
|
||
} catch (e) {
|
||
return 54345;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 BitBrowserAPI 单例实例
|
||
* @param {boolean} [forceNew=false] - 是否强制创建新实例(用于重读端口配置)
|
||
* @returns {BitBrowserAPI} API 实例
|
||
*/
|
||
function getApi(forceNew = false) {
|
||
if (!_apiInstance || forceNew) {
|
||
const port = getBitBrowserPort();
|
||
_apiInstance = new BitBrowserAPI(`http://127.0.0.1:${port}`);
|
||
}
|
||
return _apiInstance;
|
||
}
|
||
|
||
/**
|
||
* 重置 API 单例,下次调用 getApi 时重新创建
|
||
*/
|
||
function resetApi() {
|
||
_apiInstance = null;
|
||
}
|
||
|
||
// ==================== 代理设置 ====================
|
||
|
||
/**
|
||
* 在打开浏览器前根据账号配置更新代理设置
|
||
* 若浏览器已在运行则跳过代理更新
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @param {string} [email=''] - 账号邮箱,用于从数据库读取代理配置
|
||
* @returns {Promise<{success: boolean, msg?: string}>} 操作结果
|
||
*/
|
||
async function applyProxyBeforeOpen(browserId, email = '') {
|
||
try {
|
||
if (!email) return { success: true };
|
||
const { getAccount } = require('./database');
|
||
const account = getAccount(email);
|
||
if (!account) {
|
||
console.log(`[Proxy] 未找到账号记录,跳过代理设置: ${email}`);
|
||
return { success: true };
|
||
}
|
||
const proxyIp = (account.proxy_ip || '').trim();
|
||
console.log(`[Proxy] 准备设置代理: email=${email}, proxy_ip='${proxyIp}'`);
|
||
const api = getApi();
|
||
|
||
// 检查浏览器是否已在运行
|
||
const pidsResult = await api.getAliveBrowserPids([browserId]);
|
||
if (pidsResult.success) {
|
||
const pidsData = pidsResult.data || {};
|
||
if (pidsData[browserId]) {
|
||
console.log(`[Proxy] 浏览器已在运行中,跳过代理更新: ${browserId}`);
|
||
return { success: true };
|
||
}
|
||
}
|
||
|
||
if (proxyIp) {
|
||
const lower = proxyIp.toLowerCase();
|
||
if (['noproxy', 'direct', 'none'].includes(lower)) {
|
||
const result = await api.updateBrowserProxy([browserId], { proxyMethod: 2, proxyType: 'noproxy' });
|
||
if (!result.success) return { success: false, msg: result.msg || '代理设置失败' };
|
||
console.log(`[Proxy] 已设置为直连: ${browserId}`);
|
||
return { success: true };
|
||
}
|
||
|
||
const parts = proxyIp.split(':');
|
||
if (parts.length < 2) {
|
||
console.log(`[Proxy] 代理格式错误: ${proxyIp}`);
|
||
return { success: false, msg: `代理格式错误: ${proxyIp}` };
|
||
}
|
||
|
||
const proxyConfig = {
|
||
proxyMethod: 2,
|
||
proxyType: 'socks5',
|
||
host: parts[0].trim(),
|
||
port: parts[1].trim(),
|
||
proxyUserName: parts[2] ? parts[2].trim() : '',
|
||
proxyPassword: parts[3] ? parts[3].trim() : '',
|
||
};
|
||
console.log(`[Proxy] 代理配置: host=${proxyConfig.host}, port=${proxyConfig.port}`);
|
||
const result = await api.updateBrowserProxy([browserId], proxyConfig);
|
||
if (!result.success) {
|
||
const msg = result.msg || '';
|
||
if (msg.includes('操作失败')) {
|
||
console.log(`[Proxy] 代理更新被拒绝(浏览器可能已运行),跳过: ${msg}`);
|
||
return { success: true };
|
||
}
|
||
return { success: false, msg: msg || '代理设置失败' };
|
||
}
|
||
console.log(`[Proxy] 设置代理成功: ${browserId}`);
|
||
} else {
|
||
console.log(`[Proxy] 未配置代理,跳过设置: ${browserId}`);
|
||
}
|
||
return { success: true };
|
||
} catch (e) {
|
||
console.log(`[Proxy] 设置代理异常: ${e.message}`);
|
||
return { success: false, msg: e.message };
|
||
}
|
||
}
|
||
|
||
// ==================== 便捷函数 ====================
|
||
|
||
/**
|
||
* 打开浏览器(含代理预设置)
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @param {object} [options={}] - 打开选项
|
||
* @param {string[]} [options.args] - 启动参数
|
||
* @param {boolean} [options.queue=true] - 是否排队
|
||
* @param {string} [options.email=''] - 账号邮箱,用于设置代理
|
||
* @param {string} [options.newPageUrl] - 打开后跳转 URL
|
||
* @returns {Promise<object>} 打开结果,data.ws 为 WebSocket 端点
|
||
*/
|
||
async function openBrowser(browserId, { args, queue = true, email = '', newPageUrl } = {}) {
|
||
const proxyResult = await applyProxyBeforeOpen(browserId, email);
|
||
if (!proxyResult.success) return proxyResult;
|
||
return getApi().openBrowser(browserId, { args, queue, newPageUrl });
|
||
}
|
||
|
||
/**
|
||
* 带重试机制的浏览器打开函数
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @param {object} [options={}] - 选项
|
||
* @param {string} [options.email=''] - 账号邮箱
|
||
* @param {number} [options.maxRetries=3] - 最大重试次数
|
||
* @param {number} [options.retryDelay=2000] - 重试间隔(毫秒)
|
||
* @param {Function} [options.logCallback] - 日志回调函数,接收一个字符串参数
|
||
* @returns {Promise<object>} 打开结果,data.ws 为 WebSocket 端点
|
||
*/
|
||
async function openBrowserWithRetry(browserId, { email = '', maxRetries = 3, retryDelay = 2000, logCallback, ...opts } = {}) {
|
||
const log = (msg) => { if (logCallback) logCallback(msg); };
|
||
let lastResult = { success: false, msg: '未知错误' };
|
||
const RATE_LIMIT_RE = /429|请求频繁|rate.?limit|too.?many/i;
|
||
|
||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||
// 重试前等待:429 用更长的退避,其他错误用 retryDelay
|
||
if (attempt > 0) {
|
||
const isRateLimit = RATE_LIMIT_RE.test(lastResult.msg || '');
|
||
const delay = isRateLimit ? attempt * 5000 : retryDelay;
|
||
log(`打开失败: ${lastResult.msg || ''},${delay / 1000}秒后重试...`);
|
||
await new Promise(r => setTimeout(r, delay));
|
||
}
|
||
|
||
log(`尝试打开浏览器 (第${attempt + 1}/${maxRetries}次)`);
|
||
|
||
// 通过信号量排队,限制同时发往比特浏览器的 open 请求数
|
||
await _openBrowserSem.acquire();
|
||
try {
|
||
const result = await openBrowser(browserId, { email, ...opts });
|
||
if (result.success) {
|
||
const data = result.data;
|
||
if (data && data.ws) return result;
|
||
lastResult = { success: false, msg: `API返回成功但ws端点为空: ${JSON.stringify(data)}` };
|
||
} else {
|
||
lastResult = result;
|
||
}
|
||
} catch (e) {
|
||
lastResult = { success: false, msg: e.message };
|
||
} finally {
|
||
_openBrowserSem.release();
|
||
}
|
||
}
|
||
|
||
log(`浏览器打开失败(已重试${maxRetries}次): ${lastResult.msg || ''}`);
|
||
return lastResult;
|
||
}
|
||
|
||
/**
|
||
* 关闭指定浏览器窗口
|
||
* @param {string} browserId - 浏览器窗口 ID
|
||
* @returns {Promise<object>} 关闭结果
|
||
*/
|
||
async function closeBrowser(browserId) {
|
||
return getApi().closeBrowser(browserId);
|
||
}
|
||
|
||
/**
|
||
* 获取所有浏览器窗口的简单列表
|
||
* @param {object} [options={}] - 查询选项
|
||
* @param {number} [options.page=0] - 页码
|
||
* @param {number} [options.maxItems=10000] - 最多读取的窗口总数
|
||
* @param {number} [options.requestPageSize=100] - 每次向比特API请求的数量(API上限100)
|
||
* @param {number} [options.pageSize] - 旧参数,未传 maxItems 时作为总数上限兼容
|
||
* @returns {Promise<object[]>} 浏览器窗口对象数组
|
||
*/
|
||
async function collectBrowserPages(api, options = {}) {
|
||
const { page = 0 } = options || {};
|
||
const startPage = Math.max(0, parseInt(page, 10) || 0);
|
||
const legacyLimit = options && options.pageSize !== undefined ? options.pageSize : null;
|
||
const maxItems = Math.max(1, parseInt(
|
||
options && options.maxItems !== undefined ? options.maxItems : (legacyLimit || 10000),
|
||
10,
|
||
) || 10000);
|
||
const pageRequestSize = Math.max(1, Math.min(100, parseInt(
|
||
options && options.requestPageSize !== undefined ? options.requestPageSize : 100,
|
||
10,
|
||
) || 100));
|
||
const browsers = [];
|
||
const seenIds = new Set();
|
||
let currentPage = startPage;
|
||
let totalNum = null;
|
||
const maxPages = Math.max(1, Math.ceil(maxItems / pageRequestSize) + 1);
|
||
|
||
while (browsers.length < maxItems && currentPage < startPage + maxPages) {
|
||
const requestSize = Math.min(pageRequestSize, maxItems - browsers.length);
|
||
const response = await api.listBrowsers({ page: currentPage, pageSize: requestSize });
|
||
if (!response || !response.success) break;
|
||
|
||
const data = response.data;
|
||
const list = Array.isArray(data) ? data : (data && Array.isArray(data.list) ? data.list : []);
|
||
if (data && !Array.isArray(data) && Number.isFinite(Number(data.totalNum))) {
|
||
totalNum = Number(data.totalNum);
|
||
}
|
||
if (list.length === 0) break;
|
||
|
||
const countBeforePage = browsers.length;
|
||
for (const browser of list) {
|
||
const id = String((browser && (browser.id || browser.browserId || browser.browser_id)) || '');
|
||
if (id && seenIds.has(id)) continue;
|
||
if (id) seenIds.add(id);
|
||
// 下游统一按 id 建立映射,兼容部分比特版本返回 browserId/browser_id。
|
||
browsers.push(id && browser.id !== id ? { ...browser, id } : browser);
|
||
if (browsers.length >= maxItems) break;
|
||
}
|
||
|
||
if (totalNum !== null && browsers.length >= totalNum) break;
|
||
if (browsers.length === countBeforePage) break;
|
||
if (list.length < requestSize) break;
|
||
currentPage++;
|
||
}
|
||
|
||
return browsers;
|
||
}
|
||
|
||
async function getBrowserListSimple(options = {}) {
|
||
return collectBrowserPages(getApi(), options);
|
||
}
|
||
|
||
/**
|
||
* 将账号中保存的 host:port:user:password 代理转为比特浏览器代理配置。
|
||
* 空值表示直连。
|
||
*/
|
||
function parseAccountProxy(proxyValue) {
|
||
const value = String(proxyValue || '').trim();
|
||
if (!value) return { proxyMethod: 2, proxyType: 'noproxy' };
|
||
const parts = value.split(':');
|
||
const host = String(parts.shift() || '').trim();
|
||
const port = String(parts.shift() || '').trim();
|
||
if (!host || !port) return null;
|
||
const proxyUserName = String(parts.shift() || '').trim();
|
||
const proxyPassword = parts.join(':').trim();
|
||
return {
|
||
proxyMethod: 2,
|
||
proxyType: 'socks5',
|
||
host,
|
||
port,
|
||
proxyUserName,
|
||
proxyPassword,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 根据账号信息创建比特浏览器窗口
|
||
* @param {object} account - 账号信息,包含 email、proxy_ip 等字段
|
||
* @param {string} [browserName='默认模板'] - 窗口名称(账号无邮箱时使用)
|
||
* @param {object[]|null} [existingBrowsers=null] - 已有浏览器列表,为 null 时自动获取
|
||
* @param {boolean} [reuseExisting=false] - 同名窗口已存在时是否直接复用
|
||
* @returns {Promise<{browserId: string|null, error: string|null}>} 创建结果
|
||
*/
|
||
async function createBrowserFromAccount(account, browserName = '默认模板', existingBrowsers = null, reuseExisting = false) {
|
||
const api = getApi();
|
||
|
||
const jsonData = { ...DEFAULT_BROWSER_TEMPLATE };
|
||
jsonData.name = account.email || browserName;
|
||
|
||
// 设置代理
|
||
const proxyConfig = parseAccountProxy(account.proxy_ip);
|
||
if (!proxyConfig) return { browserId: null, error: `代理格式错误: ${account.proxy_ip}` };
|
||
Object.assign(jsonData, proxyConfig);
|
||
|
||
// 检查是否已存在该账号的窗口
|
||
const browsers = existingBrowsers !== null ? existingBrowsers : await getBrowserListSimple();
|
||
for (const b of browsers) {
|
||
const matchName = account.email && (b.name === account.email || b.userName === account.email);
|
||
if (matchName) {
|
||
// 检查是否为家庭组母号,如果是则复用
|
||
try {
|
||
const detail = await api.request('/browser/detail', { id: b.id });
|
||
if (detail.success && detail.data && detail.data.remark === '家庭组母号') {
|
||
console.log(`[BitAPI] 发现家庭组母号浏览器,复用: ${b.name} (${b.id})`);
|
||
const { updateAccountTag } = require('./database');
|
||
updateAccountTag(account.email, '家庭组母号');
|
||
return { browserId: b.id, browserSeq: b.seq ?? null, error: null, isFamily: true };
|
||
}
|
||
} catch (_) {}
|
||
if (reuseExisting) {
|
||
console.log(`[BitAPI] 发现已有同名窗口,直接复用: ${b.name} (${b.id})`);
|
||
return { browserId: b.id, browserSeq: b.seq ?? null, error: null, reused: true };
|
||
}
|
||
return { browserId: null, error: `该账号已有对应窗口: ${b.name} (ID: ${b.id})` };
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 遇到"请求频繁"时最多重试 3 次,每次等待时间递增
|
||
const MAX_RETRIES = 3;
|
||
const RATE_LIMIT_PATTERN = /请求频繁|rate.?limit|too.?many.?request/i;
|
||
|
||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||
const result = await api.request('/browser/update', jsonData, 30000);
|
||
if (result.success) {
|
||
const browserId = result.data && result.data.id;
|
||
if (!browserId) return { browserId: null, error: 'API返回成功但未获取到ID' };
|
||
const browserSeq = result.data.seq ?? result.data.browserSeq ?? result.data.browser_seq ?? null;
|
||
return { browserId, browserSeq, error: null };
|
||
}
|
||
|
||
const msg = result.msg || '';
|
||
if (RATE_LIMIT_PATTERN.test(msg) && attempt < MAX_RETRIES) {
|
||
const waitMs = attempt * 2000; // 2s, 4s
|
||
console.log(`[BitAPI] 创建浏览器请求频繁,${waitMs / 1000}s 后重试 (${attempt}/${MAX_RETRIES}): ${msg}`);
|
||
await new Promise(r => setTimeout(r, waitMs));
|
||
continue;
|
||
}
|
||
|
||
return { browserId: null, error: msg || '创建失败' };
|
||
}
|
||
|
||
return { browserId: null, error: '请求频繁,重试次数已耗尽' };
|
||
} catch (e) {
|
||
return { browserId: null, error: `请求异常: ${e.message}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 逐个删除浏览器窗口(带回调)
|
||
* @param {string[]} browserIds - 要删除的浏览器 ID 数组
|
||
* @param {Function|null} [callback=null] - 每次删除后的回调,接收 (browserId, success, msg)
|
||
* @returns {Promise<{successCount: number, total: number}>} 删除统计
|
||
*/
|
||
async function deleteBrowsersBatch(browserIds, callback = null) {
|
||
const api = getApi();
|
||
const normalizedIds = Array.from(new Set((Array.isArray(browserIds) ? browserIds : []).filter(Boolean)));
|
||
let successCount = 0;
|
||
|
||
for (let start = 0; start < normalizedIds.length; start += 100) {
|
||
const chunk = normalizedIds.slice(start, start + 100);
|
||
try {
|
||
// 中文注释:优先走比特浏览器原生批量接口,显著缩短大批量删除耗时。
|
||
const batchResult = await api.deleteBrowsers(chunk);
|
||
if (batchResult && batchResult.success === true) {
|
||
successCount += chunk.length;
|
||
if (callback) chunk.forEach(browserId => callback(browserId, true, ''));
|
||
continue;
|
||
}
|
||
} catch (_) {}
|
||
|
||
// 中文注释:批量接口不可用时再回退到单个删除,保持兼容旧环境。
|
||
for (let i = 0; i < chunk.length; i++) {
|
||
const browserId = chunk[i];
|
||
const result = await api.deleteBrowser(browserId);
|
||
const success = result.success === true;
|
||
if (success) successCount++;
|
||
if (callback) callback(browserId, success, success ? '' : (result.msg || ''));
|
||
if (i < chunk.length - 1) await new Promise(r => setTimeout(r, 80));
|
||
}
|
||
}
|
||
|
||
return { successCount, total: normalizedIds.length };
|
||
}
|
||
|
||
// ==================== 默认浏览器模板 ====================
|
||
|
||
const DEFAULT_BROWSER_TEMPLATE = {
|
||
browserFingerPrint: {
|
||
coreVersion: '148',
|
||
ostype: 'PC',
|
||
os: 'Win32',
|
||
architecture: 'x86',
|
||
osVersion: '',
|
||
platformVersion: '10.0.0',
|
||
version: '',
|
||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.96 Safari/537.36',
|
||
isIpCreateTimeZone: true,
|
||
isIpCreatePosition: true,
|
||
isIpCreateDisplayLanguage: true,
|
||
isIpCreateLanguage: true,
|
||
webRTC: '3',
|
||
position: '1',
|
||
resolutionType: '0',
|
||
resolution: '1920 x 1080',
|
||
openWidth: 1280,
|
||
openHeight: 720,
|
||
fontType: '0',
|
||
canvas: '0',
|
||
webGL: '0',
|
||
webGLMeta: '0',
|
||
audioContext: '0',
|
||
mediaDevice: '1',
|
||
speechVoices: '0',
|
||
hardwareConcurrency: '6',
|
||
deviceMemory: '8',
|
||
deviceInfoEnabled: true,
|
||
clientRectNoiseEnabled: true,
|
||
doNotTrack: '0',
|
||
flash: '1',
|
||
portScanProtect: '0',
|
||
colorDepth: 24,
|
||
devicePixelRatio: 1,
|
||
enablePlugins: false,
|
||
windowSizeLimit: true,
|
||
coreProduct: 'chrome',
|
||
navigatorVendor: 'Google Inc.',
|
||
defaultAccuracy: 10,
|
||
},
|
||
proxyMethod: 2,
|
||
proxyType: 'noproxy',
|
||
ipCheckService: 'ip-api',
|
||
syncTabs: false,
|
||
syncCookies: true,
|
||
syncIndexedDb: true,
|
||
syncLocalStorage: true,
|
||
syncBookmarks: false,
|
||
syncAuthorization: true,
|
||
syncHistory: false,
|
||
syncGoogleAccount: false,
|
||
syncExtensions: false,
|
||
syncUserExtensions: false,
|
||
syncSessions: false,
|
||
allowedSignin: false,
|
||
clearCacheFilesBeforeLaunch: false,
|
||
clearCookiesBeforeLaunch: false,
|
||
clearHistoriesBeforeLaunch: false,
|
||
randomFingerprint: false,
|
||
muteAudio: false,
|
||
disableGpu: false,
|
||
enableBackgroundMode: false,
|
||
credentialsEnableService: false,
|
||
disableTranslatePopup: false,
|
||
disableClipboard: false,
|
||
disableNotifications: false,
|
||
memorySaver: false,
|
||
abortImage: false,
|
||
abortMedia: false,
|
||
stopWhileNetError: false,
|
||
stopWhileCountryChange: false,
|
||
stopWhileIpChange: false,
|
||
isRandomFinger: true,
|
||
remarkType: 1,
|
||
duplicateCheck: 0,
|
||
workbench: 'localserver',
|
||
};
|
||
|
||
module.exports = {
|
||
BitBrowserAPI,
|
||
getApi,
|
||
resetApi,
|
||
openBrowser,
|
||
openBrowserWithRetry,
|
||
setOpenBrowserConcurrency,
|
||
closeBrowser,
|
||
getBrowserListSimple,
|
||
createBrowserFromAccount,
|
||
deleteBrowsersBatch,
|
||
DEFAULT_BROWSER_TEMPLATE,
|
||
_private: { Semaphore, openBrowserSemaphore: _openBrowserSem, collectBrowserPages, parseAccountProxy },
|
||
};
|