初始化提交:go比特版本精简

This commit is contained in:
2026-09-20 17:02:11 +08:00
commit f41a979462
213 changed files with 58288 additions and 0 deletions
+711
View File
@@ -0,0 +1,711 @@
/**
* @file bit_api.js
* @brief 比特浏览器 Local API 封装
* @details 基于官方文档 https://doc2.bitbrowser.cn/
* 所有接口使用 POST 方法,JSON 格式
*/
const axios = require('axios');
const { getSetting } = require('./database');
// ==================== 信号量(限制并发打开浏览器请求) ====================
/**
* 简单信号量,用于限制同时进行的 openBrowser API 请求数
* 避免比特浏览器因请求过于集中而返回 429
*/
class Semaphore {
constructor(max) {
this.max = max;
this.current = 0;
this.queue = [];
}
acquire() {
return new Promise(resolve => {
if (this.current < this.max) {
this.current++;
resolve();
} else {
this.queue.push(resolve);
}
});
}
release() {
this.current--;
if (this.queue.length > 0 && this.current < this.max) {
this.current++;
this.queue.shift()();
}
}
/**
* 动态调整并发上限,并立即释放新增的可用名额。
* @param {number} max - 新的并发上限
* @returns {number} 标准化后的并发上限
*/
setMax(max) {
this.max = Math.max(1, Math.min(100, parseInt(max, 10) || 1));
while (this.current < this.max && this.queue.length > 0) {
this.current++;
this.queue.shift()();
}
return this.max;
}
/** 返回当前信号量状态,供诊断和测试使用。 */
getStats() {
return { max: this.max, current: this.current, queued: this.queue.length };
}
}
// 初始值仅用于任务配置前;每个 Worker 启动后会同步为用户设置的任务并发数。
const _openBrowserSem = new Semaphore(2);
/**
* 让比特浏览器打开请求并发跟随任务并发。
* 每秒启动速度仍由 browser_lifecycle 的 openThrottle 独立控制。
* @param {number} concurrency - 当前任务并发数
* @returns {number} 实际采用的打开请求并发数
*/
function setOpenBrowserConcurrency(concurrency) {
return _openBrowserSem.setMax(concurrency);
}
// ==================== API 类 ====================
class BitBrowserAPI {
/**
* 构造函数
* @param {string} [baseUrl='http://127.0.0.1:54345'] - 比特浏览器 API 基础 URL
*/
constructor(baseUrl = 'http://127.0.0.1:54345') {
this.baseUrl = baseUrl;
this.headers = { 'Content-Type': 'application/json' };
}
/**
* 发送 POST 请求到比特浏览器 API
* @param {string} endpoint - API 路径(如 '/browser/open')
* @param {object} [data={}] - 请求体数据
* @param {number} [timeout=30000] - 请求超时时间(毫秒)
* @returns {Promise<object>} API 响应数据
*/
async request(endpoint, data = {}, timeout = 30000) {
const url = `${this.baseUrl}${endpoint}`;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await axios.post(url, data, {
headers: this.headers,
timeout,
});
return response.data;
} catch (e) {
if (e.response && e.response.status === 429 && attempt < 2) {
const delay = (attempt + 1) * 1000; // 1s, 2s
await new Promise(r => setTimeout(r, delay));
continue;
}
if (e.response) {
return { success: false, msg: `HTTP ${e.response.status}: ${e.response.statusText}` };
}
return { success: false, msg: `请求失败: ${e.message}` };
}
}
}
/**
* 检测比特浏览器是否在线
* @returns {Promise<object>} 健康检查响应
*/
async healthCheck() {
return this.request('/health');
}
/**
* 创建新的浏览器窗口
* @param {object} [options={}] - 创建选项
* @param {string} [options.name='new browser'] - 窗口名称
* @param {string} [options.groupId] - 分组 ID
* @param {object} [options.browserFingerprint] - 指纹配置
* @param {number} [options.proxyMethod=2] - 代理方式
* @param {string} [options.proxyType='noproxy'] - 代理类型
* @returns {Promise<object>} 创建结果,data.id 为新窗口 ID
*/
async createBrowser({ name = 'new browser', groupId, browserFingerprint, proxyMethod = 2, proxyType = 'noproxy', ...rest } = {}) {
const data = {
name,
proxyMethod,
proxyType,
browserFingerPrint: browserFingerprint || {},
...rest,
};
if (groupId) data.groupId = groupId;
return this.request('/browser/update', data);
}
/**
* 打开指定浏览器窗口
* @param {string} browserId - 浏览器窗口 ID
* @param {object} [options={}] - 打开选项
* @param {string[]} [options.args] - 启动参数
* @param {boolean} [options.queue=true] - 是否排队等待
* @param {boolean} [options.ignoreDefaultUrls] - 是否忽略默认标签页
* @param {string} [options.newPageUrl] - 打开后跳转到的 URL
* @returns {Promise<object>} 打开结果,data.ws 为 WebSocket 端点
*/
async openBrowser(browserId, { args, queue = true, ignoreDefaultUrls, newPageUrl } = {}) {
const data = { id: browserId, queue };
if (args) data.args = args;
if (ignoreDefaultUrls) data.ignoreDefaultUrls = ignoreDefaultUrls;
if (newPageUrl) data.newPageUrl = newPageUrl;
return this.request('/browser/open', data, 60000);
}
/**
* 关闭指定浏览器窗口
* @param {string} browserId - 浏览器窗口 ID
* @returns {Promise<object>} 关闭结果
*/
async closeBrowser(browserId) {
return this.request('/browser/close', { id: browserId });
}
/**
* 删除指定浏览器窗口(含数据)
* @param {string} browserId - 浏览器窗口 ID
* @returns {Promise<object>} 删除结果
*/
async deleteBrowser(browserId) {
return this.request('/browser/delete', { id: browserId });
}
/**
* 批量删除浏览器窗口(最多 100 个)
* @param {string[]} browserIds - 浏览器窗口 ID 数组
* @returns {Promise<object>} 删除结果
*/
async deleteBrowsers(browserIds) {
return this.request('/browser/delete/ids', { ids: browserIds.slice(0, 100) });
}
/**
* 获取浏览器窗口详情
* @param {string} browserId - 浏览器窗口 ID
* @returns {Promise<object>} 窗口详情
*/
async getBrowserDetail(browserId) {
return this.request('/browser/detail', { id: browserId });
}
/**
* 分页列举浏览器窗口
* @param {object} [options={}] - 查询选项
* @param {number} [options.page=0] - 页码
* @param {number} [options.pageSize=10] - 每页数量(最大 100)
* @param {string} [options.groupId] - 按分组过滤
* @param {string} [options.name] - 按名称过滤
* @param {string} [options.sort='desc'] - 排序方向
* @returns {Promise<object>} 窗口列表
*/
async listBrowsers({ page = 0, pageSize = 10, groupId, name, sort = 'desc' } = {}) {
const data = { page, pageSize: Math.min(pageSize, 100), sort };
if (groupId) data.groupId = groupId;
if (name) data.name = name;
return this.request('/browser/list', data);
}
/**
* 批量更新浏览器代理设置
* @param {string[]} browserIds - 浏览器窗口 ID 数组
* @param {object} [options={}] - 代理配置
* @param {number} [options.proxyMethod=2] - 代理方式
* @param {string} [options.proxyType='noproxy'] - 代理类型
* @returns {Promise<object>} 更新结果
*/
async updateBrowserProxy(browserIds, { proxyMethod = 2, proxyType = 'noproxy', ...rest } = {}) {
return this.request('/browser/proxy/update', {
ids: browserIds,
proxyMethod,
proxyType,
...rest,
});
}
/**
* 查询浏览器进程是否存活
* @param {string[]} browserIds - 浏览器窗口 ID 数组
* @returns {Promise<object>} 进程状态映射,data[id] 为进程 PID 或 0
*/
async getAliveBrowserPids(browserIds) {
return this.request('/browser/pids/alive', { ids: browserIds });
}
}
// ==================== 全局实例(单例) ====================
let _apiInstance = null;
/**
* 从数据库设置中读取比特浏览器端口号
* @returns {number} 端口号,默认 54345
*/
function getBitBrowserPort() {
try {
const portStr = getSetting('bit_browser_port', '54345');
return parseInt(portStr, 10) || 54345;
} catch (e) {
return 54345;
}
}
/**
* 获取 BitBrowserAPI 单例实例
* @param {boolean} [forceNew=false] - 是否强制创建新实例(用于重读端口配置)
* @returns {BitBrowserAPI} API 实例
*/
function getApi(forceNew = false) {
if (!_apiInstance || forceNew) {
const port = getBitBrowserPort();
_apiInstance = new BitBrowserAPI(`http://127.0.0.1:${port}`);
}
return _apiInstance;
}
/**
* 重置 API 单例,下次调用 getApi 时重新创建
*/
function resetApi() {
_apiInstance = null;
}
// ==================== 代理设置 ====================
/**
* 在打开浏览器前根据账号配置更新代理设置
* 若浏览器已在运行则跳过代理更新
* @param {string} browserId - 浏览器窗口 ID
* @param {string} [email=''] - 账号邮箱,用于从数据库读取代理配置
* @returns {Promise<{success: boolean, msg?: string}>} 操作结果
*/
async function applyProxyBeforeOpen(browserId, email = '') {
try {
if (!email) return { success: true };
const { getAccount } = require('./database');
const account = getAccount(email);
if (!account) {
console.log(`[Proxy] 未找到账号记录,跳过代理设置: ${email}`);
return { success: true };
}
const proxyIp = (account.proxy_ip || '').trim();
console.log(`[Proxy] 准备设置代理: email=${email}, proxy_ip='${proxyIp}'`);
const api = getApi();
// 检查浏览器是否已在运行
const pidsResult = await api.getAliveBrowserPids([browserId]);
if (pidsResult.success) {
const pidsData = pidsResult.data || {};
if (pidsData[browserId]) {
console.log(`[Proxy] 浏览器已在运行中,跳过代理更新: ${browserId}`);
return { success: true };
}
}
if (proxyIp) {
const lower = proxyIp.toLowerCase();
if (['noproxy', 'direct', 'none'].includes(lower)) {
const result = await api.updateBrowserProxy([browserId], { proxyMethod: 2, proxyType: 'noproxy' });
if (!result.success) return { success: false, msg: result.msg || '代理设置失败' };
console.log(`[Proxy] 已设置为直连: ${browserId}`);
return { success: true };
}
const parts = proxyIp.split(':');
if (parts.length < 2) {
console.log(`[Proxy] 代理格式错误: ${proxyIp}`);
return { success: false, msg: `代理格式错误: ${proxyIp}` };
}
const proxyConfig = {
proxyMethod: 2,
proxyType: 'socks5',
host: parts[0].trim(),
port: parts[1].trim(),
proxyUserName: parts[2] ? parts[2].trim() : '',
proxyPassword: parts[3] ? parts[3].trim() : '',
};
console.log(`[Proxy] 代理配置: host=${proxyConfig.host}, port=${proxyConfig.port}`);
const result = await api.updateBrowserProxy([browserId], proxyConfig);
if (!result.success) {
const msg = result.msg || '';
if (msg.includes('操作失败')) {
console.log(`[Proxy] 代理更新被拒绝(浏览器可能已运行),跳过: ${msg}`);
return { success: true };
}
return { success: false, msg: msg || '代理设置失败' };
}
console.log(`[Proxy] 设置代理成功: ${browserId}`);
} else {
console.log(`[Proxy] 未配置代理,跳过设置: ${browserId}`);
}
return { success: true };
} catch (e) {
console.log(`[Proxy] 设置代理异常: ${e.message}`);
return { success: false, msg: e.message };
}
}
// ==================== 便捷函数 ====================
/**
* 打开浏览器(含代理预设置)
* @param {string} browserId - 浏览器窗口 ID
* @param {object} [options={}] - 打开选项
* @param {string[]} [options.args] - 启动参数
* @param {boolean} [options.queue=true] - 是否排队
* @param {string} [options.email=''] - 账号邮箱,用于设置代理
* @param {string} [options.newPageUrl] - 打开后跳转 URL
* @returns {Promise<object>} 打开结果,data.ws 为 WebSocket 端点
*/
async function openBrowser(browserId, { args, queue = true, email = '', newPageUrl } = {}) {
const proxyResult = await applyProxyBeforeOpen(browserId, email);
if (!proxyResult.success) return proxyResult;
return getApi().openBrowser(browserId, { args, queue, newPageUrl });
}
/**
* 带重试机制的浏览器打开函数
* @param {string} browserId - 浏览器窗口 ID
* @param {object} [options={}] - 选项
* @param {string} [options.email=''] - 账号邮箱
* @param {number} [options.maxRetries=3] - 最大重试次数
* @param {number} [options.retryDelay=2000] - 重试间隔(毫秒)
* @param {Function} [options.logCallback] - 日志回调函数,接收一个字符串参数
* @returns {Promise<object>} 打开结果,data.ws 为 WebSocket 端点
*/
async function openBrowserWithRetry(browserId, { email = '', maxRetries = 3, retryDelay = 2000, logCallback, ...opts } = {}) {
const log = (msg) => { if (logCallback) logCallback(msg); };
let lastResult = { success: false, msg: '未知错误' };
const RATE_LIMIT_RE = /429|请求频繁|rate.?limit|too.?many/i;
for (let attempt = 0; attempt < maxRetries; attempt++) {
// 重试前等待:429 用更长的退避,其他错误用 retryDelay
if (attempt > 0) {
const isRateLimit = RATE_LIMIT_RE.test(lastResult.msg || '');
const delay = isRateLimit ? attempt * 5000 : retryDelay;
log(`打开失败: ${lastResult.msg || ''},${delay / 1000}秒后重试...`);
await new Promise(r => setTimeout(r, delay));
}
log(`尝试打开浏览器 (第${attempt + 1}/${maxRetries}次)`);
// 通过信号量排队,限制同时发往比特浏览器的 open 请求数
await _openBrowserSem.acquire();
try {
const result = await openBrowser(browserId, { email, ...opts });
if (result.success) {
const data = result.data;
if (data && data.ws) return result;
lastResult = { success: false, msg: `API返回成功但ws端点为空: ${JSON.stringify(data)}` };
} else {
lastResult = result;
}
} catch (e) {
lastResult = { success: false, msg: e.message };
} finally {
_openBrowserSem.release();
}
}
log(`浏览器打开失败(已重试${maxRetries}次): ${lastResult.msg || ''}`);
return lastResult;
}
/**
* 关闭指定浏览器窗口
* @param {string} browserId - 浏览器窗口 ID
* @returns {Promise<object>} 关闭结果
*/
async function closeBrowser(browserId) {
return getApi().closeBrowser(browserId);
}
/**
* 获取所有浏览器窗口的简单列表
* @param {object} [options={}] - 查询选项
* @param {number} [options.page=0] - 页码
* @param {number} [options.maxItems=10000] - 最多读取的窗口总数
* @param {number} [options.requestPageSize=100] - 每次向比特API请求的数量(API上限100)
* @param {number} [options.pageSize] - 旧参数,未传 maxItems 时作为总数上限兼容
* @returns {Promise<object[]>} 浏览器窗口对象数组
*/
async function collectBrowserPages(api, options = {}) {
const { page = 0 } = options || {};
const startPage = Math.max(0, parseInt(page, 10) || 0);
const legacyLimit = options && options.pageSize !== undefined ? options.pageSize : null;
const maxItems = Math.max(1, parseInt(
options && options.maxItems !== undefined ? options.maxItems : (legacyLimit || 10000),
10,
) || 10000);
const pageRequestSize = Math.max(1, Math.min(100, parseInt(
options && options.requestPageSize !== undefined ? options.requestPageSize : 100,
10,
) || 100));
const browsers = [];
const seenIds = new Set();
let currentPage = startPage;
let totalNum = null;
const maxPages = Math.max(1, Math.ceil(maxItems / pageRequestSize) + 1);
while (browsers.length < maxItems && currentPage < startPage + maxPages) {
const requestSize = Math.min(pageRequestSize, maxItems - browsers.length);
const response = await api.listBrowsers({ page: currentPage, pageSize: requestSize });
if (!response || !response.success) break;
const data = response.data;
const list = Array.isArray(data) ? data : (data && Array.isArray(data.list) ? data.list : []);
if (data && !Array.isArray(data) && Number.isFinite(Number(data.totalNum))) {
totalNum = Number(data.totalNum);
}
if (list.length === 0) break;
const countBeforePage = browsers.length;
for (const browser of list) {
const id = String((browser && (browser.id || browser.browserId || browser.browser_id)) || '');
if (id && seenIds.has(id)) continue;
if (id) seenIds.add(id);
// 下游统一按 id 建立映射,兼容部分比特版本返回 browserId/browser_id。
browsers.push(id && browser.id !== id ? { ...browser, id } : browser);
if (browsers.length >= maxItems) break;
}
if (totalNum !== null && browsers.length >= totalNum) break;
if (browsers.length === countBeforePage) break;
if (list.length < requestSize) break;
currentPage++;
}
return browsers;
}
async function getBrowserListSimple(options = {}) {
return collectBrowserPages(getApi(), options);
}
/**
* 根据账号信息创建比特浏览器窗口
* @param {object} account - 账号信息,包含 email、proxy_ip 等字段
* @param {string} [browserName='默认模板'] - 窗口名称(账号无邮箱时使用)
* @param {object[]|null} [existingBrowsers=null] - 已有浏览器列表,为 null 时自动获取
* @param {boolean} [reuseExisting=false] - 同名窗口已存在时是否直接复用
* @returns {Promise<{browserId: string|null, error: string|null}>} 创建结果
*/
async function createBrowserFromAccount(account, browserName = '默认模板', existingBrowsers = null, reuseExisting = false) {
const api = getApi();
const jsonData = { ...DEFAULT_BROWSER_TEMPLATE };
jsonData.name = account.email || browserName;
// 设置代理
const 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 browsers = existingBrowsers !== null ? existingBrowsers : await getBrowserListSimple();
for (const b of browsers) {
const matchName = account.email && (b.name === account.email || b.userName === account.email);
if (matchName) {
// 检查是否为家庭组母号,如果是则复用
try {
const detail = await api.request('/browser/detail', { id: b.id });
if (detail.success && detail.data && detail.data.remark === '家庭组母号') {
console.log(`[BitAPI] 发现家庭组母号浏览器,复用: ${b.name} (${b.id})`);
const { updateAccountTag } = require('./database');
updateAccountTag(account.email, '家庭组母号');
return { browserId: b.id, browserSeq: b.seq ?? null, error: null, isFamily: true };
}
} catch (_) {}
if (reuseExisting) {
console.log(`[BitAPI] 发现已有同名窗口,直接复用: ${b.name} (${b.id})`);
return { browserId: b.id, browserSeq: b.seq ?? null, error: null, reused: true };
}
return { browserId: null, error: `该账号已有对应窗口: ${b.name} (ID: ${b.id})` };
}
}
try {
// 遇到"请求频繁"时最多重试 3 次,每次等待时间递增
const MAX_RETRIES = 3;
const RATE_LIMIT_PATTERN = /请求频繁|rate.?limit|too.?many.?request/i;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const result = await api.request('/browser/update', jsonData, 30000);
if (result.success) {
const browserId = result.data && result.data.id;
if (!browserId) return { browserId: null, error: 'API返回成功但未获取到ID' };
const browserSeq = result.data.seq ?? result.data.browserSeq ?? result.data.browser_seq ?? null;
return { browserId, browserSeq, error: null };
}
const msg = result.msg || '';
if (RATE_LIMIT_PATTERN.test(msg) && attempt < MAX_RETRIES) {
const waitMs = attempt * 2000; // 2s, 4s
console.log(`[BitAPI] 创建浏览器请求频繁,${waitMs / 1000}s 后重试 (${attempt}/${MAX_RETRIES}): ${msg}`);
await new Promise(r => setTimeout(r, waitMs));
continue;
}
return { browserId: null, error: msg || '创建失败' };
}
return { browserId: null, error: '请求频繁,重试次数已耗尽' };
} catch (e) {
return { browserId: null, error: `请求异常: ${e.message}` };
}
}
/**
* 逐个删除浏览器窗口(带回调)
* @param {string[]} browserIds - 要删除的浏览器 ID 数组
* @param {Function|null} [callback=null] - 每次删除后的回调,接收 (browserId, success, msg)
* @returns {Promise<{successCount: number, total: number}>} 删除统计
*/
async function deleteBrowsersBatch(browserIds, callback = null) {
const api = getApi();
const normalizedIds = Array.from(new Set((Array.isArray(browserIds) ? browserIds : []).filter(Boolean)));
let successCount = 0;
for (let start = 0; start < normalizedIds.length; start += 100) {
const chunk = normalizedIds.slice(start, start + 100);
try {
// 中文注释:优先走比特浏览器原生批量接口,显著缩短大批量删除耗时。
const batchResult = await api.deleteBrowsers(chunk);
if (batchResult && batchResult.success === true) {
successCount += chunk.length;
if (callback) chunk.forEach(browserId => callback(browserId, true, ''));
continue;
}
} catch (_) {}
// 中文注释:批量接口不可用时再回退到单个删除,保持兼容旧环境。
for (let i = 0; i < chunk.length; i++) {
const browserId = chunk[i];
const result = await api.deleteBrowser(browserId);
const success = result.success === true;
if (success) successCount++;
if (callback) callback(browserId, success, success ? '' : (result.msg || ''));
if (i < chunk.length - 1) await new Promise(r => setTimeout(r, 80));
}
}
return { successCount, total: normalizedIds.length };
}
// ==================== 默认浏览器模板 ====================
const DEFAULT_BROWSER_TEMPLATE = {
browserFingerPrint: {
coreVersion: '148',
ostype: 'PC',
os: 'Win32',
architecture: 'x86',
osVersion: '',
platformVersion: '10.0.0',
version: '',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.96 Safari/537.36',
isIpCreateTimeZone: true,
isIpCreatePosition: true,
isIpCreateDisplayLanguage: true,
isIpCreateLanguage: true,
webRTC: '3',
position: '1',
resolutionType: '0',
resolution: '1920 x 1080',
openWidth: 1280,
openHeight: 720,
fontType: '0',
canvas: '0',
webGL: '0',
webGLMeta: '0',
audioContext: '0',
mediaDevice: '1',
speechVoices: '0',
hardwareConcurrency: '6',
deviceMemory: '8',
deviceInfoEnabled: true,
clientRectNoiseEnabled: true,
doNotTrack: '0',
flash: '1',
portScanProtect: '0',
colorDepth: 24,
devicePixelRatio: 1,
enablePlugins: false,
windowSizeLimit: true,
coreProduct: 'chrome',
navigatorVendor: 'Google Inc.',
defaultAccuracy: 10,
},
proxyMethod: 2,
proxyType: 'noproxy',
ipCheckService: 'ip-api',
syncTabs: false,
syncCookies: true,
syncIndexedDb: true,
syncLocalStorage: true,
syncBookmarks: false,
syncAuthorization: true,
syncHistory: false,
syncGoogleAccount: false,
syncExtensions: false,
syncUserExtensions: false,
syncSessions: false,
allowedSignin: false,
clearCacheFilesBeforeLaunch: false,
clearCookiesBeforeLaunch: false,
clearHistoriesBeforeLaunch: false,
randomFingerprint: false,
muteAudio: false,
disableGpu: false,
enableBackgroundMode: false,
credentialsEnableService: false,
disableTranslatePopup: false,
disableClipboard: false,
disableNotifications: false,
memorySaver: false,
abortImage: false,
abortMedia: false,
stopWhileNetError: false,
stopWhileCountryChange: false,
stopWhileIpChange: false,
isRandomFinger: true,
remarkType: 1,
duplicateCheck: 0,
workbench: 'localserver',
};
module.exports = {
BitBrowserAPI,
getApi,
resetApi,
openBrowser,
openBrowserWithRetry,
setOpenBrowserConcurrency,
closeBrowser,
getBrowserListSimple,
createBrowserFromAccount,
deleteBrowsersBatch,
DEFAULT_BROWSER_TEMPLATE,
_private: { Semaphore, openBrowserSemaphore: _openBrowserSem, collectBrowserPages },
};