This commit is contained in:
dengqichen 2025-11-18 21:51:23 +08:00
parent 753ed9bda7
commit 331fd8e4bd
6 changed files with 1570 additions and 94 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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;

View File

@ -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: 保存到数据库 ====================

View File

@ -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);
}
/**

View File

@ -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 };
}
}