This commit is contained in:
dengqichen 2025-11-18 21:33:04 +08:00
parent b61aa2c8e9
commit 753ed9bda7
6 changed files with 463 additions and 98 deletions

View File

@ -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}" 未消失`);
}
}
}
}
/**
* 等待页面内容变化
*/

View File

@ -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);
// 可选的等待时间
if (this.config.waitAfter) {
await new Promise(resolve => setTimeout(resolve, this.config.waitAfter));
// 验证页面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', '验证页面元素...');
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} 个关键元素`);
}
}

View File

@ -1,5 +1,4 @@
const BaseAction = require('./base-action');
const logger = require('../../tools/account-register/utils/logger');
const BaseAction = require('../core/base-action');
/**
* 重试块动作 - 将一组步骤作为整体进行重试

View File

@ -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: 邮箱验证 ====================

View File

@ -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}`);
// 子类可以重写
}
// 截图
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}`);
/**
* 执行重试策略框架通用方法
*/
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;
}
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} 个步骤`);
}
/**

View File

@ -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 人机验证');
for (let retryCount = 0; retryCount <= maxRetries; retryCount++) {
try {
if (retryCount > 0) {
this.log('warn', `Turnstile 超时,执行重试策略: ${retryStrategy} (${retryCount}/${maxRetries})...`);
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;
});
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: 实现获取订阅信息逻辑
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) {
// 忽略
}
}
return { success: true };
// 跳转到订阅使用页面
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: 实现数据库保存逻辑
try {
// 导入数据库模块
const database = require('../../tools/account-register/database');
return { success: true };
// 初始化数据库连接
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 };
}
}
}