auto-account-machine/src/tools/automation-framework/actions/click-action.js
2025-11-19 13:22:54 +08:00

344 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;