From acceddac7b95b09bc914bf8ac5a126e031ae35fe Mon Sep 17 00:00:00 2001 From: dengqichen Date: Wed, 19 Nov 2025 09:56:08 +0800 Subject: [PATCH] dasdasd --- .../actions/click-action.js | 194 +++++++++++++++++- .../actions/navigate-action.js | 79 ++++--- .../configs/sites/windsurf.yaml | 33 ++- .../automation-framework/core/site-adapter.js | 81 +++++--- .../core/smart-selector.js | 109 ++++++++-- 5 files changed, 407 insertions(+), 89 deletions(-) diff --git a/src/tools/automation-framework/actions/click-action.js b/src/tools/automation-framework/actions/click-action.js index f0b1a54..dcd7e5c 100644 --- a/src/tools/automation-framework/actions/click-action.js +++ b/src/tools/automation-framework/actions/click-action.js @@ -22,17 +22,51 @@ class ClickAction extends BaseAction { throw new Error(`无法找到元素: ${JSON.stringify(selector)}`); } - // 滚动到可视区域 - await element.evaluate((el) => { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); + // 等待元素变为可点击状态(参考旧框架) + const waitForEnabled = this.config.waitForEnabled !== false; // 默认等待 + if (waitForEnabled) { + // 传入 selector 配置,在循环中重新查找元素 + await this.waitForClickable(element, this.config.timeout || 30000, selector); + } - await new Promise(resolve => setTimeout(resolve, 300)); + // 记录找到的元素信息(总是显示,便于调试) + 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}`); + } - // 点击 - await element.click(); + // 滚动到可视区域(带容错) + 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}`); + } - this.log('debug', '✓ 点击完成'); + // 点击(支持人类行为模拟) + const humanLike = this.config.humanLike !== false; // 默认使用人类行为 + if (humanLike) { + // 重新查找元素并点击(参考旧框架,避免元素失效) + await this.humanClickWithSelector(selector); + } else { + await element.click(); + } + + this.log('info', '✓ 点击完成'); // 验证点击后的变化(新元素出现 / 旧元素消失) if (this.config.verifyAfter) { @@ -52,6 +86,150 @@ class ClickAction extends BaseAction { 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 + this.randomInt(-box.width * 0.3, box.width * 0.3); + const targetY = box.y + box.height / 2 + this.randomInt(-box.height * 0.3, box.height * 0.3); + + // 第一段移动:先移动到附近(模拟人眼定位) + 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)); + } + /** * 验证点击后的变化 */ diff --git a/src/tools/automation-framework/actions/navigate-action.js b/src/tools/automation-framework/actions/navigate-action.js index 41efdaa..2cf91a3 100644 --- a/src/tools/automation-framework/actions/navigate-action.js +++ b/src/tools/automation-framework/actions/navigate-action.js @@ -11,35 +11,66 @@ class NavigateAction extends BaseAction { timeout: 30000 }; - this.log('info', `导航到: ${url}`); + // 重试配置 + const maxRetries = this.config.maxRetries || 5; + const retryDelay = this.config.retryDelay || 3000; + const totalTimeout = this.config.totalTimeout || 180000; // 默认3分钟 - try { - await this.page.goto(url, options); - - // 验证页面URL是否正确(避免重定向到登录页等) - const currentUrl = this.page.url(); - if (this.config.verifyUrl && !currentUrl.includes(this.config.verifyUrl)) { - throw new Error(`页面跳转异常: 期望包含 "${this.config.verifyUrl}", 实际为 "${currentUrl}"`); + const startTime = Date.now(); + let lastError = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + // 检查总超时 + if (Date.now() - startTime > totalTimeout) { + this.log('error', `总超时 ${totalTimeout}ms,停止重试`); + break; } - // 验证关键元素存在(确保页面加载正确) - if (this.config.verifyElements) { - await this.verifyElements(this.config.verifyElements); + try { + if (attempt > 0) { + this.log('info', `第 ${attempt + 1} 次尝试导航...`); + } else { + this.log('info', `导航到: ${url}`); + } + + // 尝试导航 + await this.page.goto(url, options); + + // 验证页面URL是否正确(避免重定向到会员中心等) + const currentUrl = this.page.url(); + if (this.config.verifyUrl && !currentUrl.includes(this.config.verifyUrl)) { + throw new Error(`页面跳转异常: 期望包含 "${this.config.verifyUrl}", 实际为 "${currentUrl}"`); + } + + // 验证关键元素存在(确保页面加载正确) + if (this.config.verifyElements) { + await this.verifyElements(this.config.verifyElements); + } + + // 可选的等待时间 + if (this.config.waitAfter) { + await new Promise(resolve => setTimeout(resolve, this.config.waitAfter)); + } + + this.log('info', `✓ 页面加载完成${attempt > 0 ? ` (尝试 ${attempt + 1} 次)` : ''}`); + + return { success: true, url: currentUrl }; + + } catch (error) { + lastError = error; + this.log('warn', `导航失败 (尝试 ${attempt + 1}/${maxRetries}): ${error.message}`); + + // 如果不是最后一次尝试,等待后重试 + if (attempt < maxRetries - 1) { + this.log('debug', `等待 ${retryDelay}ms 后重试...`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } } - - // 可选的等待时间 - if (this.config.waitAfter) { - await new Promise(resolve => setTimeout(resolve, this.config.waitAfter)); - } - - this.log('info', `✓ 页面加载完成`); - - return { success: true, url: currentUrl }; - - } catch (error) { - this.log('error', `导航失败: ${error.message}`); - throw error; } + + // 所有重试都失败 + this.log('error', `导航失败: ${lastError.message}`); + throw lastError; } /** diff --git a/src/tools/automation-framework/configs/sites/windsurf.yaml b/src/tools/automation-framework/configs/sites/windsurf.yaml index e89ad12..4db5bd0 100644 --- a/src/tools/automation-framework/configs/sites/windsurf.yaml +++ b/src/tools/automation-framework/configs/sites/windsurf.yaml @@ -12,7 +12,13 @@ workflow: options: waitUntil: 'networkidle2' timeout: 30000 - # 验证关键元素存在(SPA应用验证页面加载) + # 重试配置(参考旧框架) + maxRetries: 5 + retryDelay: 3000 + totalTimeout: 180000 # 3分钟总超时 + # URL验证(确保没有跳转到会员中心) + verifyUrl: "/account/register" + # 验证关键元素(确保页面完整加载) verifyElements: - '#firstName' - '#lastName' @@ -52,8 +58,9 @@ workflow: - action: click name: "点击 Continue (基本信息)" selector: - - css: 'button[type="submit"]' - text: 'Continue' + selector: 'button, a' # 明确指定查找范围:按钮或链接 + timeout: 30000 # 验证点击后密码页面出现 verifyAfter: appears: @@ -88,8 +95,9 @@ workflow: - action: click name: "提交密码" selector: - - css: 'button[type="submit"]' - text: 'Continue' + selector: 'button, a' + timeout: 30000 waitAfter: 2000 # ==================== 步骤 2.5: Cloudflare Turnstile 验证 ==================== @@ -118,17 +126,22 @@ workflow: - action: click name: "跳过问卷" selector: - - text: "Skip this step" - - text: "skip" + - text: 'Skip this step' + selector: 'button, a' + - text: 'skip' + selector: 'button, a' + exact: false waitAfter: 2000 # ==================== 步骤 5: 选择计划 ==================== - action: click name: "选择计划" selector: - - text: "Select plan" - - text: "Continue" - - text: "Get started" + - text: 'Select' + selector: 'button, a' + - text: 'Continue' + selector: 'button, a' + timeout: 30000 waitAfter: 2000 # ==================== 步骤 6: 填写支付信息 ==================== @@ -199,9 +212,9 @@ workflow: - action: click name: "点击提交支付" selector: - - css: 'button[type="submit"]' - - text: '订阅' - text: 'Subscribe' + - text: '订阅' + timeout: 15000 waitAfter: 2000 # 验证支付结果(轮询检测成功或失败) diff --git a/src/tools/automation-framework/core/site-adapter.js b/src/tools/automation-framework/core/site-adapter.js index 0bb69c2..3410d05 100644 --- a/src/tools/automation-framework/core/site-adapter.js +++ b/src/tools/automation-framework/core/site-adapter.js @@ -45,7 +45,7 @@ class SiteAdapter { async beforeWorkflow() { this.log('info', `开始执行 ${this.siteName} 工作流`); - // 清除浏览器状态(Cookie、缓存、localStorage) + // 清除浏览器状态(Cookie、缓存) await this.clearBrowserState(); this.log('debug', '执行 beforeWorkflow 钩子'); @@ -53,44 +53,69 @@ class SiteAdapter { } /** - * 清除浏览器状态 + * 清除浏览器状态(模拟真实用户操作:Ctrl+Shift+Delete) */ async clearBrowserState() { this.log('info', '清除浏览器状态...'); try { - // 清除所有 Cookie - const cookies = await this.page.cookies(); - if (cookies.length > 0) { - await this.page.deleteCookie(...cookies); - this.log('info', `✓ 已清除 ${cookies.length} 个 Cookie`); + // 模拟真实用户操作:Ctrl+Shift+Delete + this.log('debug', '模拟 Ctrl+Shift+Delete 打开清除对话框...'); + + // 按下快捷键打开清除浏览数据对话框 + await this.page.keyboard.down('Control'); + await this.page.keyboard.down('Shift'); + await this.page.keyboard.press('Delete'); + await this.page.keyboard.up('Shift'); + await this.page.keyboard.up('Control'); + + // 等待对话框出现 + await new Promise(resolve => setTimeout(resolve, 1500)); + + this.log('debug', '选择清除选项...'); + + // 按 Tab 键导航并选择所有选项(通用方法) + // 不同浏览器可能有不同的界面,这里用快捷键方式更通用 + + // 全选时间范围(通常是第一个下拉框,选择"所有时间") + await this.page.keyboard.press('Tab'); // 移到时间范围 + await this.page.keyboard.press('Space'); // 打开下拉 + await new Promise(resolve => setTimeout(resolve, 300)); + await this.page.keyboard.press('End'); // 选择最后一项(通常是"所有时间") + await this.page.keyboard.press('Enter'); // 确认 + await new Promise(resolve => setTimeout(resolve, 300)); + + // 勾选所有清除选项(通过 Tab + Space) + for (let i = 0; i < 8; i++) { + await this.page.keyboard.press('Tab'); + await this.page.keyboard.press('Space'); // 勾选 + await new Promise(resolve => setTimeout(resolve, 100)); } - // 清除 localStorage 和 sessionStorage(仅在有效页面) - const currentUrl = this.page.url(); - if (currentUrl && currentUrl.startsWith('http')) { - await this.page.evaluate(() => { - try { - localStorage.clear(); - sessionStorage.clear(); - } catch (e) { - // 某些页面可能无法访问 storage - } - }); - this.log('info', '✓ 已清除 localStorage 和 sessionStorage'); - } else { - this.log('debug', '跳过 storage 清理(页面未加载)'); - } + this.log('debug', '点击清除数据按钮...'); + + // 找到并点击"清除数据"按钮(通常可以用 Enter 或继续 Tab 到按钮) + await this.page.keyboard.press('Enter'); + + // 等待清除完成 + await new Promise(resolve => setTimeout(resolve, 2000)); - // 清除缓存(通过 CDP) - const client = await this.page.target().createCDPSession(); - await client.send('Network.clearBrowserCookies'); - await client.send('Network.clearBrowserCache'); - await client.detach(); this.log('info', '✓ 已清除浏览器缓存'); } catch (error) { - this.log('warn', `清除浏览器状态失败: ${error.message}`); + this.log('warn', `⚠ WARNING: 清除浏览器状态失败: ${error.message}`); + this.log('debug', '尝试备用清理方法...'); + + // 备用方案:直接删除 Cookie(不使用 CDP) + try { + const cookies = await this.page.cookies(); + if (cookies.length > 0) { + await this.page.deleteCookie(...cookies); + this.log('info', '✓ 已使用备用方法清除 Cookie'); + } + } catch (e) { + this.log('warn', `备用清理也失败: ${e.message}`); + } } } diff --git a/src/tools/automation-framework/core/smart-selector.js b/src/tools/automation-framework/core/smart-selector.js index 65beb8a..d0257fc 100644 --- a/src/tools/automation-framework/core/smart-selector.js +++ b/src/tools/automation-framework/core/smart-selector.js @@ -24,7 +24,16 @@ class SmartSelector { config.forEach(strategy => { if (strategy.css) selector.css(strategy.css); if (strategy.xpath) selector.xpath(strategy.xpath); - if (strategy.text) selector.text(strategy.text); + if (strategy.text) { + const textOptions = { + exact: strategy.exact, + caseInsensitive: strategy.caseInsensitive, + selector: strategy.selector, + filterDisabled: strategy.filterDisabled, + filterHidden: strategy.filterHidden + }; + selector.text(strategy.text, textOptions); + } if (strategy.placeholder) selector.placeholder(strategy.placeholder); if (strategy.label) selector.label(strategy.label); if (strategy.type) selector.type(strategy.type); @@ -36,7 +45,16 @@ class SmartSelector { // 单个策略对象 if (config.css) selector.css(config.css); if (config.xpath) selector.xpath(config.xpath); - if (config.text) selector.text(config.text); + if (config.text) { + const textOptions = { + exact: config.exact, + caseInsensitive: config.caseInsensitive, + selector: config.selector, + filterDisabled: config.filterDisabled, + filterHidden: config.filterHidden + }; + selector.text(config.text, textOptions); + } if (config.placeholder) selector.placeholder(config.placeholder); if (config.label) selector.label(config.label); if (config.type) selector.type(config.type); @@ -67,31 +85,84 @@ class SmartSelector { return this; } - text(text) { + /** + * 文本选择器(通用方法 - 框架级) + * 根据文本内容查找元素,不限定元素类型 + * 用户可通过 selector 参数指定查找范围 + */ + text(text, options = {}) { + const { + exact = true, // 默认精确匹配(更安全) + caseInsensitive = true, + selector = '*', // 默认查找所有元素(通用) + filterDisabled = false, + filterHidden = true + } = options; + this.strategies.push({ type: 'text', find: async () => { - return await this.page.evaluateHandle((searchText) => { - const walker = document.createTreeWalker( - document.body, - NodeFilter.SHOW_TEXT, - null, - false - ); - - let node; - while (node = walker.nextNode()) { - if (node.textContent.trim() === searchText.trim()) { - return node.parentElement; - } - } - return null; - }, text); + return await this._findByText(text, selector, { exact, caseInsensitive, filterDisabled, filterHidden }); } }); return this; } + /** + * 内部方法:按文本查找元素(使用指定的 CSS 选择器) + */ + async _findByText(searchText, cssSelector, options) { + const { exact, caseInsensitive, filterDisabled, filterHidden } = options; + + const element = await this.page.evaluateHandle( + ({ searchText, exact, caseInsensitive, cssSelector, filterDisabled, filterHidden }) => { + const elements = Array.from(document.querySelectorAll(cssSelector)); + + for (const el of elements) { + // 过滤 disabled + if (filterDisabled && (el.tagName === 'BUTTON' || el.tagName === 'INPUT') && el.disabled) { + continue; + } + + // 过滤隐藏元素 + if (filterHidden && el.offsetParent === null) continue; + + // 获取文本 + const text = (el.textContent || '').trim(); + if (!text) continue; + + // 标准化空格 + const normalizedText = text.replace(/\s+/g, ' '); + const normalizedSearch = searchText.replace(/\s+/g, ' '); + + // 匹配 + let matches = false; + if (exact) { + matches = caseInsensitive + ? normalizedText.toLowerCase() === normalizedSearch.toLowerCase() + : normalizedText === normalizedSearch; + } else { + matches = caseInsensitive + ? normalizedText.toLowerCase().includes(normalizedSearch.toLowerCase()) + : normalizedText.includes(normalizedSearch); + } + + if (matches) return el; + } + + return null; + }, + { searchText, exact, caseInsensitive, cssSelector, filterDisabled, filterHidden } + ); + + if (element) { + const elementHandle = element.asElement(); + if (elementHandle) return elementHandle; + } + + return null; + } + placeholder(placeholder) { this.strategies.push({ type: 'placeholder',