Python~使用playwright操控Metamask实现自动签名授权

2025 年 9 月 7 日 星期日(已编辑)
/
6
摘要
像使用真实浏览器一样替代繁琐的人工操控,制作浏览器自动化脚本之旅

Python~使用playwright操控Metamask实现自动签名授权

前言

在区块链开发和测试过程中,我们经常需要频繁地与 MetaMask 钱包进行交互。手动操作不仅效率低下,还容易出错。本教程将教你如何使用 Python 和 Playwright 来自动化操控 MetaMask 钱包,实现连接钱包、签名交易等常见操作。

核心技术栈:

  • Python 3.x
  • Playwright(浏览器自动化库)
  • Chrome 浏览器(调试模式)
  • MetaMask 浏览器扩展

重要说明:

  1. 本教程使用 Chrome 调试模式 + Playwright CDP 连接
  2. 不是使用 ChromeDriver 或 Selenium
  3. 即使使用调试模式,Selenium 也会被 MetaMask 的 LavaMoat 检测,而 Playwright 不会

为什么不用 Selenium?

很多人以为只要用了 Chrome 调试模式就能避免检测,但实际上:

  • ❌ Selenium 连接调试端口 → 仍然会注入 navigator.webdriver = true → 被 LavaMoat 阻止
  • ✅ Playwright CDP 连接 → 不注入任何标识 → 完全隐形

这是我踩过的坑,希望能帮到你!


一、为什么不使用 Selenium?遭遇 LavaMoat 的困境

1.1 我最初的尝试:Selenium + Chrome 调试模式

在开始这个项目时,我最初使用的是 Selenium 连接到 Chrome 调试模式。代码看起来很简单:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

# 连接到已经启动的Chrome调试端口
options = Options()
options.add_experimental_option("debuggerAddress", "127.0.0.1:9222")
driver = webdriver.Chrome(options=options)

# 打开MetaMask
driver.get("chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/home.html")

然而,当我尝试操作 MetaMask 时,遇到了一个棘手的问题:LavaMoat 防护机制

1.2 什么是 LavaMoat?

LavaMoat 是 MetaMask 团队开发的一个安全工具,用于保护浏览器扩展免受供应链攻击。它的工作原理是:

  1. 运行时隔离:为第三方依赖创建安全沙箱
  2. 权限最小化:限制代码可以访问的 API
  3. 自动化检测:识别并阻止可疑的自动化行为

从 MetaMask v10.x 版本开始,LavaMoat 成为了默认启用的安全特性。

1.3 LavaMoat 如何检测 Selenium?

这里是关键点:即使使用了 Chrome 调试模式,Selenium 连接后仍然会在浏览器中注入自动化标识!

当使用 Selenium 连接到调试端口时,LavaMoat 会检测到以下特征:

// 在浏览器控制台检查
console.log(navigator.webdriver);  
// Selenium: true ❌
// Playwright CDP: undefined ✅

// MetaMask 内部检测代码(简化版)
if (window.navigator.webdriver === true) {
    console.error('检测到自动化工具,LavaMoat 阻止执行');
    throw new Error('Automation detected');
}

// 其他检测点
if (window.document.documentElement.getAttribute('webdriver')) {
    // Selenium 会在 DOM 中添加 webdriver 属性
}

这就是问题所在

  • 使用 Selenium 连接调试端口:navigator.webdriver = true
  • 使用 Playwright CDP 连接:navigator.webdriver = undefined

1.4 我遇到的实际错误

使用 Selenium 操作 MetaMask 时,会看到类似这样的错误:

LavaMoat - Suspicious activity detected
Error: Execution context was destroyed
TypeError: Cannot read properties of undefined

在浏览器控制台中,可以看到:

LavaMoat Preinstall Hook - instrumentation active
[LavaMoat] Potentially unsafe call detected

1.5 尝试过的解决方案(都失败了)

我尝试了多种方法来解决 Selenium 的 navigator.webdriver 问题,但都不理想:

❌ 方法一:使用 CDP 命令隐藏 webdriver 属性

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_experimental_option("debuggerAddress", "127.0.0.1:9222")
driver = webdriver.Chrome(options=options)

# 尝试删除 webdriver 属性
driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
    'source': '''
        Object.defineProperty(navigator, 'webdriver', {
            get: () => undefined
        })
    '''
})

结果:这个脚本会在新文档加载时执行,但 Selenium 会在更早的时机注入 webdriver 属性,所以这个方法无效。而且 LavaMoat 还会检测其他特征(如 DOM 属性)。

❌ 方法二:使用 undetected-chromedriver

import undetected_chromedriver as uc

options = uc.ChromeOptions()
options.debugger_address = "127.0.0.1:9222"
driver = uc.Chrome(options=options)

结果:undetected-chromedriver 对普通网站的反爬检测有效,但它本质上还是 Selenium,仍然会设置 navigator.webdriver = true,MetaMask 的 LavaMoat 依然能检测到。

❌ 方法三:修改 Selenium 源码

理论上可以修改 Selenium 源码,移除 webdriver 标识的注入,但这:

  • 需要深入了解 Selenium 内部机制
  • 每次更新都要重新修改
  • 维护成本极高
  • 可能破坏其他功能

❌ 方法四:使用旧版 MetaMask(不推荐)

使用 v10.x 之前的 MetaMask 版本(没有 LavaMoat),但这:

  • 存在安全风险
  • 无法使用最新功能
  • 与新版 DApp 可能不兼容
  • 不是长期解决方案

1.6 最终解决方案:从 Selenium 切换到 Playwright

经过深入研究,我发现关键不在于是否使用调试模式(我一直都在用),而在于用什么工具连接到调试端口

