mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 增强浏览器特征伪装 (ref #1)
This commit is contained in:
+107
-109
@@ -1,14 +1,17 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import puppeteer from 'puppeteer-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { createCursor } from 'ghost-cursor';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const stealth = StealthPlugin();
|
||||
stealth.enabledEvasions.delete('user-agent-override');
|
||||
stealth.enabledEvasions.delete('iframe.contentWindow');
|
||||
puppeteer.use(stealth);
|
||||
|
||||
// --- 配置常量 ---
|
||||
const CHROME_PATH = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserData');
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
@@ -46,48 +49,7 @@ function getMimeType(filePath) {
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// --- 核心拟人化算法 (贝塞尔曲线 + 物理模拟) ---
|
||||
|
||||
/**
|
||||
* 三次贝塞尔曲线计算
|
||||
*/
|
||||
function cubicBezier(t, p0, p1, p2, p3) {
|
||||
const k = 1 - t;
|
||||
return k * k * k * p0 + 3 * k * k * t * p1 + 3 * k * t * t * p2 + t * t * t * p3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟人类鼠标移动轨迹
|
||||
* @param {object} page Puppeteer 页面对象
|
||||
* @param {number} startX 起始 X 坐标
|
||||
* @param {number} startY 起始 Y 坐标
|
||||
* @param {number} targetX 目标 X 坐标
|
||||
* @param {number} targetY 目标 Y 坐标
|
||||
*/
|
||||
async function humanMove(page, startX, startY, targetX, targetY) {
|
||||
const distance = Math.sqrt(Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2));
|
||||
const steps = Math.floor(Math.max(distance / 8, 15));
|
||||
|
||||
const offset = distance * 0.4;
|
||||
// 生成两个随机控制点,使轨迹弯曲
|
||||
const cp1X = startX + (targetX - startX) / 3 + random(-offset, offset);
|
||||
const cp1Y = startY + (targetY - startY) / 3 + random(-offset, offset);
|
||||
const cp2X = startX + 2 * (targetX - startX) / 3 + random(-offset, offset);
|
||||
const cp2Y = startY + 2 * (targetY - startY) / 3 + random(-offset, offset);
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
// 缓动函数:起步快,结尾慢,模拟人类肌肉运动
|
||||
const easeT = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
|
||||
let x = cubicBezier(easeT, startX, cp1X, cp2X, targetX);
|
||||
let y = cubicBezier(easeT, startY, cp1Y, cp2Y, targetY);
|
||||
|
||||
// 添加微小的随机抖动
|
||||
if (i % 3 === 0) { x += random(-1, 1); y += random(-1, 1); }
|
||||
await page.mouse.move(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素(包含拟人化移动和点击)
|
||||
@@ -98,24 +60,15 @@ async function safeClick(page, selector) {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (!el) throw new Error(`未找到: ${selector}`);
|
||||
const box = await el.boundingBox();
|
||||
if (!box) throw new Error(`不可见: ${selector}`);
|
||||
|
||||
// 先稍微移动一下当前位置 (增加真实感)
|
||||
await page.mouse.move(box.x - random(50, 100), box.y - random(50, 100), { steps: 2 });
|
||||
// 使用 ghost-cursor 点击
|
||||
if (page.cursor) {
|
||||
await page.cursor.click(el);
|
||||
return;
|
||||
}
|
||||
|
||||
// 目标点击位置在元素内部随机区域
|
||||
const targetX = box.x + box.width * random(0.3, 0.7);
|
||||
const targetY = box.y + box.height * random(0.3, 0.7);
|
||||
|
||||
// 移动鼠标到目标位置
|
||||
await humanMove(page, box.x - 50, box.y - 50, targetX, targetY);
|
||||
|
||||
// 模拟点击过程:按下 -> 停顿 -> 抬起
|
||||
await sleep(100, 300);
|
||||
await page.mouse.down();
|
||||
await sleep(60, 120);
|
||||
await page.mouse.up();
|
||||
// 降级逻辑
|
||||
await el.click();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
@@ -242,35 +195,32 @@ function extractImage(text) {
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config 配置对象 (包含 chrome 配置)
|
||||
* @returns {Promise<{browser: object, page: object, client: object, width: number, height: number}>}
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
console.log('>>> [Browser] 开始初始化浏览器');
|
||||
|
||||
const chromeConfig = config?.chrome || {};
|
||||
const width = Math.floor(random(900, 1100));
|
||||
const height = Math.floor(random(500, 700));
|
||||
|
||||
// 1. 基础参数
|
||||
const args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
`--window-size=${width},${height}`,
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-infobars',
|
||||
'--test-type',
|
||||
'--no-zygote',
|
||||
'--disable-dev-shm-usage'
|
||||
'--disable-dev-shm-usage',
|
||||
];
|
||||
|
||||
// 2. Headless 模式配置
|
||||
// 2. Headless 模式配置 & 窗口大小
|
||||
let headlessMode = false;
|
||||
if (chromeConfig.headless) {
|
||||
headlessMode = 'new';
|
||||
args.push('--disable-gl-drawing-for-tests');
|
||||
console.log('>>> [Browser] Headless 模式: 启用');
|
||||
// 无头模式锁死分辨率
|
||||
args.push('--window-size=1280,690');
|
||||
console.log('>>> [Browser] Headless 模式: 启用 (1280x690)');
|
||||
} else {
|
||||
console.log('>>> [Browser] Headless 模式: 禁用');
|
||||
// 有头模式:最大化窗口以适配屏幕
|
||||
args.push('--start-maximized');
|
||||
console.log('>>> [Browser] Headless 模式: 禁用 (最大化窗口)');
|
||||
}
|
||||
|
||||
// 3. GPU 配置
|
||||
@@ -323,9 +273,11 @@ async function initBrowser(config) {
|
||||
args: args
|
||||
});
|
||||
|
||||
// 重用第一个标签页
|
||||
const pages = await browser.pages();
|
||||
const page = pages[0];
|
||||
// 保留第一个标签页 (about:blank),在新标签页中打开
|
||||
const page = await browser.newPage();
|
||||
|
||||
// 初始化 ghost-cursor
|
||||
page.cursor = createCursor(page);
|
||||
|
||||
// 5. 代理认证 (仅当未使用 proxy-chain 桥接时)
|
||||
if (chromeConfig.proxy && chromeConfig.proxy.enable && chromeConfig.proxy.user && !proxyUrlForChrome) {
|
||||
@@ -336,11 +288,6 @@ async function initBrowser(config) {
|
||||
console.log('>>> [Browser] 代理认证: 已设置 (HTTP Basic Auth)');
|
||||
}
|
||||
|
||||
// 隐藏 WebDriver 特征
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
});
|
||||
|
||||
// 创建 CDP 会话以监听网络请求
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
@@ -351,12 +298,19 @@ async function initBrowser(config) {
|
||||
|
||||
console.log('>>> [Warmup] 正在随机浏览页面以建立信任...');
|
||||
|
||||
// 计算屏幕中心点
|
||||
// 计算屏幕中心点 (动态获取视口大小)
|
||||
let viewport = page.viewport();
|
||||
// 如果是 null (有时候 headless 初始化慢),给个默认值
|
||||
let width = viewport ? viewport.width : 1280;
|
||||
let height = viewport ? viewport.height : 690;
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// 第一次移动:从左上角移动到中心附近
|
||||
await humanMove(page, 0, 0, centerX + random(-200, 200), centerY + random(-200, 200));
|
||||
if (page.cursor) {
|
||||
await page.cursor.moveTo({ x: centerX + random(-200, 200), y: centerY + random(-200, 200) });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
|
||||
// 模拟滚动行为
|
||||
@@ -373,11 +327,14 @@ async function initBrowser(config) {
|
||||
// 移动鼠标到输入框
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
await humanMove(page, centerX, centerY, box.x + box.width / 2, box.y + box.height / 2);
|
||||
if (page.cursor) {
|
||||
await page.cursor.moveTo({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
|
||||
console.log('>>> [Browser] 浏览器初始化完成,系统就绪');
|
||||
console.log('>>> [Browser] 当程序有任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!');
|
||||
|
||||
// 注册清理钩子:浏览器关闭时关闭代理服务器
|
||||
if (proxyUrlForChrome) {
|
||||
@@ -391,21 +348,21 @@ async function initBrowser(config) {
|
||||
});
|
||||
}
|
||||
|
||||
return { browser, page, client, width, height };
|
||||
return { browser, page, client };
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context 浏览器上下文 {page, client, width, height}
|
||||
* @param {object} context 浏览器上下文 {page, client}
|
||||
* @param {string} prompt 提示词
|
||||
* @param {string[]} imgPaths 图片路径数组
|
||||
* @param {string|null} modelId 模型 UUID (可选)
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>}
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId) {
|
||||
const { page, client, width, height } = context;
|
||||
const { page, client } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
let requestHandler = null;
|
||||
let fetchPausedHandler = null;
|
||||
|
||||
try {
|
||||
// 1. 强制开启新会话 (通过URL跳转)
|
||||
@@ -428,34 +385,70 @@ async function generateImage(context, prompt, imgPaths, modelId) {
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// --- 注入 Fetch 拦截器 (Puppeteer Request Interception) ---
|
||||
// 注入 CDP Fetch 拦截器
|
||||
if (modelId) {
|
||||
await page.setRequestInterception(true);
|
||||
requestHandler = async (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes('/nextjs-api/stream/') && request.method() === 'POST') {
|
||||
// 1. 启用 Fetch 域拦截,仅拦截特定 URL
|
||||
await client.send('Fetch.enable', {
|
||||
patterns: [{
|
||||
urlPattern: '*nextjs-api/stream*',
|
||||
requestStage: 'Request'
|
||||
}]
|
||||
});
|
||||
|
||||
// 2. 定义拦截处理函数
|
||||
fetchPausedHandler = async (event) => {
|
||||
const { requestId, request } = event;
|
||||
|
||||
if (request.method === 'POST' && request.postData) {
|
||||
try {
|
||||
const postData = request.postData();
|
||||
if (postData) {
|
||||
const data = JSON.parse(postData);
|
||||
console.log(`[Browser] 正在拦截请求。原始 modelAId: ${data.modelAId}`);
|
||||
// 尝试解码可能是 base64 编码的postData
|
||||
let rawBody = request.postData;
|
||||
// 尝试解析 JSON
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
// 尝试 Base64 解码
|
||||
try {
|
||||
rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e2) {
|
||||
// 无法解析,跳过
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.modelAId) {
|
||||
console.log(`>>> [CDP] 正在拦截请求。原始 modelAId: ${data.modelAId}`);
|
||||
|
||||
// 修改 modelAId
|
||||
data.modelAId = modelId;
|
||||
const newPostData = JSON.stringify(data);
|
||||
|
||||
console.log(`[Browser] 请求已修改。新 modelAId: ${data.modelAId}`);
|
||||
await request.continue({ postData: newPostData });
|
||||
// 重新序列化并转为 Base64 (Fetch.continueRequest 需要 base64)
|
||||
const newBody = JSON.stringify(data);
|
||||
const newBodyBase64 = Buffer.from(newBody).toString('base64');
|
||||
|
||||
console.log(`>>> [CDP] 请求已修改。新 modelAId: ${data.modelAId}`);
|
||||
|
||||
await client.send('Fetch.continueRequest', {
|
||||
requestId,
|
||||
postData: newBodyBase64
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Browser] 拦截修改失败:', e);
|
||||
console.error('[CDP] 拦截处理出错:', e);
|
||||
}
|
||||
}
|
||||
await request.continue();
|
||||
|
||||
// 如果不匹配或出错,直接放行
|
||||
try {
|
||||
await client.send('Fetch.continueRequest', { requestId });
|
||||
} catch (e) { }
|
||||
};
|
||||
page.on('request', requestHandler);
|
||||
console.log(`>>> [Test] 已启用请求拦截,目标模型: ${modelId}`);
|
||||
|
||||
// 3. 监听拦截事件
|
||||
client.on('Fetch.requestPaused', fetchPausedHandler);
|
||||
console.log(`>>> [Test] 已启用 CDP Fetch 拦截,目标模型: ${modelId}`);
|
||||
}
|
||||
|
||||
// 4. 发送
|
||||
@@ -515,7 +508,12 @@ async function generateImage(context, prompt, imgPaths, modelId) {
|
||||
});
|
||||
|
||||
// 任务结束,像人一样把鼠标移开,防止遮挡或误触
|
||||
await humanMove(page, width / 2, height / 2, width - 100, height / 2);
|
||||
if (page.cursor) {
|
||||
const vp = page.viewport();
|
||||
const w = vp ? vp.width : 1280;
|
||||
const h = vp ? vp.height : 690;
|
||||
await page.cursor.moveTo({ x: w - 100, y: h / 2 });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -523,10 +521,10 @@ async function generateImage(context, prompt, imgPaths, modelId) {
|
||||
console.error('>>> [Error] 生成任务失败:', err.message);
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
if (requestHandler) {
|
||||
page.off('request', requestHandler);
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
try {
|
||||
await page.setRequestInterception(false);
|
||||
await client.send('Fetch.disable');
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user