mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
refactor: 代码解耦合,完善WebUI功能,添加网页VNC
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
uploadFilesViaChooser
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
normalizePageError,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import {
|
||||
sleep,
|
||||
safeClick
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
gotoWithCheck,
|
||||
normalizePageError,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sleep,
|
||||
safeClick,
|
||||
pasteImages
|
||||
} from '../../browser/utils.js';
|
||||
} from '../engine/utils.js';
|
||||
import {
|
||||
fillPrompt,
|
||||
submit,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @fileoverview 浏览器启动与生命周期管理
|
||||
* @description 负责启动 Camoufox(Playwright 内核)、注入指纹与代理,并在进程退出时做资源清理。
|
||||
* 导航和预热行为由工作池负责,本模块只负责启动浏览器。
|
||||
*
|
||||
* 约定:
|
||||
* - 登录模式会尽量保留 Profile(用户数据目录)
|
||||
* - 清理采用三级退出:Playwright close -> SIGTERM -> SIGKILL
|
||||
*/
|
||||
|
||||
import { Camoufox } from 'camoufox-js';
|
||||
import { FingerprintGenerator } from 'fingerprint-generator';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createCursor } from 'ghost-cursor-playwright-port';
|
||||
import { getRealViewport, clamp, random, sleep } from './utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getBrowserProxy, cleanupProxy } from '../../utils/proxy.js';
|
||||
|
||||
// 全局状态:用于在登录模式下管理残留进程与复用上下文
|
||||
let globalBrowserProcess = null;
|
||||
let globalContext = null; // 替代 globalBrowser
|
||||
|
||||
/**
|
||||
* 清理浏览器资源和进程
|
||||
* 实现三级退出机制: Playwright close -> SIGTERM -> SIGKILL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function cleanup() {
|
||||
|
||||
// Level 1: 通过 Playwright 协议优雅关闭 Context,保存 Profile
|
||||
if (globalContext) {
|
||||
try {
|
||||
logger.debug('浏览器', '正在断开远程调试连接并保存 Profile...');
|
||||
await globalContext.close();
|
||||
globalContext = null;
|
||||
logger.debug('浏览器', '已关闭浏览器上下文');
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `关闭上下文失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2 & 3: 处理残留进程 (主要用于登录模式)
|
||||
if (globalBrowserProcess && !globalBrowserProcess.killed) {
|
||||
logger.info('浏览器', '正在终止浏览器进程...');
|
||||
try {
|
||||
// Level 2: 发送 SIGTERM (软杀)
|
||||
globalBrowserProcess.kill('SIGTERM');
|
||||
|
||||
// 等待进程退出
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 2000) {
|
||||
try {
|
||||
process.kill(globalBrowserProcess.pid, 0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
// Level 3: 强制查杀 (SIGKILL)
|
||||
try {
|
||||
process.kill(globalBrowserProcess.pid, 0);
|
||||
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
|
||||
process.kill(-globalBrowserProcess.pid, 'SIGKILL');
|
||||
} catch (e) { }
|
||||
|
||||
globalBrowserProcess = null;
|
||||
logger.info('浏览器', '浏览器进程已终止');
|
||||
}
|
||||
|
||||
// 清理代理
|
||||
await cleanupProxy();
|
||||
}
|
||||
|
||||
// 防止重复注册
|
||||
let signalHandlersRegistered = false;
|
||||
|
||||
/**
|
||||
* 注册进程退出信号处理
|
||||
* @private
|
||||
*/
|
||||
function registerCleanupHandlers() {
|
||||
if (signalHandlersRegistered) return;
|
||||
|
||||
process.on('exit', () => {
|
||||
if (globalBrowserProcess) globalBrowserProcess.kill();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前操作系统名称
|
||||
* 将 Node.js 的 platform 转换为 Camoufox/FingerprintGenerator 支持的格式
|
||||
*/
|
||||
function getCurrentOS() {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') return 'windows';
|
||||
if (platform === 'darwin') return 'macos';
|
||||
// 其他情况默认为 linux
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或生成持久化指纹
|
||||
* @param {string} filePath - JSON文件保存路径
|
||||
*/
|
||||
function getPersistentFingerprint(filePath) {
|
||||
// 确保 data 目录存在
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// 尝试读取现有指纹
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const savedData = JSON.parse(fileContent);
|
||||
|
||||
// 简单校验:确保读取的是一个对象
|
||||
if (savedData && typeof savedData === 'object') {
|
||||
return savedData;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `读取指纹文件失败,将重新生成: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新指纹
|
||||
const currentOS = getCurrentOS();
|
||||
logger.info('浏览器', `正在为系统 [${currentOS}] 生成新指纹...`);
|
||||
|
||||
// 为不同系统使用不同的配置策略
|
||||
const generatorOptions = {
|
||||
browsers: ['firefox'],
|
||||
operatingSystems: [currentOS],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
screen: {
|
||||
minWidth: 1280, maxWidth: 1366,
|
||||
minHeight: 720, maxHeight: 768
|
||||
}
|
||||
};
|
||||
|
||||
const generator = new FingerprintGenerator(generatorOptions);
|
||||
|
||||
const result = generator.getFingerprint();
|
||||
|
||||
// 关键点:我们只需要 result.fingerprint 部分
|
||||
const fingerprintToSave = result.fingerprint;
|
||||
|
||||
// 保存到文件
|
||||
fs.writeFileSync(filePath, JSON.stringify(fingerprintToSave, null, 2));
|
||||
logger.info('浏览器', `新指纹已保存至: ${filePath}`);
|
||||
|
||||
return fingerprintToSave;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动浏览器实例 (仅负责启动,不负责导航和预热)
|
||||
*
|
||||
* 导航到目标页面、注册导航处理器、预热行为由工作池 (pool.js) 负责。
|
||||
*
|
||||
* @param {object} config - 全局配置对象
|
||||
* @param {object} options - 启动选项
|
||||
* @param {string} options.userDataDir - 用户数据目录路径
|
||||
* @param {string} [options.userDataMark] - 用户数据目录标识 (用于日志显示)
|
||||
* @param {object} [options.proxyConfig] - Worker 级代理配置
|
||||
* @returns {Promise<{context: object, page: object}>} 浏览器上下文和初始页面
|
||||
*/
|
||||
export async function initBrowserBase(config, options = {}) {
|
||||
const {
|
||||
userDataDir,
|
||||
instanceName = null,
|
||||
proxyConfig = null
|
||||
} = options;
|
||||
|
||||
// 日志标识 (优先使用实例名称)
|
||||
const markLabel = instanceName || '默认';
|
||||
|
||||
// 检测登录模式和 Xvfb 模式
|
||||
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
||||
const isXvfbMode = process.env.XVFB_RUNNING === 'true';
|
||||
const headlessMode = config?.browser?.headless && !isLoginMode && !isXvfbMode;
|
||||
|
||||
// 如果配置了无头模式但被强制禁用,输出原因
|
||||
if (config?.browser?.headless && !headlessMode) {
|
||||
const reasons = [];
|
||||
if (isLoginMode) reasons.push('登录模式');
|
||||
if (isXvfbMode) reasons.push('Xvfb 模式');
|
||||
logger.info('浏览器', `[${markLabel}] 无头模式已被禁用 (${reasons.join(' + ')})`);
|
||||
}
|
||||
|
||||
logger.info('浏览器', `[${markLabel}] 启动浏览器实例...`);
|
||||
|
||||
const browserConfig = config?.browser || {};
|
||||
|
||||
// 获取指纹对象(指纹文件放在对应的 userDataDir 内)
|
||||
const fingerprintPath = path.join(userDataDir, 'fingerprint.json');
|
||||
const myFingerprint = getPersistentFingerprint(fingerprintPath);
|
||||
|
||||
// 构造 Camoufox 启动选项
|
||||
const currentOS = getCurrentOS();
|
||||
const camoufoxLaunchOptions = {
|
||||
executable_path: browserConfig.path || undefined,
|
||||
headless: headlessMode,
|
||||
user_data_dir: userDataDir,
|
||||
window: [1366, 768],
|
||||
ff_version: 135,
|
||||
fingerprint: myFingerprint,
|
||||
os: currentOS,
|
||||
i_know_what_im_doing: true,
|
||||
block_webrtc: true,
|
||||
exclude_addons: ['UBO'],
|
||||
geoip: true,
|
||||
config: {
|
||||
forceScopeAccess: true
|
||||
}
|
||||
};
|
||||
|
||||
// 代理配置
|
||||
const proxyObj = await getBrowserProxy(proxyConfig);
|
||||
if (proxyObj) {
|
||||
camoufoxLaunchOptions.proxy = proxyObj;
|
||||
}
|
||||
|
||||
// 启动 Camoufox
|
||||
const context = await Camoufox(camoufoxLaunchOptions);
|
||||
globalContext = context;
|
||||
|
||||
// 构建状态描述
|
||||
const statusParts = [];
|
||||
statusParts.push(`无头模式: ${headlessMode ? '是' : '否'}`);
|
||||
if (proxyObj) statusParts.push('代理: 已配置');
|
||||
logger.info('浏览器', `[${markLabel}] 浏览器已启动 (${statusParts.join(', ')})`);
|
||||
|
||||
// 注册清理处理器
|
||||
registerCleanupHandlers();
|
||||
|
||||
// 注册断开连接事件
|
||||
context.on('close', async () => {
|
||||
logger.warn('浏览器', `[${markLabel}] 浏览器已断开连接`);
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 获取或创建 Page
|
||||
let page;
|
||||
const existingPages = context.pages();
|
||||
if (existingPages.length > 0) {
|
||||
page = existingPages[0];
|
||||
} else {
|
||||
page = await context.newPage();
|
||||
}
|
||||
|
||||
// 强制刷新视口大小
|
||||
await page.setViewportSize({ width: 1366, height: 768 });
|
||||
|
||||
// 返回 context 和 page(导航、预热、cursor 初始化由工作池负责)
|
||||
return {
|
||||
context,
|
||||
page
|
||||
};
|
||||
}
|
||||
|
||||
// 导出工具函数供 pool.js 使用
|
||||
export { createCursor, getRealViewport, clamp, random, sleep };
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* @fileoverview 浏览器自动化工具函数
|
||||
* @description 封装 Playwright 页面常用操作,供后端适配器复用。
|
||||
*
|
||||
* 职责边界:
|
||||
* - 浏览器原子操作(点击、输入、上传等)
|
||||
* - 页面状态检测(isPageValid、createPageCloseWatcher)
|
||||
* - 拟人化交互(humanType、safeClick)
|
||||
* - 工具函数(random、sleep、getMimeType)
|
||||
*
|
||||
* 注意:业务逻辑应放在 backend/utils.js
|
||||
*
|
||||
* 主要函数:
|
||||
* - `random` / `sleep`:随机与延迟工具
|
||||
* - `getMimeType`:根据文件扩展名推断 MIME
|
||||
* - `getRealViewport` / `clamp`:视口与坐标工具(防止越界)
|
||||
* - `queryDeep`:深层查询(包含 Shadow DOM / iframe)
|
||||
* - `safeClick` / `humanType`:拟人化点击与输入
|
||||
* - `pasteImages` / `uploadFilesViaChooser`:图片粘贴/上传辅助
|
||||
* - `isPageValid` / `createPageCloseWatcher`:页面有效性与关闭/崩溃监听
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* 生成指定范围内的随机数
|
||||
* @param {number} min - 最小值
|
||||
* @param {number} max - 最大值
|
||||
* @returns {number} 随机数
|
||||
*/
|
||||
export function random(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机休眠一段时间
|
||||
* @param {number} min - 最小毫秒数
|
||||
* @param {number} max - 最大毫秒数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function sleep(min, max) {
|
||||
return new Promise(r => setTimeout(r, Math.floor(random(min, max))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取 MIME 类型
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {string} MIME 类型
|
||||
*/
|
||||
export function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const map = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 无痕获取当前页面实时视口
|
||||
* 使用纯净的匿名函数执行,不污染 Global Scope
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
||||
* @returns {Promise<{width: number, height: number, safeWidth: number, safeHeight: number}>} 视口尺寸及安全区域
|
||||
*/
|
||||
export async function getRealViewport(page) {
|
||||
try {
|
||||
return await page.evaluate(() => {
|
||||
// 仅读取标准属性,不进行任何写入操作
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
return {
|
||||
width: w,
|
||||
height: h,
|
||||
// 预留 20px 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
|
||||
safeWidth: w - 20,
|
||||
safeHeight: h
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback: 如果上下文丢失,返回安全保守值
|
||||
return { width: 1280, height: 720, safeWidth: 1260, safeHeight: 720 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 坐标钳位函数
|
||||
* 强制将坐标限制在合法视口范围内,防止 "Node is not visible" 报错
|
||||
* @param {number} value - 原始坐标值
|
||||
* @param {number} min - 最小值
|
||||
* @param {number} max - 最大值
|
||||
* @returns {number} 修正后的坐标值
|
||||
*/
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度查找 Shadow DOM 中的元素
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
||||
* @param {string} selector - CSS 选择器
|
||||
* @param {import('playwright-core').ElementHandle} [rootHandle=null] - 可选的根节点句柄
|
||||
* @returns {Promise<import('playwright-core').ElementHandle|null>} 找到的元素句柄或 null
|
||||
*/
|
||||
export async function queryDeep(page, selector, rootHandle = null) {
|
||||
// Playwright evaluateHandle 只接受一个参数,包装成数组传递
|
||||
return await page.evaluateHandle(([sel, root]) => {
|
||||
function find(node, s) {
|
||||
if (!node) return null;
|
||||
if (node instanceof Element && node.matches(s)) return node;
|
||||
let found = node.querySelector(s);
|
||||
if (found) return found;
|
||||
if (node.shadowRoot) {
|
||||
found = find(node.shadowRoot, s);
|
||||
if (found) return found;
|
||||
}
|
||||
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const child = walker.currentNode;
|
||||
if (child.shadowRoot) {
|
||||
found = find(child.shadowRoot, s);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(root || document.body, sel);
|
||||
}, [selector, rootHandle]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算拟人化的随机点击坐标
|
||||
* @param {object} box - 元素边界框 {x, y, width, height}
|
||||
* @param {string} [type='random'] - 点击类型: 'input'(偏左) 或 'random'/'button'(随机)
|
||||
* @returns {{x: number, y: number}} 计算出的坐标
|
||||
*/
|
||||
export function getHumanClickPoint(box, type = 'random') {
|
||||
let x, y;
|
||||
if (type === 'input') {
|
||||
// 输入框: 偏左 (5% - 40% 宽度), 垂直居中附近 (20% - 80% 高度)
|
||||
x = box.x + box.width * random(0.05, 0.4);
|
||||
y = box.y + box.height * random(0.2, 0.8);
|
||||
} else {
|
||||
// 按钮/其他: 中心附近随机 (20% - 80% 宽度/高度)
|
||||
x = box.x + box.width * random(0.2, 0.8);
|
||||
y = box.y + box.height * random(0.2, 0.8);
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素 (包含拟人化移动和点击)
|
||||
* 支持 CSS selector、ElementHandle 和 Locator 三种输入
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string|import('playwright-core').ElementHandle|import('playwright-core').Locator} target - CSS 选择器、元素句柄或 Locator
|
||||
* @param {object} [options] - 点击选项
|
||||
* @param {string} [options.bias='random'] - 偏移偏好: 'input' 或 'random'
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function safeClick(page, target, options = {}) {
|
||||
try {
|
||||
let el;
|
||||
|
||||
// 判断输入类型
|
||||
if (typeof target === 'string') {
|
||||
// CSS selector
|
||||
el = await page.$(target);
|
||||
if (!el) throw new Error(`未找到: ${target}`);
|
||||
} else if (typeof target.elementHandle === 'function') {
|
||||
// Locator (来自 page.getByRole, page.getByText 等)
|
||||
el = await target.elementHandle();
|
||||
if (!el) throw new Error(`Locator 未匹配到元素`);
|
||||
} else {
|
||||
// ElementHandle
|
||||
el = target;
|
||||
if (!el || !el.asElement()) throw new Error(`Element handle invalid`);
|
||||
}
|
||||
|
||||
// 使用 ghost-cursor 点击
|
||||
if (page.cursor) {
|
||||
const box = await el.boundingBox();
|
||||
if (box) {
|
||||
const { x, y } = getHumanClickPoint(box, options.bias || 'random');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
await page.mouse.click(x, y);
|
||||
return;
|
||||
}
|
||||
// 如果无法获取 box,降级到默认点击
|
||||
await page.cursor.click(el);
|
||||
return;
|
||||
}
|
||||
|
||||
// 降级逻辑
|
||||
await el.click();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟人类键盘输入
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {string} text - 要输入的文本
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function humanType(page, target, text) {
|
||||
let el;
|
||||
|
||||
// 判断是 selector 还是 ElementHandle
|
||||
if (typeof target === 'string') {
|
||||
el = await page.$(target);
|
||||
if (!el) throw new Error(`Element not found: ${target}`);
|
||||
} else {
|
||||
el = target;
|
||||
if (!el) throw new Error(`Element handle invalid`);
|
||||
}
|
||||
|
||||
await el.focus();
|
||||
|
||||
// 智能输入策略
|
||||
if (text.length < 50) {
|
||||
// 短文本: 保持拟人化逐字输入
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
// 处理换行符 (避免触发发送)
|
||||
if (char === '\r' && nextChar === '\n') {
|
||||
// Windows 换行符 (\r\n)
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.up('Shift');
|
||||
i++; // 跳过 \n
|
||||
await sleep(30, 100);
|
||||
continue;
|
||||
} else if (char === '\n' || char === '\r') {
|
||||
// Unix/Mac 换行符 (\n 或 \r)
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.up('Shift');
|
||||
await sleep(30, 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 模拟错字 (5% 概率)
|
||||
if (Math.random() < 0.05) {
|
||||
await page.keyboard.type('x', { delay: random(50, 150) });
|
||||
await sleep(100, 300);
|
||||
await page.keyboard.press('Backspace', { delay: random(50, 100) });
|
||||
}
|
||||
await page.keyboard.type(char, { delay: random(30, 100) });
|
||||
// 随机击键间隔
|
||||
await sleep(30, 100);
|
||||
}
|
||||
} else {
|
||||
// 长文本: 假装打字 -> 停顿 -> 粘贴
|
||||
const fakeCount = Math.floor(random(3, 8));
|
||||
const fakeText = text.substring(0, fakeCount);
|
||||
|
||||
// 1. 假装打字几个字符
|
||||
for (let i = 0; i < fakeText.length; i++) {
|
||||
await page.keyboard.type(fakeText[i], { delay: random(30, 100) });
|
||||
}
|
||||
|
||||
// 2. 停顿思考
|
||||
await sleep(500, 1000);
|
||||
|
||||
// 3. 全选删除 (macOS 使用 Meta/Command, Windows/Linux 使用 Control)
|
||||
const modifierKey = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
await page.keyboard.down(modifierKey);
|
||||
await page.keyboard.press('A');
|
||||
await page.keyboard.up(modifierKey);
|
||||
await sleep(100, 300);
|
||||
await page.keyboard.press('Backspace');
|
||||
await sleep(100, 300);
|
||||
|
||||
// 4. 瞬间粘贴全部文本 (始终使用已获取的 ElementHandle,支持 Shadow DOM)
|
||||
await page.evaluate((content) => {
|
||||
document.execCommand('insertText', false, content);
|
||||
}, text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找页面上所有的文件输入框 (包括 Shadow DOM)
|
||||
* @private
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @returns {Promise<import('playwright-core').ElementHandle[]>} 文件输入框 ElementHandle 数组
|
||||
*/
|
||||
async function findAllFileInputs(page) {
|
||||
// 使用 Playwright 的 evaluateHandle 在浏览器上下文中深度遍历
|
||||
const inputsHandle = await page.evaluateHandle(() => {
|
||||
const inputs = [];
|
||||
|
||||
function traverse(root) {
|
||||
if (!root) return;
|
||||
|
||||
// 1. 检查当前节点下的 input
|
||||
const nodes = root.querySelectorAll('input[type="file"]');
|
||||
nodes.forEach(n => inputs.push(n));
|
||||
|
||||
// 2. 遍历 Shadow DOM
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (node.shadowRoot) {
|
||||
traverse(node.shadowRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(document.body);
|
||||
return inputs;
|
||||
});
|
||||
|
||||
const properties = await inputsHandle.getProperties();
|
||||
const handles = [];
|
||||
for (const prop of properties.values()) {
|
||||
const elementHandle = prop.asElement();
|
||||
if (elementHandle) handles.push(elementHandle);
|
||||
}
|
||||
return handles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一图片上传入口 (Camoufox/Playwright 专用稳定版)
|
||||
* 策略: 深度搜索原生 input[type="file"] -> setInputFiles
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄 (用于聚焦)
|
||||
* @param {string[]} filePaths - 图片文件路径数组
|
||||
* @param {Object} [options] - 可选配置
|
||||
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function pasteImages(page, target, filePaths, options = {}) {
|
||||
if (!filePaths || filePaths.length === 0) return;
|
||||
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片...`);
|
||||
|
||||
// 1. 拟人化: 先点击一下目标区域 (让后台看起来像是用户聚焦了输入框)
|
||||
await safeClick(page, target, { bias: 'input' });
|
||||
await sleep(500, 1000);
|
||||
|
||||
try {
|
||||
logger.debug('浏览器', '正在深度扫描文件上传控件...');
|
||||
const fileInputs = await findAllFileInputs(page);
|
||||
|
||||
if (fileInputs.length === 0) {
|
||||
throw new Error('未找到任何 input[type="file"] 控件,无法上传');
|
||||
}
|
||||
|
||||
logger.info('浏览器', `找到 ${fileInputs.length} 个文件输入框,尝试上传...`);
|
||||
|
||||
// LMArena 通常只有一个用于聊天的上传控件,或者我们尝试第一个可用的
|
||||
// 如果有多个,通常最后一个是当前对话框的,或者我们可以尝试全部 (比较暴力但有效)
|
||||
let uploaded = false;
|
||||
|
||||
for (const handle of fileInputs) {
|
||||
try {
|
||||
// 检查元素是否连接在 DOM 上
|
||||
const isConnected = await handle.evaluate(el => el.isConnected);
|
||||
if (!isConnected) continue;
|
||||
|
||||
// 使用 Playwright 原生上传 (绕过所有事件拦截)
|
||||
await handle.setInputFiles(filePaths);
|
||||
uploaded = true;
|
||||
logger.debug('浏览器', '已通过原生控件提交图片');
|
||||
break; // 只要有一个成功就停止
|
||||
} catch (e) {
|
||||
// 忽略不可操作的 input (比如被禁用的)
|
||||
logger.debug('浏览器', `跳过不可用的文件输入框: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!uploaded) {
|
||||
throw new Error('所有文件控件均无法接受输入');
|
||||
}
|
||||
|
||||
// 如果提供了自定义的上传确认函数,使用它
|
||||
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
|
||||
const expectedUploads = filePaths.length;
|
||||
let validatedCount = 0;
|
||||
|
||||
const uploadPromise = new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
logger.warn('浏览器', `图片上传等待超时 (已确认: ${validatedCount}/${expectedUploads})`);
|
||||
resolve();
|
||||
}, 60000); // 60s 超时
|
||||
|
||||
const onResponse = (response) => {
|
||||
if (options.uploadValidator(response)) {
|
||||
validatedCount++;
|
||||
logger.info('浏览器', `图片上传进度: ${validatedCount}/${expectedUploads}`);
|
||||
if (validatedCount >= expectedUploads) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
page.off('response', onResponse);
|
||||
};
|
||||
|
||||
page.on('response', onResponse);
|
||||
});
|
||||
|
||||
logger.info('浏览器', `已提交图片, 正在等待上传确认...`);
|
||||
await uploadPromise;
|
||||
logger.info('浏览器', `所有图片上传完成`);
|
||||
} else {
|
||||
// 默认行为: 等待上传预览出现
|
||||
logger.info('浏览器', `已提交图片, 等待预览生成...`);
|
||||
await sleep(2000, 4000);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logger.error('浏览器', `上传失败: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 filechooser 事件上传文件 (适用于无 DOM input 元素的场景,如 Firefox)
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面对象
|
||||
* @param {string|import('playwright-core').ElementHandle|import('playwright-core').Locator} triggerTarget - 触发文件选择的按钮
|
||||
* @param {string[]} filePaths - 文件路径数组
|
||||
* @param {Object} [options] - 可选配置
|
||||
* @param {Function} [options.uploadValidator] - 自定义上传确认回调函数, 接收 response 参数,返回 true 表示该响应代表一次成功上传
|
||||
* @param {number} [options.timeout=60000] - 上传超时时间 (毫秒)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function uploadFilesViaChooser(page, triggerTarget, filePaths, options = {}) {
|
||||
if (!filePaths || filePaths.length === 0) return;
|
||||
|
||||
const timeout = options.timeout || 60000;
|
||||
const expectedUploads = filePaths.length;
|
||||
let uploadedCount = 0;
|
||||
|
||||
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片 (filechooser 模式)...`);
|
||||
|
||||
// 设置上传确认监听
|
||||
const uploadPromise = new Promise((resolve) => {
|
||||
if (!options.uploadValidator) {
|
||||
// 无验证器,直接 resolve
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
logger.warn('浏览器', `图片上传等待超时 (已确认: ${uploadedCount}/${expectedUploads})`);
|
||||
resolve();
|
||||
}, timeout);
|
||||
|
||||
const onResponse = (response) => {
|
||||
if (options.uploadValidator(response)) {
|
||||
uploadedCount++;
|
||||
logger.info('浏览器', `图片上传进度: ${uploadedCount}/${expectedUploads}`);
|
||||
if (uploadedCount >= expectedUploads) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
page.off('response', onResponse);
|
||||
};
|
||||
|
||||
page.on('response', onResponse);
|
||||
});
|
||||
|
||||
// 设置等待 filechooser 事件(在点击之前)
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
|
||||
// 点击触发按钮
|
||||
await safeClick(page, triggerTarget, { bias: 'button' });
|
||||
|
||||
// 等待 filechooser 事件并设置文件
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(filePaths);
|
||||
logger.debug('浏览器', '已通过 filechooser 提交文件');
|
||||
|
||||
// 等待上传完成(如果有验证器)
|
||||
if (options.uploadValidator) {
|
||||
await uploadPromise;
|
||||
logger.info('浏览器', '所有图片上传完成');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查页面是否有效
|
||||
* @param {import('playwright-core').Page} page
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPageValid(page) {
|
||||
try {
|
||||
return page && !page.isClosed();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建页面关闭/崩溃监听Promise
|
||||
* @param {import('playwright-core').Page} page
|
||||
* @returns {{promise: Promise, cleanup: Function}}
|
||||
*/
|
||||
export function createPageCloseWatcher(page) {
|
||||
let closeHandler, crashHandler;
|
||||
|
||||
const promise = new Promise((_, reject) => {
|
||||
closeHandler = () => reject(new Error('PAGE_CLOSED'));
|
||||
crashHandler = () => reject(new Error('PAGE_CRASHED'));
|
||||
|
||||
page.once('close', closeHandler);
|
||||
page.once('crash', crashHandler);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (closeHandler) page.off('close', closeHandler);
|
||||
if (crashHandler) page.off('crash', crashHandler);
|
||||
};
|
||||
|
||||
return { promise, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前页面的所有 Cookies (实时从浏览器获取)
|
||||
* @param {import('playwright-core').Page} page - Playwright 页面实例
|
||||
* @returns {Promise<object[]>} Cookies 数组 (JSON 格式)
|
||||
*/
|
||||
export async function getCookies(page) {
|
||||
const context = page.context();
|
||||
return await context.cookies();
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import { loadConfig } from '../config/index.js';
|
||||
import { PoolManager } from './pool/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { registry } from '../registry.js';
|
||||
import { createStrategySelector } from '../strategy.js';
|
||||
import { executeWithFailover } from '../failover.js';
|
||||
import { createStrategySelector } from '../strategies/index.js';
|
||||
import { executeWithFailover } from '../strategies/failover.js';
|
||||
import { normalizeError } from '../utils/error.js';
|
||||
import { Worker } from './Worker.js';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { initBrowserBase, createCursor } from '../../browser/launcher.js';
|
||||
import { initBrowserBase, createCursor } from '../engine/launcher.js';
|
||||
import { registry } from '../registry.js';
|
||||
import { tryGotoWithCheck } from '../utils/page.js';
|
||||
|
||||
@@ -137,13 +137,14 @@ export class Worker {
|
||||
logger.info('工作池', `[${this.name}] 正在连接目标页面...`);
|
||||
await this._navigateToTarget(targetUrl);
|
||||
|
||||
// 登录模式处理
|
||||
// 登录模式:注册浏览器关闭事件(不阻塞)
|
||||
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
||||
if (isLoginMode) {
|
||||
logger.info('工作池', `[${this.name}] 登录模式已就绪,请在浏览器中完成登录`);
|
||||
logger.info('工作池', `[${this.name}] 完成后可直接关闭浏览器窗口或按 Ctrl+C 退出`);
|
||||
await new Promise(resolve => this.browser.on('close', resolve));
|
||||
process.exit(0);
|
||||
this.browser.on('close', () => {
|
||||
logger.info('工作池', `[${this.name}] 浏览器已关闭,登录模式结束`);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('工作池', `[${this.name}] 初始化完成`);
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* - 负载均衡策略在 strategy.js 中
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { RETRY } from '../utils/constants.js';
|
||||
import { isRetryableError, normalizeError } from './utils/error.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { RETRY } from '../../utils/constants.js';
|
||||
import { isRetryableError, normalizeError } from '../utils/error.js';
|
||||
|
||||
// 重新导出错误分类函数以保持兼容性
|
||||
export { isRetryableError, normalizeError };
|
||||
@@ -3,7 +3,7 @@
|
||||
* @description 页面认证锁、输入框等待、表单提交等页面级操作
|
||||
*/
|
||||
|
||||
import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../../browser/utils.js';
|
||||
import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../engine/utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// ==========================================
|
||||
|
||||
Reference in New Issue
Block a user