This commit is contained in:
dengqichen 2025-11-19 09:56:08 +08:00
parent a288aa01aa
commit acceddac7b
5 changed files with 407 additions and 89 deletions

View File

@ -22,17 +22,51 @@ class ClickAction extends BaseAction {
throw new Error(`无法找到元素: ${JSON.stringify(selector)}`);
}
// 滚动到可视区域
// 等待元素变为可点击状态(参考旧框架)
const waitForEnabled = this.config.waitForEnabled !== false; // 默认等待
if (waitForEnabled) {
// 传入 selector 配置,在循环中重新查找元素
await this.waitForClickable(element, this.config.timeout || 30000, selector);
}
// 记录找到的元素信息(总是显示,便于调试)
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}`);
}
// 滚动到可视区域(带容错)
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}`);
}
// 点击
// 点击(支持人类行为模拟)
const humanLike = this.config.humanLike !== false; // 默认使用人类行为
if (humanLike) {
// 重新查找元素并点击(参考旧框架,避免元素失效)
await this.humanClickWithSelector(selector);
} else {
await element.click();
}
this.log('debug', '✓ 点击完成');
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));
}
/**
* 验证点击后的变化
*/

View File

@ -11,12 +11,32 @@ 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分钟
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;
}
try {
if (attempt > 0) {
this.log('info', `${attempt + 1} 次尝试导航...`);
} else {
this.log('info', `导航到: ${url}`);
}
// 尝试导航
await this.page.goto(url, options);
// 验证页面URL是否正确避免重定向到登录页等
// 验证页面URL是否正确避免重定向到会员中心等)
const currentUrl = this.page.url();
if (this.config.verifyUrl && !currentUrl.includes(this.config.verifyUrl)) {
throw new Error(`页面跳转异常: 期望包含 "${this.config.verifyUrl}", 实际为 "${currentUrl}"`);
@ -32,15 +52,26 @@ class NavigateAction extends BaseAction {
await new Promise(resolve => setTimeout(resolve, this.config.waitAfter));
}
this.log('info', `✓ 页面加载完成`);
this.log('info', `✓ 页面加载完成${attempt > 0 ? ` (尝试 ${attempt + 1} 次)` : ''}`);
return { success: true, url: currentUrl };
} catch (error) {
this.log('error', `导航失败: ${error.message}`);
throw 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));
}
}
}
// 所有重试都失败
this.log('error', `导航失败: ${lastError.message}`);
throw lastError;
}
/**
* 验证关键元素存在

View File

@ -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
# 验证支付结果(轮询检测成功或失败)

View File

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

View File

@ -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',