/** * @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} 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} 健康检查响应 */ 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} 创建结果,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} 打开结果,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} 关闭结果 */ async closeBrowser(browserId) { return this.request('/browser/close', { id: browserId }); } /** * 删除指定浏览器窗口(含数据) * @param {string} browserId - 浏览器窗口 ID * @returns {Promise} 删除结果 */ async deleteBrowser(browserId) { return this.request('/browser/delete', { id: browserId }); } /** * 批量删除浏览器窗口(最多 100 个) * @param {string[]} browserIds - 浏览器窗口 ID 数组 * @returns {Promise} 删除结果 */ async deleteBrowsers(browserIds) { return this.request('/browser/delete/ids', { ids: browserIds.slice(0, 100) }); } /** * 获取浏览器窗口详情 * @param {string} browserId - 浏览器窗口 ID * @returns {Promise} 窗口详情 */ 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} 窗口列表 */ 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} 更新结果 */ 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} 进程状态映射,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} 打开结果,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} 打开结果,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} 关闭结果 */ 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} 浏览器窗口对象数组 */ 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 }, };