feat: 增强浏览器特征伪装 (ref #1)

This commit is contained in:
foxhui
2025-11-26 03:27:12 +08:00
Unverified
parent 4bebdd4d3c
commit 34661cc879
5 changed files with 575 additions and 113 deletions
+107 -109
View File
@@ -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) { }
}
}