用 Selenium 自动化空投交互:我如何搞定 Web3 批量填表任务

5 阅读1分钟

背景

上个月,我参与了一个新兴 Layer2 网络的生态激励计划。为了获得潜在的空投资格,需要让我的多个测试钱包在该网络的 DeFi 协议上进行存款、兑换等交互操作。我有大约 200 个钱包地址需要处理,每个地址要完成 3-4 个不同的交互步骤。

最初我尝试手动操作:连接钱包、授权、确认交易……做了不到 10 个就意识到这完全不可行。一个地址完整走完流程大约需要 2-3 分钟,200 个地址就是 6-10 个小时的重复劳动,而且中间难免出错。

我需要一个自动化方案。虽然这些 DeFi 协议都有 API,但空投规则明确要求“通过前端界面交互”,后台调用 API 可能不会被计入。所以,我必须模拟真实的浏览器操作——这就是我选择 Selenium 的原因。

问题分析

我的第一反应是:“这不就是普通的表单自动化吗?”但实际开始后,我发现 Web3 前端有几个特殊之处:

  1. 钱包连接不是普通表单:点击“连接钱包”后,会弹出 MetaMask 窗口(或类似的钱包扩展),这不是普通的浏览器弹窗
  2. 交易确认需要签名:授权、交易等操作需要用户在钱包中点击确认,这个确认界面在扩展内
  3. Gas 费动态变化:每次交易的 Gas 费都可能不同,需要动态处理
  4. 网络切换:需要确保钱包在正确的网络上

我最初尝试用 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);
}

踩坑记录

在实际开发过程中,我遇到了不少问题,这里记录几个典型的:

  1. MetaMask 弹窗无法定位
    问题:点击连接钱包后,Selenium 找不到 MetaMask 弹窗。
    解决:发现是因为弹窗在新标签页打开,而不是新窗口。我需要先获取所有窗口句柄,然后切换到最新的那个。另外,需要增加足够的等待时间,因为 MetaMask 扩展加载需要时间。

  2. 交易被检测为机器人
    问题:连续执行几个交易后,DApp 开始返回错误,提示疑似机器人操作。
    解决:我增加了随机延迟(1-5秒)在每个操作之间,并模拟了人类操作模式(如随机移动鼠标、滚动页面)。最重要的是,我设置了 --disable-blink-features=AutomationControlled 参数来隐藏自动化特征。

  3. Gas 费不足导致交易失败
    问题:某些复杂合约交互需要更高的 Gas Limit,默认设置会导致交易失败。
    解决:我在 handleTransactionConfirmation 方法中添加了 Gas Limit 调整逻辑,对于特定类型的交易(如合约授权),自动设置更高的 Gas Limit。

  4. 页面元素加载时机问题
    问题:Web3 页面大量使用动态加载,元素出现时机不确定。
    解决:我结合使用了隐式等待(implicit wait)和显式等待(until.elementLocated),并为关键操作添加了重试机制。对于特别不稳定的元素,我使用 try-catch 包裹,失败后等待更长时间再重试。

小结

通过这个项目,我深刻体会到 Web3 自动化与传统 Web 自动化的不同之处——核心在于钱包交互的处理。最终,我成功用这套方案完成了 200 多个钱包的交互任务,节省了数十小时的手动操作时间。这个方案还可以进一步优化,比如加入代理支持、分布式执行等,但作为一次性任务,当前的实现已经足够可靠。