使用 Playwright 的 CDP 连接可以完美绕过 LavaMoat 的检测:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    # Playwright 通过 CDP 连接,不会注入 webdriver 标识
    browser = p.chromium.connect_over_cdp("http://localhost:9222")
    context = browser.contexts[0]
    page = context.pages[0]
    
    # 在浏览器控制台验证
    webdriver_value = page.evaluate("() => navigator.webdriver")
    print(webdriver_value)  # 输出: None (即 undefined) ✅

核心差异:

  1. Selenium 连接调试端口

    • 使用 options.add_experimental_option("debuggerAddress", "...")
    • 仍然会注入 navigator.webdriver = true
    • 仍然会在 DOM 中添加自动化标识
    • 被 LavaMoat 检测
  2. Playwright CDP 连接

    • 使用 connect_over_cdp()
    • 不会修改任何浏览器属性
    • 完全像正常用户在操作浏览器
    • LavaMoat 无法检测
  3. 为什么 Playwright 不会被检测?

    • Playwright 的 CDP 连接是纯协议通信
    • 不会像 Selenium 那样在浏览器中注入脚本
    • 所有操作都通过 Chrome DevTools Protocol 完成
    • 浏览器环境保持完全"干净"

1.7 Selenium vs Playwright 连接调试模式对比

两种方案都使用 Chrome 调试模式,区别在于连接方式

特性 Selenium + 调试端口 Playwright CDP
连接方式 debuggerAddress connect_over_cdp()
LavaMoat 兼容性 ❌ 被检测和阻止 ✅ 完全兼容
navigator.webdriver true undefined
DOM 注入 ❌ 有自动化标识 ✅ 无任何注入
扩展支持 ✅ 支持 ✅ 完美支持
状态持久化 ✅ 支持 ✅ 支持
学习曲线 中等
性能 中等 优秀
社区支持 成熟 快速增长

结论:即使都用调试模式,Selenium 的连接方式会暴露自动化特征,而 Playwright 则完全隐形。

1.8 如何验证差异?(实验对比)

你可以自己验证这个差异:

实验 1:使用 Selenium 连接调试端口

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_experimental_option("debuggerAddress", "127.0.0.1:9222")
driver = webdriver.Chrome(options=options)

# 检查 webdriver 属性
result = driver.execute_script("return navigator.webdriver")
print(f"Selenium: navigator.webdriver = {result}")  # 输出: True ❌

实验 2:使用 Playwright CDP 连接

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp("http://localhost:9222")
    context = browser.contexts[0]
    page = context.new_page()
    
    # 检查 webdriver 属性
    result = page.evaluate("() => navigator.webdriver")
    print(f"Playwright: navigator.webdriver = {result}")  # 输出: None (undefined) ✅

实验 3:在 MetaMask 中测试

启动调试模式后,分别用两种方式打开 MetaMask:

# 方式1:Selenium
driver.get("chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/home.html")
# 结果:可能看到 LavaMoat 警告或功能异常

# 方式2:Playwright
page.goto("chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/home.html")
# 结果:完全正常工作

通过这些实验,你可以清楚地看到两种方式的本质差异。


二、为什么选择 Playwright + Chrome 调试模式?

基于上述 LavaMoat 的问题,我选择了 Playwright + Chrome 调试模式方案。这个方案的核心优势:

2.1 技术优势

  1. 无需额外驱动:直接使用系统安装的 Chrome
  2. 完美支持扩展:可以加载和操作任何浏览器扩展
  3. 状态持久化:可以复用已登录的 MetaMask 账户
  4. 绕过 LavaMoat:不会被识别为自动化工具
  5. 更加灵活:通过 CDP 协议可以实现更多高级功能

2.2 实战验证

使用这个方案后:

# 使用 CDP 连接,LavaMoat 检测结果:
# navigator.webdriver = undefined  ✅
# 正常的浏览器环境  ✅
# MetaMask 正常工作  ✅

三、核心概念:CDP(Chrome DevTools Protocol)

3.1 什么是 CDP?

CDP(Chrome DevTools Protocol) 是 Chrome 浏览器提供的一套底层通信协议,允许外部程序通过 WebSocket 与浏览器进行通信和控制。

通俗理解:

  • CDP 就像是 Chrome 浏览器的"遥控器接口"
  • 开发者工具(F12)就是通过 CDP 与浏览器通信的
  • 我们的自动化脚本也可以用同样的协议控制浏览器

CDP 的特点:

  • 🔧 底层协议:基于 WebSocket 的 JSON-RPC 通信
  • 🎯 功能强大:可以控制页面、网络、性能监控等
  • 🔒 原生支持:Chrome/Chromium 内置,无需额外驱动
  • 🌐 标准化:W3C 标准的一部分

3.2 Selenium 能用 CDP 吗?

能,但有区别! 这是个关键点:

Selenium 使用 CDP 的两种方式:

