mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 初步支持 Gemini Business 并重构和整理
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
import puppeteer from 'puppeteer-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { createCursor } from 'ghost-cursor';
|
||||
import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain';
|
||||
import { spawn } from 'child_process';
|
||||
import { getRealViewport, clamp, random, sleep } from './utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// 配置 Stealth 插件
|
||||
const stealth = StealthPlugin();
|
||||
stealth.enabledEvasions.delete('iframe.contentWindow');
|
||||
puppeteer.use(stealth);
|
||||
|
||||
// 全局状态跟踪
|
||||
let globalChromeProcess = null;
|
||||
let globalBrowser = null;
|
||||
let globalProxyUrl = null;
|
||||
|
||||
/**
|
||||
* 清理浏览器资源和进程
|
||||
* 实现三级退出机制: Puppeteer close -> SIGTERM -> SIGKILL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function cleanup() {
|
||||
|
||||
// Level 1: 通过 Puppeteer 协议优雅关闭,释放锁并保存 Profile
|
||||
if (globalBrowser) {
|
||||
try {
|
||||
logger.debug('浏览器', '正在断开远程调试连接...');
|
||||
await globalBrowser.close();
|
||||
globalBrowser = null;
|
||||
logger.debug('浏览器', '已断开远程调试连接');
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `断开远程调试连接失败 (可能已断开): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2 & 3: 处理残留进程
|
||||
if (globalChromeProcess && !globalChromeProcess.killed) {
|
||||
logger.info('浏览器', '正在终止浏览器进程...');
|
||||
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);
|
||||
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
|
||||
process.kill(-globalChromeProcess.pid, 'SIGKILL');
|
||||
} catch (e) { }
|
||||
|
||||
globalChromeProcess = null;
|
||||
logger.info('浏览器', '浏览器进程进程已终止');
|
||||
}
|
||||
|
||||
// 清理代理
|
||||
if (globalProxyUrl) {
|
||||
try {
|
||||
logger.debug('浏览器', '正在关闭 Socks5 代理桥接');
|
||||
await closeAnonymizedProxy(globalProxyUrl, true);
|
||||
logger.debug('浏览器', '已关闭 Socks5 代理桥接');
|
||||
} catch (e) {
|
||||
logger.error('浏览器', '关闭 Socks5 代理桥接失败', { error: e.message });
|
||||
}
|
||||
globalProxyUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 防止重复注册
|
||||
let signalHandlersRegistered = false;
|
||||
|
||||
/**
|
||||
* 注册进程退出信号处理
|
||||
* @private
|
||||
*/
|
||||
function registerCleanupHandlers() {
|
||||
if (signalHandlersRegistered) return;
|
||||
|
||||
process.on('exit', () => {
|
||||
if (globalChromeProcess) globalChromeProcess.kill();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器 (统一启动逻辑)
|
||||
* @param {object} config - 配置对象
|
||||
* @param {object} [config.chrome] - Chrome 配置
|
||||
* @param {boolean} [config.chrome.headless] - 是否开启 Headless 模式
|
||||
* @param {string} [config.chrome.path] - Chrome 可执行文件路径
|
||||
* @param {boolean} [config.chrome.gpu] - 是否启用 GPU
|
||||
* @param {object} [config.chrome.proxy] - 代理配置
|
||||
* @param {object} options - 启动选项
|
||||
* @param {string} options.userDataDir - 用户数据目录路径
|
||||
* @param {string} options.targetUrl - 目标 URL
|
||||
* @param {string} options.productName - 产品名称(用于日志)
|
||||
* @param {boolean} [options.reuseExistingTab=false] - 是否复用已有特定域名的 tab
|
||||
* @param {Function} [options.waitInputValidator] - 自定义输入框等待验证函数
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
*/
|
||||
export async function initBrowserBase(config, options) {
|
||||
const {
|
||||
userDataDir,
|
||||
targetUrl,
|
||||
productName,
|
||||
reuseExistingTab = false,
|
||||
waitInputValidator = null
|
||||
} = options;
|
||||
|
||||
// 检测登录模式
|
||||
const isLoginMode = process.argv.includes('-login');
|
||||
const ENABLE_AUTOMATION_MODE = !isLoginMode;
|
||||
|
||||
logger.info('浏览器', `开始初始化浏览器 (${productName})`);
|
||||
logger.info('浏览器', `自动化模式: ${ENABLE_AUTOMATION_MODE ? '开启' : '关闭'}`);
|
||||
if (isLoginMode) {
|
||||
logger.warn('浏览器', '当前为登录模式,请手动完成登录后关闭登录模式以继续自动化程序!');
|
||||
}
|
||||
|
||||
const chromeConfig = config?.chrome || {};
|
||||
const remoteDebuggingPort = 9222;
|
||||
|
||||
// Chrome 启动参数
|
||||
const args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--no-first-run'
|
||||
];
|
||||
|
||||
// Headless 模式配置
|
||||
if (chromeConfig.headless && !isLoginMode) {
|
||||
args.push('--headless=new');
|
||||
args.push('--window-size=1280,690');
|
||||
args.push('--headless=new');
|
||||
args.push('--window-size=1280,690');
|
||||
logger.info('浏览器', 'Headless 模式: 启用 (1280x690)');
|
||||
} else {
|
||||
if (isLoginMode && chromeConfig.headless) {
|
||||
logger.warn('浏览器', '登录模式下强制禁用 Headless 模式。');
|
||||
}
|
||||
// 有头模式:最大化窗口以适配屏幕
|
||||
args.push('--start-maximized');
|
||||
logger.info('浏览器', 'Headless 模式: 禁用 (最大化窗口)');
|
||||
}
|
||||
|
||||
// GPU 配置
|
||||
if (chromeConfig.gpu === false) {
|
||||
args.push(
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--animation-duration-scale=0',
|
||||
'--disable-smooth-scrolling'
|
||||
);
|
||||
logger.info('浏览器', 'GPU 加速: 禁用');
|
||||
} else {
|
||||
logger.info('浏览器', 'GPU 加速: 启用');
|
||||
}
|
||||
|
||||
// 代理配置
|
||||
let proxyUrlForChrome = null;
|
||||
if (chromeConfig.proxy && chromeConfig.proxy.enable) {
|
||||
const { type, host, port, user, passwd } = chromeConfig.proxy;
|
||||
|
||||
// 特殊处理 SOCKS5 + Auth (Chrome 原生不支持)
|
||||
if (type === 'socks5' && user && passwd) {
|
||||
try {
|
||||
const upstreamUrl = `socks5://${user}:${passwd}@${host}:${port}`;
|
||||
logger.info('浏览器', '检测到需鉴权的 Socks5 代理,正在创建本地代理桥接...');
|
||||
// 创建本地中间代理 (无认证 -> 有认证)
|
||||
proxyUrlForChrome = await anonymizeProxy(upstreamUrl);
|
||||
globalProxyUrl = proxyUrlForChrome; // 记录全局代理
|
||||
logger.info('浏览器', `本地代理桥接已建立: ${proxyUrlForChrome} -> ${host}:${port}`);
|
||||
|
||||
args.push(`--proxy-server=${proxyUrlForChrome}`);
|
||||
args.push('--disable-quic');
|
||||
logger.warn('浏览器', '为增强代理兼容性,已禁用QUIC (HTTP/3)');
|
||||
logger.info('浏览器', `代理配置: ${type}://${host}:${port}`);
|
||||
} catch (e) {
|
||||
logger.error('浏览器', '本地代理桥接创建失败', { error: e.message });
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// 常规 HTTP 代理或无认证 SOCKS5
|
||||
const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`;
|
||||
args.push(`--proxy-server=${proxyUrl}`);
|
||||
args.push('--disable-quic');
|
||||
logger.warn('浏览器', '为增强代理兼容性,已禁用QUIC (HTTP/3)');
|
||||
logger.info('浏览器', `代理配置: ${type}://${host}:${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
const chromePath = chromeConfig.path;
|
||||
|
||||
// --- 模式分支 ---
|
||||
|
||||
if (!ENABLE_AUTOMATION_MODE) {
|
||||
// 仅启动浏览器
|
||||
logger.info('浏览器', '正在以登录模式启动浏览器...');
|
||||
logger.debug('浏览器', `启动路径: ${chromePath}`);
|
||||
|
||||
// 在手动模式下自动打开目标页面
|
||||
args.push(targetUrl);
|
||||
|
||||
const chromeProcess = spawn(chromePath, args, {
|
||||
detached: false,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
globalChromeProcess = chromeProcess;
|
||||
|
||||
// 注册清理处理器
|
||||
registerCleanupHandlers();
|
||||
logger.info('浏览器', '浏览器已启动,脚本将持续运行直到浏览器关闭...');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
chromeProcess.on('close', async (code) => {
|
||||
logger.warn('浏览器', `浏览器已被关闭 (退出码: ${code})`);
|
||||
await cleanup();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
logger.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) {
|
||||
logger.debug('浏览器', '检测到已运行的浏览器实例,正在连接...');
|
||||
browserWSEndpoint = data.webSocketDebuggerUrl;
|
||||
logger.info('浏览器', '已连接到已运行的浏览器实例,程序将复用实例');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('浏览器', '未检测到运行中的浏览器实例,正在启动新实例...');
|
||||
}
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
const automationArgs = [...args, `--remote-debugging-port=${remoteDebuggingPort}`];
|
||||
logger.info('浏览器', '正在启动浏览器...');
|
||||
logger.debug('浏览器', `启动路径: ${chromePath}`);
|
||||
|
||||
const chromeProcess = spawn(chromePath, automationArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
chromeProcess.unref();
|
||||
globalChromeProcess = chromeProcess;
|
||||
|
||||
logger.debug('浏览器', '浏览器已启动,等待调试端口就绪...');
|
||||
|
||||
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;
|
||||
logger.debug('浏览器', '浏览器调试接口已就绪');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
throw new Error('无法连接到 Chrome 远程调试端口,请检查 Chrome 是否成功启动。');
|
||||
}
|
||||
}
|
||||
|
||||
// 连接 Puppeteer
|
||||
const browser = await puppeteer.connect({
|
||||
browserWSEndpoint: browserWSEndpoint,
|
||||
defaultViewport: null
|
||||
});
|
||||
|
||||
globalBrowser = browser; // 保存实例引用供 cleanup 使用
|
||||
|
||||
logger.info('浏览器', '远程调试已连接');
|
||||
|
||||
// 注册清理处理器
|
||||
registerCleanupHandlers();
|
||||
|
||||
browser.on('disconnected', async () => {
|
||||
logger.warn('浏览器', '浏览器已断开连接');
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 获取或创建页面
|
||||
let page;
|
||||
if (reuseExistingTab) {
|
||||
// 复用已有标签页
|
||||
const pages = await browser.pages();
|
||||
const urlDomain = new URL(targetUrl).hostname;
|
||||
page = pages.find(p => p.url().includes(urlDomain));
|
||||
|
||||
if (!page) {
|
||||
page = await browser.newPage();
|
||||
logger.debug('浏览器', '已创建新标签页');
|
||||
} else {
|
||||
logger.warn('浏览器', '检测到已有目标网站标签页,程序将复用标签页');
|
||||
}
|
||||
} else {
|
||||
// 总是新建标签页
|
||||
page = await browser.newPage();
|
||||
logger.debug('浏览器', '已创建新标签页');
|
||||
}
|
||||
|
||||
// 初始化 ghost-cursor
|
||||
page.cursor = createCursor(page);
|
||||
|
||||
// 代理认证 (仅当未使用 proxy-chain 桥接时)
|
||||
if (chromeConfig.proxy && chromeConfig.proxy.enable && chromeConfig.proxy.user && !proxyUrlForChrome) {
|
||||
await page.authenticate({
|
||||
username: chromeConfig.proxy.user,
|
||||
password: chromeConfig.proxy.passwd
|
||||
});
|
||||
logger.info('浏览器', '代理认证: 已激活 (HTTP Basic Auth)');
|
||||
}
|
||||
|
||||
// 创建 CDP 会话
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
// 注册清理钩子
|
||||
if (proxyUrlForChrome) {
|
||||
logger.warn('浏览器', '因使用了本地代理桥接,请保持此程序运行,否则浏览器将失去代理连接');
|
||||
}
|
||||
|
||||
// --- 行为预热建立人机检测信任 ---
|
||||
const urlDomain = new URL(targetUrl).hostname;
|
||||
if (!page.url().includes(urlDomain)) {
|
||||
logger.info('浏览器', `正在连接 ${productName}...`);
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
|
||||
} else {
|
||||
logger.info('浏览器', `页面已在 ${productName},跳过跳转`);
|
||||
}
|
||||
|
||||
logger.info('浏览器', '正在随机浏览页面以建立信任...');
|
||||
|
||||
// 计算屏幕中心点 (动态获取视口大小)
|
||||
const vp = await getRealViewport(page);
|
||||
|
||||
// 计算动态中心点
|
||||
const centerX = vp.width / 2;
|
||||
const centerY = vp.height / 2;
|
||||
|
||||
// 第一次移动:从左上角移动到中心附近
|
||||
if (page.cursor) {
|
||||
// 使用 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);
|
||||
|
||||
// 模拟滚动行为
|
||||
try {
|
||||
await page.mouse.wheel({ deltaY: random(100, 300) });
|
||||
await sleep(800, 1500);
|
||||
await page.mouse.wheel({ deltaY: -random(50, 100) });
|
||||
} catch (e) { }
|
||||
|
||||
// 如果提供了自定义输入框验证函数,使用它
|
||||
if (waitInputValidator && typeof waitInputValidator === 'function') {
|
||||
await waitInputValidator(page);
|
||||
}
|
||||
|
||||
logger.info('浏览器', '浏览器初始化完成,系统就绪');
|
||||
logger.warn('浏览器', '当任务运行时请勿随意调节窗口大小,以免鼠标轨迹错位!');
|
||||
|
||||
return { browser, page, client };
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* 生成指定范围内的随机数
|
||||
* @param {number} min 最小值
|
||||
* @param {number} max 最大值
|
||||
* @returns {number} 随机数
|
||||
*/
|
||||
export function random(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机休眠一段时间
|
||||
* @param {number} min 最小毫秒数
|
||||
* @param {number} max 最大毫秒数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function sleep(min, max) {
|
||||
return new Promise(r => setTimeout(r, Math.floor(random(min, max))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取 MIME 类型
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {string} MIME 类型
|
||||
*/
|
||||
export function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const map = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* [Security Enhanced] 无痕获取当前页面实时视口
|
||||
* 使用纯净的匿名函数执行,不污染 Global Scope,不留指纹
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @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 缓冲,防止鼠标移到滚动条上或贴边触发浏览器原生手势
|
||||
safeWidth: w - 20,
|
||||
safeHeight: h
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback: 如果上下文丢失,返回安全保守值
|
||||
return { width: 1280, height: 720, safeWidth: 1260, safeHeight: 720 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Safety] 坐标钳位函数
|
||||
* 强制将坐标限制在合法视口范围内,杜绝 "Node is not visible" 报错
|
||||
* @param {number} value - 原始坐标值
|
||||
* @param {number} min - 最小值
|
||||
* @param {number} max - 最大值
|
||||
* @returns {number} 修正后的坐标值
|
||||
*/
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度查找 Shadow DOM 中的元素
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @param {string} selector - CSS 选择器
|
||||
* @param {import('puppeteer').ElementHandle} [rootHandle=null] - 可选的根节点句柄
|
||||
* @returns {Promise<import('puppeteer').ElementHandle|null>} 找到的元素句柄或 null
|
||||
*/
|
||||
export async function queryDeep(page, selector, rootHandle = null) {
|
||||
return await page.evaluateHandle((sel, root) => {
|
||||
function find(node, s) {
|
||||
if (!node) return null;
|
||||
if (node instanceof Element && node.matches(s)) return node;
|
||||
let found = node.querySelector(s);
|
||||
if (found) return found;
|
||||
if (node.shadowRoot) {
|
||||
found = find(node.shadowRoot, s);
|
||||
if (found) return found;
|
||||
}
|
||||
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT, null, false);
|
||||
while (walker.nextNode()) {
|
||||
const child = walker.currentNode;
|
||||
if (child.shadowRoot) {
|
||||
found = find(child.shadowRoot, s);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(root || document.body, sel);
|
||||
}, selector, rootHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全点击元素(包含拟人化移动和点击)
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function safeClick(page, target) {
|
||||
try {
|
||||
let el;
|
||||
|
||||
// 判断是 selector 还是 ElementHandle
|
||||
if (typeof target === 'string') {
|
||||
el = await page.$(target);
|
||||
if (!el) throw new Error(`未找到: ${target}`);
|
||||
} else {
|
||||
el = target;
|
||||
if (!el || !el.asElement()) throw new Error(`Element handle invalid`);
|
||||
}
|
||||
|
||||
// 使用 ghost-cursor 点击
|
||||
if (page.cursor) {
|
||||
await page.cursor.click(el);
|
||||
return;
|
||||
}
|
||||
|
||||
// 降级逻辑
|
||||
await el.click();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟人类键盘输入
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {string} text - 要输入的文本
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function humanType(page, target, text) {
|
||||
let el;
|
||||
|
||||
// 判断是 selector 还是 ElementHandle
|
||||
if (typeof target === 'string') {
|
||||
el = await page.$(target);
|
||||
if (!el) throw new Error(`Element not found: ${target}`);
|
||||
} else {
|
||||
el = target;
|
||||
if (!el) throw new Error(`Element handle invalid`);
|
||||
}
|
||||
|
||||
await el.focus();
|
||||
|
||||
// 智能输入策略
|
||||
if (text.length < 50) {
|
||||
// 短文本:保持拟人化逐字输入
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
// 模拟错字 (5% 概率)
|
||||
if (Math.random() < 0.05) {
|
||||
await page.keyboard.type('x', { delay: random(50, 150) });
|
||||
await sleep(100, 300);
|
||||
await page.keyboard.press('Backspace', { delay: random(50, 100) });
|
||||
}
|
||||
await page.keyboard.type(char, { delay: random(30, 100) });
|
||||
// 随机击键间隔
|
||||
await sleep(30, 100);
|
||||
}
|
||||
} else {
|
||||
// 长文本:假装打字 -> 停顿 -> 粘贴
|
||||
const fakeCount = Math.floor(random(3, 8));
|
||||
const fakeText = text.substring(0, fakeCount);
|
||||
|
||||
// 1. 假装打字几个字符
|
||||
for (let i = 0; i < fakeText.length; i++) {
|
||||
await page.keyboard.type(fakeText[i], { delay: random(30, 100) });
|
||||
}
|
||||
|
||||
// 2. 停顿思考 (0.5 - 1秒)
|
||||
await sleep(500, 1000);
|
||||
|
||||
// 3. 全选删除 (模拟 Ctrl+A -> Backspace)
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('A');
|
||||
await page.keyboard.up('Control');
|
||||
await sleep(100, 300);
|
||||
await page.keyboard.press('Backspace');
|
||||
await sleep(100, 300);
|
||||
|
||||
// 4. 瞬间粘贴全部文本 (模拟 Ctrl+V)
|
||||
if (typeof target === 'string') {
|
||||
// 对于 selector,使用 querySelector
|
||||
await page.evaluate((sel, content) => {
|
||||
const input = document.querySelector(sel);
|
||||
input.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, target, text);
|
||||
} else {
|
||||
// 对于 ElementHandle,直接使用
|
||||
await page.evaluate((el, content) => {
|
||||
el.focus();
|
||||
document.execCommand('insertText', false, content);
|
||||
}, el, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粘贴图片到输入框
|
||||
* 支持 CSS selector 和 ElementHandle 两种输入
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面对象
|
||||
* @param {string|import('puppeteer').ElementHandle} target - CSS 选择器或元素句柄
|
||||
* @param {string[]} filePaths - 图片文件路径数组
|
||||
* @param {Object} [options] - 可选配置
|
||||
* @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);
|
||||
|
||||
if (filesData.length === 0) return;
|
||||
|
||||
// 点击输入框以获取焦点
|
||||
await safeClick(page, target);
|
||||
await sleep(500, 800);
|
||||
|
||||
// 如果提供了自定义的上传确认函数,使用它
|
||||
if (options.uploadValidator && typeof options.uploadValidator === 'function') {
|
||||
const expectedUploads = filesData.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);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user