diff --git a/README.md b/README.md
index 7540074..262f7a1 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,8 @@ node server.js
> 浏览器自动化功能依赖本机运行的「比特浏览器」(本地接口 `127.0.0.1:54345`)。服务器本身不需要它即可启动,但执行"打开浏览器"等任务前必须先启动比特浏览器,否则日志会出现 `ECONNREFUSED 127.0.0.1:54345`。
+账号导入和暂存恢复只写入数据库,不预创建比特浏览器窗口。执行登录、改密、获取凭证、打开浏览器或打开网址等浏览器任务时,系统会为当前邮箱创建或复用临时比特窗口并同步代理;单账号任务结束后立即关闭、删除窗口并清空本地浏览器编号,然后再处理下一个邮箱。
+
## 主要依赖
| 包 | 用途 |
diff --git a/public/app.js b/public/app.js
index 104ef3b..033a3c3 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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 });
diff --git a/public/index.html b/public/index.html
index af70252..4e4c44e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -204,7 +204,6 @@
操作
-
@@ -2082,6 +2081,9 @@
跳过重复邮箱(已有账号不做任何修改)
+
+ 导入只保存账号;执行具体任务时才创建临时比特浏览器窗口,单个账号处理结束后自动删除。
+
@@ -2162,7 +2164,7 @@
+ title="恢复只回写账号,具体任务执行时再创建临时比特窗口">恢复到主列表
@@ -2355,13 +2357,13 @@
class="w-full" @keydown.enter.prevent="confirmGoogleAccountAgTask()">
-
+
- 供应数据中的辅助邮箱及后续字段会被忽略;数据库已有邮箱会跳过且不覆盖。创建浏览器时固定使用直连(无代理),并发同时用于浏览器创建和反重力凭证获取。
+ 供应数据中的辅助邮箱及后续字段会被忽略;数据库已有邮箱会跳过且不覆盖。临时比特浏览器固定使用直连(无代理),并严格按邮箱串行处理:完成一个、删除一个,再开始下一个。
diff --git a/src/core/bit_api.js b/src/core/bit_api.js
index 99ed432..88ac66e 100644
--- a/src/core/bit_api.js
+++ b/src/core/bit_api.js
@@ -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 },
};
diff --git a/src/pipeline/browser_lifecycle.js b/src/pipeline/browser_lifecycle.js
index 760a175..a6c5bb8 100644
--- a/src/pipeline/browser_lifecycle.js
+++ b/src/pipeline/browser_lifecycle.js
@@ -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 (_) { }
}
diff --git a/src/routes/operations.js b/src/routes/operations.js
index 57bb95b..31a6573 100644
--- a/src/routes/operations.js
+++ b/src/routes/operations.js
@@ -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) {
diff --git a/src/services/task_browser_lifecycle.js b/src/services/task_browser_lifecycle.js
new file mode 100644
index 0000000..0dd5292
--- /dev/null
+++ b/src/services/task_browser_lifecycle.js
@@ -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 },
+};
diff --git a/src/utils/concurrent_runner.js b/src/utils/concurrent_runner.js
index c4ff620..d9676cf 100644
--- a/src/utils/concurrent_runner.js
+++ b/src/utils/concurrent_runner.js
@@ -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) {
diff --git a/src/workers/task_runner.js b/src/workers/task_runner.js
index fe6b03b..8d705b8 100644
--- a/src/workers/task_runner.js
+++ b/src/workers/task_runner.js
@@ -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 {
diff --git a/tests/bit_api_open_concurrency.test.js b/tests/bit_api_open_concurrency.test.js
index 22beee1..65bd36d 100644
--- a/tests/bit_api_open_concurrency.test.js
+++ b/tests/bit_api_open_concurrency.test.js
@@ -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);
diff --git a/tests/concurrent_runner_resilience.test.js b/tests/concurrent_runner_resilience.test.js
index e07618b..f9473d8 100644
--- a/tests/concurrent_runner_resilience.test.js
+++ b/tests/concurrent_runner_resilience.test.js
@@ -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);
diff --git a/tests/google_account_ag_feature.test.js b/tests/google_account_ag_feature.test.js
index 1a1d814..6dfe0b2 100644
--- a/tests/google_account_ag_feature.test.js
+++ b/tests/google_account_ag_feature.test.js
@@ -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 不得记录供应商原始响应');
diff --git a/tests/import_browser_batch_service.test.js b/tests/import_browser_batch_service.test.js
index eef7557..0ba44a4 100644
--- a/tests/import_browser_batch_service.test.js
+++ b/tests/import_browser_batch_service.test.js
@@ -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 => {
diff --git a/tests/staging_restore_browser_creation.test.js b/tests/staging_restore_browser_creation.test.js
index 9658858..e99f198 100644
--- a/tests/staging_restore_browser_creation.test.js
+++ b/tests/staging_restore_browser_creation.test.js
@@ -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');
diff --git a/tests/task_browser_lifecycle.test.js b/tests/task_browser_lifecycle.test.js
new file mode 100644
index 0000000..8316cc1
--- /dev/null
+++ b/tests/task_browser_lifecycle.test.js
@@ -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;
+});