方式1:使用 CDP 命令(execute_cdp_cmd

from selenium import webdriver

driver = webdriver.Chrome()
# Selenium 可以执行 CDP 命令
driver.execute_cdp_cmd('Network.enable', {})

方式2:连接到调试端口

options = webdriver.ChromeOptions()
options.add_experimental_option("debuggerAddress", "127.0.0.1:9222")
driver = webdriver.Chrome(options=options)

核心问题:Selenium 的连接机制

即使 Selenium 能用 CDP 命令,但它的连接机制有根本缺陷:

┌─────────────────────────────────────────────┐
│         Selenium 的连接过程                   │
├─────────────────────────────────────────────┤
│ 1. ChromeDriver 启动或连接到 Chrome         │
│ 2. 通过 WebDriver 协议建立连接               │
│ 3. ⚠️  自动注入 navigator.webdriver = true  │
│ 4. ⚠️  在 DOM 中添加 webdriver 属性         │
│ 5. 然后才能使用 CDP 命令                    │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│      Playwright CDP 的连接过程                │
├─────────────────────────────────────────────┤
│ 1. 直接通过 CDP 协议连接到调试端口           │
│ 2. ✅ 不注入任何 webdriver 标识              │
│ 3. ✅ 不修改任何浏览器属性                   │
│ 4. 完全像普通用户在操作浏览器                │
└─────────────────────────────────────────────┘

技术原理对比

Selenium 的问题:

# Selenium 即使连接调试端口,也会:
options.add_experimental_option("debuggerAddress", "127.0.0.1:9222")
driver = webdriver.Chrome(options=options)

# 在浏览器中检查:
driver.execute_script("return navigator.webdriver")  # → True ❌

# 为什么?因为 Selenium 的底层是 WebDriver 协议
# WebDriver 规范要求设置 navigator.webdriver = true
# 这是写在规范里的,无法避免!

Playwright 的优势:

# Playwright 纯 CDP 连接:
browser = p.chromium.connect_over_cdp("http://localhost:9222")
page = context.pages[0]

# 在浏览器中检查:
page.evaluate("() => navigator.webdriver")  # → None (undefined) ✅

# 为什么?因为 Playwright 不走 WebDriver 协议
# 直接使用 CDP 协议,无需注入任何标识

3.3 为什么 Selenium 必须注入 webdriver?

这是 W3C WebDriver 标准规定的:

The webdriver-active flag is set to true when the 
user agent is under remote control which is 
signalled by defining the "webdriver" property on 
the Navigator object to true.

翻译:当浏览器处于远程控制时,必须将 navigator.webdriver 设置为 true

这是为了:

  1. 透明性:网站有权知道用户是否在使用自动化工具
  2. 安全性:防止恶意自动化攻击
  3. 标准化:所有 WebDriver 实现必须遵守

所以 Selenium 无法绕过这个限制,因为它遵循 WebDriver 标准。

3.4 为什么 Playwright 不受限制?

Playwright 不使用 WebDriver 协议,而是直接使用 CDP:

WebDriver 协议:
Browser ←→ ChromeDriver ←→ Selenium ←→ Your Code
          (必须注入标识)

CDP 协议:
Browser ←→ Playwright ←→ Your Code
     (纯协议通信,无注入)

类比说明:

  • Selenium + WebDriver:像是租用了一辆有"教练车"标识的车,交警一眼就能看出
  • Playwright + CDP:像是远程控制你自己的私家车,外观和正常车完全一样

3.5 Chrome 调试模式是什么?

Chrome 调试模式(Remote Debugging)就是启用 CDP 服务的方式:

# 启动 Chrome 并开启 CDP 服务
chrome --remote-debugging-port=9222

启用后,Chrome 会:

  1. 在 9222 端口开启 WebSocket 服务
  2. 接受 CDP 协议的连接
  3. 允许外部程序通过 CDP 控制浏览器

两种工具都可以连接这个端口,但连接方式不同:

  • Selenium:先建立 WebDriver 连接(注入标识),再使用 CDP 命令
  • Playwright:直接建立 CDP 连接(无任何注入)

3.6 总结:为什么选择 Playwright?

对比项 Selenium Playwright
底层协议 WebDriver + CDP 纯 CDP
能用 CDP 命令吗? ✅ 能 ✅ 能
navigator.webdriver ❌ 必须为 true ✅ undefined
是否注入标识 ❌ 是(协议规定) ✅ 否
LavaMoat 检测 ❌ 会被检测 ✅ 无法检测
适合 MetaMask ❌ 不适合 ✅ 完美

结论:Selenium 能用 CDP 命令,但无法避免 WebDriver 协议的注入问题。只有完全基于 CDP 的工具(如 Playwright)才能真正"隐形"。


四、Chrome 调试模式详解

4.1 如何启动 Chrome 调试模式?

macOS 系统

方法一:命令行启动

打开终端,执行以下命令:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --user-data-dir=~/chrome-debug-profile \
  --no-first-run \
  --no-default-browser-check

方法二:创建启动脚本

创建一个 start-chrome-debug.sh 文件:

#!/bin/bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --user-data-dir=~/chrome-debug-profile \
  --no-first-run \
  --no-default-browser-check \
  --disable-default-apps

给予执行权限并运行:

chmod +x start-chrome-debug.sh
./start-chrome-debug.sh

Windows 系统

方法一:命令行启动

打开命令提示符(CMD),执行:

"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
  --remote-debugging-port=9222 ^
  --user-data-dir="%USERPROFILE%\chrome-debug-profile"

方法二:创建快捷方式

  1. 右键点击 Chrome 快捷方式,选择"属性"
  2. 在"目标"字段后添加参数: "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\chrome-debug-profile"
  3. 点击"确定"保存

Linux 系统

打开终端,执行:

google-chrome \
  --remote-debugging-port=9222 \
  --user-data-dir=~/chrome-debug-profile \
  --no-first-run \
  --no-default-browser-check

4.2 命令行参数详解

  • --remote-debugging-port=9222:指定调试端口,可以改为其他未占用端口
  • --user-data-dir:指定用户数据目录,用于保存浏览器状态(包括扩展、登录信息等)
  • --no-first-run:跳过首次运行向导
  • --no-default-browser-check:不检查是否为默认浏览器
  • --disable-default-apps:禁用默认应用

4.3 验证调试模式是否启动成功

启动 Chrome 后,在浏览器中访问:

http://localhost:9222/json

如果能看到 JSON 格式的页面信息,说明调试模式已成功启动。


五、环境准备

5.1 安装依赖

创建项目目录并设置虚拟环境:

# 创建项目目录
mkdir metamask-automation
cd metamask-automation

# 创建虚拟环境
python3 -m venv .venv

# 激活虚拟环境
# macOS/Linux:
source .venv/bin/activate
# Windows:
.venv\Scripts\activate

# 安装依赖
pip install playwright python-dotenv

5.2 安装 Playwright 浏览器

playwright install chromium

5.3 安装 MetaMask 扩展

  1. 启动 Chrome 调试模式(使用上述方法)
  2. 访问 MetaMask 官网 或 Chrome 应用商店
  3. 安装 MetaMask 扩展
  4. 完成钱包的初始化设置(创建或导入钱包)
  5. 重要:记住你的 MetaMask 密码,后续自动化会用到

5.4 获取 MetaMask 扩展 ID

安装完 MetaMask 后,需要获取其扩展 ID:

  1. 在 Chrome 地址栏输入:chrome://extensions/
  2. 找到 MetaMask 扩展
  3. 扩展 ID 通常是:nkbihfbeogaeaoehlefnkodbefgpgknn(官方版本的固定ID)

六、代码实现

6.1 项目结构

metamask-automation/
├── .env                    # 环境变量配置
├── .venv/                  # 虚拟环境
├── chrome/
│   ├── __init__.py
│   ├── launch.py          # 浏览器启动模块
│   └── metamask.py        # MetaMask 操作模块
├── util/
│   ├── __init__.py
│   └── logger.py          # 日志模块
├── config.py              # 配置文件
└── main.py                # 主程序

6.2 创建配置文件

config.py

import os
from pathlib import Path
from dataclasses import dataclass
from dotenv import load_dotenv

load_dotenv()

@dataclass
class ChromeConfig:
    """Chrome 浏览器配置"""
    metamask_password: str
    debug_port: int = 9222
    debug_dir: str = Path(__file__).resolve().parent / "chrome/chrome_debug"

class Config:
    def __init__(self):
        # 从环境变量读取 MetaMask 密码
        pwd = os.getenv("METAMASK_PASSWORD")
        if not pwd:
            raise ValueError("请在 .env 文件中设置 METAMASK_PASSWORD")
        
        self.chrome = ChromeConfig(metamask_password=pwd)

.env

# MetaMask 钱包密码
METAMASK_PASSWORD=your_metamask_password_here

6.3 实现浏览器启动模块

util/logger.py

import logging
import os
from pathlib import Path

class Logger:
    """日志工具类"""
    
    @staticmethod
    def get_logger(name: str):
        """获取 logger 实例"""
        # 创建 logs 目录
        log_dir = Path(__file__).resolve().parent.parent / "logs"
        log_dir.mkdir(exist_ok=True)
        
        # 配置日志
        logger = logging.getLogger(name)
        logger.setLevel(logging.INFO)
        
        # 控制台输出
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.INFO)
        
        # 文件输出
        file_handler = logging.FileHandler(
            log_dir / "app.log", 
            encoding='utf-8'
        )
        file_handler.setLevel(logging.INFO)
        
        # 设置格式
        formatter = logging.Formatter(
            '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
        )
        console_handler.setFormatter(formatter)
        file_handler.setFormatter(formatter)
        
        logger.addHandler(console_handler)
        logger.addHandler(file_handler)
        
        return logger

