feat: 将项目迁移到Playwright+Camoufox方案

This commit is contained in:
foxhui
2025-12-06 23:37:36 +08:00
Unverified
parent 5c758a7288
commit e88d4941c9
23 changed files with 2901 additions and 2314 deletions
+191 -236
View File
@@ -1,52 +1,48 @@
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import { createCursor } from 'ghost-cursor';
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
import { spawn } from 'child_process';
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 '../logger.js';
// 配置 Stealth 插件
const stealth = StealthPlugin();
stealth.enabledEvasions.delete('iframe.contentWindow');
puppeteer.use(stealth);
import { logger } from '../utils/logger.js';
import { getBrowserProxy, cleanupProxy } from '../utils/proxy.js';
// 全局状态跟踪
let globalChromeProcess = null;
let globalBrowser = null;
let globalProxyUrl = null;
let globalBrowserProcess = null;
let globalContext = null; // 替代 globalBrowser
/**
* 清理浏览器资源和进程
* 实现三级退出机制: Puppeteer close -> SIGTERM -> SIGKILL
* 实现三级退出机制: Playwright close -> SIGTERM -> SIGKILL
* @returns {Promise<void>}
*/
export async function cleanup() {
// Level 1: 通过 Puppeteer 协议优雅关闭,释放锁并保存 Profile
if (globalBrowser) {
// Level 1: 通过 Playwright 协议优雅关闭 Context保存 Profile
if (globalContext) {
try {
logger.debug('浏览器', '正在断开远程调试连接...');
await globalBrowser.close();
globalBrowser = null;
logger.debug('浏览器', '已断开远程调试连接');
logger.debug('浏览器', '正在断开远程调试连接并保存 Profile...');
await globalContext.close();
globalContext = null;
logger.debug('浏览器', '已关闭浏览器上下文');
} catch (e) {
logger.warn('浏览器', `断开远程调试连接失败 (可能已断开): ${e.message}`);
logger.warn('浏览器', `关闭上下文失败: ${e.message}`);
}
}
// Level 2 & 3: 处理残留进程
if (globalChromeProcess && !globalChromeProcess.killed) {
// Level 2 & 3: 处理残留进程 (主要用于登录模式)
if (globalBrowserProcess && !globalBrowserProcess.killed) {
logger.info('浏览器', '正在终止浏览器进程...');
try {
// Level 2: 发送 SIGTERM (软杀)
globalChromeProcess.kill('SIGTERM');
globalBrowserProcess.kill('SIGTERM');
// 等待进程退出
const start = Date.now();
while (Date.now() - start < 2000) {
try {
process.kill(globalChromeProcess.pid, 0);
process.kill(globalBrowserProcess.pid, 0);
await new Promise(r => setTimeout(r, 200));
} catch (e) {
break;
@@ -56,26 +52,17 @@ export async function cleanup() {
// Level 3: 强制查杀 (SIGKILL)
try {
process.kill(globalChromeProcess.pid, 0);
process.kill(globalBrowserProcess.pid, 0);
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
process.kill(-globalChromeProcess.pid, 'SIGKILL');
process.kill(-globalBrowserProcess.pid, 'SIGKILL');
} catch (e) { }
globalChromeProcess = null;
logger.info('浏览器', '浏览器进程进程已终止');
globalBrowserProcess = null;
logger.info('浏览器', '浏览器进程已终止');
}
// 清理代理
if (globalProxyUrl) {
try {
logger.debug('浏览器', '正在关闭 Socks5 代理桥接');
await closeAnonymizedProxy(globalProxyUrl, true);
logger.debug('浏览器', '已关闭 Socks5 代理桥接');
} catch (e) {
logger.error('浏览器', '关闭 Socks5 代理桥接失败', { error: e.message });
}
globalProxyUrl = null;
}
await cleanupProxy();
}
// 防止重复注册
@@ -89,7 +76,7 @@ function registerCleanupHandlers() {
if (signalHandlersRegistered) return;
process.on('exit', () => {
if (globalChromeProcess) globalChromeProcess.kill();
if (globalBrowserProcess) globalBrowserProcess.kill();
});
process.on('SIGINT', async () => {
@@ -105,14 +92,83 @@ function registerCleanupHandlers() {
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') {
logger.info('浏览器', '已加载本地持久化指纹');
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: 1920,
minHeight: 720, maxHeight: 1080
}
};
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;
}
/**
* 初始化浏览器 (统一启动逻辑)
* @param {object} config - 配置对象
* @param {object} [config.chrome] - Chrome 配置
* @param {boolean} [config.chrome.headless] - 是否开启 Headless 模式
* @param {string} [config.chrome.path] - Chrome 可执行文件路径
* @param {boolean} [config.chrome.gpu] - 是否启用 GPU
* @param {object} [config.chrome.proxy] - 代理配置
* @param {object} [config.browser] - Browser 配置
* @param {boolean} [config.browser.headless] - 是否开启 Headless 模式
* @param {string} [config.browser.path] - Camoufox 可执行文件路径
* @param {boolean} [config.browser.gpu] - 是否启用 GPU
* @param {object} [config.browser.proxy] - 代理配置
* @param {object} options - 启动选项
* @param {string} options.userDataDir - 用户数据目录路径
* @param {string} options.targetUrl - 目标 URL
@@ -126,7 +182,6 @@ export async function initBrowserBase(config, options) {
userDataDir,
targetUrl,
productName,
reuseExistingTab = false,
waitInputValidator = null
} = options;
@@ -140,41 +195,48 @@ export async function initBrowserBase(config, options) {
logger.warn('浏览器', '当前为登录模式,请手动完成登录后关闭登录模式以继续自动化程序!');
}
const chromeConfig = config?.chrome || {};
const remoteDebuggingPort = 9222;
const browserConfig = config?.browser || {};
// Chrome 启动参数
const args = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
`--user-data-dir=${userDataDir}`,
'--no-first-run'
];
// 获取指纹对象
const fingerprintPath = path.join(process.cwd(), 'data', 'camoufoxFingerprints.json');
const myFingerprint = getPersistentFingerprint(fingerprintPath);
// 构造 Camoufox 启动选项
logger.info('浏览器', '正在启动 Camoufox 浏览器...');
const currentOS = getCurrentOS();
const camoufoxLaunchOptions = {
// 基础选项 (snake_case)
executable_path: browserConfig.path || undefined,
headless: browserConfig.headless && !isLoginMode,
user_data_dir: userDataDir,
window: [1280, 720],
ff_version: 135,
fingerprint: myFingerprint,
os: currentOS,
i_know_what_im_doing: true,
block_webrtc: true,
exclude_addons: ['UBO'],
geoip: false,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--no-first-run'
]
};
// Headless 模式配置
if (chromeConfig.headless && !isLoginMode) {
args.push('--headless=new');
args.push('--window-size=1280,690');
args.push('--headless=new');
args.push('--window-size=1280,690');
logger.info('浏览器', 'Headless 模式: 启用 (1280x690)');
if (browserConfig.headless && !isLoginMode) {
logger.info('浏览器', 'Headless 模式: 启用');
} else {
if (isLoginMode && chromeConfig.headless) {
logger.warn('浏览器', '登录模式下强制禁用 Headless 模式。');
}
// 有头模式:最大化窗口以适配屏幕
args.push('--start-maximized');
logger.info('浏览器', 'Headless 模式: 禁用 (最大化窗口)');
logger.info('浏览器', 'Headless 模式: 禁用');
}
// GPU 配置
if (chromeConfig.gpu === false) {
args.push(
// GPU 配置适配
if (browserConfig.gpu === false) {
camoufoxLaunchOptions.args.push(
'--disable-gpu',
'--use-gl=swiftshader',
'--disable-accelerated-2d-canvas',
'--animation-duration-scale=0',
'--disable-smooth-scrolling'
);
@@ -183,190 +245,79 @@ export async function initBrowserBase(config, options) {
logger.info('浏览器', 'GPU 加速: 启用');
}
// 代理配置
let proxyUrlForChrome = null;
if (chromeConfig.proxy && chromeConfig.proxy.enable) {
const { type, host, port, user, passwd } = chromeConfig.proxy;
// 特殊处理 SOCKS5 + Auth (Chrome 原生不支持)
if (type === 'socks5' && user && passwd) {
try {
const upstreamUrl = `socks5://${user}:${passwd}@${host}:${port}`;
logger.info('浏览器', '检测到需鉴权的 Socks5 代理,正在创建本地代理桥接...');
// 创建本地中间代理 (无认证 -> 有认证)
proxyUrlForChrome = await anonymizeProxy(upstreamUrl);
globalProxyUrl = proxyUrlForChrome; // 记录全局代理
logger.info('浏览器', `本地代理桥接已建立: ${proxyUrlForChrome} -> ${host}:${port}`);
args.push(`--proxy-server=${proxyUrlForChrome}`);
args.push('--disable-quic');
logger.warn('浏览器', '为增强代理兼容性,已禁用QUIC (HTTP/3)');
logger.info('浏览器', `代理配置: ${type}://${host}:${port}`);
} catch (e) {
logger.error('浏览器', '本地代理桥接创建失败', { error: e.message });
throw e;
}
} else {
// 常规 HTTP 代理或无认证 SOCKS5
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
args.push(`--proxy-server=${proxyUrl}`);
args.push('--disable-quic');
logger.warn('浏览器', '为增强代理兼容性,已禁用QUIC (HTTP/3)');
logger.info('浏览器', `代理配置: ${type}://${host}:${port}`);
}
// 代理配置适配
const proxyObject = await getBrowserProxy(browserConfig.proxy);
if (proxyObject) {
camoufoxLaunchOptions.proxy = proxyObject;
}
const chromePath = chromeConfig.path;
// 启动 Camoufox
const context = await Camoufox(camoufoxLaunchOptions);
globalContext = context; // 存储全局 Context
// --- 模式分支 ---
if (!ENABLE_AUTOMATION_MODE) {
// 仅启动浏览器
logger.info('浏览器', '正在以登录模式启动浏览器...');
logger.debug('浏览器', `启动路径: ${chromePath}`);
// 在手动模式下自动打开目标页面
args.push(targetUrl);
const chromeProcess = spawn(chromePath, args, {
detached: false,
stdio: 'ignore'
});
globalChromeProcess = chromeProcess;
// 注册清理处理器
registerCleanupHandlers();
logger.info('浏览器', '浏览器已启动,脚本将持续运行直到浏览器关闭...');
await new Promise((resolve) => {
chromeProcess.on('close', async (code) => {
logger.warn('浏览器', `浏览器已被关闭 (退出码: ${code})`);
await cleanup();
resolve();
});
});
logger.info('浏览器', '浏览器已被关闭,脚本退出');
process.exit(0);
return null;
}
// --- 自动化模式 ---
let browserWSEndpoint = null;
try {
const res = await fetch(`http://127.0.0.1:${remoteDebuggingPort}/json/version`);
if (res.ok) {
const data = await res.json();
if (data && data.webSocketDebuggerUrl) {
logger.debug('浏览器', '检测到已运行的浏览器实例,正在连接...');
browserWSEndpoint = data.webSocketDebuggerUrl;
logger.info('浏览器', '已连接到已运行的浏览器实例,程序将复用实例');
}
}
} catch (e) {
logger.debug('浏览器', '未检测到运行中的浏览器实例,正在启动新实例...');
}
if (!browserWSEndpoint) {
const automationArgs = [...args, `--remote-debugging-port=${remoteDebuggingPort}`];
logger.info('浏览器', '正在启动浏览器...');
logger.debug('浏览器', `启动路径: ${chromePath}`);
const chromeProcess = spawn(chromePath, automationArgs, {
detached: true,
stdio: 'ignore'
});
chromeProcess.unref();
globalChromeProcess = chromeProcess;
logger.debug('浏览器', '浏览器已启动,等待调试端口就绪...');
for (let i = 0; i < 20; i++) {
await sleep(1000, 1500);
try {
const res = await fetch(`http://127.0.0.1:${remoteDebuggingPort}/json/version`);
if (res.ok) {
const data = await res.json();
if (data && data.webSocketDebuggerUrl) {
browserWSEndpoint = data.webSocketDebuggerUrl;
logger.debug('浏览器', '浏览器调试接口已就绪');
break;
}
}
} catch (e) { }
}
if (!browserWSEndpoint) {
throw new Error('无法连接到 Chrome 远程调试端口,请检查 Chrome 是否成功启动。');
}
}
// 连接 Puppeteer
const browser = await puppeteer.connect({
browserWSEndpoint: browserWSEndpoint,
defaultViewport: null
});
globalBrowser = browser; // 保存实例引用供 cleanup 使用
logger.info('浏览器', '远程调试已连接');
logger.info('浏览器', 'Camoufox 浏览器已启动');
// 注册清理处理器
registerCleanupHandlers();
browser.on('disconnected', async () => {
logger.warn('浏览器', '浏览器已断开连接');
// 注册断开连接事件
context.on('close', async () => {
logger.warn('浏览器', 'Camoufox 浏览器已断开连接');
await cleanup();
process.exit(0);
});
// 获取或创建页面
// 获取或创建 Page
let page;
if (reuseExistingTab) {
// 复用已有标签页
const pages = await browser.pages();
const urlDomain = new URL(targetUrl).hostname;
page = pages.find(p => p.url().includes(urlDomain));
if (!page) {
page = await browser.newPage();
logger.debug('浏览器', '已创建新标签页');
const existingPages = context.pages(); // 获取启动时自动打开的页面
if (!page) {
if (existingPages.length > 0) {
page = existingPages[0];
logger.debug('浏览器', '复用浏览器启动时的默认标签页');
} else {
logger.warn('浏览器', '检测到已有目标网站标签页,程序将复用标签页');
page = await context.newPage();
logger.debug('浏览器', '浏览器没有标签,已创建新标签页');
}
} else {
// 总是新建标签页
page = await browser.newPage();
logger.debug('浏览器', '已创建新标签页');
}
// 强制刷新一下视口大小,防止复用默认窗口时尺寸不对
if (camoufoxLaunchOptions.viewport) {
await page.setViewportSize(camoufoxLaunchOptions.viewport);
}
// 登录模式挂起逻辑
if (isLoginMode) {
// 尝试导航到目标页面方便用户登录
try {
logger.info('浏览器', `正在连接 ${productName}...`);
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
} catch (e) {
logger.warn('浏览器', `打开页面失败: ${e.message}`);
}
logger.info('浏览器', '请在弹出的浏览器窗口中手动完成登录操作');
logger.info('浏览器', '完成后可直接关闭浏览器窗口或在终端结束程序');
await new Promise((resolve) => {
context.on('close', () => {
logger.info('浏览器', '检测到浏览器窗口关闭,程序即将退出');
resolve();
});
});
await cleanup();
process.exit(0);
}
// 初始化 ghost-cursor
page.cursor = createCursor(page);
// 代理认证 (仅当未使用 proxy-chain 桥接时)
if (chromeConfig.proxy && chromeConfig.proxy.enable && chromeConfig.proxy.user && !proxyUrlForChrome) {
await page.authenticate({
username: chromeConfig.proxy.user,
password: chromeConfig.proxy.passwd
});
logger.info('浏览器', '代理认证: 已激活 (HTTP Basic Auth)');
}
// 创建 CDP 会话
const client = await page.target().createCDPSession();
await client.send('Network.enable');
// 注册清理钩子
if (proxyUrlForChrome) {
logger.warn('浏览器', '因使用了本地代理桥接,请保持此程序运行,否则浏览器将失去代理连接');
}
// --- 行为预热建立人机检测信任 ---
const urlDomain = new URL(targetUrl).hostname;
if (!page.url().includes(urlDomain)) {
logger.info('浏览器', `正在连接 ${productName}...`);
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
} else {
logger.info('浏览器', `页面已在 ${productName},跳过跳转`);
}
@@ -386,7 +337,7 @@ export async function initBrowserBase(config, options) {
const targetX = clamp(centerX + random(-200, 200), 10, vp.safeWidth);
const targetY = clamp(centerY + random(-200, 200), 10, vp.safeHeight);
// 重置 cursor 内部状态 (可选,增加拟人化)
// 移动鼠标 (增加拟人化)
await page.cursor.moveTo({ x: targetX, y: targetY });
}
await sleep(500, 1000);
@@ -406,5 +357,9 @@ export async function initBrowserBase(config, options) {
logger.info('浏览器', '浏览器初始化完成,系统就绪');
logger.warn('浏览器', '当任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!');
return { browser, page, client };
// 返回对象 (兼容性处理)
return {
browser: context,
page
};
}
+206 -125
View File
@@ -1,11 +1,10 @@
import fs from 'fs';
import path from 'path';
import { logger } from '../logger.js';
import { logger } from '../utils/logger.js';
/**
* 生成指定范围内的随机数
* @param {number} min 最小值
* @param {number} max 最大值
* @param {number} min - 最小值
* @param {number} max - 最大值
* @returns {number} 随机数
*/
export function random(min, max) {
@@ -14,8 +13,8 @@ export function random(min, max) {
/**
* 随机休眠一段时间
* @param {number} min 最小毫秒数
* @param {number} max 最大毫秒数
* @param {number} min - 最小毫秒数
* @param {number} max - 最大毫秒数
* @returns {Promise<void>}
*/
export function sleep(min, max) {
@@ -24,7 +23,7 @@ export function sleep(min, max) {
/**
* 根据文件扩展名获取 MIME 类型
* @param {string} filePath 文件路径
* @param {string} filePath - 文件路径
* @returns {string} MIME 类型
*/
export function getMimeType(filePath) {
@@ -40,34 +39,34 @@ export function getMimeType(filePath) {
}
/**
* [Security Enhanced] 无痕获取当前页面实时视口
* 使用纯净的匿名函数执行,不污染 Global Scope,不留指纹
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* 无痕获取当前页面实时视口
* 使用纯净的匿名函数执行不污染 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 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
// 预留 20px 缓冲防止鼠标移到滚动条上或贴边触发浏览器原生手势
safeWidth: w - 20,
safeHeight: h
};
});
} catch (e) {
// Fallback: 如果上下文丢失,返回安全保守值
// Fallback: 如果上下文丢失返回安全保守值
return { width: 1280, height: 720, safeWidth: 1260, safeHeight: 720 };
}
}
/**
* [Safety] 坐标钳位函数
* 强制将坐标限制在合法视口范围内,杜绝 "Node is not visible" 报错
* 坐标钳位函数
* 强制将坐标限制在合法视口范围内,防止 "Node is not visible" 报错
* @param {number} value - 原始坐标值
* @param {number} min - 最小值
* @param {number} max - 最大值
@@ -79,13 +78,14 @@ export function clamp(value, min, max) {
/**
* 深度查找 Shadow DOM 中的元素
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {import('playwright-core').Page} page - Playwright 页面实例
* @param {string} selector - CSS 选择器
* @param {import('puppeteer').ElementHandle} [rootHandle=null] - 可选的根节点句柄
* @returns {Promise<import('puppeteer').ElementHandle|null>} 找到的元素句柄或 null
* @param {import('playwright-core').ElementHandle} [rootHandle=null] - 可选的根节点句柄
* @returns {Promise<import('playwright-core').ElementHandle|null>} 找到的元素句柄或 null
*/
export async function queryDeep(page, selector, rootHandle = null) {
return await page.evaluateHandle((sel, root) => {
// 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;
@@ -106,17 +106,39 @@ export async function queryDeep(page, selector, rootHandle = null) {
return null;
}
return find(root || document.body, sel);
}, selector, rootHandle);
}, [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 两种输入
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄
* @param {object} [options] - 点击选项
* @param {string} [options.bias='random'] - 偏移偏好: 'input' 或 'random'
* @returns {Promise<void>}
*/
export async function safeClick(page, target) {
export async function safeClick(page, target, options = {}) {
try {
let el;
@@ -131,6 +153,14 @@ export async function safeClick(page, target) {
// 使用 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;
}
@@ -145,8 +175,8 @@ export async function safeClick(page, target) {
/**
* 模拟人类键盘输入
* 支持 CSS selector 和 ElementHandle 两种输入
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
* @param {import('playwright-core').Page} page - Playwright 页面对象
* @param {string|import('playwright-core').ElementHandle} target - CSS 选择器或元素句柄
* @param {string} text - 要输入的文本
* @returns {Promise<void>}
*/
@@ -166,9 +196,29 @@ export async function humanType(page, target, text) {
// 智能输入策略
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) });
@@ -180,7 +230,7 @@ export async function humanType(page, target, text) {
await sleep(30, 100);
}
} else {
// 长文本:假装打字 -> 停顿 -> 粘贴
// 长文本: 假装打字 -> 停顿 -> 粘贴
const fakeCount = Math.floor(random(3, 8));
const fakeText = text.substring(0, fakeCount);
@@ -189,10 +239,10 @@ export async function humanType(page, target, text) {
await page.keyboard.type(fakeText[i], { delay: random(30, 100) });
}
// 2. 停顿思考 (0.5 - 1秒)
// 2. 停顿思考
await sleep(500, 1000);
// 3. 全选删除 (模拟 Ctrl+A -> Backspace)
// 3. 全选删除
await page.keyboard.down('Control');
await page.keyboard.press('A');
await page.keyboard.up('Control');
@@ -200,127 +250,158 @@ export async function humanType(page, target, text) {
await page.keyboard.press('Backspace');
await sleep(100, 300);
// 4. 瞬间粘贴全部文本 (模拟 Ctrl+V)
// 4. 瞬间粘贴全部文本
if (typeof target === 'string') {
// 对于 selector,使用 querySelector
await page.evaluate((sel, content) => {
await page.evaluate(({ sel, content }) => {
const input = document.querySelector(sel);
input.focus();
document.execCommand('insertText', false, content);
}, target, text);
}, { sel: target, content: text });
} else {
// 对于 ElementHandle,直接使用
await page.evaluate((el, content) => {
await page.evaluate(({ el, content }) => {
el.focus();
document.execCommand('insertText', false, content);
}, el, text);
}, { el: target, content: text });
}
}
}
/**
* 粘贴图片到输入框
* 支持 CSS selector 和 ElementHandle 两种输入
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
* 查找页面上所有的文件输入框 (包括 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 参数
* @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} 张图片...`);
// 读取图片文件并转换为 Base64
const filesData = filePaths.map(p => {
const clean = p.replace(/['"]/g, '').trim();
if (!fs.existsSync(clean)) return null;
return {
base64: fs.readFileSync(clean).toString('base64'),
mime: getMimeType(clean),
filename: path.basename(clean)
};
}).filter(f => f);
logger.info('浏览器', `正在处理 ${filePaths.length} 张图片...`);
if (filesData.length === 0) return;
// 1. 拟人化: 先点击一下目标区域 (让后台看起来像是用户聚焦了输入框)
await safeClick(page, target, { bias: 'input' });
await sleep(500, 1000);
// 点击输入框以获取焦点
await safeClick(page, target);
await sleep(500, 800);
try {
logger.info('浏览器', '正在深度扫描文件上传控件...');
const fileInputs = await findAllFileInputs(page);
// 如果提供了自定义的上传确认函数,使用它
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
const expectedUploads = filesData.length;
let validatedCount = 0;
if (fileInputs.length === 0) {
throw new Error('未找到任何 input[type="file"] 控件,无法上传');
}
const uploadPromise = new Promise((resolve) => {
const timeout = setTimeout(() => {
cleanup();
logger.warn('浏览器', `图片上传等待超时 (已确认: ${validatedCount}/${expectedUploads})`);
resolve();
}, 60000); // 60s 超时
logger.info('浏览器', `找到 ${fileInputs.length} 个文件输入框,尝试上传...`);
const onResponse = (response) => {
if (options.uploadValidator(response)) {
validatedCount++;
logger.info('浏览器', `图片上传进度: ${validatedCount}/${expectedUploads}`);
if (validatedCount >= expectedUploads) {
cleanup();
resolve();
// 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.info('浏览器', '已通过原生控件注入文件');
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);
};
const cleanup = () => {
clearTimeout(timeout);
page.off('response', onResponse);
};
page.on('response', onResponse);
});
page.on('response', onResponse);
});
// 执行粘贴
await executePaste(page, target, filesData);
logger.info('浏览器', `粘贴完成,正在等待图片上传确认...`);
await uploadPromise;
logger.info('浏览器', `所有图片上传完成`);
} else {
// 默认行为:简单粘贴并等待固定时间
await executePaste(page, target, filesData);
logger.info('浏览器', `粘贴完成,等待缩略图缓冲`);
// 等待图片上传和缩略图生成
await sleep(2500, 4000);
}
}
/**
* 执行粘贴操作的内部函数
* @private
*/
async function executePaste(page, target, filesData) {
// 统一处理 selector 和 ElementHandle
if (typeof target === 'string') {
await page.evaluate(async (sel, files) => {
const element = document.querySelector(sel);
const dt = new DataTransfer();
for (const f of files) {
const bin = atob(f.base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
dt.items.add(new File([arr], f.filename, { type: f.mime }));
}
element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt }));
}, target, filesData);
} else {
await page.evaluate(async (el, files) => {
const dt = new DataTransfer();
for (const f of files) {
const bin = atob(f.base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
dt.items.add(new File([arr], f.filename, { type: f.mime }));
}
el.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: dt }));
}, target, filesData);
logger.info('浏览器', `文件已注入,正在等待上传确认...`);
await uploadPromise;
logger.info('浏览器', `所有图片上传完成`);
} else {
// 默认行为: 等待上传预览出现
logger.info('浏览器', `文件已注入,等待预览生成...`);
await sleep(2000, 4000);
}
} catch (e) {
logger.error('浏览器', `上传失败: ${e.message}`);
throw e;
}
}