背景
上个月,我参与了一个新兴 Layer2 网络的生态激励计划。为了获得潜在的空投资格,需要让我的多个测试钱包在该网络的 DeFi 协议上进行存款、兑换等交互操作。我有大约 200 个钱包地址需要处理,每个地址要完成 3-4 个不同的交互步骤。
最初我尝试手动操作:连接钱包、授权、确认交易……做了不到 10 个就意识到这完全不可行。一个地址完整走完流程大约需要 2-3 分钟,200 个地址就是 6-10 个小时的重复劳动,而且中间难免出错。
我需要一个自动化方案。虽然这些 DeFi 协议都有 API,但空投规则明确要求“通过前端界面交互”,后台调用 API 可能不会被计入。所以,我必须模拟真实的浏览器操作——这就是我选择 Selenium 的原因。
问题分析
我的第一反应是:“这不就是普通的表单自动化吗?”但实际开始后,我发现 Web3 前端有几个特殊之处:
- 钱包连接不是普通表单:点击“连接钱包”后,会弹出 MetaMask 窗口(或类似的钱包扩展),这不是普通的浏览器弹窗
- 交易确认需要签名:授权、交易等操作需要用户在钱包中点击确认,这个确认界面在扩展内
- Gas 费动态变化:每次交易的 Gas 费都可能不同,需要动态处理
- 网络切换:需要确保钱包在正确的网络上
我最初尝试用 Puppeteer,但在处理 MetaMask 扩展时遇到了权限问题。Selenium 虽然配置稍复杂,但可以通过加载已安装扩展的方式直接操作已登录的钱包,这成了我的突破口。
核心实现
1. 环境搭建与浏览器配置
首先,我需要一个已经安装并登录了测试钱包的 Chrome 浏览器环境。我创建了一个专门的 Chrome 用户数据目录,手动登录了所有测试钱包(这是唯一需要手动的一步,但只需做一次)。
const { Builder, By, until } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
class Web3Automator {
constructor() {
this.driver = null;
this.currentWalletIndex = 0;
this.walletCredentials = [
{ privateKey: '0x...', address: '0x...' },
// ... 其他钱包信息
];
}
async initBrowser() {
// 关键:使用已安装MetaMask的用户数据目录
const options = new chrome.Options();
options.addArguments(
`--user-data-dir=/path/to/chrome/profile`,
'--profile-directory=Default', // 使用默认配置文件
'--disable-blink-features=AutomationControlled' // 避免被检测为自动化工具
);
// 这里有个坑:如果直接加载扩展,可能会被DApp检测到并拒绝连接
// 所以我选择使用已经登录了钱包的浏览器环境
this.driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(options)
.build();
// 设置隐式等待,处理Web3页面的动态加载
await this.driver.manage().setTimeouts({ implicit: 10000 });
}
}
2. 处理钱包连接流程
Web3 DApp 的钱包连接流程通常是:点击连接按钮 → 选择钱包类型 → 授权连接。我需要自动化这个过程。
class Web3Automator {
// ... 初始化代码
async connectWallet() {
try {
// 1. 找到并点击连接钱包按钮
// 注意:不同DApp的按钮文本可能不同
const connectButtons = await this.driver.findElements(
By.xpath("//button[contains(., 'Connect') or contains(., '连接') or contains(., 'Connect Wallet')]")
);
if (connectButtons.length > 0) {
await connectButtons[0].click();
// 2. 等待钱包选择弹窗出现
await this.driver.sleep(2000);
// 3. 选择MetaMask(通常是第一个选项)
const walletOptions = await this.driver.findElements(
By.xpath("//div[contains(text(), 'MetaMask') or contains(text(), 'Injected')]")
);
if (walletOptions.length > 0) {
await walletOptions[0].click();
// 4. 处理MetaMask授权弹窗
await this.handleMetaMaskPopup();
console.log('钱包连接成功');
return true;
}
}
return false;
} catch (error) {
console.error('连接钱包失败:', error);
return false;
}
}
async handleMetaMaskPopup() {
// 切换到MetaMask弹窗
const handles = await this.driver.getAllWindowHandles();
if (handles.length > 1) {
// 切换到新窗口(MetaMask弹窗)
await this.driver.switchTo().window(handles[1]);
// 点击连接/下一步按钮
const nextButton = await this.driver.wait(
until.elementLocated(By.xpath("//button[contains(., 'Next') or contains(., 'Connect')]")),
5000
);
await nextButton.click();
// 点击确认/签名按钮
const confirmButton = await this.driver.wait(
until.elementLocated(By.xpath("//button[contains(., 'Confirm') or contains(., 'Sign')]")),
5000
);
await confirmButton.click();
// 切回主窗口
await this.driver.switchTo().window(handles[0]);
await this.driver.sleep(3000); // 等待连接完成
}
}
}
3. 执行交易操作
交易操作是最复杂的部分,因为涉及到 Gas 费设置、交易确认等多个步骤。
class Web3Automator {
// ... 其他代码
async executeTransaction(actionType, params = {}) {
try {
// 根据actionType执行不同的操作
switch (actionType) {
case 'swap':
await this.executeSwap(params);
break;
case 'deposit':
await this.executeDeposit(params);
break;
case 'approve':
await this.executeApprove(params);
break;
}
// 处理交易确认弹窗
await this.handleTransactionConfirmation();
return true;
} catch (error) {
console.error('交易执行失败:', error);
return false;
}
}
async executeSwap(params) {
// 1. 输入兑换金额
const input = await this.driver.wait(
until.elementLocated(By.xpath("//input[@placeholder='0.0' or contains(@class, 'token-amount-input')]")),
5000
);
await input.clear();
await input.sendKeys(params.amount.toString());
// 2. 选择输出代币(如果需要)
if (params.outputToken) {
const tokenSelect = await this.driver.findElement(
By.xpath("//button[contains(@class, 'token-selector')]")
);
await tokenSelect.click();
// 搜索并选择代币
const searchInput = await this.driver.findElement(
By.xpath("//input[@placeholder='Search' or contains(@placeholder, '代币')]")
);
await searchInput.sendKeys(params.outputToken);
const tokenOption = await this.driver.wait(
until.elementLocated(By.xpath(`//div[contains(text(), '${params.outputToken}')]`)),
3000
);
await tokenOption.click();
}
// 3. 点击兑换按钮
const swapButton = await this.driver.wait(
until.elementLocated(By.xpath("//button[contains(., 'Swap') or contains(., '兑换')]")),
5000
);
// 等待汇率计算完成(按钮变为可点击状态)
await this.driver.wait(
until.elementIsEnabled(swapButton),
10000
);
await swapButton.click();
}
async handleTransactionConfirmation() {
// 切换到MetaMask弹窗
const handles = await this.driver.getAllWindowHandles();
if (handles.length > 1) {
await this.driver.switchTo().window(handles[1]);
// 这里有个重要细节:需要处理Gas费设置
// 有些交易可能需要调整Gas费
try {
// 尝试点击"Edit"按钮调整Gas费
const editButton = await this.driver.findElements(
By.xpath("//button[contains(., 'Edit')]")
);
if (editButton.length > 0) {
await editButton[0].click();
// 选择"Advanced"选项
const advancedOption = await this.driver.wait(
until.elementLocated(By.xpath("//div[contains(text(), 'Advanced')]")),
3000
);
await advancedOption.click();
// 设置合适的Gas Limit(避免交易失败)
const gasLimitInput = await this.driver.findElement(
By.xpath("//input[@placeholder='Gas Limit']")
);
await gasLimitInput.clear();
await gasLimitInput.sendKeys('300000'); // 设置一个较高的安全值
// 保存设置
const saveButton = await this.driver.findElement(
By.xpath("//button[contains(., 'Save')]")
);
await saveButton.click();
}
} catch (error) {
// 如果没有Edit按钮,继续正常流程
console.log('无需调整Gas费设置');
}
// 点击确认交易
const confirmButton = await this.driver.wait(
until.elementLocated(By.xpath("//button[contains(., 'Confirm')]")),
5000
);
await confirmButton.click();
// 等待交易提交
await this.driver.sleep(5000);
// 切回主窗口
await this.driver.switchTo().window(handles[0]);
// 等待交易完成(DApp页面显示成功状态)
await this.driver.sleep(10000);
}
}
}
4. 切换钱包账户
由于我需要操作多个钱包,必须在每个任务完成后切换钱包账户。
class Web3Automator {
// ... 其他代码
async switchWalletAccount() {
try {
// 1. 点击钱包地址显示区域(通常显示为缩写地址)
const walletButton = await this.driver.findElements(
By.xpath("//button[contains(@class, 'wallet-address') or contains(text(), '0x')]")
);
if (walletButton.length > 0) {
await walletButton[0].click();
// 2. 点击断开连接
const disconnectButton = await this.driver.wait(
until.elementLocated(By.xpath("//button[contains(., 'Disconnect') or contains(., '断开连接')]")),
3000
);
await disconnectButton.click();
// 3. 切换到下一个钱包(在MetaMask中)
await this.switchMetaMaskAccount();
// 4. 重新连接钱包
await this.driver.sleep(2000);
await this.connectWallet();
this.currentWalletIndex = (this.currentWalletIndex + 1) % this.walletCredentials.length;
console.log(`切换到钱包: ${this.walletCredentials[this.currentWalletIndex].address}`);
return true;
}
return false;
} catch (error) {
console.error('切换钱包失败:', error);
return false;
}
}
async switchMetaMaskAccount() {
const handles = await this.driver.getAllWindowHandles();
if (handles.length > 1) {
await this.driver.switchTo().window(handles[1]);
// 点击账户头像
const accountButton = await this.driver.wait(
until.elementLocated(By.xpath("//button[@data-testid='account-menu-icon']")),
5000
);
await accountButton.click();
// 选择下一个账户
const accounts = await this.driver.findElements(
By.xpath("//div[@data-testid='account-list-item']")
);
if (accounts.length > this.currentWalletIndex + 1) {
await accounts[this.currentWalletIndex + 1].click();
} else {
// 如果已经到最后一个,回到第一个
await accounts[0].click();
}
await this.driver.switchTo().window(handles[0]);
}
}
}
完整代码
const { Builder, By, until } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
class Web3Automator {
constructor(walletCredentials) {
this.driver = null;
this.currentWalletIndex = 0;
this.walletCredentials = walletCredentials;
this.taskQueue = [];
}
async initBrowser() {
const options = new chrome.Options();
options.addArguments(
`--user-data-dir=/path/to/chrome/profile`,
'--profile-directory=Default',
'--disable-blink-features=AutomationControlled',
'--start-maximized' // 最大化窗口,确保所有元素可见
);
this.driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(options)
.build();
await this.driver.manage().setTimeouts({
implicit: 10000,
pageLoad: 30000,
script: 30000
});
console.log('浏览器初始化完成');
}
async navigateTo(url) {
await this.driver.get(url);
console.log(`已导航到: ${url}`);
await this.driver.sleep(5000); // 等待页面完全加载
}
async runTask(task) {
console.log(`开始执行任务: ${task.name}`);
// 导航到任务页面
await this.navigateTo(task.url);
// 连接钱包
const connected = await this.connectWallet();
if (!connected) {
throw new Error('钱包连接失败');
}
// 执行具体操作
for (const action of task.actions) {
console.log(`执行操作: ${action.type}`);
const success = await this.executeTransaction(action.type, action.params);
if (!success) {
throw new Error(`操作 ${action.type} 执行失败`);
}
// 操作间隔,避免请求过于频繁
await this.driver.sleep(3000);
}
console.log(`任务完成: ${task.name}`);
}
async runAllTasks() {
for (let i = 0; i < this.taskQueue.length; i++) {
try {
console.log(`\n=== 开始第 ${i + 1} 个任务 ===`);
await this.runTask(this.taskQueue[i]);
// 如果不是最后一个任务,切换钱包
if (i < this.taskQueue.length - 1) {
console.log('准备切换钱包...');
await this.switchWalletAccount();
await this.driver.sleep(5000); // 等待钱包切换完成
}
} catch (error) {
console.error(`任务 ${i + 1} 执行失败:`, error);
// 继续执行下一个任务
continue;
}
}
console.log('\n=== 所有任务执行完成 ===');
}
async close() {
if (this.driver) {
await this.driver.quit();
console.log('浏览器已关闭');
}
}
// 之前定义的各个方法(connectWallet, handleMetaMaskPopup等)都在这里
// 为了简洁,这里不再重复,实际使用时需要包含所有方法
}
// 使用示例
async function main() {
const walletCredentials = [
{ address: '0x123...', name: 'Wallet 1' },
{ address: '0x456...', name: 'Wallet 2' },
// ... 更多钱包
];
const automator = new Web3Automator(walletCredentials);
try {
await automator.initBrowser();
// 定义任务队列
automator.taskQueue = [
{
name: '在Uniswap兑换ETH为USDC',
url: 'https://app.uniswap.org/swap',
actions: [
{ type: 'swap', params: { amount: 0.01, outputToken: 'USDC' } }
]
},
{
name: '在AAVE存入USDC',
url: 'https://app.aave.com/markets',
actions: [
{ type: 'approve', params: { token: 'USDC' } },
{ type: 'deposit', params: { amount: 10, token: 'USDC' } }
]
}
// ... 更多任务
];
await automator.runAllTasks();
} catch (error) {
console.error('自动化执行失败:', error);
} finally {
await automator.close();
}
}
// 运行主函数
if (require.main === module) {
main().catch(console.error);
}
踩坑记录
在实际开发过程中,我遇到了不少问题,这里记录几个典型的:
-
MetaMask 弹窗无法定位
问题:点击连接钱包后,Selenium 找不到 MetaMask 弹窗。
解决:发现是因为弹窗在新标签页打开,而不是新窗口。我需要先获取所有窗口句柄,然后切换到最新的那个。另外,需要增加足够的等待时间,因为 MetaMask 扩展加载需要时间。 -
交易被检测为机器人
问题:连续执行几个交易后,DApp 开始返回错误,提示疑似机器人操作。
解决:我增加了随机延迟(1-5秒)在每个操作之间,并模拟了人类操作模式(如随机移动鼠标、滚动页面)。最重要的是,我设置了--disable-blink-features=AutomationControlled参数来隐藏自动化特征。 -
Gas 费不足导致交易失败
问题:某些复杂合约交互需要更高的 Gas Limit,默认设置会导致交易失败。
解决:我在handleTransactionConfirmation方法中添加了 Gas Limit 调整逻辑,对于特定类型的交易(如合约授权),自动设置更高的 Gas Limit。 -
页面元素加载时机问题
问题:Web3 页面大量使用动态加载,元素出现时机不确定。
解决:我结合使用了隐式等待(implicit wait)和显式等待(until.elementLocated),并为关键操作添加了重试机制。对于特别不稳定的元素,我使用try-catch包裹,失败后等待更长时间再重试。
小结
通过这个项目,我深刻体会到 Web3 自动化与传统 Web 自动化的不同之处——核心在于钱包交互的处理。最终,我成功用这套方案完成了 200 多个钱包的交互任务,节省了数十小时的手动操作时间。这个方案还可以进一步优化,比如加入代理支持、分布式执行等,但作为一次性任务,当前的实现已经足够可靠。