chrome/launch.py

import os
import time
import socket
import subprocess
import platform
import shutil
from util.logger import Logger

logger = Logger.get_logger(__name__)

class LaunchBrowser:
    """浏览器启动类 - 负责启动 Chrome 调试模式"""
    
    def __init__(self, debug_dir: str, debug_port: int = 9222):
        """
        初始化浏览器启动器
        
        Args:
            debug_dir: Chrome 用户数据目录
            debug_port: 调试端口
        """
        self.debug_dir = debug_dir
        self.debug_port = debug_port
        self.chrome_process = None
    
    def get_platform(self):
        """获取当前操作系统平台"""
        system_name = platform.system().lower()
        
        if system_name == "windows":
            return "windows"
        elif system_name == "darwin":
            return "macos"
        elif system_name == "linux":
            return "linux"
        else:
            return "unknown"
    
    def launch_browser(self):
        """启动浏览器(如果调试端口已开启则跳过)"""
        # 检查端口是否已经开放
        if self._is_debug_port_open():
            logger.info(f"Chrome 调试端口 {self.debug_port} 已开启,跳过启动")
            return
        
        # 端口未开放,启动新的 Chrome
        logger.info(f"在端口 {self.debug_port} 启动新的 Chrome 实例")
        command = self._get_launch_command()
        self._execute_command(command)
        
        # 等待 Chrome 启动
        if not self._wait_for_debug_port(timeout=30):
            raise Exception(f"Chrome 在端口 {self.debug_port} 启动失败")
        
        logger.info(f"Chrome 在端口 {self.debug_port} 成功启动")
    
    def _is_debug_port_open(self):
        """检查调试端口是否已经开放"""
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(1)
            result = sock.connect_ex(('127.0.0.1', self.debug_port))
            sock.close()
            return result == 0
        except Exception:
            return False
    
    def _wait_for_debug_port(self, timeout=30):
        """等待调试端口开放"""
        logger.info(f"等待 Chrome 调试端口 {self.debug_port}...")
        start_time = time.time()
        
        while time.time() - start_time < timeout:
            if self._is_debug_port_open():
                time.sleep(2)  # 端口开放后再等待一会,确保完全启动
                return True
            time.sleep(1)
        
        return False
    
    def _get_launch_command(self):
        """获取启动命令(根据不同平台)"""
        platform_name = self.get_platform()
        
        # 确保用户数据目录存在
        self._prepare_user_data_dir()
        
        if platform_name == "macos":
            return [
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                f"--remote-debugging-port={self.debug_port}",
                f"--user-data-dir={self.debug_dir}",
                "--no-first-run",
                "--no-default-browser-check",
                "--disable-default-apps",
            ]
        elif platform_name == "windows":
            chrome_paths = [
                r"C:\Program Files\Google\Chrome\Application\chrome.exe",
                r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
            ]
            chrome_path = None
            for path in chrome_paths:
                if os.path.exists(path):
                    chrome_path = path
                    break
            
            if not chrome_path:
                raise Exception("未找到 Chrome 浏览器")
            
            return [
                chrome_path,
                f"--remote-debugging-port={self.debug_port}",
                f"--user-data-dir={self.debug_dir}",
                "--no-first-run",
                "--no-default-browser-check",
            ]
        elif platform_name == "linux":
            return [
                "google-chrome",
                f"--remote-debugging-port={self.debug_port}",
                f"--user-data-dir={self.debug_dir}",
                "--no-first-run",
                "--no-default-browser-check",
            ]
        else:
            raise NotImplementedError(f"不支持的平台: {platform_name}")
    
    def _execute_command(self, command):
        """执行启动命令"""
        platform_name = self.get_platform()
        
        if platform_name == "macos" or platform_name == "linux":
            # macOS 和 Linux 使用 subprocess.Popen
            self.chrome_process = subprocess.Popen(
                command, 
                stdout=subprocess.DEVNULL, 
                stderr=subprocess.DEVNULL
            )
        else:
            # Windows
            self.chrome_process = subprocess.Popen(
                command,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL
            )
    
    def _prepare_user_data_dir(self):
        """准备用户数据目录"""
        # 如果目录不存在,尝试从默认 Chrome 目录复制
        if not os.path.exists(self.debug_dir):
            logger.info("首次运行,准备用户数据目录...")
            self._copy_user_data_dir()
        else:
            logger.info(f"使用现有用户数据目录: {self.debug_dir}")
    
    def _copy_user_data_dir(self):
        """从默认 Chrome 目录复制用户数据"""
        platform_name = self.get_platform()
        
        # 根据平台确定源目录
        if platform_name == "macos":
            source_dir = os.path.join(
                os.path.expanduser("~"),
                "Library",
                "Application Support",
                "Google",
                "Chrome",
            )
        elif platform_name == "windows":
            source_dir = os.path.join(
                os.path.expanduser("~"),
                "AppData",
                "Local",
                "Google",
                "Chrome",
                "User Data",
            )
        elif platform_name == "linux":
            source_dir = os.path.join(
                os.path.expanduser("~"), 
                ".config", 
                "google-chrome"
            )
        else:
            raise NotImplementedError(f"不支持的平台: {platform_name}")
        
        try:
            if os.path.exists(source_dir):
                shutil.copytree(source_dir, self.debug_dir, dirs_exist_ok=False)
                logger.info(f"已复制 Chrome 用户数据到: {self.debug_dir}")
            else:
                # 如果源目录不存在,创建一个空目录
                os.makedirs(self.debug_dir, exist_ok=True)
                logger.warning("未找到默认 Chrome 目录,创建新的用户数据目录")
        except FileExistsError:
            logger.warning(f"用户数据目录已存在: {self.debug_dir}")
        except Exception as e:
            logger.error(f"复制用户数据目录时出错: {e}")
            os.makedirs(self.debug_dir, exist_ok=True)
    
    def close_browser(self):
        """关闭浏览器进程"""
        if self.chrome_process:
            self.chrome_process.terminate()
            time.sleep(1)
            if self.chrome_process.poll() is None:
                self.chrome_process.kill()

