From 331fd8e4bd69c25df6d2fc4d0a6472c2a22683c7 Mon Sep 17 00:00:00 2001 From: dengqichen Date: Tue, 18 Nov 2025 21:51:23 +0800 Subject: [PATCH] dasdasd --- src/automation-framework/README.md | 1078 +++++++++++++++++ .../actions/extract-action.js | 147 +++ .../actions/verify-action.js | 240 ++++ .../configs/sites/windsurf.yaml | 113 +- .../core/workflow-engine.js | 4 + .../sites/windsurf-adapter.js | 82 +- 6 files changed, 1570 insertions(+), 94 deletions(-) create mode 100644 src/automation-framework/README.md create mode 100644 src/automation-framework/actions/extract-action.js create mode 100644 src/automation-framework/actions/verify-action.js diff --git a/src/automation-framework/README.md b/src/automation-framework/README.md new file mode 100644 index 0000000..07a1de3 --- /dev/null +++ b/src/automation-framework/README.md @@ -0,0 +1,1078 @@ +# 自动化框架使用教程 + +## 目录 +- [框架概述](#框架概述) +- [快速开始](#快速开始) +- [YAML 配置结构](#yaml-配置结构) +- [内置 Actions](#内置-actions) + - [navigate - 页面导航](#navigate---页面导航) + - [fillForm - 表单填写](#fillform---表单填写) + - [click - 点击操作](#click---点击操作) + - [wait - 等待](#wait---等待) + - [extract - 数据提取](#extract---数据提取) + - [verify - 结果验证](#verify---结果验证) + - [retryBlock - 重试块](#retryblock---重试块) + - [custom - 自定义逻辑](#custom---自定义逻辑) +- [变量替换](#变量替换) +- [站点适配器开发](#站点适配器开发) +- [最佳实践](#最佳实践) + +--- + +## 框架概述 + +这是一个**配置驱动**的浏览器自动化框架,基于 Puppeteer 构建。核心理念: + +- ✅ **90% 配置,10% 代码** - 大部分操作通过 YAML 配置 +- ✅ **声明式编程** - 只需描述"做什么",不需要"怎么做" +- ✅ **可复用** - 一次配置,多处使用 +- ✅ **易维护** - 修改配置即可调整流程 + +--- + +## 快速开始 + +### 1. 创建站点配置文件 + +在 `src/automation-framework/configs/sites/` 创建 `mysite.yaml`: + +```yaml +# 站点信息 +site: + name: MySite + url: https://mysite.com/register + +# 工作流定义 +workflow: + - action: navigate + name: "打开注册页面" + url: "https://mysite.com/register" + + - action: fillForm + name: "填写注册信息" + fields: + username: + find: + - css: '#username' + value: "{{account.username}}" + + email: + find: + - css: '#email' + value: "{{account.email}}" + + - action: click + name: "提交注册" + selector: + - css: 'button[type="submit"]' +``` + +### 2. 创建站点适配器 + +在 `src/automation-framework/sites/` 创建 `mysite-adapter.js`: + +```javascript +const SiteAdapter = require('../core/site-adapter'); + +class MySiteAdapter extends SiteAdapter { + constructor(context) { + super(context, 'mysite'); + } + + async beforeWorkflow() { + await super.beforeWorkflow(); + + // 生成测试数据 + this.context.data = { + account: { + username: 'testuser', + email: 'test@example.com' + } + }; + } +} + +module.exports = MySiteAdapter; +``` + +### 3. 运行 + +```javascript +const AutomationFactory = require('./src/automation-framework'); + +const factory = new AutomationFactory({ + logger: console, + adspowerUserId: 'your-profile-id' +}); + +await factory.execute('mysite'); +``` + +--- + +## YAML 配置结构 + +### 基本结构 + +```yaml +# 站点信息 +site: + name: 站点名称 + url: 站点URL + +# 工作流定义 +workflow: + - action: 动作类型 + name: 步骤名称 + # 动作特定参数 + ... + +# 错误处理配置(可选) +errorHandling: + screenshot: true + retry: + enabled: true + maxAttempts: 3 + delay: 2000 +``` + +--- + +## 内置 Actions + +### navigate - 页面导航 + +**用途**:打开或跳转到指定页面 + +**必需参数**: +- `url` (string): 目标URL,支持变量替换 + +**可选参数**: +- `name` (string): 步骤名称 +- `options` (object): 导航选项 + - `waitUntil` (string): 等待条件,默认 `'networkidle2'` + - `timeout` (number): 超时时间(毫秒),默认 30000 +- `verifyUrl` (string): 验证URL是否包含此字符串 +- `verifyElements` (array): 验证这些元素存在 +- `waitAfter` (number): 导航后等待时间(毫秒) + +**示例**: + +```yaml +# 基本导航 +- action: navigate + url: "https://example.com/register" + +# 带验证的导航 +- action: navigate + name: "打开注册页面" + url: "https://example.com/register" + verifyUrl: "/register" + verifyElements: + - '#username' + - '#email' + - 'button[type="submit"]' + waitAfter: 2000 + +# 自定义选项 +- action: navigate + url: "{{config.baseUrl}}/login" + options: + waitUntil: 'domcontentloaded' + timeout: 60000 +``` + +--- + +### fillForm - 表单填写 + +**用途**:填写表单字段 + +**必需参数**: +- `fields` (object): 字段配置对象,键为字段名 + +**可选参数**: +- `name` (string): 步骤名称 +- `humanLike` (boolean): 是否模拟人类输入,默认 `true` + +**字段配置**: +- `find` (array): 选择器数组,按顺序尝试 + - `css` (string): CSS 选择器 + - `xpath` (string): XPath 选择器 + - `text` (string): 按文本查找 + - `name` (string): 按 name 属性查找 +- `value` (string): 字段值,支持变量替换 +- `clear` (boolean): 填写前是否清空,默认 `true` + +**示例**: + +```yaml +# 基本表单填写 +- action: fillForm + name: "填写注册信息" + fields: + username: + find: + - css: '#username' + value: "{{account.username}}" + + email: + find: + - css: 'input[name="email"]' + - css: '#email' + value: "{{account.email}}" + +# 模拟人类输入 +- action: fillForm + name: "填写个人信息" + humanLike: true + fields: + firstName: + find: + - css: '#firstName' + value: "{{account.firstName}}" + clear: true + + lastName: + find: + - css: '#lastName' + value: "{{account.lastName}}" + +# 密码字段 +- action: fillForm + name: "设置密码" + humanLike: false + fields: + password: + find: + - css: 'input[type="password"]' + value: "{{account.password}}" + + passwordConfirm: + find: + - css: 'input[placeholder*="Confirm"]' + value: "{{account.password}}" +``` + +--- + +### click - 点击操作 + +**用途**:点击页面元素 + +**必需参数**: +- `selector` (array 或 object): 元素选择器 + +**可选参数**: +- `name` (string): 步骤名称 +- `verifyAfter` (object): 点击后验证 + - `appears` (array): 应该出现的元素 + - `disappears` (array): 应该消失的元素 + - `timeout` (number): 验证超时时间 +- `waitForPageChange` (boolean): 是否等待页面变化 +- `checkSelector` (object): 检查的选择器 +- `waitAfter` (number): 点击后等待时间(毫秒) +- `optional` (boolean): 是否可选,默认 `false` + +**选择器格式**: +```yaml +selector: + - css: '#submit-btn' + - xpath: '//button[@id="submit"]' + - text: 'Continue' + - name: 'submit' +``` + +**示例**: + +```yaml +# 简单点击 +- action: click + selector: + - css: 'button[type="submit"]' + +# 按文本点击 +- action: click + name: "点击继续按钮" + selector: + - text: 'Continue' + - text: '继续' + waitAfter: 2000 + +# 带验证的点击 +- action: click + name: "提交表单" + selector: + - css: '#submit-btn' + verifyAfter: + appears: + - '#success-message' + - '.confirmation' + disappears: + - '.loading-spinner' + timeout: 10000 + +# 可选点击(找不到元素不报错) +- action: click + name: "关闭弹窗" + selector: + - css: '.modal-close' + optional: true + waitAfter: 500 +``` + +--- + +### wait - 等待 + +**用途**:等待特定条件 + +**必需参数**: +- `type` (string): 等待类型 + - `delay`: 固定时间延迟 + - `element`: 等待元素出现 + - `navigation`: 等待页面导航 + +**可选参数**: +- `name` (string): 步骤名称 +- `duration` (number): 延迟时长(毫秒),用于 `delay` 类型 +- `find` (array): 元素选择器,用于 `element` 类型 +- `timeout` (number): 超时时间(毫秒),默认 30000 + +**示例**: + +```yaml +# 固定延迟 +- action: wait + name: "等待2秒" + type: delay + duration: 2000 + +# 等待元素出现 +- action: wait + name: "等待密码输入框" + type: element + find: + - css: 'input[type="password"]' + timeout: 15000 + +# 等待多个选择器之一 +- action: wait + name: "等待确认页面" + type: element + find: + - css: '#confirmation' + - text: 'Success' + timeout: 30000 + +# 等待页面导航 +- action: wait + name: "等待页面跳转" + type: navigation + timeout: 10000 +``` + +--- + +### extract - 数据提取 + +**用途**:从页面提取数据并保存到上下文 + +**必需参数**: +- `selector` (string): CSS 选择器 +- `contextKey` (string): 保存到上下文的键名 + +**可选参数**: +- `name` (string): 步骤名称 +- `extractType` (string): 提取类型 + - `text`: 文本内容(默认) + - `html`: HTML 内容 + - `attribute`: 属性值 + - `value`: 表单值 +- `attribute` (string): 属性名,用于 `attribute` 类型 +- `regex` (string): 正则表达式,用于提取特定内容 +- `saveTo` (object): 捕获组映射 +- `filter` (object): 元素过滤 + - `contains` (string): 包含文本 + - `notContains` (string): 不包含文本 +- `multiple` (boolean): 是否提取多个元素,默认 `false` +- `required` (boolean): 是否必需,默认 `true` + +**示例**: + +```yaml +# 简单文本提取 +- action: extract + name: "提取页面标题" + selector: "h1.title" + extractType: "text" + contextKey: "pageTitle" + +# 提取属性 +- action: extract + name: "提取头像URL" + selector: "img.avatar" + extractType: "attribute" + attribute: "src" + contextKey: "avatarUrl" + +# 正则提取 + 捕获组 +- action: extract + name: "提取配额信息" + selector: "p.quota" + extractType: "text" + regex: "(\\d+)\\s*/\\s*(\\d+)" + saveTo: + used: "$1" + total: "$2" + contextKey: "quotaInfo" + +# 过滤 + 提取 +- action: extract + name: "提取账单周期" + selector: "p.info" + filter: + contains: "Next billing" + regex: "(\\d+)\\s+days.*on\\s+([A-Za-z]+\\s+\\d+,\\s+\\d{4})" + saveTo: + days: "$1" + date: "$2" + contextKey: "billingInfo" + +# 提取多个元素 +- action: extract + name: "提取所有列表项" + selector: "li.item" + extractType: "text" + multiple: true + contextKey: "items" + +# 可选提取(找不到不报错) +- action: extract + name: "提取错误消息" + selector: ".error-message" + contextKey: "errorMsg" + required: false +``` + +--- + +### verify - 结果验证 + +**用途**:验证操作结果(如支付成功/失败),支持轮询检测多种条件 + +**必需参数**: +- `conditions` (object): 验证条件 + - `success` (array): 成功条件列表(任一满足即成功) + - `failure` (array): 失败条件列表(任一满足即失败) + +**可选参数**: +- `name` (string): 步骤名称 +- `timeout` (number): 超时时间(毫秒),默认 10000 +- `pollInterval` (number): 轮询间隔(毫秒),默认 500 +- `onSuccess` (string): 成功时的行为 + - `continue`: 继续执行(默认) + - `return`: 返回结果 +- `onFailure` (string): 失败时的行为 + - `throw`: 抛出异常(默认,**触发 retryBlock 重试**) + - `continue`: 继续执行(忽略失败) + - `return`: 返回结果 +- `onTimeout` (string): 超时时的行为,默认 `throw` + +**onFailure 行为详解**: + +| 值 | 行为 | 使用场景 | +|---|------|---------| +| `throw` | 抛出异常 | **在 retryBlock 内使用**,失败时触发重试 | +| `continue` | 继续执行 | 失败不影响流程,只记录结果 | +| `return` | 返回结果 | 由后续步骤处理失败情况 | + +**重要**: +- ✅ 在 `retryBlock` 内使用 `onFailure: "throw"` 可触发重试 +- ✅ 只有所有重试都失败,才会导致整个流程失败 +- ❌ 单次验证失败不会终止流程(会自动重试) + +**支持的条件类型**: + +| 条件类型 | 说明 | 示例 | +|---------|------|------| +| `urlContains` | URL 包含指定文本 | `urlContains: "success"` | +| `urlNotContains` | URL 不包含指定文本 | `urlNotContains: "stripe.com"` | +| `urlEquals` | URL 完全匹配 | `urlEquals: "https://app.com/dashboard"` | +| `elementExists` | 元素存在 | `elementExists: ".success-message"` | +| `elementNotExists` | 元素不存在 | `elementNotExists: ".loading"` | +| `elementVisible` | 元素可见 | `elementVisible: "#confirmation"` | +| `elementHidden` | 元素隐藏 | `elementHidden: ".modal"` | +| `textContains` | 页面包含文本 | `textContains: "Payment successful"` | +| `textNotContains` | 页面不包含文本 | `textNotContains: "Error"` | +| `elementTextContains` | 元素包含文本 | `{selector: ".status", text: "Active"}` | + +**示例**: + +```yaml +# 验证支付结果 +- action: verify + name: "验证支付结果" + conditions: + success: + - urlNotContains: "checkout.stripe.com" + - elementExists: ".payment-success" + failure: + - textContains: "card was declined" + - textContains: "declined" + - elementExists: ".error-message" + timeout: 15000 + pollInterval: 500 + onFailure: "throw" + +# 验证页面跳转 +- action: verify + name: "验证登录成功" + conditions: + success: + - urlContains: "/dashboard" + - elementExists: ".user-profile" + failure: + - textContains: "Invalid credentials" + - elementExists: ".login-error" + timeout: 10000 + onFailure: "throw" + +# 验证元素状态 +- action: verify + name: "验证加载完成" + conditions: + success: + - elementHidden: ".loading-spinner" + - elementVisible: ".content" + timeout: 30000 + onTimeout: "continue" + +# 复杂验证 +- action: verify + name: "验证注册结果" + conditions: + success: + - urlEquals: "https://app.com/welcome" + failure: + - textContains: "Email already exists" + - textContains: "Username taken" + - elementExists: ".validation-error" + timeout: 10000 + pollInterval: 1000 + onSuccess: "return" + onFailure: "throw" +``` + +**配合 retryBlock 使用**: + +```yaml +- action: retryBlock + name: "支付流程" + maxRetries: 5 + onRetryBefore: + - action: custom + handler: "regenerateCard" + steps: + - action: fillForm + fields: + cardNumber: + value: "{{card.number}}" + + - action: custom + handler: "handleCaptcha" + + - action: click + name: "提交支付" + selector: + - css: "#submit-payment" + + # verify 检测到失败会抛异常,触发 retryBlock 重试 + - action: verify + name: "验证支付结果" + conditions: + success: + - urlNotContains: "stripe.com" + failure: + - textContains: "declined" + timeout: 10000 + onFailure: "throw" +``` + +--- + +### retryBlock - 重试块 + +**用途**:将一组步骤作为整体进行重试 + +**必需参数**: +- `steps` (array): 要重试的步骤数组 + +**可选参数**: +- `name` (string): 重试块名称 +- `maxRetries` (number): 最大重试次数,默认 3 +- `retryDelay` (number): 重试间隔(毫秒),默认 2000 +- `onRetryBefore` (array): 重试前执行的步骤 +- `onRetryAfter` (array): 重试后执行的步骤 + +**示例**: + +```yaml +# 基本重试块 +- action: retryBlock + name: "支付流程" + maxRetries: 5 + retryDelay: 3000 + steps: + - action: fillForm + name: "填写银行卡" + fields: + cardNumber: + find: + - css: '#cardNumber' + value: "{{card.number}}" + + - action: click + name: "提交支付" + selector: + - css: '#submit-payment' + +# 带重试钩子的重试块 +- action: retryBlock + name: "注册流程(含验证码重试)" + maxRetries: 3 + onRetryBefore: + # 重试前重新生成数据 + - action: custom + handler: "regenerateAccount" + onRetryAfter: + # 重试后记录日志 + - action: custom + handler: "logRetryAttempt" + steps: + - action: fillForm + name: "填写注册信息" + fields: + email: + find: + - css: '#email' + value: "{{account.email}}" + + - action: custom + name: "处理验证码" + handler: "handleCaptcha" +``` + +--- + +### custom - 自定义逻辑 + +**用途**:执行站点适配器中的自定义方法 + +**必需参数**: +- `handler` (string): 适配器中的方法名 + +**可选参数**: +- `name` (string): 步骤名称 +- `params` (object): 传递给方法的参数 +- `optional` (boolean): 是否可选,默认 `false` + +**示例**: + +```yaml +# 简单自定义调用 +- action: custom + name: "处理验证码" + handler: "handleCaptcha" + +# 带参数的自定义调用 +- action: custom + name: "处理 Turnstile 验证" + handler: "handleTurnstile" + params: + timeout: 30000 + maxRetries: 3 + +# 可选自定义调用 +- action: custom + name: "获取订阅信息" + handler: "getSubscriptionInfo" + optional: true + +# 重新生成数据 +- action: custom + handler: "regenerateCard" + params: + country: "MO" +``` + +**对应的适配器方法**: + +```javascript +class MySiteAdapter extends SiteAdapter { + async handleCaptcha(params) { + // 处理验证码逻辑 + return { success: true }; + } + + async handleTurnstile(params) { + const { timeout, maxRetries } = params; + // 处理 Turnstile 验证 + return { success: true }; + } + + async regenerateCard(params) { + const { country } = params; + // 重新生成银行卡 + this.context.data.card = this.cardGen.generate(country); + } +} +``` + +--- + +## 变量替换 + +框架支持在 YAML 中使用变量,格式为 `{{path.to.variable}}` + +### 可用变量源 + +**1. context.data** - 运行时数据 + +```yaml +- action: fillForm + fields: + email: + value: "{{account.email}}" # context.data.account.email + + cardNumber: + value: "{{card.number}}" # context.data.card.number +``` + +**2. config.site** - 站点配置 + +```yaml +- action: navigate + url: "{{site.url}}" # config.site.url +``` + +### 在适配器中设置变量 + +```javascript +async beforeWorkflow() { + this.context.data = { + account: { + email: 'test@example.com', + password: 'SecurePass123', + firstName: 'John', + lastName: 'Doe' + }, + card: { + number: '4111111111111111', + month: '12', + year: '25', + cvv: '123' + } + }; +} +``` + +--- + +## 站点适配器开发 + +### 基本结构 + +```javascript +const SiteAdapter = require('../core/site-adapter'); + +class MySiteAdapter extends SiteAdapter { + constructor(context) { + super(context, 'mysite'); // 'mysite' 对应 mysite.yaml + } + + /** + * 工作流执行前 - 准备数据 + */ + async beforeWorkflow() { + await super.beforeWorkflow(); // 清除浏览器状态 + + // 生成测试数据 + this.context.data = { + account: this.generateAccount(), + card: this.generateCard() + }; + } + + /** + * 工作流执行后 - 清理或保存 + */ + async afterWorkflow() { + await super.afterWorkflow(); + // 保存结果等 + } + + /** + * 自定义方法 - 由 YAML 的 custom action 调用 + */ + async handleCaptcha(params) { + // 实现验证码处理 + return { success: true }; + } + + /** + * 重启钩子 - restart 策略调用 + */ + async onRestart(options) { + // 返回需要重新执行的步骤名称 + return [ + '填写基本信息', + '设置密码' + ]; + } +} + +module.exports = MySiteAdapter; +``` + +### 生命周期钩子 + +| 钩子 | 调用时机 | 用途 | +|------|---------|------| +| `beforeWorkflow()` | 工作流开始前 | 准备数据、清理状态 | +| `afterWorkflow()` | 工作流完成后 | 保存结果、清理资源 | +| `onError(error)` | 发生错误时 | 错误处理、截图 | +| `onRestart(options)` | restart 重试时 | 返回需要重新执行的步骤 | + +### 重试策略 + +框架提供三种重试策略(在 `executeRetryStrategy` 中实现): + +**1. refresh - 刷新页面保持状态** +```yaml +params: + retryStrategy: 'refresh' +``` +- 适用于刷新后保持当前页面状态的网站 +- 只刷新页面,不重新填写 + +**2. restart - 刷新后重新开始** +```yaml +params: + retryStrategy: 'restart' +``` +- 适用于刷新后回到第一步的网站(如 Windsurf) +- 自动调用 `onRestart()` 获取需要重新执行的步骤 + +**3. wait - 延长等待时间** +```yaml +params: + retryStrategy: 'wait' + waitTime: 10000 +``` +- 不刷新页面,只等待更长时间 +- 适用于加载慢的情况 + +### 框架提供的工具方法 + +```javascript +// 日志 +this.log('info', '消息'); +this.log('success', '✓ 成功消息'); +this.log('warn', '⚠ 警告消息'); +this.log('error', '✗ 错误消息'); + +// 重新执行 YAML 步骤 +await this.rerunSteps(['步骤名称1', '步骤名称2']); +await this.rerunSteps([0, 1, 2]); // 按索引 + +// 重试策略 +await this.executeRetryStrategy('refresh', retryCount); +await this.executeRetryStrategy('restart', retryCount); +await this.executeRetryStrategy('wait', retryCount, { waitTime: 5000 }); +``` + +--- + +## 最佳实践 + +### 1. 步骤命名清晰 + +```yaml +# ❌ 不好 +- action: click + selector: + - css: 'button' + +# ✅ 好 +- action: click + name: "点击 Continue 按钮(基本信息页)" + selector: + - css: 'button[type="submit"]' +``` + +### 2. 使用多个选择器备选 + +```yaml +fields: + email: + find: + - css: '#email' # 首选 + - css: 'input[name="email"]' # 备选1 + - xpath: '//input[@type="email"]' # 备选2 +``` + +### 3. 验证关键步骤 + +```yaml +# 导航后验证 +- action: navigate + url: "https://example.com/register" + verifyElements: + - '#username' + - '#email' + +# 点击后验证 +- action: click + name: "提交表单" + selector: + - css: '#submit' + verifyAfter: + appears: + - '#success-message' +``` + +### 4. 合理使用 optional + +```yaml +# 可能不存在的弹窗 +- action: click + name: "关闭欢迎弹窗" + selector: + - css: '.welcome-modal .close' + optional: true + +# 不影响主流程的数据提取 +- action: extract + name: "提取备注信息" + selector: ".notes" + contextKey: "notes" + required: false +``` + +### 5. 模块化配置 + +```yaml +# 将重复逻辑抽取为 retryBlock +- action: retryBlock + name: "支付流程" + maxRetries: 5 + onRetryBefore: + - action: custom + handler: "regenerateCard" + steps: + # 复用的步骤组 + - action: fillForm + ... + - action: click + ... +``` + +### 6. 适当使用 humanLike + +```yaml +# 重要表单使用人类行为模拟 +- action: fillForm + name: "填写个人信息" + humanLike: true # 随机延迟、打字速度 + fields: + firstName: + ... + +# 支付信息快速填写 +- action: fillForm + name: "填写银行卡" + humanLike: false # 快速填写 + fields: + cardNumber: + ... +``` + +### 7. 代码vs配置的选择 + +| 场景 | 推荐方式 | 原因 | +|------|---------|------| +| 点击、填表、等待 | YAML | 简单、可维护 | +| 数据提取 | YAML (extract) + 处理(custom) | 提取配置化,处理逻辑化 | +| 验证码、人机验证 | custom | 复杂逻辑 | +| 条件判断、循环 | custom | YAML 不支持 | +| 数据库操作 | custom | 需要导入模块 | + +--- + +## 完整示例 + +查看 `src/automation-framework/configs/sites/windsurf.yaml` 了解完整的真实案例。 + +--- + +## 常见问题 + +### Q: 如何调试 YAML 配置? + +A: 查看日志输出,每个步骤都会打印执行信息: +``` +[Windsurf] [1/14] 打开注册页面 +[Windsurf] 导航到: https://windsurf.com/account/register +[Windsurf] ✓ 页面加载完成 +``` + +### Q: 元素找不到怎么办? + +A: +1. 使用 `verifyElements` 验证元素存在 +2. 增加 `waitAfter` 等待时间 +3. 使用多个选择器备选 +4. 设置 `optional: true` 跳过 + +### Q: 如何处理动态内容? + +A: 使用 `wait` action 等待元素出现: +```yaml +- action: wait + type: element + find: + - css: '.dynamic-content' + timeout: 15000 +``` + +### Q: 变量替换不生效? + +A: 检查: +1. 变量路径是否正确(如 `{{account.email}}`) +2. 是否在 `beforeWorkflow()` 中设置了 `this.context.data` +3. 日志中查看变量值 + +--- + +## 更新日志 + +- **v1.0.0** - 基础框架 + - navigate, fillForm, click, wait, custom +- **v1.1.0** - 增强功能 + - retryBlock 重试块 + - 人类行为模拟 + - 点击验证 +- **v1.2.0** - 数据提取 + - extract action + - 正则提取 + - 多元素提取 +- **v1.3.0** - 结果验证 + - verify action + - 支持 10 种条件类型 + - 轮询检测机制 + - 配合 retryBlock 实现智能重试 + +--- + +**框架持续演进中,欢迎贡献新功能!** 🚀 diff --git a/src/automation-framework/actions/extract-action.js b/src/automation-framework/actions/extract-action.js new file mode 100644 index 0000000..118c350 --- /dev/null +++ b/src/automation-framework/actions/extract-action.js @@ -0,0 +1,147 @@ +const BaseAction = require('../core/base-action'); + +/** + * 数据提取动作 - 从页面提取数据并保存到上下文 + * + * Example: + * - action: extract + * name: Extract quota info + * selector: p.caption1 + * extractType: text + * regex: (\\d+)\\s*\\/\\s*(\\d+) + * saveTo: + * used: $1 + * total: $2 + * contextKey: quotaInfo + */ +class ExtractAction extends BaseAction { + async execute() { + const { + selector, + extractType = 'text', + regex, + saveTo, + contextKey, + filter, + multiple = false, + required = true + } = this.config; + + if (!selector) { + throw new Error('Extract action 需要 selector 参数'); + } + + this.log('debug', `提取数据: ${selector}`); + + try { + // 在页面中查找并提取数据 + const extractedData = await this.page.evaluate((config) => { + const { selector, extractType, filter, multiple } = config; + + // 查找元素 + let elements = Array.from(document.querySelectorAll(selector)); + + // 过滤元素 + if (filter) { + if (filter.contains) { + elements = elements.filter(el => + el.textContent.includes(filter.contains) + ); + } + if (filter.notContains) { + elements = elements.filter(el => + !el.textContent.includes(filter.notContains) + ); + } + } + + if (elements.length === 0) { + return null; + } + + // 提取数据 + const extractFrom = (element) => { + switch (extractType) { + case 'text': + return element.textContent.trim(); + case 'html': + return element.innerHTML; + case 'attribute': + return element.getAttribute(config.attribute); + case 'value': + return element.value; + default: + return element.textContent.trim(); + } + }; + + if (multiple) { + return elements.map(extractFrom); + } else { + return extractFrom(elements[0]); + } + }, { selector, extractType, filter, multiple, attribute: this.config.attribute }); + + if (extractedData === null) { + if (required) { + throw new Error(`未找到匹配的元素: ${selector}`); + } else { + this.log('warn', `未找到元素: ${selector},跳过提取`); + return { success: true, data: null }; + } + } + + this.log('debug', `提取到原始数据: ${JSON.stringify(extractedData)}`); + + // 应用正则表达式 + let processedData = extractedData; + if (regex && typeof extractedData === 'string') { + const regexObj = new RegExp(regex); + const match = extractedData.match(regexObj); + + if (match) { + // 如果有 saveTo 配置,使用捕获组 + if (saveTo && typeof saveTo === 'object') { + processedData = {}; + for (const [key, value] of Object.entries(saveTo)) { + // $1, $2 等替换为捕获组 + if (typeof value === 'string' && value.startsWith('$')) { + const groupIndex = parseInt(value.substring(1)); + processedData[key] = match[groupIndex] || null; + } else { + processedData[key] = value; + } + } + } else { + // 返回第一个捕获组或整个匹配 + processedData = match[1] || match[0]; + } + } else if (required) { + throw new Error(`正则表达式不匹配: ${regex}`); + } else { + this.log('warn', `正则表达式不匹配: ${regex}`); + processedData = null; + } + } + + // 保存到上下文 + if (contextKey && processedData !== null) { + if (!this.context.data) { + this.context.data = {}; + } + this.context.data[contextKey] = processedData; + this.log('info', `✓ 数据已保存到 context.${contextKey}`); + } + + this.log('debug', `处理后的数据: ${JSON.stringify(processedData)}`); + + return { success: true, data: processedData }; + + } catch (error) { + this.log('error', `数据提取失败: ${error.message}`); + throw error; + } + } +} + +module.exports = ExtractAction; diff --git a/src/automation-framework/actions/verify-action.js b/src/automation-framework/actions/verify-action.js new file mode 100644 index 0000000..f2294bc --- /dev/null +++ b/src/automation-framework/actions/verify-action.js @@ -0,0 +1,240 @@ +const BaseAction = require('../core/base-action'); + +/** + * 验证动作 - 检测页面状态并根据结果采取行动 + * + * 用途:验证操作结果(如支付成功/失败),支持轮询检测 + * + * Example: + * - action: verify + * name: Verify payment result + * conditions: + * success: + * - urlNotContains: stripe.com + * - elementExists: .payment-success + * failure: + * - elementExists: .error-message + * - textContains: declined + * timeout: 10000 + * pollInterval: 500 + * onFailure: throw + */ +class VerifyAction extends BaseAction { + async execute() { + const { + conditions, + timeout = 10000, + pollInterval = 500, + onSuccess = 'continue', + onFailure = 'throw', + onTimeout = 'throw' + } = this.config; + + if (!conditions) { + throw new Error('Verify action 需要 conditions 参数'); + } + + this.log('debug', '开始验证...'); + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + // 检查成功条件 + if (conditions.success) { + const successResult = await this.checkConditions(conditions.success); + if (successResult.matched) { + this.log('success', `✓ 验证成功: ${successResult.reason}`); + return this.handleResult('success', onSuccess); + } + } + + // 检查失败条件 + if (conditions.failure) { + const failureResult = await this.checkConditions(conditions.failure); + if (failureResult.matched) { + this.log('error', `✗ 验证失败: ${failureResult.reason}`); + return this.handleResult('failure', onFailure, failureResult.reason); + } + } + + // 等待后继续轮询 + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + // 超时 + this.log('warn', `⚠ 验证超时(${timeout}ms)`); + return this.handleResult('timeout', onTimeout, '验证超时'); + } + + /** + * 检查条件组(任一满足即可) + */ + async checkConditions(conditionList) { + if (!Array.isArray(conditionList)) { + conditionList = [conditionList]; + } + + for (const condition of conditionList) { + const result = await this.checkSingleCondition(condition); + if (result.matched) { + return result; + } + } + + return { matched: false }; + } + + /** + * 检查单个条件 + */ + async checkSingleCondition(condition) { + // 条件类型1: urlContains / urlNotContains + if (condition.urlContains !== undefined) { + const currentUrl = this.page.url(); + const matched = currentUrl.includes(condition.urlContains); + return { + matched, + reason: matched ? `URL 包含 "${condition.urlContains}"` : null + }; + } + + if (condition.urlNotContains !== undefined) { + const currentUrl = this.page.url(); + const matched = !currentUrl.includes(condition.urlNotContains); + return { + matched, + reason: matched ? `URL 不包含 "${condition.urlNotContains}"` : null + }; + } + + // 条件类型2: urlEquals + if (condition.urlEquals !== undefined) { + const currentUrl = this.page.url(); + const matched = currentUrl === condition.urlEquals; + return { + matched, + reason: matched ? `URL 等于 "${condition.urlEquals}"` : null + }; + } + + // 条件类型3: elementExists / elementNotExists + if (condition.elementExists !== undefined) { + const element = await this.page.$(condition.elementExists); + const matched = !!element; + return { + matched, + reason: matched ? `元素存在: ${condition.elementExists}` : null + }; + } + + if (condition.elementNotExists !== undefined) { + const element = await this.page.$(condition.elementNotExists); + const matched = !element; + return { + matched, + reason: matched ? `元素不存在: ${condition.elementNotExists}` : null + }; + } + + // 条件类型4: elementVisible / elementHidden + if (condition.elementVisible !== undefined) { + const visible = await this.page.evaluate((selector) => { + const el = document.querySelector(selector); + if (!el) return false; + const style = window.getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }, condition.elementVisible); + + return { + matched: visible, + reason: visible ? `元素可见: ${condition.elementVisible}` : null + }; + } + + if (condition.elementHidden !== undefined) { + const hidden = await this.page.evaluate((selector) => { + const el = document.querySelector(selector); + if (!el) return true; + const style = window.getComputedStyle(el); + return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0'; + }, condition.elementHidden); + + return { + matched: hidden, + reason: hidden ? `元素隐藏: ${condition.elementHidden}` : null + }; + } + + // 条件类型5: textContains / textNotContains + if (condition.textContains !== undefined) { + const hasText = await this.page.evaluate((text) => { + return document.body.textContent.includes(text); + }, condition.textContains); + + return { + matched: hasText, + reason: hasText ? `页面包含文本: "${condition.textContains}"` : null + }; + } + + if (condition.textNotContains !== undefined) { + const hasText = await this.page.evaluate((text) => { + return document.body.textContent.includes(text); + }, condition.textNotContains); + + return { + matched: !hasText, + reason: !hasText ? `页面不包含文本: "${condition.textNotContains}"` : null + }; + } + + // 条件类型6: elementTextContains + if (condition.elementTextContains !== undefined) { + const { selector, text } = condition.elementTextContains; + const hasText = await this.page.evaluate((sel, txt) => { + const el = document.querySelector(sel); + return el && el.textContent.includes(txt); + }, selector, text); + + return { + matched: hasText, + reason: hasText ? `元素 ${selector} 包含文本 "${text}"` : null + }; + } + + // 条件类型7: custom - 自定义 JS 函数 + if (condition.custom !== undefined) { + const matched = await this.page.evaluate(condition.custom); + return { + matched, + reason: matched ? '自定义条件满足' : null + }; + } + + return { matched: false }; + } + + /** + * 处理验证结果 + */ + handleResult(resultType, action, reason = '') { + switch (action) { + case 'continue': + // 继续执行,不做任何事 + return { success: true, result: resultType }; + + case 'throw': + // 抛出异常,触发重试或错误处理 + throw new Error(`验证${resultType}: ${reason}`); + + case 'return': + // 返回结果,由调用者处理 + return { success: resultType === 'success', result: resultType, reason }; + + default: + return { success: true, result: resultType }; + } + } +} + +module.exports = VerifyAction; diff --git a/src/automation-framework/configs/sites/windsurf.yaml b/src/automation-framework/configs/sites/windsurf.yaml index f8f0658..e89ad12 100644 --- a/src/automation-framework/configs/sites/windsurf.yaml +++ b/src/automation-framework/configs/sites/windsurf.yaml @@ -131,9 +131,32 @@ workflow: - text: "Get started" waitAfter: 2000 - # ==================== 步骤 6: 填写支付信息(带重试) ==================== + # ==================== 步骤 6: 填写支付信息 ==================== + # 6.1 选择银行卡支付方式 + - action: click + name: "选择银行卡支付" + selector: + - css: 'input[type="radio"][value="card"]' + waitAfter: 3000 + + # 6.2 等待支付表单加载 + - action: wait + name: "等待支付表单" + type: element + find: + - css: '#cardNumber' + timeout: 30000 + + # 6.3 处理 hCaptcha(只需处理一次) + - action: custom + name: "hCaptcha 验证" + handler: "handleHCaptcha" + params: + timeout: 120000 + + # ==================== 步骤 6.4: 提交支付(带重试) ==================== - action: retryBlock - name: "支付流程" + name: "提交支付并验证" maxRetries: 5 retryDelay: 2000 onRetryBefore: @@ -142,22 +165,7 @@ workflow: handler: "regenerateCard" steps: - # 6.1 选择银行卡支付方式 - - action: click - name: "选择银行卡支付" - selector: - - css: 'input[type="radio"][value="card"]' - waitAfter: 3000 - - # 6.2 等待支付表单加载 - - action: wait - name: "等待支付表单" - type: element - find: - - css: '#cardNumber' - timeout: 30000 - - # 6.3 填写银行卡信息(使用重新生成的卡号) + # 填写银行卡信息(使用重新生成的卡号) - action: fillForm name: "填写银行卡信息" humanLike: false @@ -182,27 +190,70 @@ workflow: - css: '#billingName' value: "{{account.firstName}} {{account.lastName}}" - # 6.4 选择澳门地址(动态字段,需要自定义处理) + # 选择澳门地址(动态字段,需要自定义处理) - action: custom name: "选择澳门地址" handler: "selectBillingAddress" - # 6.5 处理 hCaptcha - - action: custom - name: "hCaptcha 验证" - handler: "handleHCaptcha" - params: - timeout: 120000 + # 提交支付 + - action: click + name: "点击提交支付" + selector: + - css: 'button[type="submit"]' + - text: '订阅' + - text: 'Subscribe' + waitAfter: 2000 - # 6.6 提交支付并检查结果(失败会触发重试) - - action: custom - name: "提交并验证支付" - handler: "submitAndVerifyPayment" + # 验证支付结果(轮询检测成功或失败) + - action: verify + name: "验证支付结果" + conditions: + success: + - urlNotContains: "stripe.com" + - urlNotContains: "checkout.stripe.com" + failure: + - textContains: "card was declined" + - textContains: "Your card was declined" + - textContains: "declined" + - textContains: "卡片被拒绝" + - elementExists: ".error-message" + timeout: 15000 + pollInterval: 500 + onFailure: "throw" # ==================== 步骤 7: 获取订阅信息 ==================== + # 7.1 跳转到订阅使用页面 + - action: navigate + name: "跳转订阅页面" + url: "https://windsurf.com/subscription/usage" + waitAfter: 3000 + + # 7.2 提取配额信息 + - action: extract + name: "提取配额信息" + selector: "p.caption1.font-medium.text-sk-black\\/80 span.caption3 span" + multiple: true + contextKey: "quotaRaw" + required: false + + # 7.3 提取账单周期信息 + - action: extract + name: "提取账单周期" + selector: "p.caption1" + extractType: "text" + filter: + contains: "Next billing cycle" + regex: "(\\d+)\\s+days?.*on\\s+([A-Za-z]+\\s+\\d+,\\s+\\d{4})" + saveTo: + days: "$1" + date: "$2" + contextKey: "billingInfo" + required: false + + # 7.4 处理提取的数据(自定义) - action: custom - name: "获取订阅信息" - handler: "getSubscriptionInfo" + name: "处理订阅数据" + handler: "processSubscriptionData" optional: true # ==================== 步骤 8: 保存到数据库 ==================== diff --git a/src/automation-framework/core/workflow-engine.js b/src/automation-framework/core/workflow-engine.js index fd5c4c7..21d2ff9 100644 --- a/src/automation-framework/core/workflow-engine.js +++ b/src/automation-framework/core/workflow-engine.js @@ -7,6 +7,8 @@ const ClickAction = require('../actions/click-action'); const WaitAction = require('../actions/wait-action'); const CustomAction = require('../actions/custom-action'); const RetryBlockAction = require('../actions/retry-block-action'); +const ExtractAction = require('../actions/extract-action'); +const VerifyAction = require('../actions/verify-action'); /** * 工作流引擎 - 执行配置驱动的自动化流程 @@ -32,6 +34,8 @@ class WorkflowEngine { this.actionRegistry.register('wait', WaitAction); this.actionRegistry.register('custom', CustomAction); this.actionRegistry.register('retryBlock', RetryBlockAction); + this.actionRegistry.register('extract', ExtractAction); + this.actionRegistry.register('verify', VerifyAction); } /** diff --git a/src/automation-framework/sites/windsurf-adapter.js b/src/automation-framework/sites/windsurf-adapter.js index ba4caf5..af077aa 100644 --- a/src/automation-framework/sites/windsurf-adapter.js +++ b/src/automation-framework/sites/windsurf-adapter.js @@ -419,76 +419,31 @@ class WindsurfAdapter extends SiteAdapter { } /** - * 步骤 7: 获取订阅信息 + * 处理订阅数据 - 从 extract action 提取的原始数据中构造结构化对象 */ - async getSubscriptionInfo(params) { - this.log('info', '获取订阅信息'); + async processSubscriptionData(params) { + this.log('info', '处理订阅数据...'); 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) { - // 忽略 - } - } + // 处理配额信息(从多个 span 中提取) + const quotaRaw = this.context.data.quotaRaw; + let quotaInfo = null; - // 跳转到订阅使用页面 - 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`); + if (quotaRaw && Array.isArray(quotaRaw) && quotaRaw.length >= 2) { + quotaInfo = { + used: quotaRaw[0].trim(), + total: quotaRaw[1].trim().replace('/', '').trim() + }; this.context.data.quotaInfo = quotaInfo; + this.log('success', `✓ 配额: ${quotaInfo.used} / ${quotaInfo.total} used`); } 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() - }; - }); - + // billingInfo 已由 extract action 处理好 + const billingInfo = this.context.data.billingInfo; if (billingInfo && billingInfo.days) { - this.log('success', `✓ 下次账单: ${billingInfo.days} 天后 (${billingInfo.date})`); - this.context.data.billingInfo = billingInfo; + this.log('success', `✓ 下次账单: ${billingInfo.days} 天后 (${billingInfo.date || 'N/A'})`); } else { this.log('warn', '未找到账单周期信息'); } @@ -496,11 +451,12 @@ class WindsurfAdapter extends SiteAdapter { // 打印汇总信息 this.log('success', `✓ 配额: ${quotaInfo ? `${quotaInfo.used}/${quotaInfo.total}` : 'N/A'} | 下次账单: ${billingInfo?.days || 'N/A'}天后`); - return { success: true, quotaInfo, billingInfo }; + return { success: true }; } catch (error) { - this.log('error', `获取订阅信息失败: ${error.message}`); - throw error; + this.log('error', `处理订阅数据失败: ${error.message}`); + // 不抛出异常,允许流程继续 + return { success: true, error: error.message }; } }