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