6.4 实现 MetaMask 操作模块

chrome/metamask.py

from time import sleep
from playwright.sync_api import BrowserContext, Page
from util.logger import Logger

logger = Logger.get_logger(__name__)

class MetaMask:
    """MetaMask 钱包操作类"""
    
    def __init__(self, context: BrowserContext, password: str):
        """
        初始化 MetaMask 操作类
        
        Args:
            context: Playwright 浏览器上下文
            password: MetaMask 钱包密码
        """
        self.context = context
        self.password = password
        # MetaMask 扩展的 URL(官方扩展的固定 ID)
        self.url = "chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/home.html"
    
    def _open_metamask_page(self) -> Page:
        """打开 MetaMask 页面"""
        logger.info("打开 MetaMask 扩展页面")
        page = self.context.new_page()
        page.goto(self.url)
        return page
    
    def _unlock_metamask(self, page: Page):
        """
        解锁 MetaMask 钱包
        
        如果 MetaMask 处于锁定状态,输入密码解锁
        """
        sleep(1)
        
        # 查找密码输入框
        password_input = page.locator('input[type="password"]')
        
        # 如果找到密码输入框,说明需要解锁
        if password_input.count() > 0:
            logger.info("MetaMask 处于锁定状态,正在解锁...")
            password_input.fill(self.password)
            page.get_by_test_id("unlock-submit").click()
            sleep(2)
            logger.info("MetaMask 解锁成功")
        else:
            logger.info("MetaMask 已处于解锁状态")
    
    def connect_wallet(self):
        """
        连接钱包到网站
        
        用于处理网站的"连接钱包"请求
        当网站请求连接 MetaMask 时,会弹出一个新窗口
        本方法自动点击"连接"按钮完成授权
        """
        logger.info("开始连接 MetaMask 钱包...")
        
        page = self._open_metamask_page()
        self._unlock_metamask(page)
        
        # 等待连接按钮出现并点击
        try:
            page.wait_for_selector('button[data-testid="confirm-btn"]', timeout=5000)
            page.get_by_test_id("confirm-btn").click()
            logger.info("已点击连接按钮")
            sleep(1)
        except Exception as e:
            logger.warning(f"未找到连接按钮: {e}")
        
        # 关闭 MetaMask 页面
        page.close()
        logger.info("MetaMask 钱包连接完成")
    
    def sign_transaction(self):
        """
        签名交易
        
        当网站请求签名交易时,MetaMask 会弹出确认窗口
        本方法自动点击"确认"按钮完成签名
        """
        logger.info("开始签名交易...")
        
        page = self._open_metamask_page()
        self._unlock_metamask(page)
        
        # 刷新页面以加载最新的交易请求
        page.reload()
        
        # 等待确认按钮出现并点击
        try:
            page.wait_for_selector(
                'button[data-testid="confirm-footer-button"]', 
                timeout=10000
            )
            page.get_by_test_id("confirm-footer-button").click()
            logger.info("已确认交易签名")
            sleep(2)
        except Exception as e:
            logger.error(f"签名交易失败: {e}")
            raise
        
        # 关闭 MetaMask 页面
        page.close()
        logger.info("交易签名完成")
    
    def get_account_address(self) -> str:
        """
        获取当前账户地址
        
        Returns:
            当前账户的以太坊地址
        """
        logger.info("获取账户地址...")
        
        page = self._open_metamask_page()
        self._unlock_metamask(page)
        
        try:
            # 点击账户菜单
            page.click('[data-testid="account-menu-icon"]')
            sleep(1)
            
            # 获取账户地址
            address_element = page.locator('[data-testid="address-copy-button-text"]')
            address = address_element.inner_text()
            
            logger.info(f"当前账户地址: {address}")
            page.close()
            return address
        except Exception as e:
            logger.error(f"获取账户地址失败: {e}")
            page.close()
            raise
    
    def switch_network(self, network_name: str):
        """
        切换网络
        
        Args:
            network_name: 网络名称,如 "Ethereum Mainnet", "Sepolia" 等
        """
        logger.info(f"切换到网络: {network_name}")
        
        page = self._open_metamask_page()
        self._unlock_metamask(page)
        
        try:
            # 点击网络切换按钮
            page.click('[data-testid="network-display"]')
            sleep(1)
            
            # 选择目标网络
            page.click(f'text="{network_name}"')
            sleep(2)
            
            logger.info(f"已切换到网络: {network_name}")
        except Exception as e:
            logger.error(f"切换网络失败: {e}")
            raise
        finally:
            page.close()

