From e4ecef912884f5db5d3a61e031c66c9ae1b97a18 Mon Sep 17 00:00:00 2001 From: foxhui Date: Thu, 27 Nov 2025 18:43:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=B5=8F=E8=A7=88=E5=99=A8=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=88=86=E7=A6=BB=E5=BC=8F=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- CHANGELOG.md | 12 ++ lib/lmarena.js | 315 ++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 283 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index f0a606c..c98ab5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ node_modules/ data/ -client.js +test/ config.yaml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2aba2..656e47c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.1] - 2024-11-27 + +### Added +- **登录模式** + - 添加登录模式(-login),便于手动登录 + +### Changed +- **浏览器启动** + - 采用自动程序与浏览器分离的模式,让程序连接远程调试端口,可能可以更好的减少特征 + +--- + ## [1.2.0] - 2024-11-26 ### Added diff --git a/lib/lmarena.js b/lib/lmarena.js index 247eabb..e6ba022 100644 --- a/lib/lmarena.js +++ b/lib/lmarena.js @@ -4,10 +4,11 @@ import { createCursor } from 'ghost-cursor'; import fs from 'fs'; import path from 'path'; import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain'; +import { spawn } from 'child_process'; const stealth = StealthPlugin(); -stealth.enabledEvasions.delete('user-agent-override'); +//stealth.enabledEvasions.delete('user-agent-override'); stealth.enabledEvasions.delete('iframe.contentWindow'); puppeteer.use(stealth); @@ -16,6 +17,89 @@ 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'); +// --- 自动化开关 --- +const isLoginMode = process.argv.includes('-login'); +const ENABLE_AUTOMATION_MODE = !isLoginMode; + +if (isLoginMode) { + console.log('>>> [Mode] 检测到登录模式 (-login),自动化已禁用。请手动完成登录。'); +} + +// 全局状态跟踪 +let globalChromeProcess = null; +let globalBrowser = null; +let globalProxyUrl = null; + +// 资源清理与进程退出处理 +async function cleanup() { + console.log('>>> [System] 正在清理资源...'); + + // Level 1: 通过 Puppeteer 协议优雅关闭,释放锁并保存 Profile + if (globalBrowser) { + try { + console.log('>>> [System] 正在关闭 Puppeteer 连接...'); + await globalBrowser.close(); + globalBrowser = null; + } catch (e) { + console.warn('>>> [Warn] Puppeteer 关闭失败 (可能已断开):', e.message); + } + } + + // Level 2 & 3: 处理残留进程 + if (globalChromeProcess && !globalChromeProcess.killed) { + console.log('>>> [System] 正在终止 Chrome 进程...'); + try { + // Level 2: 发送 SIGTERM (软杀) + globalChromeProcess.kill('SIGTERM'); + + // 等待进程退出 + const start = Date.now(); + while (Date.now() - start < 2000) { + try { + process.kill(globalChromeProcess.pid, 0); + await new Promise(r => setTimeout(r, 200)); + } catch (e) { + break; + } + } + } catch (e) { } + + // Level 3: 强制查杀 (SIGKILL) + try { + process.kill(globalChromeProcess.pid, 0); + console.log('>>> [System] 进程无响应,执行强制查杀 (SIGKILL)...'); + process.kill(-globalChromeProcess.pid, 'SIGKILL'); + } catch (e) { } + + globalChromeProcess = null; + console.log('>>> [System] Chrome 进程已终止。'); + } + + // 清理代理 + if (globalProxyUrl) { + try { + await closeAnonymizedProxy(globalProxyUrl, true); + console.log('>>> [System] 代理桥接已关闭。'); + } catch (e) { + console.error('>>> [Error] 关闭代理桥接失败:', e); + } + globalProxyUrl = null; + } +} + +// 注册进程退出信号 +process.on('exit', () => { + if (globalChromeProcess) globalChromeProcess.kill(); +}); +process.on('SIGINT', async () => { + await cleanup(); + process.exit(); +}); +process.on('SIGTERM', async () => { + await cleanup(); + process.exit(); +}); + // 确保临时目录存在 if (!fs.existsSync(TEMP_DIR)) { fs.mkdirSync(TEMP_DIR, { recursive: true }); @@ -50,6 +134,37 @@ function getMimeType(filePath) { } +/** + * [Security Enhanced] 无痕获取当前页面实时视口 + * 使用纯净的匿名函数执行,不污染 Global Scope,不留指纹 + */ +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 }; + } +} + +/** + * [Safety] 坐标钳位函数 + * 强制将坐标限制在合法视口范围内,杜绝 "Node is not visible" 报错 + */ +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} /** * 安全点击元素(包含拟人化移动和点击) @@ -198,32 +313,39 @@ function extractImage(text) { * @returns {Promise<{browser: object, page: object, client: object}>} */ async function initBrowser(config) { - console.log('>>> [Browser] 开始初始化浏览器'); + console.log(`>>> [Browser] 开始初始化浏览器 (LMArena - 分离模式) | 自动化模式: ${ENABLE_AUTOMATION_MODE ? '开启' : '关闭'}`); const chromeConfig = config?.chrome || {}; + const remoteDebuggingPort = 9222; - // 1. 基础参数 + // Chrome 启动参数 const args = [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage', + `--user-data-dir=${USER_DATA_DIR}`, + '--no-first-run' ]; - // 2. Headless 模式配置 & 窗口大小 + // Headless 模式配置 let headlessMode = false; - if (chromeConfig.headless) { + if (chromeConfig.headless && !isLoginMode) { headlessMode = 'new'; // 无头模式锁死分辨率 + args.push('--headless=new'); args.push('--window-size=1280,690'); console.log('>>> [Browser] Headless 模式: 启用 (1280x690)'); } else { + if (isLoginMode && chromeConfig.headless) { + console.log('>>> [Mode] 登录模式下强制禁用 Headless 模式。'); + } // 有头模式:最大化窗口以适配屏幕 args.push('--start-maximized'); console.log('>>> [Browser] Headless 模式: 禁用 (最大化窗口)'); } - // 3. GPU 配置 + // GPU 配置 if (chromeConfig.gpu === false) { args.push( '--disable-gpu', @@ -238,7 +360,7 @@ async function initBrowser(config) { console.log('>>> [Browser] GPU 加速: 启用'); } - // 4. 代理配置 + // 代理配置 let proxyUrlForChrome = null; if (chromeConfig.proxy && chromeConfig.proxy.enable) { const { type, host, port, user, passwd } = chromeConfig.proxy; @@ -250,6 +372,7 @@ async function initBrowser(config) { console.log(`>>> [Browser] 检测到 SOCKS5 认证代理,正在创建本地桥接...`); // 创建本地中间代理 (无认证 -> 有认证) proxyUrlForChrome = await anonymizeProxy(upstreamUrl); + globalProxyUrl = proxyUrlForChrome; // 记录全局代理 console.log(`>>> [Browser] 本地桥接已建立: ${proxyUrlForChrome} -> ${host}:${port}`); args.push(`--proxy-server=${proxyUrlForChrome}`); @@ -267,22 +390,117 @@ async function initBrowser(config) { } } - const browser = await puppeteer.launch({ - headless: headlessMode, - executablePath: chromeConfig.path || undefined, - userDataDir: USER_DATA_DIR, - defaultViewport: null, - ignoreDefaultArgs: ['--enable-automation'], - args: args + const chromePath = chromeConfig.path; + + // --- 模式分支 --- + + if (!ENABLE_AUTOMATION_MODE) { + // 仅启动浏览器 + console.log(`>>> [Browser] 正在以手动模式启动 Chrome (无远程调试)...`); + console.log(`>>> [Browser] 启动路径: ${chromePath}`); + + // 在手动模式下自动打开目标页面 + args.push(TARGET_URL); + + const chromeProcess = spawn(chromePath, args, { + detached: false, + stdio: 'ignore' + }); + globalChromeProcess = chromeProcess; + + console.log('>>> [Success] Chrome 已启动。脚本将持续运行直到浏览器关闭...'); + + await new Promise((resolve) => { + chromeProcess.on('close', async (code) => { + console.log(`>>> [Browser] Chrome 已关闭 (退出码: ${code})`); + await cleanup(); + resolve(); + }); + }); + + console.log('>>> [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) { + console.log('>>> [Browser] 检测到已运行的 Chrome 实例,准备连接...'); + browserWSEndpoint = data.webSocketDebuggerUrl; + } + } + } catch (e) { + console.log('>>> [Browser] 未检测到运行中的 Chrome,正在启动新实例...'); + } + + if (!browserWSEndpoint) { + const automationArgs = [...args, `--remote-debugging-port=${remoteDebuggingPort}`]; + console.log(`>>> [Browser] 启动 Chrome (自动化模式): ${chromePath}`); + + const chromeProcess = spawn(chromePath, automationArgs, { + detached: true, + stdio: 'ignore' + }); + chromeProcess.unref(); + globalChromeProcess = chromeProcess; + + console.log('>>> [Browser] Chrome 已启动,等待调试端口就绪...'); + + 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; + console.log('>>> [Browser] Chrome 调试接口已就绪。'); + break; + } + } + } catch (e) { } + } + + if (!browserWSEndpoint) { + throw new Error('无法连接到 Chrome 远程调试端口,请检查 Chrome 是否成功启动。'); + } + } + + // 连接 Puppeteer + const browser = await puppeteer.connect({ + browserWSEndpoint: browserWSEndpoint, + defaultViewport: null }); - // 保留第一个标签页 (about:blank),在新标签页中打开 - const page = await browser.newPage(); + globalBrowser = browser; // [新增] 保存实例引用供 cleanup 使用 + + console.log('>>> [Browser] Puppeteer 已连接到 Chrome 实例。'); + + browser.on('disconnected', async () => { + console.log('>>> [Browser] 浏览器已断开连接 (可能已被关闭)。'); + await cleanup(); + process.exit(0); + }); + + // 获取页面 + const pages = await browser.pages(); + let page = pages.find(p => p.url().includes('lmarena.ai')); + if (!page) { + page = await browser.newPage(); + } else { + console.log('>>> [Browser] 复用已有标签页。'); + } // 初始化 ghost-cursor page.cursor = createCursor(page); - // 5. 代理认证 (仅当未使用 proxy-chain 桥接时) + // 代理认证 (仅当未使用 proxy-chain 桥接时) if (chromeConfig.proxy && chromeConfig.proxy.enable && chromeConfig.proxy.user && !proxyUrlForChrome) { await page.authenticate({ username: chromeConfig.proxy.user, @@ -291,28 +509,40 @@ async function initBrowser(config) { console.log('>>> [Browser] 代理认证: 已设置 (HTTP Basic Auth)'); } - // 创建 CDP 会话以监听网络请求 + // 创建 CDP 会话 const client = await page.target().createCDPSession(); await client.send('Network.enable'); - // --- [行为预热] 建立人机检测信任 --- - console.log('>>> [Browser] 正在连接 LMArena...'); - await page.goto(TARGET_URL, { waitUntil: 'networkidle2' }); + // 注册清理钩子 + if (proxyUrlForChrome) { + console.log('>>> [Warn] 使用了本地代理桥接。请保持此脚本运行,否则 Chrome 将失去代理连接。'); + } + + // --- 行为预热建立人机检测信任 --- + if (!page.url().includes('lmarena.ai')) { + console.log('>>> [Browser] 正在连接 LMArena...'); + await page.goto(TARGET_URL, { waitUntil: 'networkidle2' }); + } else { + console.log('>>> [Browser] 页面已在 LMArena,跳过跳转。'); + } console.log('>>> [Warmup] 正在随机浏览页面以建立信任...'); // 计算屏幕中心点 (动态获取视口大小) - let viewport = page.viewport(); - // 如果是 null (有时候 headless 初始化慢),给个默认值 - let width = viewport ? viewport.width : 1280; - let height = viewport ? viewport.height : 690; + const vp = await getRealViewport(page); - const centerX = width / 2; - const centerY = height / 2; + // 计算动态中心点 + const centerX = vp.width / 2; + const centerY = vp.height / 2; // 第一次移动:从左上角移动到中心附近 if (page.cursor) { - await page.cursor.moveTo({ x: centerX + random(-200, 200), y: centerY + random(-200, 200) }); + // 使用 clamp 确保随机偏移后仍在屏幕内 + 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); @@ -339,18 +569,6 @@ async function initBrowser(config) { console.log('>>> [Browser] 浏览器初始化完成,系统就绪'); console.log('>>> [Browser] 当程序有任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!'); - // 注册清理钩子:浏览器关闭时关闭代理服务器 - if (proxyUrlForChrome) { - browser.on('disconnected', async () => { - console.log('>>> [Browser] 浏览器断开,正在清理代理桥接...'); - try { - await closeAnonymizedProxy(proxyUrlForChrome, true); - } catch (e) { - console.error('>>> [Warn] 代理清理失败:', e.message); - } - }); - } - return { browser, page, client }; } @@ -510,12 +728,19 @@ async function generateImage(context, prompt, imgPaths, modelId) { }, 120000); }); - // 任务结束,像人一样把鼠标移开,防止遮挡或误触 + // 任务结束,基于当前窗口比例智能移开鼠标 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 }); + // 1. 再次获取最新视口 (用户可能在生成过程中改变了窗口大小) + const currentVp = await getRealViewport(page); + + // 2. 计算相对坐标:停靠在屏幕右侧 85% ~ 95% 的位置 + const relativeX = currentVp.safeWidth * random(0.85, 0.95); + const relativeY = currentVp.height * random(0.3, 0.7); // 高度居中随机 + + // 3. 再次检查 + const finalX = clamp(relativeX, 0, currentVp.safeWidth); + const finalY = clamp(relativeY, 0, currentVp.safeHeight); + await page.cursor.moveTo({ x: finalX, y: finalY }); } return result;