mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 增加登录模式,浏览器改用分离式启动
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
client.js
|
||||
test/
|
||||
config.yaml
|
||||
@@ -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
|
||||
|
||||
+270
-45
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user