6.5 实现主程序

main.py

from playwright.sync_api import sync_playwright
from chrome.launch import LaunchBrowser
from chrome.metamask import MetaMask
from config import Config
from util.logger import Logger

logger = Logger.get_logger(__name__)

class MetaMaskAutomation:
    """MetaMask 自动化主类"""
    
    def __init__(self, config: Config):
        self.config = config
        self.launcher = LaunchBrowser(
            config.chrome.debug_dir, 
            config.chrome.debug_port
        )
        self.metamask = None
        self.context = None
        self.page = None
    
    def run(self):
        """运行自动化任务"""
        try:
            # 1. 启动 Chrome 调试模式
            logger.info("=" * 60)
            logger.info("步骤 1: 启动 Chrome 调试模式")
            logger.info("=" * 60)
            self.launcher.launch_browser()
            
            # 2. 使用 Playwright 连接到 Chrome
            logger.info("=" * 60)
            logger.info("步骤 2: 连接到 Chrome 浏览器")
            logger.info("=" * 60)
            
            with sync_playwright() as p:
                logger.info(
                    f"正在连接到 http://localhost:{self.config.chrome.debug_port}"
                )
                browser = p.chromium.connect_over_cdp(
                    f"http://localhost:{self.config.chrome.debug_port}",
                    timeout=30000
                )
                logger.info("成功连接到浏览器")
                
                # 获取浏览器上下文
                contexts = browser.contexts
                if not contexts:
                    raise Exception("没有可用的浏览器上下文")
                
                self.context = contexts[0]
                
                # 3. 初始化 MetaMask 操作对象
                logger.info("=" * 60)
                logger.info("步骤 3: 初始化 MetaMask")
                logger.info("=" * 60)
                
                self.metamask = MetaMask(
                    self.context,
                    self.config.chrome.metamask_password
                )
                
                # 4. 执行自动化任务
                self.execute_tasks()
                
                # 保持浏览器打开,等待用户操作
                logger.info("=" * 60)
                logger.info("自动化任务完成!")
                logger.info("浏览器将保持打开状态,按 Ctrl+C 退出")
                logger.info("=" * 60)
                
                input()  # 等待用户按回车键
                
        except KeyboardInterrupt:
            logger.info("\n用户中断程序")
        except Exception as e:
            logger.error(f"发生错误: {e}")
            raise
    
    def execute_tasks(self):
        """执行具体的自动化任务"""
        logger.info("=" * 60)
        logger.info("开始执行自动化任务")
        logger.info("=" * 60)
        
        # 示例任务 1: 获取账户地址
        try:
            address = self.metamask.get_account_address()
            logger.info(f"✓ 成功获取账户地址: {address}")
        except Exception as e:
            logger.error(f"✗ 获取账户地址失败: {e}")
        
        # 示例任务 2: 打开一个 DApp 网站
        logger.info("\n打开示例网站...")
        if len(self.context.pages) == 0:
            self.page = self.context.new_page()
        else:
            self.page = self.context.pages[-1]
        
        # 你可以在这里打开任何需要连接 MetaMask 的网站
        # 例如:self.page.goto("https://app.uniswap.org")
        self.page.goto("https://metamask.io/")
        logger.info("✓ 已打开示例网站")
        
        # 示例任务 3: 如果需要连接钱包,可以调用
        # self.metamask.connect_wallet()
        
        # 示例任务 4: 如果需要签名交易,可以调用
        # self.metamask.sign_transaction()

