344 lines
12 KiB
JavaScript
344 lines
12 KiB
JavaScript
const BaseAction = require('../core/base-action');
|
||
const SmartSelector = require('../core/smart-selector');
|
||
|
||
/**
|
||
* 点击动作
|
||
*/
|
||
class ClickAction extends BaseAction {
|
||
async execute() {
|
||
const selector = this.config.selector || this.config.find;
|
||
|
||
if (!selector) {
|
||
throw new Error('缺少选择器配置');
|
||
}
|
||
|
||
this.log('info', '执行点击');
|
||
|
||
// 查找元素
|
||
const smartSelector = SmartSelector.fromConfig(selector, this.page);
|
||
const element = await smartSelector.find(this.config.timeout || 10000);
|
||
|
||
if (!element) {
|
||
throw new Error(`无法找到元素: ${JSON.stringify(selector)}`);
|
||
}
|
||
|
||
// 等待元素变为可点击状态(参考旧框架)
|
||
const waitForEnabled = this.config.waitForEnabled !== false; // 默认等待
|
||
if (waitForEnabled) {
|
||
// 传入 selector 配置,在循环中重新查找元素
|
||
await this.waitForClickable(element, this.config.timeout || 30000, selector);
|
||
}
|
||
|
||
// 记录找到的元素信息(总是显示,便于调试)
|
||
try {
|
||
const info = await element.evaluate((el) => {
|
||
const tag = el.tagName;
|
||
const id = el.id ? `#${el.id}` : '';
|
||
const cls = el.className ? `.${el.className.split(' ')[0]}` : '';
|
||
const text = (el.textContent || '').trim().substring(0, 30);
|
||
const disabled = el.disabled ? ' [DISABLED]' : '';
|
||
return `${tag}${id}${cls} "${text}"${disabled}`;
|
||
});
|
||
this.log('info', `→ 找到元素: ${info}`);
|
||
} catch (e) {
|
||
this.log('warn', `无法获取元素信息: ${e.message}`);
|
||
}
|
||
|
||
// 滚动到可视区域(带容错)
|
||
try {
|
||
await element.evaluate((el) => {
|
||
if (el && typeof el.scrollIntoView === 'function') {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
});
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
} catch (error) {
|
||
this.log('warn', `滚动失败,继续尝试点击: ${error.message}`);
|
||
}
|
||
|
||
// 点击(支持人类行为模拟)
|
||
const humanLike = this.config.humanLike !== false; // 默认使用人类行为
|
||
if (humanLike) {
|
||
// 重新查找元素并点击(参考旧框架,避免元素失效)
|
||
await this.humanClickWithSelector(selector);
|
||
} else {
|
||
await element.click();
|
||
}
|
||
|
||
this.log('info', '✓ 点击完成');
|
||
|
||
// 验证点击后的变化(新元素出现 / 旧元素消失)
|
||
if (this.config.verifyAfter) {
|
||
await this.verifyAfterClick(this.config.verifyAfter);
|
||
}
|
||
|
||
// 等待页面变化(如果配置了)
|
||
if (this.config.waitForPageChange) {
|
||
await this.waitForPageChange(this.config.checkSelector);
|
||
}
|
||
|
||
// 可选的等待时间
|
||
if (this.config.waitAfter) {
|
||
await new Promise(resolve => setTimeout(resolve, this.config.waitAfter));
|
||
}
|
||
|
||
return { success: true };
|
||
}
|
||
|
||
/**
|
||
* 等待元素变为可点击状态(参考旧框架)
|
||
* @param {ElementHandle} element - 元素句柄(可选,如果不传则在循环中重新查找)
|
||
* @param {number} timeout - 超时时间
|
||
* @param {Object} selectorConfig - 选择器配置(用于重新查找)
|
||
*/
|
||
async waitForClickable(element, timeout, selectorConfig = null) {
|
||
this.log('info', '→ 等待元素可点击...');
|
||
|
||
const startTime = Date.now();
|
||
let lastLogTime = 0;
|
||
|
||
while (Date.now() - startTime < timeout) {
|
||
try {
|
||
// 如果提供了 selectorConfig,每次重新查找元素(参考旧框架)
|
||
let currentElement = element;
|
||
if (selectorConfig) {
|
||
const smartSelector = SmartSelector.fromConfig(selectorConfig, this.page);
|
||
currentElement = await smartSelector.find(1000);
|
||
if (!currentElement) {
|
||
// 元素不存在,继续等待
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const isClickable = await currentElement.evaluate((el) => {
|
||
// 更严格的检查:
|
||
// 1. 必须可见
|
||
if (el.offsetParent === null) return false;
|
||
// 2. 如果是 button/input,检查 disabled 属性
|
||
if (el.tagName === 'BUTTON' || el.tagName === 'INPUT') {
|
||
if (el.disabled) return false;
|
||
}
|
||
// 3. 检查是否被遮挡(可选)
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width === 0 || rect.height === 0) return false;
|
||
|
||
return true;
|
||
});
|
||
|
||
if (isClickable) {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
this.log('info', `✓ 元素已可点击 (耗时: ${elapsed}秒)`);
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
// 元素可能被重新渲染,继续等待
|
||
this.log('debug', `元素检查失败: ${error.message}`);
|
||
}
|
||
|
||
// 每5秒输出一次进度
|
||
const elapsed = Date.now() - startTime;
|
||
if (elapsed - lastLogTime >= 5000) {
|
||
this.log('info', `→ 等待元素可点击中... 已用时 ${(elapsed/1000).toFixed(0)}秒`);
|
||
lastLogTime = elapsed;
|
||
}
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
}
|
||
|
||
this.log('warn', '⚠️ 等待元素可点击超时,将尝试点击');
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 人类行为模拟点击 - 使用选择器(参考旧框架,每次重新查找元素)
|
||
*/
|
||
async humanClickWithSelector(selectorConfig) {
|
||
this.log('info', '→ 使用人类行为模拟点击...');
|
||
|
||
try {
|
||
// 重新查找元素(避免元素失效)
|
||
const smartSelector = SmartSelector.fromConfig(selectorConfig, this.page);
|
||
const element = await smartSelector.find(5000);
|
||
|
||
if (!element) {
|
||
throw new Error(`重新查找元素失败: ${JSON.stringify(selectorConfig)}`);
|
||
}
|
||
|
||
this.log('debug', '✓ 已重新定位元素');
|
||
|
||
// 获取元素的边界框
|
||
const box = await element.boundingBox();
|
||
if (!box) {
|
||
this.log('warn', '⚠️ 无法获取元素边界框,使用直接点击');
|
||
await element.click();
|
||
return;
|
||
}
|
||
|
||
this.log('debug', `元素位置: x=${box.x.toFixed(0)}, y=${box.y.toFixed(0)}, w=${box.width.toFixed(0)}, h=${box.height.toFixed(0)}`);
|
||
|
||
// 计算点击位置(直接点击中心,避免随机偏移导致点击失败)
|
||
const targetX = box.x + box.width / 2;
|
||
const targetY = box.y + box.height / 2;
|
||
|
||
// 第一段移动:先移动到附近(模拟人眼定位)
|
||
const nearX = targetX + this.randomInt(-50, 50);
|
||
const nearY = targetY + this.randomInt(-50, 50);
|
||
const steps1 = this.randomInt(10, 20);
|
||
|
||
this.log('debug', `移动鼠标到附近: (${nearX.toFixed(0)}, ${nearY.toFixed(0)})`);
|
||
await this.page.mouse.move(nearX, nearY, { steps: Math.floor(steps1 / 2) });
|
||
await this.randomDelay(50, 150);
|
||
|
||
// 第二段移动:移动到目标位置
|
||
this.log('debug', `移动鼠标到目标: (${targetX.toFixed(0)}, ${targetY.toFixed(0)})`);
|
||
await this.page.mouse.move(targetX, targetY, { steps: Math.floor(steps1 / 2) });
|
||
|
||
// 短暂停顿(模拟人类反应)
|
||
await this.randomDelay(50, 200);
|
||
|
||
// 点击(使用 down + up,而不是 click)
|
||
this.log('debug', '执行点击 (mouse down + up)...');
|
||
await this.page.mouse.down();
|
||
await this.randomDelay(50, 120);
|
||
await this.page.mouse.up();
|
||
|
||
// 点击后延迟(参考旧框架)
|
||
await this.randomDelay(1000, 2000);
|
||
|
||
this.log('info', '✓ 人类行为点击完成');
|
||
|
||
} catch (error) {
|
||
this.log('error', `⚠️ 人类行为点击失败: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 随机整数
|
||
*/
|
||
randomInt(min, max) {
|
||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||
}
|
||
|
||
/**
|
||
* 随机延迟
|
||
*/
|
||
async randomDelay(min, max) {
|
||
const delay = this.randomInt(min, max);
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
}
|
||
|
||
/**
|
||
* 验证点击后的变化
|
||
*/
|
||
async verifyAfterClick(config) {
|
||
const { appears, disappears, checked, timeout = 10000 } = config;
|
||
|
||
// 验证新元素出现
|
||
if (appears) {
|
||
this.log('debug', '验证新元素出现...');
|
||
for (const selector of (Array.isArray(appears) ? appears : [appears])) {
|
||
try {
|
||
await this.page.waitForSelector(selector, { timeout, visible: true });
|
||
this.log('debug', `✓ 新元素已出现: ${selector}`);
|
||
} catch (error) {
|
||
throw new Error(`点击后验证失败: 元素 "${selector}" 未出现`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 验证旧元素消失
|
||
if (disappears) {
|
||
this.log('debug', '验证旧元素消失...');
|
||
for (const selector of (Array.isArray(disappears) ? disappears : [disappears])) {
|
||
try {
|
||
await this.page.waitForSelector(selector, { timeout, hidden: true });
|
||
this.log('debug', `✓ 旧元素已消失: ${selector}`);
|
||
} catch (error) {
|
||
throw new Error(`点击后验证失败: 元素 "${selector}" 未消失`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 验证 checked 状态(用于 radio/checkbox)
|
||
if (checked !== undefined) {
|
||
this.log('debug', `验证 checked 状态: ${checked}...`);
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
|
||
// 获取 CSS 选择器
|
||
const selectorConfig = this.config.selector;
|
||
let cssSelector = null;
|
||
|
||
if (typeof selectorConfig === 'string') {
|
||
cssSelector = selectorConfig;
|
||
} else if (Array.isArray(selectorConfig)) {
|
||
// 取第一个 css 选择器
|
||
for (const sel of selectorConfig) {
|
||
if (typeof sel === 'string') {
|
||
cssSelector = sel;
|
||
break;
|
||
} else if (sel.css) {
|
||
cssSelector = sel.css;
|
||
break;
|
||
}
|
||
}
|
||
} else if (selectorConfig.css) {
|
||
cssSelector = selectorConfig.css;
|
||
}
|
||
|
||
if (!cssSelector) {
|
||
throw new Error('无法从选择器配置中提取 CSS 选择器');
|
||
}
|
||
|
||
const isChecked = await this.page.evaluate((sel) => {
|
||
const element = document.querySelector(sel);
|
||
return element && element.checked === true;
|
||
}, cssSelector);
|
||
|
||
const expectedState = checked === true;
|
||
if (isChecked !== expectedState) {
|
||
throw new Error(`点击后验证失败: 元素 checked 状态为 ${isChecked},期望 ${expectedState}`);
|
||
}
|
||
|
||
this.log('debug', `✓ checked 状态验证通过: ${isChecked}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 等待页面内容变化
|
||
*/
|
||
async waitForPageChange(checkSelector, timeout = 15000) {
|
||
this.log('debug', '等待页面变化...');
|
||
|
||
const startTime = Date.now();
|
||
const initialUrl = this.page.url();
|
||
|
||
while (Date.now() - startTime < timeout) {
|
||
// 检查 URL 是否变化
|
||
if (this.page.url() !== initialUrl) {
|
||
this.log('debug', '✓ URL 已变化');
|
||
return true;
|
||
}
|
||
|
||
// 检查特定元素是否出现
|
||
if (checkSelector) {
|
||
const smartSelector = SmartSelector.fromConfig(checkSelector, this.page);
|
||
const newElement = await smartSelector.find(1000);
|
||
if (newElement) {
|
||
this.log('debug', '✓ 页面内容已变化');
|
||
return true;
|
||
}
|
||
}
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
}
|
||
|
||
this.log('warn', '等待页面变化超时');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
module.exports = ClickAction;
|