mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 将项目迁移到Playwright+Camoufox方案
This commit is contained in:
+206
-125
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user