def main():
    """主函数"""
    try:
        # 加载配置
        config = Config()
        
        # 创建自动化实例
        automation = MetaMaskAutomation(config)
        
        # 运行自动化任务
        automation.run()
        
    except Exception as e:
        logger.error(f"程序异常退出: {e}")
        raise

if __name__ == "__main__":
    main()

七、使用说明

7.1 首次运行准备

  1. 安装 MetaMask 扩展

    首先需要手动启动 Chrome 调试模式并安装 MetaMask:

    # macOS
    /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
      --remote-debugging-port=9222 \
      --user-data-dir=~/chrome-debug-profile
    

    然后在浏览器中:

    • 访问 Chrome 应用商店
    • 搜索并安装 MetaMask
    • 完成钱包初始化(创建或导入)
    • 记住密码
  2. 配置环境变量

    创建 .env 文件:

    METAMASK_PASSWORD=你的MetaMask密码
    
  3. 安装依赖

    pip install -r requirements.txt
    playwright install chromium
    

7.2 运行程序

python main.py

7.3 自定义任务

main.pyexecute_tasks 方法中,你可以:

  1. 打开任何 Web3 网站

    self.page.goto("https://app.uniswap.org")
    
  2. 等待并点击"连接钱包"按钮

    # 等待页面上的连接按钮
    connect_btn = self.page.locator('button:has-text("Connect Wallet")')
    connect_btn.click()
    
    # 选择 MetaMask
    self.page.click('text="MetaMask"')
    
    # 自动完成 MetaMask 连接授权
    self.metamask.connect_wallet()
    
  3. 执行交易并自动签名

    # 触发交易(例如点击 Swap 按钮)
    self.page.click('button[data-testid="swap-button"]')
    
    # 自动签名交易
    self.metamask.sign_transaction()
    

八、高级技巧

8.1 处理多个账户

MetaMask 可以管理多个账户,你可以通过点击账户切换:

def switch_account(self, account_index: int):
    """切换到指定账户"""
    page = self._open_metamask_page()
    self._unlock_metamask(page)
    
    # 点击账户菜单
    page.click('[data-testid="account-menu-icon"]')
    
    # 选择账户(根据索引)
    accounts = page.locator('[data-testid^="account-list-item"]')
    accounts.nth(account_index).click()
    
    page.close()

8.2 监听交易状态

def wait_for_transaction(self, timeout: int = 60):
    """等待交易完成"""
    page = self._open_metamask_page()
    
    # 等待交易确认通知
    try:
        page.wait_for_selector(
            'text="Confirmed"', 
            timeout=timeout * 1000
        )
        logger.info("交易已确认")
        return True
    except:
        logger.error("交易确认超时")
        return False
    finally:
        page.close()

8.3 批量处理交易

def execute_multiple_transactions(self, transaction_funcs: list):
    """批量执行多个交易"""
    for i, func in enumerate(transaction_funcs, 1):
        logger.info(f"执行第 {i}/{len(transaction_funcs)} 个交易")
        try:
            func()  # 执行触发交易的函数
            self.metamask.sign_transaction()
            logger.info(f"第 {i} 个交易完成")
        except Exception as e:
            logger.error(f"第 {i} 个交易失败: {e}")

8.4 错误处理和重试机制

import time
from functools import wraps

