diff --git a/src/automation-framework/actions/click-action.js b/src/automation-framework/actions/click-action.js index 276f6f5..f0b1a54 100644 --- a/src/automation-framework/actions/click-action.js +++ b/src/automation-framework/actions/click-action.js @@ -34,6 +34,11 @@ class ClickAction extends BaseAction { this.log('debug', '✓ 点击完成'); + // 验证点击后的变化(新元素出现 / 旧元素消失) + if (this.config.verifyAfter) { + await this.verifyAfterClick(this.config.verifyAfter); + } + // 等待页面变化(如果配置了) if (this.config.waitForPageChange) { await this.waitForPageChange(this.config.checkSelector); @@ -47,6 +52,39 @@ class ClickAction extends BaseAction { return { success: true }; } + /** + * 验证点击后的变化 + */ + async verifyAfterClick(config) { + const { appears, disappears, 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}" 未消失`); + } + } + } + } + /** * 等待页面内容变化 */ diff --git a/src/automation-framework/actions/navigate-action.js b/src/automation-framework/actions/navigate-action.js index 5021aef..41efdaa 100644 --- a/src/automation-framework/actions/navigate-action.js +++ b/src/automation-framework/actions/navigate-action.js @@ -13,16 +13,50 @@ class NavigateAction extends BaseAction { this.log('info', `导航到: ${url}`); - await this.page.goto(url, options); + 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}"`); + } + + // 验证关键元素存在(确保页面加载正确) + 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', `✓ 页面加载完成`); + + return { success: true, url: currentUrl }; + + } catch (error) { + this.log('error', `导航失败: ${error.message}`); + throw error; + } + } + + /** + * 验证关键元素存在 + */ + async verifyElements(selectors) { + this.log('debug', '验证页面元素...'); - // 可选的等待时间 - if (this.config.waitAfter) { - await new Promise(resolve => setTimeout(resolve, this.config.waitAfter)); + for (const selector of selectors) { + try { + await this.page.waitForSelector(selector, { timeout: 10000 }); + } catch (error) { + throw new Error(`页面元素验证失败: 找不到 "${selector}"`); + } } - this.log('info', `✓ 页面加载完成`); - - return { success: true, url }; + this.log('debug', `✓ 已验证 ${selectors.length} 个关键元素`); } } diff --git a/src/automation-framework/actions/retry-block-action.js b/src/automation-framework/actions/retry-block-action.js index f8b0216..76f8b19 100644 --- a/src/automation-framework/actions/retry-block-action.js +++ b/src/automation-framework/actions/retry-block-action.js @@ -1,5 +1,4 @@ -const BaseAction = require('./base-action'); -const logger = require('../../tools/account-register/utils/logger'); +const BaseAction = require('../core/base-action'); /** * 重试块动作 - 将一组步骤作为整体进行重试 diff --git a/src/automation-framework/configs/sites/windsurf.yaml b/src/automation-framework/configs/sites/windsurf.yaml index 232827d..f8f0658 100644 --- a/src/automation-framework/configs/sites/windsurf.yaml +++ b/src/automation-framework/configs/sites/windsurf.yaml @@ -5,13 +5,19 @@ site: # 工作流定义 workflow: - # ==================== 步骤 1: 填写基本信息 ==================== + # ==================== 步骤 1: 打开注册页面 ==================== - action: navigate name: "打开注册页面" - url: "{{site.url}}" + url: "https://windsurf.com/account/register" options: - waitUntil: networkidle2 + waitUntil: 'networkidle2' timeout: 30000 + # 验证关键元素存在(SPA应用验证页面加载) + verifyElements: + - '#firstName' + - '#lastName' + - '#email' + - 'input[type="checkbox"]' waitAfter: 2000 - action: fillForm @@ -48,6 +54,11 @@ workflow: selector: - css: 'button[type="submit"]' - text: 'Continue' + # 验证点击后密码页面出现 + verifyAfter: + appears: + - '#password' + - 'input[type="password"]' waitAfter: 2000 # ==================== 步骤 2: 设置密码 ==================== @@ -87,6 +98,12 @@ workflow: handler: "handleTurnstile" params: timeout: 30000 + maxRetries: 3 + # 重试策略: + # - 'refresh': 刷新页面(适用于保持状态的网站) + # - 'restart': 刷新后重新填写(适用于刷新=重置的网站,如 Windsurf) + # - 'wait': 只等待不刷新 + retryStrategy: 'restart' # Windsurf 刷新会回到第一步 optional: true # ==================== 步骤 3: 邮箱验证 ==================== diff --git a/src/automation-framework/core/site-adapter.js b/src/automation-framework/core/site-adapter.js index cc5dbeb..bc6dc99 100644 --- a/src/automation-framework/core/site-adapter.js +++ b/src/automation-framework/core/site-adapter.js @@ -43,10 +43,47 @@ class SiteAdapter { * 生命周期钩子 - 工作流执行前 */ async beforeWorkflow() { + this.log('info', `开始执行 ${this.siteName} 工作流`); + + // 清除浏览器状态(Cookie、缓存、localStorage) + await this.clearBrowserState(); + this.log('debug', '执行 beforeWorkflow 钩子'); // 子类可以重写 } + /** + * 清除浏览器状态 + */ + 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`); + } + + // 清除 localStorage 和 sessionStorage + await this.page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + this.log('info', '✓ 已清除 localStorage 和 sessionStorage'); + + // 清除缓存(通过 CDP) + const client = await this.page.target().createCDPSession(); + await client.send('Network.clearBrowserCookies'); + await client.send('Network.clearBrowserCache'); + this.log('info', '✓ 已清除浏览器缓存'); + + } catch (error) { + this.log('warn', `清除浏览器状态失败: ${error.message}`); + } + } + /** * 生命周期钩子 - 工作流执行后 */ @@ -60,24 +97,103 @@ class SiteAdapter { * @param {Error} error - 错误对象 */ async onError(error) { - this.log('error', `工作流执行失败: ${error.message}`); + this.log('error', `错误: ${error.message}`); + // 子类可以重写 + } + + /** + * 执行重试策略(框架通用方法) + */ + async executeRetryStrategy(strategy, retryCount, options = {}) { + switch (strategy) { + case 'refresh': + this.log('info', '策略: 刷新当前页面'); + await this.page.reload({ waitUntil: 'networkidle2', timeout: 30000 }); + await new Promise(resolve => setTimeout(resolve, 3000)); + break; + + case 'restart': + // 重新开始流程(适用于刷新后回到初始状态的网站) + this.log('warn', '策略: 刷新会重置,执行自定义恢复'); + await this.page.reload({ waitUntil: 'networkidle2', timeout: 30000 }); + await new Promise(resolve => setTimeout(resolve, 3000)); + + // 调用站点特定的恢复方法 + if (this.onRestart && typeof this.onRestart === 'function') { + const restartSteps = await this.onRestart(options); + + // 如果返回步骤索引/名称数组,则重新执行这些步骤 + if (Array.isArray(restartSteps) && restartSteps.length > 0) { + await this.rerunSteps(restartSteps); + } + } else { + this.log('warn', '未定义 onRestart 方法,跳过恢复步骤'); + } + break; + + case 'wait': + const waitTime = options.waitTime || 10000; + this.log('info', `策略: 延长等待 ${waitTime}ms(第 ${retryCount} 次)`); + await new Promise(resolve => setTimeout(resolve, waitTime)); + break; + + default: + this.log('warn', `未知重试策略: ${strategy},使用默认等待`); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + + /** + * 重启后的恢复钩子(子类重写) + * @returns {Array|void} 返回需要重新执行的步骤名称/索引数组,或 void(自定义实现) + */ + async onRestart(options) { + this.log('debug', 'onRestart hook (未实现)'); + } + + /** + * 重新执行指定的工作流步骤 + * @param {Array} stepIdentifiers - 步骤名称或索引数组 + */ + async rerunSteps(stepIdentifiers) { + if (!this.context.engine) { + this.log('error', '无法重新执行步骤:引擎未初始化'); + return; + } - // 截图 - if (this.page) { - try { - const screenshotPath = path.join( - __dirname, - '../../logs', - `error-${Date.now()}.png` - ); - await this.page.screenshot({ path: screenshotPath, fullPage: true }); - this.log('info', `错误截图已保存: ${screenshotPath}`); - } catch (e) { - this.log('warn', `截图失败: ${e.message}`); + this.log('info', `重新执行 ${stepIdentifiers.length} 个步骤...`); + + const workflowSteps = this.config.workflow || []; + const stepsToRun = []; + + // 根据标识符查找步骤 + for (const identifier of stepIdentifiers) { + if (typeof identifier === 'number') { + // 按索引查找 + if (workflowSteps[identifier]) { + stepsToRun.push(workflowSteps[identifier]); + } + } else if (typeof identifier === 'string') { + // 按名称查找 + const step = workflowSteps.find(s => s.name === identifier); + if (step) { + stepsToRun.push(step); + } } } - // 子类可以重写 + if (stepsToRun.length === 0) { + this.log('warn', '未找到要重新执行的步骤'); + return; + } + + // 重新执行这些步骤 + for (const step of stepsToRun) { + this.log('info', `→ 重新执行: ${step.name || step.action}`); + await this.context.engine.executeAction(step); + } + + this.log('success', `✓ 已重新执行 ${stepsToRun.length} 个步骤`); } /** diff --git a/src/automation-framework/sites/windsurf-adapter.js b/src/automation-framework/sites/windsurf-adapter.js index e1fc39a..ba4caf5 100644 --- a/src/automation-framework/sites/windsurf-adapter.js +++ b/src/automation-framework/sites/windsurf-adapter.js @@ -15,9 +15,10 @@ class WindsurfAdapter extends SiteAdapter { } /** - * 工作流执行前 - 生成账户数据 + * 工作流执行前 - 清理状态 + 生成账户数据 */ async beforeWorkflow() { + // 先调用父类清理浏览器状态 await super.beforeWorkflow(); this.log('info', '生成账户数据...'); @@ -50,82 +51,123 @@ class WindsurfAdapter extends SiteAdapter { } /** - * 步骤 2.5: Cloudflare Turnstile 验证 + * 步骤 2.5: Cloudflare Turnstile 验证(带智能重试) */ async handleTurnstile(params) { - const { timeout = 30000 } = params; + const { + timeout = 30000, + maxRetries = 3, + retryStrategy = 'refresh' // 'refresh' | 'wait' | 'restart' + } = params; - this.log('info', 'Cloudflare Turnstile 人机验证'); - - try { - // 等待 Turnstile 验证框出现 - await new Promise(resolve => setTimeout(resolve, 2000)); - - // 检查是否有 Turnstile - const hasTurnstile = await this.page.evaluate(() => { - return !!document.querySelector('iframe[src*="challenges.cloudflare.com"]') || - !!document.querySelector('.cf-turnstile') || - document.body.textContent.includes('Please verify that you are human'); - }); - - if (hasTurnstile) { - this.log('info', '检测到 Turnstile 验证,等待自动完成...'); - - // 等待验证通过(检查按钮是否启用或页面是否变化) - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const isPassed = await this.page.evaluate(() => { - // 检查是否有成功标记 - const successMark = document.querySelector('svg[data-status="success"]') || - document.querySelector('[aria-label*="success"]') || - document.querySelector('.cf-turnstile-success'); - - // 或者检查 Continue 按钮是否启用 - const continueBtn = Array.from(document.querySelectorAll('button')).find(btn => - btn.textContent.trim() === 'Continue' - ); - const btnEnabled = continueBtn && !continueBtn.disabled; - - return !!successMark || btnEnabled; - }); + for (let retryCount = 0; retryCount <= maxRetries; retryCount++) { + try { + if (retryCount > 0) { + this.log('warn', `Turnstile 超时,执行重试策略: ${retryStrategy} (${retryCount}/${maxRetries})...`); - if (isPassed) { - this.log('success', '✓ Turnstile 验证通过'); - - // 点击 Continue 按钮 - const continueBtn = await this.page.evaluateHandle(() => { - return Array.from(document.querySelectorAll('button')).find(btn => - btn.textContent.trim() === 'Continue' - ); - }); - - if (continueBtn) { - await continueBtn.asElement().click(); - this.log('info', '已点击 Continue 按钮'); - await new Promise(resolve => setTimeout(resolve, 2000)); - } - - return { success: true }; - } - - await new Promise(resolve => setTimeout(resolve, 500)); + // 根据策略执行不同的重试行为 + await this.executeRetryStrategy(retryStrategy, retryCount); } - throw new Error('Turnstile 验证超时'); + this.log('info', 'Cloudflare Turnstile 人机验证'); - } else { - this.log('info', '未检测到 Turnstile,跳过'); - return { success: true, skipped: true }; + // 等待 Turnstile 验证框出现 + await new Promise(resolve => setTimeout(resolve, 2000)); + + // 检查是否有 Turnstile + const hasTurnstile = await this.page.evaluate(() => { + return !!document.querySelector('iframe[src*="challenges.cloudflare.com"]') || + !!document.querySelector('.cf-turnstile') || + document.body.textContent.includes('Please verify that you are human'); + }); + + if (hasTurnstile) { + this.log('info', '检测到 Turnstile 验证,等待自动完成...'); + + // 等待验证通过(检查按钮是否启用或页面是否变化) + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const isPassed = await this.page.evaluate(() => { + // 检查是否有成功标记 + const successMark = document.querySelector('svg[data-status="success"]') || + document.querySelector('[aria-label*="success"]') || + document.querySelector('.cf-turnstile-success'); + + // 或者检查 Continue 按钮是否启用 + const continueBtn = Array.from(document.querySelectorAll('button')).find(btn => + btn.textContent.trim() === 'Continue' + ); + const btnEnabled = continueBtn && !continueBtn.disabled; + + return !!successMark || btnEnabled; + }); + + if (isPassed) { + this.log('success', '✓ Turnstile 验证通过'); + + // 点击 Continue 按钮 + const continueBtn = await this.page.evaluateHandle(() => { + return Array.from(document.querySelectorAll('button')).find(btn => + btn.textContent.trim() === 'Continue' + ); + }); + + if (continueBtn) { + await continueBtn.asElement().click(); + this.log('info', '已点击 Continue 按钮'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + return { success: true }; + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // 超时了,如果还有重试次数就继续循环 + if (retryCount < maxRetries) { + this.log('warn', `Turnstile 验证超时(${timeout}ms)`); + continue; // 进入下一次重试 + } else { + throw new Error('Turnstile 验证超时,已达最大重试次数'); + } + + } else { + this.log('info', '未检测到 Turnstile,跳过'); + return { success: true, skipped: true }; + } + + } catch (error) { + if (retryCount >= maxRetries) { + this.log('error', `Turnstile 处理最终失败: ${error.message}`); + // Turnstile 是可选的,失败也继续(但记录错误) + return { success: true, error: error.message, failed: true }; + } + // 否则继续重试 } - - } catch (error) { - this.log('warn', `Turnstile 处理失败: ${error.message}`); - // Turnstile 是可选的,失败也继续 - return { success: true, error: error.message }; } } + /** + * 重启后的恢复钩子(Windsurf 特定实现) + * Windsurf 刷新后回到第一步,返回需要重新执行的 YAML 步骤 + * @returns {Array} 需要重新执行的步骤名称 + */ + async onRestart(options) { + this.log('info', 'Windsurf 刷新后回到第一步,重新执行前面的步骤...'); + + // 返回步骤名称,框架会自动从 YAML 查找并执行 + return [ + '填写基本信息', + '勾选同意条款', + '点击 Continue (基本信息)', + '等待密码页面', + '设置密码', + '提交密码' + ]; + } + /** * 步骤 3: 邮箱验证 */ @@ -382,9 +424,84 @@ class WindsurfAdapter extends SiteAdapter { async getSubscriptionInfo(params) { this.log('info', '获取订阅信息'); - // TODO: 实现获取订阅信息逻辑 - - return { success: true }; + try { + // 关闭可能存在的弹窗 + this.log('info', '关闭可能存在的对话框...'); + for (let i = 0; i < 5; i++) { + try { + await this.page.keyboard.press('Escape'); + await new Promise(resolve => setTimeout(resolve, 300)); + } catch (e) { + // 忽略 + } + } + + // 跳转到订阅使用页面 + this.log('info', '跳转到订阅使用页面...'); + await this.page.goto('https://windsurf.com/subscription/usage', { + waitUntil: 'networkidle2', + timeout: 30000 + }); + + // 等待页面加载 + await new Promise(resolve => setTimeout(resolve, 3000)); + + // 获取配额信息 + this.log('info', '获取配额信息...'); + const quotaInfo = await this.page.evaluate(() => { + const quotaElement = document.querySelector('p.caption1.font-medium.text-sk-black\\/80'); + if (!quotaElement) return null; + + const spans = quotaElement.querySelectorAll('span.caption3 span'); + if (spans.length >= 2) { + const used = spans[0].textContent.trim(); + const total = spans[1].textContent.trim().replace('/', '').trim(); + return { used, total }; + } + return null; + }); + + if (quotaInfo) { + this.log('success', `✓ 配额: ${quotaInfo.used} / ${quotaInfo.total} used`); + this.context.data.quotaInfo = quotaInfo; + } else { + this.log('warn', '未找到配额信息'); + } + + // 获取账单周期信息 + this.log('info', '获取账单周期信息...'); + const billingInfo = await this.page.evaluate(() => { + const billingElement = Array.from(document.querySelectorAll('p.caption1')) + .find(p => p.textContent.includes('Next billing cycle')); + + if (!billingElement) return null; + + const daysMatch = billingElement.textContent.match(/(\d+)\s+days?/); + const dateMatch = billingElement.textContent.match(/on\s+([A-Za-z]+\s+\d+,\s+\d{4})/); + + return { + days: daysMatch ? daysMatch[1] : null, + date: dateMatch ? dateMatch[1] : null, + fullText: billingElement.textContent.trim() + }; + }); + + if (billingInfo && billingInfo.days) { + this.log('success', `✓ 下次账单: ${billingInfo.days} 天后 (${billingInfo.date})`); + this.context.data.billingInfo = billingInfo; + } else { + this.log('warn', '未找到账单周期信息'); + } + + // 打印汇总信息 + this.log('success', `✓ 配额: ${quotaInfo ? `${quotaInfo.used}/${quotaInfo.total}` : 'N/A'} | 下次账单: ${billingInfo?.days || 'N/A'}天后`); + + return { success: true, quotaInfo, billingInfo }; + + } catch (error) { + this.log('error', `获取订阅信息失败: ${error.message}`); + throw error; + } } /** @@ -393,9 +510,53 @@ class WindsurfAdapter extends SiteAdapter { async saveToDatabase(params) { this.log('info', '保存到数据库'); - // TODO: 实现数据库保存逻辑 - - return { success: true }; + try { + // 导入数据库模块 + const database = require('../../tools/account-register/database'); + + // 初始化数据库连接 + this.log('info', '连接数据库...'); + await database.initialize(); + + const account = this.context.data.account; + const card = this.context.data.card; + const quotaInfo = this.context.data.quotaInfo; + const billingInfo = this.context.data.billingInfo; + + // 准备账号数据 + const accountData = { + email: account.email, + password: account.password, + firstName: account.firstName, + lastName: account.lastName, + registrationTime: new Date(), + quotaUsed: quotaInfo ? parseFloat(quotaInfo.used) : 0, + quotaTotal: quotaInfo ? parseFloat(quotaInfo.total) : 0, + billingDays: billingInfo ? parseInt(billingInfo.days) : null, + billingDate: billingInfo ? billingInfo.date : null, + paymentCardNumber: card ? card.number : null, + paymentCountry: card ? card.country : 'MO', + status: 'active', + isOnSale: false + }; + + // 保存到数据库 + this.log('info', '保存账号信息...'); + const accountRepo = database.getRepository('account'); + const accountId = await accountRepo.create(accountData); + + this.log('success', `✓ 账号信息已保存到数据库 (ID: ${accountId})`); + this.log('info', ` → 邮箱: ${accountData.email}`); + this.log('info', ` → 配额: ${accountData.quotaUsed} / ${accountData.quotaTotal}`); + this.log('info', ` → 卡号: ${accountData.paymentCardNumber}`); + + return { success: true, accountId }; + + } catch (error) { + this.log('error', `保存到数据库失败: ${error.message}`); + // 数据库保存失败不影响注册流程 + return { success: true, error: error.message }; + } } }