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;