def retry(max_attempts=3, delay=2):
    """重试装饰器"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    logger.warning(
                        f"第 {attempt} 次尝试失败: {e}"
                    )
                    if attempt < max_attempts:
                        time.sleep(delay)
                    else:
                        raise
        return wrapper
    return decorator

# 使用示例
@retry(max_attempts=3, delay=5)
def sign_transaction(self):
    """签名交易(带重试)"""
    # ... 原有代码 ...

九、常见问题与解决方案

9.1 Chrome 调试端口被占用

问题:启动时提示端口已被占用

解决方案

# 查找占用端口的进程
# macOS/Linux:
lsof -i :9222

# Windows:
netstat -ano | findstr :9222

# 结束进程或更换端口

9.2 MetaMask 扩展 ID 不同

问题:MetaMask URL 打不开

解决方案

  1. 访问 chrome://extensions/
  2. 找到 MetaMask 扩展的 ID
  3. 修改 metamask.py 中的 URL

9.3 元素定位失败

问题:找不到按钮或元素

解决方案

  • MetaMask 界面可能更新,使用浏览器开发者工具检查最新的 data-testid
  • 使用更灵活的选择器,如 text="按钮文字"
  • 添加等待时间:page.wait_for_selector()

9.4 密码解锁失败

问题:输入密码后无法解锁

解决方案

  • 检查 .env 文件中的密码是否正确
  • 确保没有特殊字符导致的编码问题
  • 手动解锁一次,让 MetaMask 记住密码

9.5 无法连接到调试端口

问题:Playwright 无法连接到 Chrome

解决方案

# 增加连接超时时间
browser = p.chromium.connect_over_cdp(
    f"http://localhost:{self.debug_port}",
    timeout=60000  # 增加到 60 秒
)

9.6 遇到 LavaMoat 错误

问题:在控制台看到 LavaMoat 相关错误

解决方案

  • 确认你使用的是 Playwright CDP 连接而不是 Selenium
  • 检查 navigator.webdriver 是否为 undefined(在浏览器控制台输入检查)
  • 如果仍然有问题,尝试使用真实的用户数据目录而不是空目录

十、安全注意事项

10.1 保护敏感信息

  1. 永远不要提交 .env 文件到版本控制

    .gitignore 中添加:

    .env
    chrome/chrome_debug/
    logs/
    
  2. 使用测试钱包

    不要在自动化程序中使用包含大量资金的主钱包

  3. 加密存储密码

    对于生产环境,考虑使用密钥管理服务(如 AWS Secrets Manager)

10.2 网络安全

  1. 调试端口默认只监听 localhost,不要将其暴露到公网
  2. 使用 HTTPS 网站进行交互
  3. 验证合约地址和交易详情

10.3 代码审计

在执行交易前,务必:

  • 验证目标合约地址
  • 检查交易金额
  • 确认Gas费用合理

十一、扩展应用场景

11.1 自动化测试

class DAppTester:
    """DApp 自动化测试类"""
    
    def test_token_swap(self):
        """测试代币交换功能"""
        # 打开 DApp
        self.page.goto("https://your-dapp.com")
        
        # 连接钱包
        self.page.click('button:has-text("Connect")')
        self.metamask.connect_wallet()
        
        # 输入交易参数
        self.page.fill('input[name="amount"]', "0.01")
        
        # 执行交易
        self.page.click('button:has-text("Swap")')
        self.metamask.sign_transaction()
        
        # 验证结果
        success_msg = self.page.locator('text="Swap Successful"')
        assert success_msg.is_visible()

11.2 批量空投领取

def claim_airdrops(airdrop_urls: list):
    """批量领取空投"""
    for url in airdrop_urls:
        try:
            page.goto(url)
            page.click('button:has-text("Claim")')
            metamask.sign_transaction()
            logger.info(f"✓ 成功领取: {url}")
        except Exception as e:
            logger.error(f"✗ 领取失败 {url}: {e}")

11.3 价格监控与自动交易

def monitor_price_and_swap(target_price: float):
    """监控价格并自动交易"""
    while True:
        current_price = get_token_price()  # 自定义价格获取函数
        
        if current_price <= target_price:
            logger.info(f"价格达到目标: {current_price}")
            execute_swap()
            break
        
        time.sleep(60)  # 每分钟检查一次

十二、总结

本教程介绍了如何使用 Python 和 Playwright 通过 Chrome 调试模式来自动化操控 MetaMask 钱包。这是一个从失败(Selenium + LavaMoat 问题)到成功(Playwright + CDP)的实战经验分享。

核心要点回顾:

  1. 避开 LavaMoat 陷阱:Selenium 会被 MetaMask 的 LavaMoat 安全机制检测和阻止
  2. CDP 是关键:使用 Chrome 远程调试协议可以完美绕过自动化检测
  3. Chrome 调试模式:使用 --remote-debugging-port 启动真实的浏览器环境
  4. Playwright CDP 连接:通过 connect_over_cdp 连接到 Chrome
  5. MetaMask 自动化:解锁、连接、签名等操作的自动化实现
  6. 最佳实践:错误处理、日志记录、安全注意事项

技术方案对比总结:

方案 调试模式 优势 劣势 推荐度
Selenium + 调试端口 ✅ 是 简单易用,文档丰富 ❌ 注入 webdriver 标识,被 LavaMoat 阻止
undetected-chromedriver ✅ 是 能绕过部分网站检测 ❌ 本质仍是 Selenium,对 MetaMask 无效 ⭐⭐
Playwright CDP ✅ 是 无任何注入,完美绕过 LavaMoat 需要理解 CDP 概念 ⭐⭐⭐⭐⭐

关键发现:三种方案都使用 Chrome 调试模式,但只有 Playwright 的 CDP 连接方式不会暴露自动化特征!

后续学习方向:

  • 学习 Web3.py 进行链上数据查询
  • 研究智能合约交互
  • 实现更复杂的 DeFi 操作(如流动性挖矿、Yield Farming)
  • 集成机器学习进行交易策略优化

希望本教程能帮助你快速上手 MetaMask 自动化!如果有任何问题,欢迎交流讨论。


附录

A. 完整的 requirements.txt

playwright==1.55.0
python-dotenv==1.1.1

B. 项目文件清单

metamask-automation/
├── .env                          # 环境变量(不要提交到 Git)
├── .gitignore                    # Git 忽略配置
├── requirements.txt              # Python 依赖
├── README.md                     # 项目说明
├── chrome/
│   ├── __init__.py
│   ├── launch.py                # 浏览器启动模块
│   ├── metamask.py              # MetaMask 操作模块
│   └── chrome_debug/            # Chrome 用户数据(不要提交到 Git)
├── util/
│   ├── __init__.py
│   └── logger.py                # 日志工具
├── logs/                         # 日志目录(不要提交到 Git)
├── config.py                     # 配置文件
└── main.py                       # 主程序

C. 参考资源

D. 相关阅读


版权声明:本教程仅供学习和研究使用,请勿用于非法用途。使用自动化工具操作钱包存在风险,请谨慎使用并自行承担风险。

关于 LavaMoat:LavaMoat 是 MetaMask 的重要安全特性,本教程展示的 CDP 方法是合法的自动化技术,不涉及绕过或破解安全机制,而是使用了不触发检测的正常浏览器控制方式。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...