feat: 按任务管理比特浏览器临时窗口

This commit is contained in:
2026-09-23 18:15:08 +08:00
parent 04309f60c7
commit ff2879f102
15 changed files with 577 additions and 226 deletions
+2
View File
@@ -65,6 +65,8 @@ node server.js
> 浏览器自动化功能依赖本机运行的「比特浏览器」(本地接口 `127.0.0.1:54345`)。服务器本身不需要它即可启动,但执行"打开浏览器"等任务前必须先启动比特浏览器,否则日志会出现 `ECONNREFUSED 127.0.0.1:54345`。
账号导入和暂存恢复只写入数据库,不预创建比特浏览器窗口。执行登录、改密、获取凭证、打开浏览器或打开网址等浏览器任务时,系统会为当前邮箱创建或复用临时比特窗口并同步代理;单账号任务结束后立即关闭、删除窗口并清空本地浏览器编号,然后再处理下一个邮箱。
## 主要依赖
| 包 | 用途 |
+4 -65
View File
@@ -177,7 +177,6 @@ function geminiApp() {
inlineEditRechargeStatusEmail: null,
inlineEditAccountRefundEmail: null,
inlineEditRefundProcessEmail: null,
importAutoSetup: true,
exportFields: {
email: true, password: true, recovery_email: true, secret_key: true,
country: false, sheerid_link: false, status: false, message: false, customer_name: false,
@@ -1316,17 +1315,8 @@ function geminiApp() {
this.accountPage = 1;
await this.loadAccounts();
// 自动初始化:测速 → 创建浏览器 → 设置代理
if (this.importAutoSetup && importedEmails.length > 0) {
const importedEmailSet = new Set(importedEmails);
const setupAccounts = this.accounts.filter(a => importedEmailSet.has(a.email));
if (setupAccounts.length > 0) {
await new Promise(resolve => {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve());
else setTimeout(resolve, 0);
});
await this._runImportSetup(setupAccounts);
}
if (importedEmails.length > 0) {
this.addLog('账号已入库;执行具体任务时会自动创建临时比特浏览器,任务结束后自动删除', 'info');
}
} else {
this.addLog(`导入失败: ${data.msg}`, 'error');
@@ -1336,44 +1326,6 @@ function geminiApp() {
}
},
async _runImportSetup(accounts) {
if (this.isRunning) {
this.addLog('⚠️ 有任务正在运行,跳过自动初始化', 'warn');
return;
}
// 大批量任务默认收起日志,避免数百条日志同时参与 DOM 更新。
this.logDrawerOpen = accounts.length <= 200;
this.clearLogs();
this.addLog(`🚀 开始初始化 ${accounts.length} 个账号(测速 / 创建浏览器 / 设置代理)...`, 'info');
try {
const res = await fetch('/api/operations/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskType: 'import_setup',
accounts,
params: { threadCount: parseInt(this.threadCount) },
}),
});
const data = await res.json();
if (!data.success) {
this.addLog(`❌ 启动失败: ${data.msg}`, 'error');
return;
}
this.currentTaskId = data.taskId;
this._rememberTaskBatch('import_setup', accounts, data.taskId);
this._activeTaskType = 'import_setup';
this._setActiveTaskEmails(this._lastTaskEmails);
this._activeTaskStartedAt = this._lastTaskStartedAt;
this.isRunning = true;
this.isPaused = false;
this._rebuildDashboardStats();
this.startSSEListener(data.taskId);
} catch (e) {
this.addLog(`❌ 网络错误: ${e.message}`, 'error');
}
},
async deleteSelectedAccounts() {
if (this.selectedEmails.size === 0) return;
if (this.isDeletingAccounts) {
@@ -1564,18 +1516,8 @@ function geminiApp() {
this.stagedBatchDetail = null;
await Promise.all([this.loadStagingBatches(), this.loadAccounts()]);
const restoredAccounts = Array.isArray(data.accounts) ? data.accounts : [];
if (restoredAccounts.length === 0) {
this.addLog('⚠️ 账号已恢复,但没有取得创建窗口所需的账号数据', 'warn');
return;
}
this.addLog(`正在为恢复的 ${restoredAccounts.length} 个账号自动创建比特浏览器窗口`, 'info');
const started = await this.runTask(
'create_accounts',
{ reuseExistingBrowsers: true },
restoredAccounts,
);
if (!started) {
this.addLog('⚠️ 账号已回到主列表,但自动创建窗口任务启动失败,可勾选“刚刚运行”后点击创建浏览器重试', 'warn');
if (restoredAccounts.length > 0) {
this.addLog('恢复完成;执行具体任务时会自动创建临时比特浏览器', 'info');
}
} catch (e) {
this.addLog(`恢复失败: ${e.message}`, 'error');
@@ -2947,7 +2889,6 @@ function geminiApp() {
open: '等待打开浏览器...',
open_url: '等待打开网页...',
delete: '等待删除浏览器...',
import_setup: '等待初始化...',
custom_pipeline: '等待组合任务...',
};
return messages[taskType] || '等待处理...';
@@ -2972,7 +2913,6 @@ function geminiApp() {
ag_get_credential: '获取凭证', aistudio_combo: 'AI Studio 一键组合', aistudio_sync_key: '同步人工AI Key', gemini_cli_get_credential: '获取Cli凭证', ag_revoke_authorization: '取消授权',
google_account_ag_credential: '链接提取+获取凭证',
open: '打开浏览器', open_manual_aistudio: '打开浏览器(自动保存人工 Key)', open_url: '打开网页', delete: '删除浏览器',
import_setup: '账号初始化',
};
return labels[taskType] || taskType || '任务';
},
@@ -3429,7 +3369,6 @@ function geminiApp() {
},
// 各操作快捷方法
runCreateAccounts() { this.runTask('create_accounts'); },
runChangeProxy() { this.runTask('change_proxy'); },
runOpenBrowsers() {
this.runTask('open_manual_aistudio', { watchTimeoutMs: 30 * 60 * 1000 });
+6 -4
View File
@@ -204,7 +204,6 @@
<div class="btn-group">
<span class="btn-group-label">操作</span>
<button @click="openBatchStatusModal()" :disabled="selectedCount===0" class="btn btn-orange">更改状态</button>
<button @click="runCreateAccounts()" :disabled="isRunning" class="btn btn-primary">创建浏览器</button>
<button @click="runDeleteBrowsers()" :disabled="isRunning || selectedCount===0"
class="btn btn-red" title="删除所选账号的比特浏览器窗口并清空浏览器编号,账号保留">删除浏览器</button>
<button @click="runChangeProxy()" :disabled="isRunning" class="btn btn-primary">换代理</button>
@@ -2082,6 +2081,9 @@
<input type="checkbox" x-model="importSkipDuplicates" class="h-4 w-4">
<span>跳过重复邮箱(已有账号不做任何修改)</span>
</label>
<div class="text-xs text-blue-600 bg-blue-50 border border-blue-100 rounded p-2">
导入只保存账号;执行具体任务时才创建临时比特浏览器窗口,单个账号处理结束后自动删除。
</div>
<!-- 解析预览(第1行) -->
<template x-if="importText.trim() && getImportPreview().length > 0">
@@ -2162,7 +2164,7 @@
<button @click="copyStagedBatch()" class="btn btn-primary">复制全部</button>
<button @click="downloadStagedBatch()" class="btn btn-green">导出 TXT</button>
<button @click="restoreStagedBatch()" :disabled="isRunning" class="btn btn-gray"
title="恢复后自动创建或复用比特浏览器窗口">恢复到主列表并创建窗口</button>
title="恢复只回写账号,具体任务执行时再创建临时比特窗口">恢复到主列表</button>
<button @click="permanentlyDeleteStagedBatch()" class="btn btn-red">彻底删除文件夹</button>
</div>
</div>
@@ -2355,13 +2357,13 @@
class="w-full" @keydown.enter.prevent="confirmGoogleAccountAgTask()">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">并发(1~30)</label>
<label class="block text-xs font-medium text-gray-600 mb-1">并发参数(兼容保留)</label>
<input type="number" min="1" max="30" step="1" x-model.number="googleAccountAgConcurrency"
class="w-full" @keydown.enter.prevent="confirmGoogleAccountAgTask()">
</div>
</div>
<div class="rounded border border-blue-100 bg-blue-50 p-3 text-xs text-blue-700 leading-5 mt-3">
供应数据中的辅助邮箱及后续字段会被忽略;数据库已有邮箱会跳过且不覆盖。创建浏览器时固定使用直连(无代理),并发同时用于浏览器创建和反重力凭证获取。
供应数据中的辅助邮箱及后续字段会被忽略;数据库已有邮箱会跳过且不覆盖。临时比特浏览器固定使用直连(无代理),并严格按邮箱串行处理:完成一个、删除一个,再开始下一个。
</div>
<div class="flex gap-2 mt-3">
<button @click="showGoogleAccountAgModal=false" class="btn btn-red">取消</button>
+27 -16
View File
@@ -491,6 +491,29 @@ 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 等字段
@@ -506,21 +529,9 @@ async function createBrowserFromAccount(account, browserName = '默认模板', e
jsonData.name = account.email || browserName;
// 设置代理
const proxyIp = (account.proxy_ip || '').trim();
if (proxyIp) {
const parts = proxyIp.split(':');
if (parts.length >= 2) {
jsonData.proxyType = 'socks5';
jsonData.proxyMethod = 2;
jsonData.host = parts[0].trim();
jsonData.port = parts[1].trim();
jsonData.proxyUserName = parts[2] ? parts[2].trim() : '';
jsonData.proxyPassword = parts[3] ? parts[3].trim() : '';
}
} else {
jsonData.proxyType = 'noproxy';
jsonData.proxyMethod = 2;
}
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();
@@ -707,5 +718,5 @@ module.exports = {
createBrowserFromAccount,
deleteBrowsersBatch,
DEFAULT_BROWSER_TEMPLATE,
_private: { Semaphore, openBrowserSemaphore: _openBrowserSem, collectBrowserPages },
_private: { Semaphore, openBrowserSemaphore: _openBrowserSem, collectBrowserPages, parseAccountProxy },
};
+3 -1
View File
@@ -263,7 +263,9 @@ async function withBrowser(opts, fn) {
try { disposeDiag(); } catch (_) { }
}
stopMonitorForTask(monitor, log);
if (browser) try { await browser.close(); } catch (_) { }
// 手工打开类任务需要让用户继续操作;等用户关闭比特窗口后,
// 由任务级临时生命周期负责永久删除。
if (browser && !keepBrowserOpenWindow) try { await browser.close(); } catch (_) { }
if (!keepBrowserOpenWindow) {
try { await closeBrowser(browserId); } catch (_) { }
}
+44 -1
View File
@@ -10,7 +10,7 @@ const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { sseMiddleware, broadcast: sseBroadcast, log: sseLog, done: sseDone, progress: sseProgress } = require('../core/sse');
const db = require('../core/database');
const { closeBrowser } = require('../core/bit_api');
const { closeBrowser, deleteBrowsersBatch } = require('../core/bit_api');
const { isLocalRequest } = require('./recharge_credentials');
const { createRechargeOperationId } = require('../services/recharge_task_service');
const {
@@ -20,6 +20,30 @@ const {
// 活跃任务 Map: taskId -> { worker, taskType, accounts, stopRequested, paused }
const activeTasks = new Map();
async function cleanupTrackedTaskBrowsers(task) {
if (!task || !(task.browserIds instanceof Map) || task.browserIds.size === 0) return;
const entries = [...task.browserIds.entries()];
for (const [, browserId] of entries) {
try { await closeBrowser(browserId); } catch (_) {}
}
const succeeded = new Set();
try {
await deleteBrowsersBatch(entries.map(([, browserId]) => browserId), (browserId, success) => {
if (success) succeeded.add(String(browserId));
});
} catch (_) {}
for (const [email, browserId] of entries) {
if (!succeeded.has(String(browserId))) continue;
try {
const account = db.getAccount(email);
if (!account || String(account.browser_id || '') === String(browserId)) {
db.updateAccountBrowserId(email, '');
}
} catch (_) {}
task.browserIds.delete(email);
}
}
const LOCAL_CREDENTIAL_TASKS = new Set([
'card_pay_recharge',
'workspace_refund',
@@ -271,6 +295,7 @@ router.post('/run', (req, res) => {
startedAt,
stopRequested: false,
paused: false,
browserIds: new Map(),
});
// 只记录本次真正启动成功的账号批次,供前端在任务结束后筛选。
@@ -491,6 +516,14 @@ router.post('/run', (req, res) => {
if (msg.recharge_message !== undefined) payload.recharge_message = msg.recharge_message;
if (msg.recharged_at !== undefined) payload.recharged_at = msg.recharged_at;
sseBroadcast(taskId, payload);
} else if (msg.type === 'browser_lifecycle') {
const task = activeTasks.get(taskId);
const email = String(msg.email || '').trim();
const browserId = String(msg.browserId || '').trim();
if (task && email) {
if (msg.action === 'prepared' && browserId) task.browserIds.set(email, browserId);
if (msg.action === 'released') task.browserIds.delete(email);
}
} else if (msg.type === 'task_accounts') {
const emails = [...new Set((Array.isArray(msg.emails) ? msg.emails : [])
.map(email => String(email || '').trim())
@@ -511,6 +544,7 @@ router.post('/run', (req, res) => {
} else if (msg.type === 'done') {
doneReceived = true;
const t = activeTasks.get(taskId);
void cleanupTrackedTaskBrowsers(t);
if (msg.error && taskType === 'card_pay_recharge') {
markRechargeInterrupted(taskId, accounts);
}
@@ -529,6 +563,8 @@ router.post('/run', (req, res) => {
});
worker.on('error', (e) => {
const trackedTask = activeTasks.get(taskId);
void cleanupTrackedTaskBrowsers(trackedTask);
if (taskType === 'card_pay_recharge') markRechargeInterrupted(taskId, accounts);
const safeError = taskType === 'card_pay_recharge'
? '充值任务执行异常'
@@ -542,6 +578,8 @@ router.post('/run', (req, res) => {
});
worker.on('exit', (code) => {
const trackedTask = activeTasks.get(taskId);
void cleanupTrackedTaskBrowsers(trackedTask);
if (code !== 0 && !doneReceived) {
if (taskType === 'card_pay_recharge') markRechargeInterrupted(taskId, accounts);
sseLog(taskId, `[任务] Worker 异常退出: ${code}`);
@@ -666,6 +704,11 @@ router.post('/stop/:taskId', (req, res) => {
const id = a && a.browser_id ? String(a.browser_id) : '';
if (id) ids.add(id);
}
if (t.browserIds instanceof Map) {
for (const id of t.browserIds.values()) {
if (id) ids.add(String(id));
}
}
}
(async () => {
for (const id of ids) {
+238
View File
@@ -0,0 +1,238 @@
'use strict';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function browserIdOf(browser) {
return String((browser && (browser.id || browser.browserId || browser.browser_id)) || '').trim();
}
function browserNameOf(browser) {
return String((browser && (browser.name || browser.userName)) || '').trim();
}
/**
* 为任务提供“按账号临时创建、任务结束立即删除”的比特浏览器生命周期。
* 依赖均由 Worker 注入,便于在 worker_threads 中安全写库并做单元测试。
*/
class TaskBrowserLifecycle {
constructor(options = {}) {
this.getBrowserListSimple = options.getBrowserListSimple;
this.createBrowserFromAccount = options.createBrowserFromAccount;
this.getApi = options.getApi;
this.closeBrowser = options.closeBrowser;
this.deleteBrowsersBatch = options.deleteBrowsersBatch;
this.parseAccountProxy = options.parseAccountProxy;
this.dbWrite = options.dbWrite;
this.updateMessage = options.updateMessage;
this.acquireRequestSlot = options.acquireRequestSlot || (async () => {});
this.resolveProxy = options.resolveProxy || null;
this.log = options.log || (() => {});
this.notify = options.notify || (() => {});
this.shouldStop = options.shouldStop || (() => false);
this._knownBrowsersPromise = null;
}
async _knownBrowsers() {
if (!this._knownBrowsersPromise) {
this._knownBrowsersPromise = Promise.resolve(this.getBrowserListSimple())
.then(list => (Array.isArray(list) ? list : []))
.catch(error => {
this._knownBrowsersPromise = null;
throw error;
});
}
return this._knownBrowsersPromise;
}
_removeKnownBrowser(browserId) {
if (!this._knownBrowsersPromise) return;
this._knownBrowsersPromise = this._knownBrowsersPromise.then(list => (
list.filter(browser => browserIdOf(browser) !== String(browserId))
));
}
async prepare(account, options = {}) {
const email = String((account && account.email) || '').trim();
if (!email) return { success: false, code: 'ACCOUNT_EMAIL_MISSING', error: '账号邮箱为空' };
if (this.shouldStop()) return { success: false, skipped: true, code: 'TASK_STOPPED', error: '任务已停止' };
if (options.forceDirect) {
account.proxy_ip = '';
} else if (!String(account.proxy_ip || '').trim() && this.resolveProxy) {
const resolvedProxy = await this.resolveProxy(account);
if (resolvedProxy) account.proxy_ip = resolvedProxy;
}
await this.updateMessage(email, '正在准备临时比特浏览器窗口...');
await this.acquireRequestSlot();
const browsers = await this._knownBrowsers();
const boundId = String(account.browser_id || '').trim();
const emailKey = email.toLowerCase();
let matched = boundId
? browsers.find(browser => browserIdOf(browser) === boundId)
: null;
if (!matched) {
matched = browsers.find(browser => browserNameOf(browser).toLowerCase() === emailKey) || null;
}
let browserId = matched ? browserIdOf(matched) : '';
let browserSeq = matched ? (matched.seq ?? matched.browserSeq ?? matched.browser_seq ?? null) : null;
let reused = !!matched;
if (matched) {
const proxyConfig = this.parseAccountProxy(account.proxy_ip);
if (!proxyConfig) {
return {
success: false,
code: 'BROWSER_PROXY_INVALID',
error: `代理格式错误: ${account.proxy_ip}`,
browserId,
};
}
const proxyResult = await this.getApi().updateBrowserProxy([browserId], proxyConfig);
if (!proxyResult || proxyResult.success !== true) {
return {
success: false,
code: 'BROWSER_PROXY_UPDATE_FAILED',
error: (proxyResult && proxyResult.msg) || '更新浏览器代理失败',
browserId,
};
}
} else {
const created = await this.createBrowserFromAccount(account, '临时任务窗口', browsers, true);
if (!created || created.error || !created.browserId) {
return {
success: false,
code: 'BROWSER_CREATE_FAILED',
error: (created && created.error) || '创建临时浏览器失败',
};
}
browserId = String(created.browserId);
browserSeq = created.browserSeq ?? null;
reused = !!created.reused;
if (!reused) browsers.push({ id: browserId, name: email, seq: browserSeq });
}
account.browser_id = browserId;
account.browser_seq = browserSeq;
await this.dbWrite('updateAccountBrowserId', [email, browserId, browserSeq]);
this.notify({ type: 'browser_lifecycle', action: 'prepared', email, browserId });
this.log(`[临时比特浏览器][${email}] ${reused ? '复用并更新' : '创建'}窗口 ${browserId}`);
return { success: true, account, browserId, browserSeq, reused };
}
async release(account, browserId) {
const email = String((account && account.email) || '').trim();
const id = String(browserId || (account && account.browser_id) || '').trim();
if (!id) return { success: true, skipped: true };
try { await this.closeBrowser(id); } catch (_) {}
let deleted = false;
let lastMessage = '';
for (let attempt = 1; attempt <= 3 && !deleted; attempt++) {
await sleep(attempt === 1 ? 250 : attempt * 500);
const result = await this.deleteBrowsersBatch([id], (_deletedId, success, message) => {
deleted = success;
lastMessage = message || '';
});
if (result && result.successCount === 1) deleted = true;
if (!deleted) {
try { await this.closeBrowser(id); } catch (_) {}
}
}
if (!deleted) {
try {
const latestList = await this.getBrowserListSimple();
deleted = !latestList.some(browser => browserIdOf(browser) === id);
} catch (_) {}
}
if (!deleted) {
const message = lastMessage || '删除失败';
this.log(`[临时比特浏览器][${email}] 窗口 ${id} 删除失败: ${message}`);
this.notify({ type: 'browser_lifecycle', action: 'release_failed', email, browserId: id });
return { success: false, error: message };
}
try {
const latest = await this.dbWrite('getAccount', [email]);
if (!latest || String(latest.browser_id || '') === id) {
await this.dbWrite('updateAccountBrowserId', [email, '', null]);
}
} catch (_) {
await this.dbWrite('updateAccountBrowserId', [email, '', null]);
}
if (account) {
account.browser_id = '';
account.browser_seq = null;
}
this._removeKnownBrowser(id);
this.notify({ type: 'browser_lifecycle', action: 'released', email, browserId: id });
this.log(`[临时比特浏览器][${email}] 窗口 ${id} 已删除`);
return { success: true };
}
async run(account, work, options = {}) {
const prepared = await this.prepare(account, options);
if (!prepared.success) {
if (prepared.browserId) await this.release(account, prepared.browserId);
const email = String((account && account.email) || '').trim();
if (!prepared.skipped && email) {
await this.updateMessage(email, `❌ 临时比特浏览器准备失败: ${prepared.error}`);
}
return {
email,
ok: false,
skipped: !!prepared.skipped,
code: prepared.code || 'BROWSER_PREPARE_FAILED',
msg: prepared.error || '临时浏览器准备失败',
};
}
let result;
try {
result = await work(prepared.account, prepared);
return result;
} finally {
const preserveOnDefer = options.preserveOnDefer !== false;
if (!(preserveOnDefer && result && result.__defer === true)) {
const releaseResult = await this.release(prepared.account, prepared.browserId);
if (!releaseResult.success) {
await this.updateMessage(
String(prepared.account.email || ''),
`⚠️ 任务已结束,但临时比特浏览器删除失败: ${releaseResult.error || '未知错误'}`,
).catch(() => {});
}
}
}
}
async waitUntilClosed(browserId, options = {}) {
const id = String(browserId || '').trim();
const pollMs = Math.max(500, Number(options.pollMs) || 1000);
const timeoutMs = Math.max(0, Number(options.timeoutMs) || 0);
const startedAt = Date.now();
while (!this.shouldStop()) {
const response = await this.getApi().getAliveBrowserPids([id]);
if (response && response.success === true) {
const alive = response.data && response.data[id];
if (!alive || (Array.isArray(alive) && alive.length === 0)) {
return { closed: true, stopped: false };
}
}
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
return { closed: false, stopped: false, timeout: true };
}
await sleep(pollMs);
}
return { closed: false, stopped: true };
}
}
module.exports = {
TaskBrowserLifecycle,
_private: { browserIdOf, browserNameOf },
};
+10 -1
View File
@@ -29,6 +29,7 @@ async function runConcurrentItems(items, handler, options = {}) {
const getDeferDelay = typeof options.getDeferDelay === 'function'
? options.getDeferDelay
: null;
const deferReleasesSlot = options.deferReleasesSlot !== false;
const results = new Array(list.length);
const readyQueue = list.map((_, index) => index);
const workWaiters = [];
@@ -94,7 +95,15 @@ async function runConcurrentItems(items, handler, options = {}) {
if (delayMs > 0) {
deferred = true;
results[index] = undefined;
deferIndex(index, Math.max(1, delayMs));
if (deferReleasesSlot) {
deferIndex(index, Math.max(1, delayMs));
} else {
const deadline = Date.now() + Math.max(1, delayMs);
while (!shouldStop() && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, Math.min(250, deadline - Date.now())));
}
if (!shouldStop()) readyQueue.unshift(index);
}
}
}
} catch (error) {
+94 -118
View File
@@ -55,6 +55,7 @@ function isClosedBrowserError(message) {
// Pipeline
const { withBrowser, openThrottle } = require('../pipeline/browser_lifecycle');
const { runImportBrowserBatch, normalizeConcurrency, DEFAULT_MAX_CONCURRENCY } = require('../services/import_browser_batch_service');
const { TaskBrowserLifecycle } = require('../services/task_browser_lifecycle');
const { runSteps } = require('../pipeline/step_runner');
const { getRecipe, RECIPES, ATOM_STEPS, buildCustomRecipe } = require('../pipeline/recipes');
const PipelineContext = require('../pipeline/context');
@@ -244,6 +245,7 @@ async function runConcurrent(items, fn, concurrency, progressOptions = {}) {
getDeferDelay: result => (result && result.__defer === true
? Math.max(1, Number(result.delayMs) || 0)
: 0),
deferReleasesSlot: progressOptions.deferReleasesSlot !== false,
});
return batch.results;
}
@@ -269,6 +271,17 @@ function stopMonitorForTask(monitor, accountLog) {
// ==================== Pipeline 任务类型集合 ====================
const PIPELINE_TASKS = new Set([...Object.keys(RECIPES), 'custom_pipeline']);
const TEMPORARY_BROWSER_TASKS = new Set([
...[...PIPELINE_TASKS].filter(type => type !== 'openai_query_plan'),
'google_account_ag_credential',
'ag_revoke_authorization',
'ag_get_credential',
'gemini_cli_get_credential',
'open',
'open_manual_aistudio',
'open_url',
'change_proxy',
]);
// ==================== 任务入口 ====================
@@ -429,6 +442,7 @@ async function runTask() {
deleteBrowsersBatch,
getApi,
setOpenBrowserConcurrency,
_private: bitApiPrivate,
} = require('../core/bit_api');
const { rotateProxyForAccount, shouldRotateProxyForFailure } = require('../services/proxy_recovery_service');
const requestedThreadCount = (params && params.threadCount) || 1;
@@ -438,8 +452,11 @@ async function runTask() {
const threadCount = taskType === 'change_proxy' && accounts.length > 300
? Math.min(requestedThreadCount, 12)
: requestedThreadCount;
// 业务并发保持用户设置,打开本地比特窗口单独设安全上限,避免 API 瞬时被 50 个请求冲垮。
const openRequestConcurrency = setOpenBrowserConcurrency(Math.min(threadCount, 12));
const browserTaskConcurrency = TEMPORARY_BROWSER_TASKS.has(taskType) ? 1 : threadCount;
if (TEMPORARY_BROWSER_TASKS.has(taskType) && threadCount > 1) {
log(`[任务] 临时比特浏览器模式固定串行执行;已将浏览器任务并发从 ${threadCount} 调整为 1`);
}
const openRequestConcurrency = setOpenBrowserConcurrency(Math.min(browserTaskConcurrency, 12));
// 读取打开限速配置(每秒最多打开几个窗口),默认 9
try {
@@ -449,7 +466,7 @@ async function runTask() {
? Math.min(configuredRate, 6)
: configuredRate;
if (rate > 0) openThrottle.setRate(rate);
log(`[任务] 打开限速: ${rate}/s,线程数: ${threadCount},打开请求并发: ${openRequestConcurrency}`);
log(`[任务] 打开限速: ${rate}/s,任务并发: ${browserTaskConcurrency},打开请求并发: ${openRequestConcurrency}`);
} catch (_) { }
const monitorConfig = resolveMonitorConfig(taskType, params);
@@ -457,6 +474,30 @@ async function runTask() {
log(`[任务] 已开启业务旁监听 (${monitorConfig.mode})`);
}
const taskBrowserLifecycle = new TaskBrowserLifecycle({
getBrowserListSimple,
createBrowserFromAccount,
getApi,
closeBrowser,
deleteBrowsersBatch,
parseAccountProxy: bitApiPrivate.parseAccountProxy,
dbWrite,
updateMessage,
acquireRequestSlot: () => openThrottle.acquire(),
resolveProxy: async (account) => {
const assignedProxy = pickRandomBrowserProxy();
if (!assignedProxy) return '';
account.proxy_ip = assignedProxy;
await dbWrite('updateAccountProxyIp', [account.email, assignedProxy]);
const proxyEndpoint = assignedProxy.split(':').slice(0, 2).join(':');
log(`[临时比特浏览器][${account.email}] 已随机分配固定代理 ${proxyEndpoint}`);
return assignedProxy;
},
log,
notify: message => parentPort.postMessage(message),
shouldStop: () => stopRequested,
});
// ── Pipeline 任务(login, sheerlink, half_auto, full_auto, bind_card, change_2fa, invite_family)──
if (PIPELINE_TASKS.has(taskType)) {
@@ -521,12 +562,10 @@ async function runTask() {
msg: failure.msg || failureStatus,
};
}
return taskBrowserLifecycle.run(acc, async (preparedAccount) => {
acc = normalizeAccount(preparedAccount);
const accountLog = makeLogger(log, { context: acc.email });
const taskUpdateMessage = updateMessage;
if (!acc.browser_id) {
await taskUpdateMessage(acc.email, '⚠️ 无 browser_id,跳过');
return { email: acc.email, ok: false, skipped: true, code: 'NO_BROWSER', msg: '无 browser_id,跳过' };
}
// 套餐查询只更新独立的“账号套餐”列,不覆盖原处理结论。
if (taskType !== 'openai_query_plan') await taskUpdateMessage(
acc.email,
@@ -821,6 +860,10 @@ async function runTask() {
}
if (!runRes.shouldStop) {
if (runRes.keepBrowserOpenWindow) {
await taskUpdateMessage(acc.email, '比特浏览器已打开;关闭窗口后将自动删除临时窗口');
await taskBrowserLifecycle.waitUntilClosed(acc.browser_id);
}
lastFailure = null;
break;
}
@@ -941,7 +984,8 @@ async function runTask() {
updateMessage,
});
return { email: acc.email, ok: false, skipped: false, code: outcome.code, msg: outcome.label, meta: outcome };
}, threadCount);
});
}, browserTaskConcurrency, { deferReleasesSlot: false });
const summaryLines = buildTaskSummary(taskType, pipelineResults);
for (const line of summaryLines) log(line);
return { summaryLines };
@@ -953,7 +997,7 @@ async function runTask() {
case 'google_account_ag_credential': {
const requestedQuantity = Number.parseInt(params && params.quantity, 10);
const taskConcurrency = normalizeConcurrency(threadCount, requestedQuantity, 30);
const taskConcurrency = 1;
const defaultCustomerName = '企业字-Free';
log(`[链接取号] 开始处理,计划取号 ${requestedQuantity} 个,并发 ${taskConcurrency}`);
log('[链接取号] 执行方式:每个并发工位每次只取 1 个账号,完成浏览器创建和反重力凭证后再领取下一个');
@@ -970,7 +1014,6 @@ async function runTask() {
const taskSlots = Array.from({ length: requestedQuantity }, (_, index) => ({ slot: index + 1 }));
const importedEmails = [];
const importedEmailSet = new Set();
const existingBrowsers = await getBrowserListSimple();
const credentialSteps = [ATOM_STEPS.login.step, ATOM_STEPS.ag_get_credential.step];
const taskResults = await runConcurrent(taskSlots, async (slotItem) => {
const slot = slotItem.slot;
@@ -1056,45 +1099,9 @@ async function runTask() {
}
const account = { ...normalizeAccount(accountRow), proxy_ip: '' };
await updateMessage(account.email, '正在创建无代理浏览器...');
await openThrottle.acquire();
if (stopRequested) {
await updateMessage(account.email, '⚠️ 任务已停止,尚未创建浏览器');
return { email: account.email, ok: false, skipped: true, code: 'TASK_STOPPED', msg: '任务已停止' };
}
const browserResult = await createBrowserFromAccount(account, '默认模板', existingBrowsers, true);
if (browserResult.error || !browserResult.browserId) {
const message = browserResult.error || '未获取到浏览器 ID';
await updateMessage(account.email, `❌ 创建浏览器失败: ${message}`);
return { email: account.email, ok: false, skipped: false, code: 'BROWSER_CREATE_FAILED', msg: message };
}
if (browserResult.reused) {
const noProxyResult = await getApi().updateBrowserProxy([browserResult.browserId], {
proxyMethod: 2,
proxyType: 'noproxy',
});
if (!noProxyResult || noProxyResult.success !== true) {
const message = (noProxyResult && noProxyResult.msg) || '无法确认现有浏览器为无代理';
await updateMessage(account.email, `❌ 现有浏览器无代理设置失败: ${message}`);
return { email: account.email, ok: false, skipped: false, code: 'BROWSER_NO_PROXY_FAILED', msg: message };
}
}
account.browser_id = browserResult.browserId;
account.browser_seq = browserResult.browserSeq ?? null;
if (!browserResult.reused) {
existingBrowsers.push({
id: browserResult.browserId,
name: account.email,
seq: browserResult.browserSeq ?? null,
});
}
await dbWrite('updateAccountBrowserId', [account.email, browserResult.browserId, browserResult.browserSeq ?? null]);
await updateMessage(account.email, browserResult.reused
? `✅ 已绑定现有无代理浏览器 (id=${browserResult.browserId}),开始获取反重力凭证`
: `✅ 无代理浏览器已创建 (id=${browserResult.browserId}),开始获取反重力凭证`);
const acc = normalizeAccount(account);
return taskBrowserLifecycle.run(account, async (preparedAccount) => {
const acc = normalizeAccount(preparedAccount);
await updateMessage(acc.email, '✅ 临时无代理比特浏览器已就绪,开始获取反重力凭证');
const accountLog = makeLogger(log, { context: acc.email });
let ctxRef = null;
let directResult = null;
@@ -1167,6 +1174,7 @@ async function runTask() {
code: (directResult && directResult.code) || 'UNKNOWN',
msg: (directResult && directResult.msg) || '获取凭证失败',
};
}, { forceDirect: true });
}, taskConcurrency, {
enabled: true,
offset: 0,
@@ -2007,12 +2015,8 @@ async function runTask() {
const { revokeAntigravityAuthorization } = require('../services/ag_revoke_service');
const { ensureLoggedIn: ensureLoggedInForRevoke } = require('../services/login_service');
const revokeResults = await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
if (!acc.browser_id) {
await updateMessage(acc.email, '⚠️ 无 browser_id,跳过');
return { email: acc.email, ok: false, skipped: true, code: 'NO_BROWSER', msg: '无 browser_id' };
}
const revokeResults = await runConcurrent(accounts, account => taskBrowserLifecycle.run(account, async (preparedAccount) => {
const acc = normalizeAccount(preparedAccount);
await updateMessage(acc.email, '正在确认 Google 登录状态...');
const accountLog = makeLogger(log, { context: acc.email });
@@ -2120,7 +2124,7 @@ async function runTask() {
result = { email: acc.email, ok: false, skipped: false, code: outcome.code, msg: outcome.label, meta: outcome };
}
return result;
}, threadCount);
}), browserTaskConcurrency);
const revokeSummary = buildTaskSummary('ag_revoke_authorization', revokeResults);
for (const line of revokeSummary) log(line);
@@ -2148,12 +2152,8 @@ async function runTask() {
? Math.max(0, Math.min(3, parsedAutoProxyRetries))
: 2;
const credResults = await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
if (!acc.browser_id) {
await updateMessage(acc.email, '⚠️ 无 browser_id,跳过');
return { email: acc.email, ok: false, skipped: true, code: 'NO_BROWSER', msg: '无 browser_id' };
}
const credResults = await runConcurrent(accounts, account => taskBrowserLifecycle.run(account, async (preparedAccount) => {
const acc = normalizeAccount(preparedAccount);
await updateMessage(acc.email, '处理中...');
const accountLog = makeLogger(log, { context: acc.email });
const getSettingAsync = async (key, defVal) => {
@@ -2506,7 +2506,7 @@ async function runTask() {
result = { ...result, code: finalOutcome.code, msg: finalOutcome.label, meta: finalOutcome };
}
return result;
}, threadCount);
}), browserTaskConcurrency);
const credSummary = buildTaskSummary(taskType, credResults);
for (const line of credSummary) log(line);
@@ -2514,55 +2514,24 @@ async function runTask() {
}
case 'create_accounts': {
log('[任务] 创建浏览器窗口...');
const existingBrowsers = await getBrowserListSimple();
log('[任务] 已启用临时比特浏览器模式,不再预创建持久窗口');
await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
const assignedProxy = pickRandomBrowserProxy();
acc.proxy_ip = assignedProxy;
await dbWrite('updateAccountProxyIp', [acc.email, assignedProxy]);
const proxyEndpoint = assignedProxy.split(':').slice(0, 2).join(':');
await updateMessage(acc.email, `已随机分配固定代理 ${proxyEndpoint}`);
await updateMessage(acc.email, '正在创建浏览器...');
await openThrottle.acquire(); // 限速
const { browserId, browserSeq, error } = await createBrowserFromAccount(
acc,
'默认模板',
existingBrowsers,
!!(params && params.reuseExistingBrowsers),
);
if (error) {
await persistFailureOutcome({
email: acc.email,
code: 'BROWSER_ERROR',
message: error,
taskType: 'create_accounts',
dbWrite,
updateMessage,
});
await updateMessage(acc.email, `❌ 创建浏览器失败: ${error}`);
await updateStatus(acc.email, '创建失败');
} else {
await dbWrite('updateAccountBrowserId', [acc.email, browserId, browserSeq]);
await updateMessage(acc.email, `✅ 创建浏览器成功: ${browserId}`);
}
await updateMessage(acc.email, '无需预创建;执行具体任务时会自动创建并在完成后删除');
}, threadCount);
break;
}
case 'open': {
log('[任务] 打开浏览器...');
await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
if (!acc.browser_id) {
await updateMessage(acc.email, '⚠️ 无 browser_id,跳过');
return;
}
await runConcurrent(accounts, account => taskBrowserLifecycle.run(account, async (preparedAccount) => {
const acc = normalizeAccount(preparedAccount);
await updateMessage(acc.email, '打开浏览器...');
const accountLog = makeLogger(log, { context: acc.email });
const result = await openBrowserWithRetry(acc.browser_id, { email: acc.email, logCallback: accountLog });
if (result.success) {
await updateMessage(acc.email, '✅ 打开成功');
await updateMessage(acc.email, '✅ 打开成功;关闭窗口后将自动删除临时比特窗口');
await taskBrowserLifecycle.waitUntilClosed(acc.browser_id);
} else {
await persistFailureOutcome({
email: acc.email,
@@ -2573,7 +2542,7 @@ async function runTask() {
updateMessage,
});
}
}, threadCount);
}), browserTaskConcurrency);
break;
}
@@ -2583,12 +2552,8 @@ async function runTask() {
60000,
Math.min(2 * 60 * 60 * 1000, Number(params && params.watchTimeoutMs) || 30 * 60 * 1000),
);
await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
if (!acc.browser_id) {
await updateMessage(acc.email, '⚠️ 无 browser_id,跳过');
return;
}
await runConcurrent(accounts, account => taskBrowserLifecycle.run(account, async (preparedAccount) => {
const acc = normalizeAccount(preparedAccount);
await updateMessage(acc.email, '正在打开比特浏览器...');
const accountLog = makeLogger(log, { context: acc.email });
const opened = await openBrowserWithRetry(acc.browser_id, { email: acc.email, logCallback: accountLog });
@@ -2650,7 +2615,7 @@ async function runTask() {
accountLog(`[AIStudio人工监听] 监听异常:${message}`);
await updateMessage(acc.email, `⚠️ 浏览器已打开,但 AI Studio Key 自动监听失败:${message}`);
}
}, threadCount);
}), browserTaskConcurrency);
break;
}
@@ -2663,9 +2628,11 @@ async function runTask() {
}
let deletedCount = 0;
const successfullyDeletedIds = new Set();
const deleteFailures = new Map();
const { successCount } = await deleteBrowsersBatch(browserIds, (bid, success, msg) => {
deletedCount += 1;
if (success) successfullyDeletedIds.add(bid);
else deleteFailures.set(bid, msg || '删除失败');
parentPort.postMessage({ type: 'progress', current: deletedCount, total: browserIds.length });
log(`[删除浏览器] ${bid}: ${success ? '成功' : msg}`);
});
@@ -2674,6 +2641,9 @@ async function runTask() {
// 只有远端窗口确认删除成功后才清空绑定;失败项保留编号,方便用户重试。
if (acc.browser_id && successfullyDeletedIds.has(acc.browser_id)) {
await dbWrite('updateAccountBrowserId', [acc.email, '']);
await updateMessage(acc.email, '✅ 比特浏览器窗口已删除');
} else if (acc.browser_id) {
await updateMessage(acc.email, `❌ 删除浏览器失败:${deleteFailures.get(acc.browser_id) || '未知错误'}`);
}
}
break;
@@ -2697,12 +2667,8 @@ async function runTask() {
}
};
await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
if (!acc.browser_id) {
await updateMessage(acc.email, '⚠️ 无 browser_id,跳过');
return;
}
await runConcurrent(accounts, account => taskBrowserLifecycle.run(account, async (preparedAccount) => {
const acc = normalizeAccount(preparedAccount);
await updateMessage(acc.email, '处理中...');
const accountLog = makeLogger(log, { context: acc.email });
@@ -2726,6 +2692,8 @@ async function runTask() {
});
if (runRes && runRes.success !== false) {
await updateMessage(acc.email, `✅ 已跳转到 ${targetUrl};关闭窗口后将自动删除临时比特窗口`);
await taskBrowserLifecycle.waitUntilClosed(acc.browser_id);
lastFailure = null;
break;
}
@@ -2768,7 +2736,7 @@ async function runTask() {
});
lastFailure = { ...lastFailure, ...outcome };
}
}, threadCount);
}), browserTaskConcurrency);
break;
}
@@ -2883,8 +2851,8 @@ async function runTask() {
const fraudThreshold2 = parseInt(getSetting('fraud_score_threshold', '15') || '15', 10);
const fraudCheckEnabled2 = getSetting('fraud_check_enabled', '0') === '1';
await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
await runConcurrent(accounts, account => taskBrowserLifecycle.run(account, async (preparedAccount) => {
const acc = normalizeAccount(preparedAccount);
let newProxyStr = '';
let finalOk = false;
let reusedNexipSid = false;
@@ -3033,11 +3001,19 @@ async function runTask() {
} else {
await updateMessage(acc.email, '✅ 切换 IP 成功(代理已写入,暂无浏览器窗口)');
}
}, threadCount);
}), browserTaskConcurrency);
break;
}
case 'import_setup': {
log('[初始化] 导入后自动创建比特浏览器已停用;具体任务执行时会按账号创建临时窗口');
await runConcurrent(accounts, async (account) => {
const acc = normalizeAccount(account);
await updateMessage(acc.email, '导入完成;执行具体任务时将自动创建临时比特浏览器');
}, threadCount);
break;
/* istanbul ignore next -- 保留旧实现仅用于历史版本对照,不再执行。 */
log('[初始化] 开始账号初始化流程(测速 / 创建浏览器 / 设置代理)...');
const crypto = require('crypto');
const {
+11 -1
View File
@@ -2,7 +2,7 @@ const assert = require('assert');
const {
setOpenBrowserConcurrency,
_private: { Semaphore, openBrowserSemaphore, collectBrowserPages },
_private: { Semaphore, openBrowserSemaphore, collectBrowserPages, parseAccountProxy },
} = require('../src/core/bit_api');
(async () => {
@@ -57,6 +57,16 @@ const {
assert.strictEqual(largeBrowsers.length, 1250, '完整同步不能在1000个窗口处截断');
assert.strictEqual(largePageCalls.length, 13, '1250个窗口应按100个一页读取完成');
assert.deepStrictEqual(parseAccountProxy(''), { proxyMethod: 2, proxyType: 'noproxy' });
assert.deepStrictEqual(parseAccountProxy('127.0.0.1:1080:user:p:ass'), {
proxyMethod: 2,
proxyType: 'socks5',
host: '127.0.0.1',
port: '1080',
proxyUserName: 'user',
proxyPassword: 'p:ass',
});
console.log('bit_api_open_concurrency.test.js: PASS');
})().catch(error => {
console.error(error);
@@ -97,6 +97,26 @@ function yieldEventLoop() {
assert.strictEqual(deferredBatch.completed, 3, '延迟重试不应重复累计完成进度');
assert.strictEqual(deferredBatch.results[0].attempt, 2, '延迟项目到期后应在原索引写入最终结果');
const heldSlotRuns = [];
const heldSlotAttempts = new Map();
const heldSlotBatch = await runConcurrentItems(['sleeping', 'next'], async item => {
heldSlotRuns.push(item);
const attempt = (heldSlotAttempts.get(item) || 0) + 1;
heldSlotAttempts.set(item, attempt);
if (item === 'sleeping' && attempt === 1) return { defer: true, delayMs: 20 };
return { item, attempt };
}, {
concurrency: 1,
deferReleasesSlot: false,
getDeferDelay: result => (result && result.defer ? result.delayMs : 0),
});
assert.deepStrictEqual(
heldSlotRuns,
['sleeping', 'sleeping', 'next'],
'临时比特浏览器任务延迟等待时必须占住串行槽,删除后才能轮到下一账号',
);
assert.strictEqual(heldSlotBatch.completed, 2, '占住串行槽时仍应完成全部项目');
console.log('concurrent_runner_resilience.test.js: PASS');
})().catch(error => {
console.error(error);
+12 -7
View File
@@ -28,8 +28,10 @@ function responseStub() {
'获取Cli凭证按钮必须位于获取凭证和链接提取按钮之间');
assert.match(html, /Google 账号取号 API[\s\S]{0,2500}googleAccountCredentialForm\.value/,
'设置页应提供加密取号 API 配置');
assert.match(html, /处理账号数量(1~5000)[\s\S]{0,800}并发(1~30)/,
'任务弹窗应同时提供数量和并发');
assert.match(html, /处理账号数量(1~5000)[\s\S]{0,800}并发参数(兼容保留)/,
'任务弹窗应说明并发参数只为兼容保留');
assert.match(html, /临时比特浏览器[\s\S]{0,120}严格按邮箱串行处理:完成一个、删除一个,再开始下一个/,
'任务弹窗应告知用户临时比特浏览器严格串行');
assert(server.includes("/api/google-account-credentials"), '服务端应挂载取号凭据路由');
assert(operations.includes("'google_account_ag_credential'"), '取号任务必须受本机凭据任务保护');
assert(operations.includes('importAccountsFromText'), 'Worker 应通过主线程安全导入新账号');
@@ -44,15 +46,18 @@ function responseStub() {
'链接取号导入账号必须使用默认客户名“企业字-Free”');
assert.match(worker, /fetchGoogleAccounts\(\{[\s\S]{0,220}quantity:\s*1/,
'每次供应接口调用只能提取 1 个账号');
assert(worker.includes('const taskConcurrency = 1;'),
'浏览器账号必须固定串行,删除当前临时窗口后再处理下一个');
assert(worker.includes('const taskResults = await runConcurrent(taskSlots'),
'账号应由用户设定并发数驱动滑动队列,而不是写死固定工位数');
'账号应通过滑动队列按顺序处理');
const fetchOneAt = worker.indexOf('quantity: 1,');
const createBrowserAt = worker.indexOf("createBrowserFromAccount(account, '默认模板'", fetchOneAt);
const createBrowserAt = worker.indexOf('taskBrowserLifecycle.run(account', fetchOneAt);
const acquireCredentialAt = worker.indexOf("taskType: 'ag_get_credential'", createBrowserAt);
assert(fetchOneAt >= 0 && createBrowserAt > fetchOneAt && acquireCredentialAt > createBrowserAt,
'每个工位必须按“取 1 个→建浏览器→获取反重力凭证”的顺序处理');
assert.match(worker, /browserResult\.reused[\s\S]{0,500}proxyType:\s*'noproxy'/,
'复用孤立浏览器时也必须明确切换为无代理');
'每个工位必须按“取 1 个→准备临时比特窗口→获取反重力凭证”的顺序处理');
const slotTaskEnd = worker.indexOf('}, taskConcurrency', createBrowserAt);
assert.match(worker.slice(createBrowserAt, slotTaskEnd), /forceDirect:\s*true/,
'取号任务的临时比特窗口必须明确使用直连模式');
assert(worker.includes('ATOM_STEPS.ag_get_credential.step'),
'取号后必须复用反重力获取凭证步骤');
assert(!worker.includes('supplied.raw'), 'Worker 不得记录供应商原始响应');
+8 -2
View File
@@ -90,8 +90,14 @@ async function waitUntil(predicate, timeoutMs = 1000) {
const importStart = taskRunnerSource.indexOf("case 'import_setup'");
const importEnd = taskRunnerSource.indexOf('\n default:', importStart);
const importSection = taskRunnerSource.slice(importStart, importEnd);
assert.ok(importSection.includes('runImportBrowserBatch(readyAccounts'), '导入流程应接入并发批处理器');
assert.ok(!importSection.includes('setTimeout(r, 600)'), '导入流程不应再为每个账号固定等待 600ms');
const disabledAt = importSection.indexOf('导入后自动创建比特浏览器已停用');
const firstBreakAt = importSection.indexOf('break;');
const legacyBrowserBatchAt = importSection.indexOf('runImportBrowserBatch(readyAccounts');
assert.ok(disabledAt >= 0, '导入初始化任务必须明确停用浏览器创建');
assert.ok(firstBreakAt > disabledAt, '停用提示后应立即结束导入初始化任务');
assert.ok(legacyBrowserBatchAt === -1 || firstBreakAt < legacyBrowserBatchAt, '旧浏览器批处理代码不得再被执行');
const appSource = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
assert.ok(!appSource.includes('_runImportSetup('), '前端导入成功后不得启动浏览器初始化任务');
console.log('import_browser_batch_service.test.js PASS');
})().catch(error => {
+7 -10
View File
@@ -9,7 +9,7 @@ const vm = require('vm');
{ email: 'first@example.com', password: 'p1', browser_id: 'stale-window-1' },
{ email: 'second@example.com', password: 'p2', browser_id: '' },
];
let operationPayload = null;
let operationCalls = 0;
const context = vm.createContext({
console, Set, Map, Date, JSON, Math, String, Number, Object, Array, RegExp, Promise, Uint8Array,
setTimeout, clearTimeout,
@@ -18,8 +18,8 @@ const vm = require('vm');
return { json: async () => ({ success: true, count: 2, accounts: restoredAccounts, batchName: '待恢复' }) };
}
if (url === '/api/operations/run') {
operationPayload = JSON.parse(options.body);
return { json: async () => ({ success: true, taskId: 'restore-create-task' }) };
operationCalls += 1;
throw new Error('恢复账号不应启动浏览器任务');
}
throw new Error(`未处理的测试请求: ${url}`);
},
@@ -37,13 +37,10 @@ const vm = require('vm');
await app.restoreStagedBatch();
assert(operationPayload, '恢复后应自动启动创建浏览器任务');
assert.strictEqual(operationPayload.taskType, 'create_accounts');
assert.deepStrictEqual(Array.from(operationPayload.accounts, account => account.email), ['first@example.com', 'second@example.com']);
assert.strictEqual(operationPayload.params.reuseExistingBrowsers, true, '已有同名窗口时应复用并修正本地 ID');
assert.strictEqual(app.isRunning, true);
assert.strictEqual(app.currentTaskId, 'restore-create-task');
assert.strictEqual(app.__startedTaskId, 'restore-create-task');
assert.strictEqual(operationCalls, 0, '恢复只回写账号,比特浏览器应等具体任务开始时再创建');
assert.strictEqual(app.isRunning, false);
assert.strictEqual(app.currentTaskId, null);
assert.strictEqual(app.__startedTaskId, undefined);
assert.strictEqual(app.stagedBatchDetail, null);
console.log('staging_restore_browser_creation.test.js: PASS');
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const assert = require('assert');
const { TaskBrowserLifecycle } = require('../src/services/task_browser_lifecycle');
function createHarness(initialBrowsers = []) {
const order = [];
const notifications = [];
const rows = new Map();
let createCount = 0;
const lifecycle = new TaskBrowserLifecycle({
getBrowserListSimple: async () => initialBrowsers,
createBrowserFromAccount: async account => {
createCount++;
order.push(`create:${account.email}`);
return { browserId: `created-${createCount}`, browserSeq: createCount };
},
getApi: () => ({
updateBrowserProxy: async ids => {
order.push(`proxy:${ids[0]}`);
return { success: true };
},
getAliveBrowserPids: async () => ({ success: true, data: {} }),
}),
closeBrowser: async id => { order.push(`close:${id}`); return { success: true }; },
deleteBrowsersBatch: async (ids, callback) => {
order.push(`delete:${ids[0]}`);
callback(ids[0], true, '');
return { successCount: 1, total: 1 };
},
parseAccountProxy: value => (value
? { proxyMethod: 2, proxyType: 'socks5', host: '127.0.0.1', port: '1080' }
: { proxyMethod: 2, proxyType: 'noproxy' }),
dbWrite: async (fn, args) => {
if (fn === 'updateAccountBrowserId') {
const previous = rows.get(args[0]) || { email: args[0] };
rows.set(args[0], { ...previous, browser_id: args[1] || '', browser_seq: args[2] ?? null });
order.push(`db:${args[0]}:${args[1] || 'clear'}`);
return true;
}
if (fn === 'getAccount') return rows.get(args[0]) || null;
throw new Error(`unexpected db call: ${fn}`);
},
updateMessage: async () => {},
acquireRequestSlot: async () => { order.push('slot'); },
notify: message => notifications.push(message),
});
return { lifecycle, order, notifications, rows, getCreateCount: () => createCount };
}
(async () => {
{
const harness = createHarness([{ id: 'bit-existing', name: 'user@example.com', seq: 7 }]);
const account = { email: 'user@example.com', browser_id: 'stale-id', proxy_ip: '127.0.0.1:1080' };
const result = await harness.lifecycle.run(account, async prepared => {
harness.order.push(`work:${prepared.browser_id}`);
return { ok: true };
});
assert.deepStrictEqual(result, { ok: true });
assert.strictEqual(harness.getCreateCount(), 0, '同邮箱比特窗口应复用,旧 ID 不应阻止重绑');
assert(harness.order.indexOf('proxy:bit-existing') < harness.order.indexOf('work:bit-existing'));
assert(harness.order.indexOf('work:bit-existing') < harness.order.indexOf('delete:bit-existing'));
assert.strictEqual(account.browser_id, '', '任务结束后应清空账号浏览器绑定');
assert.strictEqual(harness.rows.get(account.email).browser_id, '');
assert.deepStrictEqual(harness.notifications.map(item => item.action), ['prepared', 'released']);
}
{
const harness = createHarness([]);
const account = { email: 'new@example.com', proxy_ip: '' };
const first = await harness.lifecycle.run(account, async prepared => ({
__defer: true,
browserId: prepared.browser_id,
}));
assert.strictEqual(first.__defer, true);
assert.strictEqual(account.browser_id, 'created-1', '延迟重试仍属于同一账号任务,不应提前删除窗口');
assert.strictEqual(harness.order.filter(item => item.startsWith('delete:')).length, 0);
const second = await harness.lifecycle.run(account, async prepared => ({ browserId: prepared.browser_id }));
assert.strictEqual(second.browserId, 'created-1');
assert.strictEqual(harness.getCreateCount(), 1, '延迟重试应继续复用当前临时窗口');
assert.strictEqual(account.browser_id, '');
assert.strictEqual(harness.order.filter(item => item === 'delete:created-1').length, 1);
}
console.log('task_browser_lifecycle.test.js PASS');
})().catch(error => {
console.error(error);
process.exitCode = 1;
});