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

This commit is contained in:
2026-09-23 18:15:08 +08:00
parent 04309f60c7
commit ff2879f102
15 changed files with 577 additions and 226 deletions
+11 -1
View File
@@ -2,7 +2,7 @@ const assert = require('assert');
const {
setOpenBrowserConcurrency,
_private: { Semaphore, openBrowserSemaphore, collectBrowserPages },
_private: { Semaphore, openBrowserSemaphore, collectBrowserPages, parseAccountProxy },
} = require('../src/core/bit_api');
(async () => {
@@ -57,6 +57,16 @@ const {
assert.strictEqual(largeBrowsers.length, 1250, '完整同步不能在1000个窗口处截断');
assert.strictEqual(largePageCalls.length, 13, '1250个窗口应按100个一页读取完成');
assert.deepStrictEqual(parseAccountProxy(''), { proxyMethod: 2, proxyType: 'noproxy' });
assert.deepStrictEqual(parseAccountProxy('127.0.0.1:1080:user:p:ass'), {
proxyMethod: 2,
proxyType: 'socks5',
host: '127.0.0.1',
port: '1080',
proxyUserName: 'user',
proxyPassword: 'p:ass',
});
console.log('bit_api_open_concurrency.test.js: PASS');
})().catch(error => {
console.error(error);
@@ -97,6 +97,26 @@ function yieldEventLoop() {
assert.strictEqual(deferredBatch.completed, 3, '延迟重试不应重复累计完成进度');
assert.strictEqual(deferredBatch.results[0].attempt, 2, '延迟项目到期后应在原索引写入最终结果');
const heldSlotRuns = [];
const heldSlotAttempts = new Map();
const heldSlotBatch = await runConcurrentItems(['sleeping', 'next'], async item => {
heldSlotRuns.push(item);
const attempt = (heldSlotAttempts.get(item) || 0) + 1;
heldSlotAttempts.set(item, attempt);
if (item === 'sleeping' && attempt === 1) return { defer: true, delayMs: 20 };
return { item, attempt };
}, {
concurrency: 1,
deferReleasesSlot: false,
getDeferDelay: result => (result && result.defer ? result.delayMs : 0),
});
assert.deepStrictEqual(
heldSlotRuns,
['sleeping', 'sleeping', 'next'],
'临时比特浏览器任务延迟等待时必须占住串行槽,删除后才能轮到下一账号',
);
assert.strictEqual(heldSlotBatch.completed, 2, '占住串行槽时仍应完成全部项目');
console.log('concurrent_runner_resilience.test.js: PASS');
})().catch(error => {
console.error(error);
+12 -7
View File
@@ -28,8 +28,10 @@ function responseStub() {
'获取Cli凭证按钮必须位于获取凭证和链接提取按钮之间');
assert.match(html, /Google 账号取号 API[\s\S]{0,2500}googleAccountCredentialForm\.value/,
'设置页应提供加密取号 API 配置');
assert.match(html, /处理账号数量(1~5000)[\s\S]{0,800}并发(1~30)/,
'任务弹窗应同时提供数量和并发');
assert.match(html, /处理账号数量(1~5000)[\s\S]{0,800}并发参数(兼容保留)/,
'任务弹窗应说明并发参数只为兼容保留');
assert.match(html, /临时比特浏览器[\s\S]{0,120}严格按邮箱串行处理:完成一个、删除一个,再开始下一个/,
'任务弹窗应告知用户临时比特浏览器严格串行');
assert(server.includes("/api/google-account-credentials"), '服务端应挂载取号凭据路由');
assert(operations.includes("'google_account_ag_credential'"), '取号任务必须受本机凭据任务保护');
assert(operations.includes('importAccountsFromText'), 'Worker 应通过主线程安全导入新账号');
@@ -44,15 +46,18 @@ function responseStub() {
'链接取号导入账号必须使用默认客户名“企业字-Free”');
assert.match(worker, /fetchGoogleAccounts\(\{[\s\S]{0,220}quantity:\s*1/,
'每次供应接口调用只能提取 1 个账号');
assert(worker.includes('const taskConcurrency = 1;'),
'浏览器账号必须固定串行,删除当前临时窗口后再处理下一个');
assert(worker.includes('const taskResults = await runConcurrent(taskSlots'),
'账号应由用户设定并发数驱动滑动队列,而不是写死固定工位数');
'账号应通过滑动队列按顺序处理');
const fetchOneAt = worker.indexOf('quantity: 1,');
const createBrowserAt = worker.indexOf("createBrowserFromAccount(account, '默认模板'", fetchOneAt);
const createBrowserAt = worker.indexOf('taskBrowserLifecycle.run(account', fetchOneAt);
const acquireCredentialAt = worker.indexOf("taskType: 'ag_get_credential'", createBrowserAt);
assert(fetchOneAt >= 0 && createBrowserAt > fetchOneAt && acquireCredentialAt > createBrowserAt,
'每个工位必须按“取 1 个→建浏览器→获取反重力凭证”的顺序处理');
assert.match(worker, /browserResult\.reused[\s\S]{0,500}proxyType:\s*'noproxy'/,
'复用孤立浏览器时也必须明确切换为无代理');
'每个工位必须按“取 1 个→准备临时比特窗口→获取反重力凭证”的顺序处理');
const slotTaskEnd = worker.indexOf('}, taskConcurrency', createBrowserAt);
assert.match(worker.slice(createBrowserAt, slotTaskEnd), /forceDirect:\s*true/,
'取号任务的临时比特窗口必须明确使用直连模式');
assert(worker.includes('ATOM_STEPS.ag_get_credential.step'),
'取号后必须复用反重力获取凭证步骤');
assert(!worker.includes('supplied.raw'), 'Worker 不得记录供应商原始响应');
+8 -2
View File
@@ -90,8 +90,14 @@ async function waitUntil(predicate, timeoutMs = 1000) {
const importStart = taskRunnerSource.indexOf("case 'import_setup'");
const importEnd = taskRunnerSource.indexOf('\n default:', importStart);
const importSection = taskRunnerSource.slice(importStart, importEnd);
assert.ok(importSection.includes('runImportBrowserBatch(readyAccounts'), '导入流程应接入并发批处理器');
assert.ok(!importSection.includes('setTimeout(r, 600)'), '导入流程不应再为每个账号固定等待 600ms');
const disabledAt = importSection.indexOf('导入后自动创建比特浏览器已停用');
const firstBreakAt = importSection.indexOf('break;');
const legacyBrowserBatchAt = importSection.indexOf('runImportBrowserBatch(readyAccounts');
assert.ok(disabledAt >= 0, '导入初始化任务必须明确停用浏览器创建');
assert.ok(firstBreakAt > disabledAt, '停用提示后应立即结束导入初始化任务');
assert.ok(legacyBrowserBatchAt === -1 || firstBreakAt < legacyBrowserBatchAt, '旧浏览器批处理代码不得再被执行');
const appSource = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
assert.ok(!appSource.includes('_runImportSetup('), '前端导入成功后不得启动浏览器初始化任务');
console.log('import_browser_batch_service.test.js PASS');
})().catch(error => {
+7 -10
View File
@@ -9,7 +9,7 @@ const vm = require('vm');
{ email: 'first@example.com', password: 'p1', browser_id: 'stale-window-1' },
{ email: 'second@example.com', password: 'p2', browser_id: '' },
];
let operationPayload = null;
let operationCalls = 0;
const context = vm.createContext({
console, Set, Map, Date, JSON, Math, String, Number, Object, Array, RegExp, Promise, Uint8Array,
setTimeout, clearTimeout,
@@ -18,8 +18,8 @@ const vm = require('vm');
return { json: async () => ({ success: true, count: 2, accounts: restoredAccounts, batchName: '待恢复' }) };
}
if (url === '/api/operations/run') {
operationPayload = JSON.parse(options.body);
return { json: async () => ({ success: true, taskId: 'restore-create-task' }) };
operationCalls += 1;
throw new Error('恢复账号不应启动浏览器任务');
}
throw new Error(`未处理的测试请求: ${url}`);
},
@@ -37,13 +37,10 @@ const vm = require('vm');
await app.restoreStagedBatch();
assert(operationPayload, '恢复后应自动启动创建浏览器任务');
assert.strictEqual(operationPayload.taskType, 'create_accounts');
assert.deepStrictEqual(Array.from(operationPayload.accounts, account => account.email), ['first@example.com', 'second@example.com']);
assert.strictEqual(operationPayload.params.reuseExistingBrowsers, true, '已有同名窗口时应复用并修正本地 ID');
assert.strictEqual(app.isRunning, true);
assert.strictEqual(app.currentTaskId, 'restore-create-task');
assert.strictEqual(app.__startedTaskId, 'restore-create-task');
assert.strictEqual(operationCalls, 0, '恢复只回写账号,比特浏览器应等具体任务开始时再创建');
assert.strictEqual(app.isRunning, false);
assert.strictEqual(app.currentTaskId, null);
assert.strictEqual(app.__startedTaskId, undefined);
assert.strictEqual(app.stagedBatchDetail, null);
console.log('staging_restore_browser_creation.test.js: PASS');
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const assert = require('assert');
const { TaskBrowserLifecycle } = require('../src/services/task_browser_lifecycle');
function createHarness(initialBrowsers = []) {
const order = [];
const notifications = [];
const rows = new Map();
let createCount = 0;
const lifecycle = new TaskBrowserLifecycle({
getBrowserListSimple: async () => initialBrowsers,
createBrowserFromAccount: async account => {
createCount++;
order.push(`create:${account.email}`);
return { browserId: `created-${createCount}`, browserSeq: createCount };
},
getApi: () => ({
updateBrowserProxy: async ids => {
order.push(`proxy:${ids[0]}`);
return { success: true };
},
getAliveBrowserPids: async () => ({ success: true, data: {} }),
}),
closeBrowser: async id => { order.push(`close:${id}`); return { success: true }; },
deleteBrowsersBatch: async (ids, callback) => {
order.push(`delete:${ids[0]}`);
callback(ids[0], true, '');
return { successCount: 1, total: 1 };
},
parseAccountProxy: value => (value
? { proxyMethod: 2, proxyType: 'socks5', host: '127.0.0.1', port: '1080' }
: { proxyMethod: 2, proxyType: 'noproxy' }),
dbWrite: async (fn, args) => {
if (fn === 'updateAccountBrowserId') {
const previous = rows.get(args[0]) || { email: args[0] };
rows.set(args[0], { ...previous, browser_id: args[1] || '', browser_seq: args[2] ?? null });
order.push(`db:${args[0]}:${args[1] || 'clear'}`);
return true;
}
if (fn === 'getAccount') return rows.get(args[0]) || null;
throw new Error(`unexpected db call: ${fn}`);
},
updateMessage: async () => {},
acquireRequestSlot: async () => { order.push('slot'); },
notify: message => notifications.push(message),
});
return { lifecycle, order, notifications, rows, getCreateCount: () => createCount };
}
(async () => {
{
const harness = createHarness([{ id: 'bit-existing', name: 'user@example.com', seq: 7 }]);
const account = { email: 'user@example.com', browser_id: 'stale-id', proxy_ip: '127.0.0.1:1080' };
const result = await harness.lifecycle.run(account, async prepared => {
harness.order.push(`work:${prepared.browser_id}`);
return { ok: true };
});
assert.deepStrictEqual(result, { ok: true });
assert.strictEqual(harness.getCreateCount(), 0, '同邮箱比特窗口应复用,旧 ID 不应阻止重绑');
assert(harness.order.indexOf('proxy:bit-existing') < harness.order.indexOf('work:bit-existing'));
assert(harness.order.indexOf('work:bit-existing') < harness.order.indexOf('delete:bit-existing'));
assert.strictEqual(account.browser_id, '', '任务结束后应清空账号浏览器绑定');
assert.strictEqual(harness.rows.get(account.email).browser_id, '');
assert.deepStrictEqual(harness.notifications.map(item => item.action), ['prepared', 'released']);
}
{
const harness = createHarness([]);
const account = { email: 'new@example.com', proxy_ip: '' };
const first = await harness.lifecycle.run(account, async prepared => ({
__defer: true,
browserId: prepared.browser_id,
}));
assert.strictEqual(first.__defer, true);
assert.strictEqual(account.browser_id, 'created-1', '延迟重试仍属于同一账号任务,不应提前删除窗口');
assert.strictEqual(harness.order.filter(item => item.startsWith('delete:')).length, 0);
const second = await harness.lifecycle.run(account, async prepared => ({ browserId: prepared.browser_id }));
assert.strictEqual(second.browserId, 'created-1');
assert.strictEqual(harness.getCreateCount(), 1, '延迟重试应继续复用当前临时窗口');
assert.strictEqual(account.browser_id, '');
assert.strictEqual(harness.order.filter(item => item === 'delete:created-1').length, 1);
}
console.log('task_browser_lifecycle.test.js PASS');
})().catch(error => {
console.error(error);
process.exitCode = 1;
});