diff --git a/aaa.js b/aaa.js index c251ee2..426504b 100644 --- a/aaa.js +++ b/aaa.js @@ -14,7 +14,7 @@ console.log('=== 生成卡号验证 ===\n'); // 生成10张银联卡 console.log('生成10张银联卡进行验证:\n'); -const cards = generator.generateBatch(10, 'unionpay'); +const cards = generator.generateBatch(100, 'unionpay'); cards.forEach((card, index) => { const isValid = luhnCheck(card.number); diff --git a/browser-automation-ts/cli/run.ts b/browser-automation-ts/cli/run.ts index e64e472..717eabf 100644 --- a/browser-automation-ts/cli/run.ts +++ b/browser-automation-ts/cli/run.ts @@ -14,8 +14,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; import { AdsPowerProvider } from '../src/providers/adspower/AdsPowerProvider'; +import { PlaywrightStealthProvider } from '../src/providers/playwright-stealth/PlaywrightStealthProvider'; import { WorkflowEngine } from '../src/workflow/WorkflowEngine'; import { ISiteAdapter, EmptyAdapter } from '../src/adapters/ISiteAdapter'; +import { IBrowserProvider } from '../src/core/interfaces/IBrowserProvider'; interface WorkflowConfig { site: string; @@ -158,13 +160,22 @@ class AutomationRunner { } } - private async initializeProvider(): Promise { - console.log('🌐 Initializing AdsPower Provider...'); + private async initializeProvider(): Promise { + const browserProvider = process.argv[2] || 'adspower'; - return new AdsPowerProvider({ - profileId: process.env.ADSPOWER_USER_ID, - siteName: this.siteName.charAt(0).toUpperCase() + this.siteName.slice(1) - }); + console.log(`🌐 Initializing ${browserProvider} Provider...`); + + if (browserProvider === 'playwright' || browserProvider === 'playwright-stealth') { + return new PlaywrightStealthProvider({ + headless: false, + viewport: { width: 1920, height: 1080 } + }); + } else { + return new AdsPowerProvider({ + profileId: process.env.ADSPOWER_USER_ID, + siteName: this.siteName.charAt(0).toUpperCase() + this.siteName.slice(1) + }); + } } private buildContext(launchResult: any, config: WorkflowConfig): any { @@ -207,7 +218,7 @@ class AutomationRunner { console.log('='.repeat(60) + '\n'); } - private async cleanup(provider: AdsPowerProvider): Promise { + private async cleanup(provider: IBrowserProvider): Promise { try { console.log('\n🔒 Closing browser...'); await provider.close(); diff --git a/browser-automation-ts/configs/sites/windsurf-adapter.ts b/browser-automation-ts/configs/sites/windsurf-adapter.ts index 9e915b9..8386a9a 100644 --- a/browser-automation-ts/configs/sites/windsurf-adapter.ts +++ b/browser-automation-ts/configs/sites/windsurf-adapter.ts @@ -28,7 +28,7 @@ export class WindsurfAdapter extends BaseAdapter { new AccountGeneratorTool(), { email: { - domain: 'qichen111.asia' + domain: 'qichen.cloud' }, password: { strategy: 'email', diff --git a/browser-automation-ts/configs/sites/windsurf.yaml b/browser-automation-ts/configs/sites/windsurf.yaml index 8d89f14..7ade147 100644 --- a/browser-automation-ts/configs/sites/windsurf.yaml +++ b/browser-automation-ts/configs/sites/windsurf.yaml @@ -1,27 +1,27 @@ # Windsurf 注册自动化配置 site: name: Windsurf - url: https://windsurf.com/account/register + url: https://windsurf.com/refer?referral_code=55424ec434 # 工作流定义 workflow: # ==================== 步骤 0: 处理邀请链接 ==================== -# - action: navigate -# name: "打开邀请链接" -# url: "https://windsurf.com/refer?referral_code=55424ec434" -# options: -# waitUntil: 'networkidle2' -# timeout: 30000 -# -# - action: click -# name: "点击接受邀请" -# selector: -# - text: 'Sign up to accept referral' -# selector: 'button' -# - css: 'button.bg-sk-aqua' -# - css: 'button:has-text("Sign up to accept referral")' -# timeout: 30000 -# waitForNavigation: true + - action: navigate + name: "打开邀请链接" + url: "https://windsurf.com/refer?referral_code=55424ec434" + options: + waitUntil: 'networkidle2' + timeout: 30000 + + - action: click + name: "点击接受邀请" + selector: + - text: 'Sign up to accept referral' + selector: 'button' + - css: 'button.bg-sk-aqua' + - css: 'button:has-text("Sign up to accept referral")' + timeout: 30000 + waitForNavigation: true # 验证跳转到注册页面(带referral_code) - action: verify diff --git a/browser-automation-ts/src/index.ts b/browser-automation-ts/src/index.ts index 5db5d82..21cb120 100644 --- a/browser-automation-ts/src/index.ts +++ b/browser-automation-ts/src/index.ts @@ -18,13 +18,16 @@ export * from './factory/BrowserFactory'; // Providers export * from './providers/adspower/AdsPowerProvider'; +export * from './providers/playwright-stealth/PlaywrightStealthProvider'; // Register providers import { BrowserFactory } from './factory/BrowserFactory'; import { AdsPowerProvider } from './providers/adspower/AdsPowerProvider'; +import { PlaywrightStealthProvider } from './providers/playwright-stealth/PlaywrightStealthProvider'; import { BrowserProviderType } from './core/types'; -// Auto-register AdsPower +// Auto-register providers BrowserFactory.register(BrowserProviderType.ADSPOWER, AdsPowerProvider); +BrowserFactory.register(BrowserProviderType.PLAYWRIGHT_STEALTH, PlaywrightStealthProvider); console.log('✅ Browser Automation Framework (TypeScript) initialized'); diff --git a/browser-automation-ts/src/providers/playwright-stealth/PlaywrightStealthProvider.ts b/browser-automation-ts/src/providers/playwright-stealth/PlaywrightStealthProvider.ts new file mode 100644 index 0000000..d3caa34 --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/PlaywrightStealthProvider.ts @@ -0,0 +1,148 @@ +/** + * Playwright Stealth Provider - 开源指纹浏览器实现 + */ + +import { BaseBrowserProvider } from '../../core/base/BaseBrowserProvider'; +import { IBrowserCapabilities, ILaunchOptions, ILaunchResult } from '../../core/types'; +import { PlaywrightStealthActionFactory } from './core/ActionFactory'; +import { chromium, Browser, Page, BrowserContext } from 'playwright'; + +export interface IPlaywrightStealthConfig { + headless?: boolean; + proxy?: { + server: string; + username?: string; + password?: string; + }; + userAgent?: string; + viewport?: { + width: number; + height: number; + }; + locale?: string; + timezone?: string; +} + +export class PlaywrightStealthProvider extends BaseBrowserProvider { + private context: BrowserContext | null = null; + private launchOptions: IPlaywrightStealthConfig; + + constructor(config: IPlaywrightStealthConfig = {}) { + super(config); + this.launchOptions = { + headless: config.headless ?? false, + locale: config.locale ?? 'zh-CN', + timezone: config.timezone ?? 'Asia/Shanghai', + ...config + }; + } + + getName(): string { + return 'Playwright Stealth'; + } + + getVersion(): string { + return '1.0.0'; + } + + isFree(): boolean { + return true; + } + + getCapabilities(): IBrowserCapabilities { + return { + stealth: true, + fingerprint: true, + proxy: true, + incognito: true, + profiles: false, + cloudflareBypass: true, + stripeCompatible: true + }; + } + + async validateConfig(): Promise { + return true; + } + + async launch(options?: ILaunchOptions): Promise { + console.log('[Playwright Stealth] Launching browser...'); + + try { + const mergedOptions = { ...this.launchOptions, ...options }; + + this.browser = await chromium.launch({ + headless: mergedOptions.headless, + args: [ + '--disable-blink-features=AutomationControlled', + '--disable-dev-shm-usage', + '--no-sandbox' + ] + }); + + this.context = await this.browser.newContext({ + viewport: mergedOptions.viewport || { width: 1920, height: 1080 }, + userAgent: mergedOptions.userAgent, + locale: mergedOptions.locale, + timezoneId: mergedOptions.timezone, + proxy: mergedOptions.proxy + }); + + await this.applyStealthScripts(this.context); + this.page = await this.context.newPage(); + + console.log('[Playwright Stealth] ✅ Browser launched'); + + return { + browser: this.browser, + page: this.page + }; + + } catch (error: any) { + console.error(`[Playwright Stealth] ❌ Failed: ${error.message}`); + throw error; + } + } + + async close(): Promise { + if (!this.browser) return; + + try { + if (this.page) await this.page.close(); + if (this.context) await this.context.close(); + await this.browser.close(); + + this.page = null; + this.context = null; + this.browser = null; + + console.log('[Playwright Stealth] ✅ Browser closed'); + } catch (error: any) { + console.error(`[Playwright Stealth] Error: ${error.message}`); + throw error; + } + } + + getActionFactory(): PlaywrightStealthActionFactory { + return new PlaywrightStealthActionFactory(); + } + + async clearCache(): Promise { + if (this.context) { + await this.context.clearCookies(); + } + } + + private async applyStealthScripts(context: BrowserContext): Promise { + await context.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + (window as any).chrome = { runtime: {} }; + Object.defineProperty(navigator, 'plugins', { + get: () => [{ name: 'Chrome PDF Plugin' }] + }); + Object.defineProperty(navigator, 'languages', { + get: () => ['zh-CN', 'zh', 'en'] + }); + }); + } +} diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/ClickAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/ClickAction.ts new file mode 100644 index 0000000..49950a5 --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/ClickAction.ts @@ -0,0 +1,237 @@ +import BaseAction from '../core/BaseAction'; +import SmartSelector from '../../../core/selectors/SmartSelector'; + +class ClickAction extends BaseAction { + async execute(): Promise { + const selector = this.config.selector || this.config.find; + + if (!selector) { + throw new Error('缺少选择器配置'); + } + + this.log('info', '执行点击'); + + const smartSelector = SmartSelector.fromConfig(selector, this.page); + const element = await smartSelector.find(this.config.timeout || 10000); + + if (!element) { + throw new Error(`元素未找到: ${JSON.stringify(selector)}`); + } + + const waitForEnabled = this.config.waitForEnabled !== false; + if (waitForEnabled) { + await this.waitForClickable(selector, this.config.timeout || 30000); + } + + try { + const info = await element.evaluate((el: any) => { + 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: any) { + this.log('warn', `无法获取元素信息: ${e.message}`); + } + + await element.scrollIntoViewIfNeeded(); + await new Promise(resolve => setTimeout(resolve, 300)); + await this.pauseDelay(); + + const humanLike = this.config.humanLike !== false; + if (humanLike) { + await this.humanClick(selector); + } else { + await element.click(); + } + + this.log('info', '✓ 点击完成'); + await this.pauseDelay(); + + if (this.config.verifyAfter) { + await this.verifyAfterClick(this.config.verifyAfter); + } + + if (this.config.waitAfter) { + await new Promise(resolve => setTimeout(resolve, this.config.waitAfter)); + } + + return { success: true }; + } + + async waitForClickable(selectorConfig: any, timeout: number): Promise { + this.log('info', '→ 等待元素可点击...'); + + const startTime = Date.now(); + let lastLogTime = 0; + + while (Date.now() - startTime < timeout) { + try { + const smartSelector = SmartSelector.fromConfig(selectorConfig, this.page); + const element = await smartSelector.find(1000); + + if (!element) { + await new Promise(resolve => setTimeout(resolve, 500)); + continue; + } + + const isClickable = await element.evaluate((el: any) => { + if (el.offsetParent === null) return false; + if (el.tagName === 'BUTTON' || el.tagName === 'INPUT') { + if (el.disabled) return false; + } + 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: any) { + this.log('debug', `元素检查失败: ${error.message}`); + } + + 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 humanClick(selectorConfig: any): Promise { + this.log('info', '→ 使用人类行为模拟点击...'); + + try { + const smartSelector = SmartSelector.fromConfig(selectorConfig, this.page); + const element = await smartSelector.find(5000); + + if (!element) { + throw new Error('重新定位失败'); + } + + 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; + const targetY = box.y + box.height / 2; + + const nearX = targetX + this.randomInt(-50, 50); + const nearY = targetY + this.randomInt(-50, 50); + const steps1 = this.randomInt(15, 30); + + this.log('debug', `移动鼠标到附近: (${nearX.toFixed(0)}, ${nearY.toFixed(0)})`); + await this.page.mouse.move(nearX, nearY, { steps: steps1 }); + await this.randomDelay(150, 400); + + this.log('debug', `移动鼠标到目标: (${targetX.toFixed(0)}, ${targetY.toFixed(0)})`); + await this.page.mouse.move(targetX, targetY, { steps: this.randomInt(10, 20) }); + await this.randomDelay(200, 500); + + this.log('debug', '执行点击 (mouse down + up)...'); + await this.page.mouse.down(); + await this.randomDelay(80, 180); + await this.page.mouse.up(); + await this.randomDelay(1200, 2500); + + this.log('info', '✓ 人类行为点击完成'); + + } catch (error: any) { + this.log('error', `⚠️ 人类行为点击失败: ${error.message}`); + throw error; + } + } + + randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + async verifyAfterClick(config: any): Promise { + const { appears, disappears, checked, timeout = 10000 } = config; + + if (appears) { + this.log('debug', '验证新元素出现...'); + for (const selector of (Array.isArray(appears) ? appears : [appears])) { + try { + await this.page.waitForSelector(selector, { timeout, state: 'visible' }); + this.log('debug', `✓ 新元素已出现: ${selector}`); + } catch (error: any) { + 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, state: 'hidden' }); + this.log('debug', `✓ 旧元素已消失: ${selector}`); + } catch (error: any) { + throw new Error(`点击后验证失败: 元素未消失 ${selector}`); + } + } + } + + if (checked !== undefined) { + this.log('debug', `验证 checked 状态: ${checked}...`); + await new Promise(resolve => setTimeout(resolve, 500)); + + const selectorConfig = this.config.selector; + let cssSelector = null; + + if (typeof selectorConfig === 'string') { + cssSelector = selectorConfig; + } else if (Array.isArray(selectorConfig)) { + for (const sel of selectorConfig) { + if (typeof sel === 'string') { + cssSelector = sel; + break; + } else if (sel.css) { + cssSelector = sel.css; + break; + } + } + } else if (selectorConfig.css) { + cssSelector = selectorConfig.css; + } + + if (!cssSelector) { + throw new Error('无法从选择器配置中提取 CSS 选择器'); + } + + const isChecked = await this.page.evaluate((sel: string) => { + const element = document.querySelector(sel) as any; + return element && element.checked === true; + }, cssSelector); + + const expectedState = checked === true; + if (isChecked !== expectedState) { + throw new Error(`点击后验证失败: checked 状态不符 (期望: ${expectedState}, 实际: ${isChecked})`); + } + + this.log('debug', `✓ checked 状态验证通过: ${isChecked}`); + } + } +} + +export default ClickAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/CustomAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/CustomAction.ts new file mode 100644 index 0000000..d583727 --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/CustomAction.ts @@ -0,0 +1,41 @@ +import BaseAction from '../core/BaseAction'; + +class CustomAction extends BaseAction { + async execute(): Promise { + const handler = this.config.handler; + const params = this.config.params || {}; + const timeout = this.config.timeout || 300000; + + if (!handler) throw new Error('缺少处理函数名称'); + + this.log('info', `执行自定义函数: ${handler}`); + + if (typeof this.context.adapter[handler] !== 'function') { + throw new Error(`自定义处理函数不存在: ${handler}`); + } + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`自定义函数超时: ${handler} (${timeout}ms)`)); + }, timeout); + }); + + try { + const result = await Promise.race([ + this.context.adapter[handler](params), + timeoutPromise + ]); + + this.log('debug', '✓ 自定义函数执行完成'); + return result; + + } catch (error: any) { + if (error.message.includes('超时')) { + this.log('error', `⚠️ ${error.message}`); + } + throw error; + } + } +} + +export default CustomAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/ExtractAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/ExtractAction.ts new file mode 100644 index 0000000..4184de9 --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/ExtractAction.ts @@ -0,0 +1,107 @@ +import BaseAction from '../core/BaseAction'; + +class ExtractAction extends BaseAction { + async execute(): Promise { + 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: any) => { + 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: any) => { + 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) { + if (saveTo && typeof saveTo === 'object') { + processedData = {}; + for (const [key, value] of Object.entries(saveTo)) { + if (typeof value === 'string' && value.startsWith('$')) { + const groupIndex = parseInt(value.substring(1)); + (processedData as any)[key] = match[groupIndex] || null; + } else { + (processedData as any)[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: any) { + this.log('error', `数据提取失败: ${error.message}`); + throw error; + } + } +} + +export default ExtractAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/FillFormAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/FillFormAction.ts new file mode 100644 index 0000000..224a099 --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/FillFormAction.ts @@ -0,0 +1,135 @@ +import BaseAction from '../core/BaseAction'; +import SmartSelector from '../../../core/selectors/SmartSelector'; + +class FillFormAction extends BaseAction { + async execute(): Promise { + const fields = this.config.fields; + const humanLike = this.config.humanLike !== false; + + if (!fields || typeof fields !== 'object') { + throw new Error('表单字段配置无效'); + } + + this.log('info', `填写表单,共 ${Object.keys(fields).length} 个字段`); + + const fieldEntries = Object.entries(fields); + for (let i = 0; i < fieldEntries.length; i++) { + const [key, fieldConfig] = fieldEntries[i]; + await this.fillField(key, fieldConfig, humanLike); + + if (i < fieldEntries.length - 1) { + await this.pauseDelay(); + } + } + + this.log('info', '✓ 表单填写完成'); + await this.thinkDelay(); + + return { success: true }; + } + + async fillField(key: string, fieldConfig: any, humanLike: boolean): Promise { + let selector, value, fieldType; + + if (typeof fieldConfig === 'object' && fieldConfig.find) { + selector = fieldConfig.find; + value = this.replaceVariables(fieldConfig.value); + fieldType = fieldConfig.type; + } else if (typeof fieldConfig === 'string') { + selector = [ + { css: `#${key}` }, + { name: key }, + { css: `input[name="${key}"]` }, + { css: `select[name="${key}"]` }, + { css: `textarea[name="${key}"]` } + ]; + value = this.replaceVariables(fieldConfig); + fieldType = 'input'; + } else { + selector = key; + value = this.replaceVariables(fieldConfig); + fieldType = 'input'; + } + + const smartSelector = SmartSelector.fromConfig(selector, this.page); + const element = await smartSelector.find(10000); + + if (!element) { + throw new Error(`元素未找到: ${key}`); + } + + this.log('debug', ` → 填写字段: ${key}`); + + if (!fieldType) { + fieldType = fieldConfig.type || 'input'; + } + + if (fieldType === 'select') { + const cssSelector = this.getCssSelector(selector); + await this.page.selectOption(cssSelector, value); + this.log('debug', ` → 已选择: ${value}`); + return; + } + + await element.click({ clickCount: 3 }); + await new Promise(resolve => setTimeout(resolve, 100)); + + const clearTimes = fieldConfig.clearTimes || this.config.clearTimes || 25; + for (let i = 0; i < clearTimes; i++) { + await this.page.keyboard.press('Backspace'); + } + await new Promise(resolve => setTimeout(resolve, 200)); + + if (humanLike) { + await this.typeHumanLike(element, value); + } else { + await element.type(value, { delay: 100 }); + } + + await element.evaluate((el: any) => { + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + getCssSelector(selector: any): string { + if (typeof selector === 'string') { + return selector; + } + + if (Array.isArray(selector)) { + for (const sel of selector) { + if (typeof sel === 'string') { + return sel; + } else if (sel.css) { + return sel.css; + } + } + return selector[0]; + } + + if (selector.css) { + return selector.css; + } + + throw new Error('无法从选择器配置中提取 CSS 选择器'); + } + + async typeHumanLike(element: any, text: string): Promise { + for (let i = 0; i < text.length; i++) { + const char = text[i]; + + await element.type(char, { + delay: Math.random() * 150 + 100 + }); + + if (i > 0 && i % (Math.floor(Math.random() * 3) + 3) === 0) { + await new Promise(resolve => setTimeout(resolve, Math.random() * 800 + 300)); + } + } + + await new Promise(resolve => setTimeout(resolve, Math.random() * 500 + 300)); + } +} + +export default FillFormAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/NavigateAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/NavigateAction.ts new file mode 100644 index 0000000..1a2a207 --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/NavigateAction.ts @@ -0,0 +1,88 @@ +import BaseAction from '../core/BaseAction'; + +class NavigateAction extends BaseAction { + async execute(): Promise { + const url = this.replaceVariables(this.config.url); + + // 转换 Puppeteer 的 waitUntil 为 Playwright 兼容的值 + let waitUntil = this.config.options?.waitUntil || 'networkidle'; + if (waitUntil === 'networkidle2' || waitUntil === 'networkidle0') { + waitUntil = 'networkidle'; + } + + const options = { + waitUntil: waitUntil as 'load' | 'domcontentloaded' | 'networkidle' | 'commit', + timeout: this.config.options?.timeout || 30000 + }; + + const maxRetries = this.config.maxRetries || 5; + const retryDelay = this.config.retryDelay || 3000; + const totalTimeout = this.config.totalTimeout || 180000; + + const startTime = Date.now(); + let lastError: any = 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); + + const currentUrl = this.page.url(); + if (this.config.verifyUrl && !currentUrl.includes(this.config.verifyUrl)) { + throw new Error(`页面跳转异常`); + } + + if (this.config.verifyElements) { + await this.verifyElements(this.config.verifyElements); + } + + this.log('info', `✓ 页面加载完成${attempt > 0 ? ` (尝试 ${attempt + 1} 次)` : ''}`); + await this.readPageDelay(); + + if (this.config.waitAfter) { + await new Promise(resolve => setTimeout(resolve, this.config.waitAfter)); + } + + return { success: true, url: currentUrl }; + + } catch (error: any) { + 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; + } + + async verifyElements(selectors: string[]): Promise { + this.log('debug', '验证页面元素...'); + + for (const selector of selectors) { + try { + await this.page.waitForSelector(selector, { timeout: 10000 }); + } catch (error: any) { + throw new Error(`元素未找到: ${selector}`); + } + } + + this.log('debug', `✓ 已验证 ${selectors.length} 个关键元素`); + } +} + +export default NavigateAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/RetryBlockAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/RetryBlockAction.ts new file mode 100644 index 0000000..56ccfdf --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/RetryBlockAction.ts @@ -0,0 +1,106 @@ +import BaseAction from '../core/BaseAction'; + +class RetryBlockAction extends BaseAction { + async execute(): Promise { + const { steps = [], maxRetries = 3, retryDelay = 1000, totalTimeout = 600000, onRetryBefore = [], onRetryAfter = [] } = this.config; + + const blockName = this.config.name || 'RetryBlock'; + + if (!steps || steps.length === 0) { + throw new Error('RetryBlock 必须包含至少一个步骤'); + } + + let lastError: any = null; + const startTime = Date.now(); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const elapsed = Date.now() - startTime; + if (elapsed > totalTimeout) { + throw new Error(`${blockName} 整体超时 (${totalTimeout}ms)`); + } + + try { + if (attempt > 0) { + this.log('info', `${blockName} - 第 ${attempt + 1} 次重试...`); + + if (onRetryBefore.length > 0) { + this.log('debug', '执行重试前钩子...'); + await this.executeHooks(onRetryBefore); + } + + if (retryDelay > 0) { + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + + this.log('debug', `执行 ${steps.length} 个步骤...`); + await this.executeSteps(steps); + + if (attempt > 0 && onRetryAfter.length > 0) { + this.log('debug', '执行重试后钩子...'); + await this.executeHooks(onRetryAfter); + } + + if (attempt > 0) { + this.log('success', `✓ ${blockName} 在第 ${attempt + 1} 次尝试后成功`); + } + + return { success: true, attempts: attempt + 1 }; + + } catch (error: any) { + lastError = error; + + if (attempt < maxRetries) { + this.log('warn', `${blockName} 执行失败: ${error.message}`); + this.log('info', `准备重试 (${attempt + 1}/${maxRetries})...`); + } else { + this.log('error', `${blockName} 在 ${maxRetries + 1} 次尝试后仍然失败`); + } + } + } + + throw new Error(`${blockName} 重试耗尽: ${lastError.message}`); + } + + async executeHooks(hooks: any[]): Promise { + for (const hookConfig of hooks) { + await this.executeStep(hookConfig); + } + } + + async executeSteps(steps: any[]): Promise { + for (const stepConfig of steps) { + await this.executeStep(stepConfig); + } + } + + async executeStep(stepConfig: any): Promise { + const actionType = stepConfig.action; + const ActionClass = this.getActionClass(actionType); + const action = new ActionClass(this.context, stepConfig); + return await action.execute(); + } + + getActionClass(actionType: string): any { + const actionMap: any = { + navigate: require('./NavigateAction').default, + fillForm: require('./FillFormAction').default, + click: require('./ClickAction').default, + wait: require('./WaitAction').default, + custom: require('./CustomAction').default, + scroll: require('./ScrollAction').default, + verify: require('./VerifyAction').default, + extract: require('./ExtractAction').default + }; + + const ActionClass = actionMap[actionType]; + + if (!ActionClass) { + throw new Error(`未知的 action 类型: ${actionType}`); + } + + return ActionClass; + } +} + +export default RetryBlockAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/ScrollAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/ScrollAction.ts new file mode 100644 index 0000000..d9ab32e --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/ScrollAction.ts @@ -0,0 +1,45 @@ +import BaseAction from '../core/BaseAction'; + +class ScrollAction extends BaseAction { + async execute(): Promise { + const { type = 'bottom', selector, x = 0, y = 0, behavior = 'smooth' } = this.config; + + this.log('debug', `执行滚动: ${type}`); + + switch (type) { + case 'bottom': + await this.page.evaluate((b: string) => { + window.scrollTo({ top: document.body.scrollHeight, left: 0, behavior: b as ScrollBehavior }); + }, behavior); + break; + case 'top': + await this.page.evaluate((b: string) => { + window.scrollTo({ top: 0, left: 0, behavior: b as ScrollBehavior }); + }, behavior); + break; + case 'element': + if (!selector) throw new Error('滚动到元素需要提供 selector'); + const element = await this.page.$(selector); + if (!element) throw new Error(`元素不存在: ${selector}`); + await element.evaluate((el: any, b: string) => { + el.scrollIntoView({ behavior: b as ScrollBehavior, block: 'center' }); + }, behavior); + break; + case 'distance': + await this.page.evaluate(({ dx, dy, b }: any) => { + window.scrollBy({ top: dy, left: dx, behavior: b as ScrollBehavior }); + }, { dx: x, dy: y, b: behavior }); + break; + default: + throw new Error(`不支持的滚动类型: ${type}`); + } + + await new Promise(resolve => setTimeout(resolve, 500)); + this.log('debug', '✓ 滚动完成'); + await this.pauseDelay(); + + return { success: true }; + } +} + +export default ScrollAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/VerifyAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/VerifyAction.ts new file mode 100644 index 0000000..8716c61 --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/VerifyAction.ts @@ -0,0 +1,108 @@ +import BaseAction from '../core/BaseAction'; + +class VerifyAction extends BaseAction { + async execute(): Promise { + 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: any): Promise<{matched: boolean; reason?: string | null}> { + 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: any): Promise<{matched: boolean; reason?: string | null}> { + 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 }; + } + + if (condition.urlEquals !== undefined) { + const currentUrl = this.page.url(); + const matched = currentUrl === condition.urlEquals; + return { matched, reason: matched ? `URL 等于 "${condition.urlEquals}"` : null }; + } + + 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 }; + } + + if (condition.textContains !== undefined) { + const hasText = await this.page.evaluate((text: string) => { + return document.body.textContent!.includes(text); + }, condition.textContains); + return { matched: hasText, reason: hasText ? `页面包含文本: "${condition.textContains}"` : null }; + } + + if (condition.custom !== undefined) { + const matched = await this.page.evaluate(condition.custom); + return { matched: !!matched, reason: matched ? '自定义条件满足' : null }; + } + + return { matched: false }; + } + + handleResult(resultType: string, action: string, reason: string | null = ''): any { + 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 }; + } + } +} + +export default VerifyAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/actions/WaitAction.ts b/browser-automation-ts/src/providers/playwright-stealth/actions/WaitAction.ts new file mode 100644 index 0000000..7dd862f --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/actions/WaitAction.ts @@ -0,0 +1,135 @@ +import BaseAction from '../core/BaseAction'; +import SmartSelector from '../../../core/selectors/SmartSelector'; + +class WaitAction extends BaseAction { + async execute(): Promise { + const type = this.config.type || 'delay'; + + switch (type) { + case 'delay': + return await this.waitDelay(); + case 'element': + return await this.waitForElement(); + case 'navigation': + return await this.waitForNavigation(); + case 'condition': + return await this.waitForCondition(); + case 'url': + return await this.waitForUrl(); + default: + throw new Error(`未知的等待类型: ${type}`); + } + } + + async waitDelay(): Promise<{success: boolean}> { + const duration = this.config.duration || this.config.ms || 1000; + this.log('debug', `等待 ${duration}ms`); + await new Promise(resolve => setTimeout(resolve, duration)); + return { success: true }; + } + + async waitForElement(): Promise<{success: boolean}> { + const selector = this.config.selector || this.config.find; + const timeout = this.config.timeout || 10000; + + if (!selector) { + throw new Error('缺少选择器配置'); + } + + this.log('debug', '等待元素出现'); + + const smartSelector = SmartSelector.fromConfig(selector, this.page); + const element = await smartSelector.find(timeout); + + if (!element) { + throw new Error(`元素未找到: ${JSON.stringify(selector)}`); + } + + this.log('debug', '✓ 元素已出现'); + return { success: true }; + } + + async waitForNavigation(): Promise<{success: boolean}> { + const timeout = this.config.timeout || 30000; + + this.log('debug', '等待页面导航'); + + let waitUntil = this.config.waitUntil || 'networkidle'; + if (waitUntil === 'networkidle2' || waitUntil === 'networkidle0') { + waitUntil = 'networkidle'; + } + + await this.page.waitForLoadState(waitUntil as any, { timeout }); + + this.log('debug', '✓ 导航完成'); + return { success: true }; + } + + async waitForCondition(): Promise<{success: boolean}> { + const handler = this.config.handler; + const timeout = this.config.timeout || 10000; + + if (!handler) { + throw new Error('缺少条件处理函数'); + } + + this.log('debug', '等待自定义条件'); + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (typeof this.context.adapter[handler] === 'function') { + const result = await this.context.adapter[handler](); + if (result) { + this.log('debug', '✓ 条件满足'); + return { success: true }; + } + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + throw new Error(`waitForCondition 超时: ${handler}`); + } + + async waitForUrl(): Promise<{success: boolean}> { + const timeout = this.config.timeout || 20000; + const urlContains = this.config.urlContains; + const urlNotContains = this.config.urlNotContains; + const urlEquals = this.config.urlEquals; + + if (!urlContains && !urlNotContains && !urlEquals) { + throw new Error('需要指定 urlContains、urlNotContains 或 urlEquals'); + } + + this.log('debug', '等待 URL 变化'); + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const currentUrl = this.page.url(); + + if (urlContains && currentUrl.includes(urlContains)) { + this.log('debug', `✓ URL 包含 "${urlContains}": ${currentUrl}`); + return { success: true }; + } + + if (urlNotContains && !currentUrl.includes(urlNotContains)) { + this.log('debug', `✓ URL 不包含 "${urlNotContains}": ${currentUrl}`); + return { success: true }; + } + + if (urlEquals && currentUrl === urlEquals) { + this.log('debug', `✓ URL 等于 "${urlEquals}"`); + return { success: true }; + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + const finalUrl = this.page.url(); + throw new Error(`waitForUrl 超时: 实际URL ${finalUrl}`); + } +} + +export default WaitAction; diff --git a/browser-automation-ts/src/providers/playwright-stealth/core/ActionFactory.ts b/browser-automation-ts/src/providers/playwright-stealth/core/ActionFactory.ts new file mode 100644 index 0000000..03246ea --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/core/ActionFactory.ts @@ -0,0 +1,53 @@ +/** + * Playwright Stealth Action Factory + */ + +import { IActionFactory } from '../../../core/interfaces/IAction'; +import ClickAction from '../actions/ClickAction'; +import WaitAction from '../actions/WaitAction'; +import NavigateAction from '../actions/NavigateAction'; +import CustomAction from '../actions/CustomAction'; +import VerifyAction from '../actions/VerifyAction'; +import FillFormAction from '../actions/FillFormAction'; +import ScrollAction from '../actions/ScrollAction'; +import ExtractAction from '../actions/ExtractAction'; +import RetryBlockAction from '../actions/RetryBlockAction'; + +export class PlaywrightStealthActionFactory implements IActionFactory { + private actions: Map; + + constructor() { + this.actions = new Map(); + this.registerDefaultActions(); + } + + private registerDefaultActions(): void { + this.actions.set('click', ClickAction); + this.actions.set('wait', WaitAction); + this.actions.set('navigate', NavigateAction); + this.actions.set('custom', CustomAction); + this.actions.set('verify', VerifyAction); + this.actions.set('fillForm', FillFormAction); + this.actions.set('scroll', ScrollAction); + this.actions.set('extract', ExtractAction); + this.actions.set('retryBlock', RetryBlockAction); + } + + getAction(actionName: string): any { + const ActionClass = this.actions.get(actionName); + + if (!ActionClass) { + throw new Error(`Unknown action: ${actionName}`); + } + + return ActionClass; + } + + hasAction(actionName: string): boolean { + return this.actions.has(actionName); + } + + registerAction(name: string, ActionClass: any): void { + this.actions.set(name, ActionClass); + } +} diff --git a/browser-automation-ts/src/providers/playwright-stealth/core/BaseAction.ts b/browser-automation-ts/src/providers/playwright-stealth/core/BaseAction.ts new file mode 100644 index 0000000..3e1014e --- /dev/null +++ b/browser-automation-ts/src/providers/playwright-stealth/core/BaseAction.ts @@ -0,0 +1,120 @@ +/** + * Playwright Stealth Provider 的 BaseAction + */ + +import { Page } from 'playwright'; + +export interface ActionContext { + page: Page; + browser: any; + logger?: any; + data?: any; + siteConfig?: any; + config?: any; + siteName?: string; + adapter?: any; +} + +export abstract class BaseAction { + protected context: ActionContext; + protected config: any; + protected page: Page; + protected logger: any; + + constructor(context: ActionContext, config: any) { + this.context = context; + this.config = config; + this.page = context.page; + this.logger = context.logger; + } + + abstract execute(): Promise; + + replaceVariables(value: any): any { + if (typeof value !== 'string') return value; + + return value.replace(/\{\{(.+?)\}\}/g, (match, expression) => { + const [path, defaultValue] = expression.split('|').map((s: string) => s.trim()); + const result = this.resolveVariablePath(path); + + if (result !== undefined && result !== null) { + return result; + } + + if (defaultValue !== undefined) { + this.log('debug', `变量 "${path}" 不存在,使用默认值: "${defaultValue}"`); + return defaultValue; + } + + this.log('warn', `⚠️ 变量 "${path}" 不存在`); + return match; + }); + } + + resolveVariablePath(path: string): any { + const keys = path.split('.'); + const rootKey = keys[0]; + + let dataSource: any; + let startIndex = 1; + + switch (rootKey) { + case 'site': + dataSource = this.context.siteConfig; + break; + case 'config': + dataSource = this.context.config; + break; + case 'env': + dataSource = process.env; + break; + default: + dataSource = this.context.data; + startIndex = 0; + } + + if (!dataSource) return undefined; + + let result = dataSource; + for (let i = startIndex; i < keys.length; i++) { + if (result && typeof result === 'object') { + result = result[keys[i]]; + } else { + return undefined; + } + } + + return result; + } + + log(level: string, message: string): void { + if (this.logger && this.logger[level]) { + this.logger[level](this.context.siteName || 'Automation', message); + } else { + console.log(`[${level.toUpperCase()}] ${message}`); + } + } + + async randomDelay(min: number, max: number): Promise { + const delay = min + Math.random() * (max - min); + await new Promise(resolve => setTimeout(resolve, delay)); + } + + async readPageDelay(): Promise { + await this.randomDelay(2000, 5000); + } + + async thinkDelay(): Promise { + await this.randomDelay(1000, 2500); + } + + async pauseDelay(): Promise { + await this.randomDelay(300, 800); + } + + async stepDelay(): Promise { + await this.randomDelay(1500, 3000); + } +} + +export default BaseAction; diff --git a/browser-automation-ts/src/tools/AccountGeneratorTool.ts b/browser-automation-ts/src/tools/AccountGeneratorTool.ts index 15a121b..b918293 100644 --- a/browser-automation-ts/src/tools/AccountGeneratorTool.ts +++ b/browser-automation-ts/src/tools/AccountGeneratorTool.ts @@ -44,7 +44,7 @@ export class AccountGeneratorTool extends BaseTool { readonly name = 'account-generator'; // 邮箱域名(与旧框架一致) - private emailDomains = ['qichen111.asia']; + private emailDomains = ['qichen.cloud']; // 英文名字库(与旧框架一致) private firstNames = {