【DrissionPage源码-0】了解CDP

前言:

如果你做过爬虫或浏览器自动化,大概率用过 Selenium。它很强大,但也有痛点:启动慢、资源占用高、操作容易被反爬检测。后来 DrissionPage 横空出世,直接用 CDP 协议控制浏览器,性能和灵活性都上了一个台阶。

我用 DrissionPage 做了不少项目,但每次遇到问题去翻源码时,总觉得雾里看花。与其被动地"用",不如主动地"造"一次。于是打算从头研究一下项目源码,整理一下。同时也缓冲一下之前的旧坑。

什么是cdp

1. CDP 的本质:JSON-RPC over WebSocket

CDP 并不是什么魔法,它本质上就是一堆 JSON 数据包在 WebSocket 上发来发去。

当打开 Chrome 浏览器的 F12 开发者工具时,那个"开发者工具窗口"其实就是一个用 HTML/JS 写的前端网页。

  • 当点击"Console"选项卡时,工具发送了一条 CDP 命令给浏览器内核。
  • 当点击"Network"查看抓包时,浏览器内核通过 CDP 把请求数据推送到工具界面。

通信过程示例:

假设你想让浏览器跳转到百度,你的程序(客户端)会通过 WebSocket 发送这样一段 JSON:

plain 复制代码
{
  "id": 1,
  "method": "Page.navigate",
  "params": {
    "url": "https://www.baidu.com"
  }
}

浏览器(服务端)收到后,执行跳转,然后回传:

plain 复制代码
{
  "id": 1,
  "result": {
    "frameId": "A1B2C3..."
  }
}

//这个frameid 在 

2. CDP 能做什么?(远超简单的 JS)

CDP 把浏览器能力划分为不同的 Domains (域),主要有:

  • Runtime 域 : 在页面上下文中执行 JS 代码(Runtime.evaluate)。
    • 反检测用途: 可以在页面加载前注入 JS(Page.addScriptToEvaluateOnNewDocument),用来覆盖 navigator.webdriver。
  • Network 域 : 拦截、修改、阻断网络请求。
    • 反检测用途: 可以拦截浏览器发出的指纹请求,替换掉 Header 或者返回假数据。
  • Page 域: 控制页面加载、截图、打印 PDF。
  • DOM 域: 直接获取 DOM 树,修改节点(不经过 JS,直接改内核数据)。
  • Debugger 域: 设置断点。这就是为什么爬虫可以逆向调试 JS。
  • Emulation 域 :
    • 关键功能 : 它可以模拟移动端、修改 User-Agent、修改时区修改地理位置。这对于通过环境检测非常有用。

3. CDP 与 Puppeteer / Playwright 的关系

Puppeteer 或 Playwright,它们其实就是 CDP 的封装库

  • Chromium: 提供 CDP 接口(服务端)。
  • Puppeteer (Node.js): 帮你把 browser.newPage() 这种好写的代码,翻译成底层的 Target.createTarget JSON 命令,并通过 WebSocket 发给 Chromium。

webdriver和cdp

1. WebDriver 与 WebKit 的关系

WebDriver 是什么?(它是"翻译官")

WebDriver 是一个 W3C 定义的标准接口协议。就像 USB 接口一样,标准是统一的,但插入的设备(浏览器)不同,就需要不同的驱动程序。

  • 谷歌 Chrome: 对应 chromedriver
  • 微软 Edge: 对应 msedgedriver
  • 火狐 Firefox: 对应 geckodriver
  • 苹果 Safari: 对应 safaridriver
  • https://github.com/LoseNine/AutoWK的 WebKit : 对应 WebDriver.exe (这是一个专门为 WebKit 内核编译的驱动)
    • autowk部分源码
plain 复制代码
import http.client
import subprocess
import os
import psutil
import json


def get_bin_file_path(filename):
    current_dir = os.path.dirname(os.path.abspath(__file__))
    exe_path = os.path.join(current_dir, ".", "bin", filename)
    exe_path = os.path.abspath(exe_path)
    return exe_path

class AutoWKBase:
    def __init__(self, host, port,webkit_path=None,webdriver_bat=None):

        self.host = host
        self.port = port
        self.headers = {"Content-Type": "application/json"}
        self.session_id = None
        self.conn = None

        if not webkit_path and not webdriver_bat:
            self.webkit_path = get_bin_file_path("MiniBrowser.exe")
            self.webdriver_bat = get_bin_file_path("WebDriver.exe")

        #用於關閉引導頁
        self.closePagefile="file:///"+get_bin_file_path("closePage.html")

        self.minibrowseraddr = f"{self.host}:{self.port + 1}"
    def launch_webkit(self,x=0,y=0,width=10,height=10,lang="en-US",timezone="America/Chicago",
                      proxyType='',proxyHost='',proxyPort='',proxyUsername='',proxyPassword='',
                      userDataDir='',fpfile='',userAgent='',headless=False,enableListen=False,networkListenPort=0):
        env = os.environ.copy()
        env["WEBKIT_INSPECTOR_SERVER"] = self.minibrowseraddr
        #给进行通信的窗口设置大小,实际上启动完就可以关闭了
        args = [
            self.webkit_path,
            f"--x={x}",
            f"--y={y}",
            f"--width={width}",
            f"--height={height}",
            f"--lang={lang}",
            f"--timezone={timezone}",
            f"--url={self.closePagefile}",
        ]

        if proxyType and proxyHost and proxyPort:
            args.append(f"--proxyType={proxyType}")
            args.append(f"--proxyHost={proxyHost}")
            args.append(f"--proxyPort={proxyPort}")
            if proxyUsername and proxyPassword:
                args.append(f"--proxyUsername={proxyUsername}")
                args.append(f"--proxyPassword={proxyPassword}")

        if userDataDir:
            args.append(f"--userDataDir={userDataDir}")

        if fpfile:
            args.append(f"--fpfile={fpfile}")

        if userAgent:
            args.append(f"--userAgent={userAgent}")

        if headless:
            args.append(f"--headless")

        if enableListen:
            args.append(f"--enableListen")
            if networkListenPort:
                args.append(f"--networkListenPort={networkListenPort}")

        self.webkit_process = subprocess.Popen(args, env=env)


    def launch_webdriver(self):
        for proc in psutil.process_iter(['name']):
            try:
                if proc.info['name'] and 'WebDriver.exe' in proc.info['name']:
                    subprocess.run(["taskkill", "/f", "/im", "WebDriver.exe"], stdout=subprocess.DEVNULL)
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                continue

        args = [
            self.webdriver_bat,
            f"--target={self.minibrowseraddr}",
            f"--port={str(self.port)}",
        ]

        self.webdriver_process = subprocess.Popen(args)

    def connect(self):
        self.conn = http.client.HTTPConnection(self.host, self.port)

    def request(self, method, endpoint, body=None):
        if body is None or body == {}:
            body = {"capabilities": {"firstMatch": [{}]}}
        self.conn.request(method, endpoint, body=json.dumps(body) if body else None, headers=self.headers)
        return json.loads(self.conn.getresponse().read().decode("utf-8"))

    def create_session(self):
        result = self.request("POST", "/session")
        self.session_id = result["value"]["sessionId"]

    def delete_session(self):
        return self.request("DELETE", f"/session/{self.session_id}")

    def close(self):
        print("[INFO] Closing connection and shutting down MiniBrowser and WebDriver...")
        if self.conn:
            self.conn.close()
        for proc in psutil.process_iter(['name']):
            try:
                if proc.info['name']:
                    if 'MiniBrowser.exe' in proc.info['name']:
                        print(f"[INFO] Terminating process: {proc.info['name']} (PID {proc.pid})")
                        proc.terminate()
                    if 'WebDriver.exe' in proc.info['name']:
                        print(f"[INFO] Terminating process: {proc.info['name']} (PID {proc.pid})")
                        subprocess.run(["taskkill", "/f", "/im", "WebDriver.exe"], stdout=subprocess.DEVNULL)
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                continue
        print("[INFO] autowk processes terminated.")

这些 Driver 的作用就是:接收 Python 发来的统一 HTTP 指令(比如"点击"),翻译成浏览器内部能听懂的私有指令。

WebKit 是什么?(它是"发动机")

WebKit 是一个浏览器排版引擎(渲染引擎)。它是浏览器的核心,负责把 HTML/CSS 代码变成你屏幕上看到的图像。

  • 血缘关系
    • Safari: 使用纯正的 WebKit 引擎。
    • Chrome : 以前也用 WebKit,后来 Google 觉得不爽,把 WebKit 拿过来改了改,起名叫 Blink(现在的 Chrome 内核其实是 WebKit 的一个分支)。
    • AutoWK / MiniBrowser : 这是一个基于纯 WebKit(类似 Safari 内核)编译出来的轻量级浏览器,不是 Chrome。

结论:因为内核不同,所以不能用 chromedriver 去控制 MiniBrowser,必须用配套的 WebDriver.exe。


2. 为什么说 Selenium "庞大"?它依赖了什么?

前面展示的 AutoWK 代码非常"原生",它只用了 Python 自带的 http.client,不需要安装任何第三方库。

相比之下,Selenium 是一个重型框架。当 pip install selenium 时,它不仅仅是下载了代码,还引入了一套复杂的生态:

  • 第三方依赖库
    • urllib3: 处理 HTTP 连接池、重试等(Selenium 不用 Python 自带的 http 库,因为它太弱)。
    • trio / trio-websocket: Selenium 4 为了支持 CDP 和异步,引入了这套庞大的异步 IO 库。
    • certifi: 处理 SSL 证书。
  • 对象封装的开销
    • 在 AutoWK 里,你发个 HTTP 请求就完了。
    • 在 Selenium 里,你获取一个元素 ele = driver.find_element(...),Selenium 会在内存里创建一个 WebElement 对象,这个对象绑定了 session ID、parent ID 等各种属性。当你有成千上万个元素时,这种封装就是一种负担。
  • 启动速度
    • 加载 selenium 库本身需要解析大量 Python 文件,而 import http.client 几乎是瞬时的。

3. 我能直接用 Python 调用 CDP 操作浏览器吗?

答案是:绝对可以,而且这是目前最高级的玩法。

只要你的 Python 能发 WebSocket 数据包,你就能控制 Chrome/Edge/CEF。

如何实现?

你需要用到 websockets 这个库(比 Selenium 轻量得多,pip install websockets),或者直接用 socket 手撸。

极简代码示例(直接控制 Chrome):

首先,启动 Chrome 并开启调试端口:

plain 复制代码
chrome.exe --remote-debugging-port=9222

然后,用 Python 控制它:

plain 复制代码
import asyncio
import websockets
import json
import requests

async def control_chrome():
    # 1. 获取 WebSocket 调试地址
    # Chrome 会在 http://127.0.0.1:9222/json 暴露当前页面的 WebSocket URL
    response = requests.get("http://127.0.0.1:9222/json")
    pages = response.json()
    # 拿到第一个标签页的 WebSocket 地址
    ws_url = pages[0]['webSocketDebuggerUrl']
    
    print(f"Connecting to: {ws_url}")

    # 2. 建立 WebSocket 连接
    async with websockets.connect(ws_url) as ws:
        
        # 3. 发送 CDP 命令:跳转到百度
        # 每一个命令都有唯一的 id,method 是 CDP 的方法名
        command = {
            "id": 1,
            "method": "Page.navigate",
            "params": {
                "url": "https://www.baidu.com"
            }
        }
        await ws.send(json.dumps(command))
        
        # 4. 接收结果
        result = await ws.recv()
        print(f"Receive: {result}")

        # 5. 发送 CDP 命令:执行 JS 获取 UserAgent
        js_command = {
            "id": 2,
            "method": "Runtime.evaluate",
            "params": {
                "expression": "navigator.userAgent"
            }
        }
        await ws.send(json.dumps(js_command))
        result = await ws.recv()
        print(f"JS Result: {result}")

# 运行
asyncio.get_event_loop().run_until_complete(control_chrome())
这种方式的优缺点:
  • 优点
    • 无敌轻量:没有 Selenium,没有 Driver,只有 WebSocket。
    • 权限最高:你可以调用 CDP 的所有隐藏功能(改指纹、拦截网络、模拟地理位置)。
    • 反检测强:因为没有 webdriver 属性注入。
  • 缺点
    • 代码难写:你需要自己管理 JSON 里的 id,自己处理异步回调。
    • 维护累:CDP 协议有时候会变,没有库帮你屏蔽差异。

CDP(Chrome DevTools Protocol)底层架构

1. 启动流程

bash 复制代码
# 启动 Chrome 并开启远程调试
chrome --remote-debugging-port=9222 --headless

# 输出类似:
# DevTools listening on ws://127.0.0.1:9222/devtools/browser/xxx

关键点:

  • Chrome 内部启动了一个 WebSocket Server
  • 端口 9222 监听外部连接
  • 不需要安装任何驱动(如 chromedriver),直接和浏览器通信

2. 通信协议(两层架构)

plain 复制代码
┌──────────────────────────────────────────────────────┐
│              Python / Node.js 客户端                  │
│         (DrissionPage, Puppeteer, etc.)             │
└───────────────────┬──────────────────────────────────┘
                    │
                    │ ① HTTP 获取 Tab 列表
                    │ GET http://localhost:9222/json
                    │
                    ▼
┌──────────────────────────────────────────────────────┐
│              Chrome HTTP 服务器                       │
│         返回所有 Tab 的 WebSocket URL                 │
└───────────────────┬──────────────────────────────────┘
                    │
                    │ ② WebSocket 连接到具体 Tab
                    │ ws://localhost:9222/devtools/page/xxx
                    │
                    ▼
┌──────────────────────────────────────────────────────┐
│           Chrome Tab (真实浏览器实例)                 │
│    - V8 引擎执行 JS                                   │
│    - Blink 渲染引擎处理 DOM                           │
│    - 真实的 window/document/navigator                │
└──────────────────────────────────────────────────────┘

3. HTTP 层:获取 Tab 信息

bash 复制代码
# 访问 http://localhost:9222/json
curl http://localhost:9222/json

# 返回 JSON(所有打开的 Tab)
[
  {
    "description": "",
    "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/XXX",
    "id": "E3F9F8C...",
    "title": "百度",
    "type": "page",
    "url": "https://www.baidu.com",
    "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/XXX"  ← 关键!
  }
]

所以这个可能会被反检测

plain 复制代码
但是 获取列表的这个 HTTP 请求 是一个特征。
如果一个网站的 JavaScript 极其狡猾,它可能尝试扫描本地端口(虽然浏览器有 CORS 保护,但在某些特定的网络配置或老版本浏览器下可能泄露):
网页 JS 尝试请求 http://localhost:9222/json。
如果请求成功并返回了 JSON,说明当前浏览器开启了远程调试端口。
结论:用户是机器人/爬虫。

关键点:

  • HTTP 负责总览当前 Tabs 信息
  • 每个 Tab 有唯一的 webSocketDebuggerUrl
  • 客户端通过这个 URL 连接到具体的 Tab

4. WebSocket 层:执行命令

Chrome DevTools 协议主要基于 JSON-RPC:每个命令都是一个带有 id/method 和可选参数的 JavaScript 结构

javascript 复制代码
// 客户端发送命令(通过 WebSocket)
{
  "id": 1,                          // 唯一 ID(用于匹配响应)
  "method": "Runtime.evaluate",     // 命令:执行 JS
  "params": {
    "expression": "document.title", // JS 代码
    "returnByValue": true
  }
}

// Chrome 返回响应
{
  "id": 1,                          // 对应请求的 ID
  "result": {
    "result": {
      "type": "string",
      "value": "百度一下,你就知道"
    }
  }
}

关键点:

  • 每个发送到 CDP 的命令必须有唯一的 'id' 参数。消息响应将通过 WebSocket 传递,并具有相同的 'id'
  • 没有 'id' 参数的传入 WebSocket 消息是协议事件
  • JS 在真实浏览器中执行,不是模拟!

5. DrissionPage 的 run_script() 底层实现

DrissionPage 源码分析(简化版)

python 复制代码
# DrissionPage 内部实现
class ChromiumPage:
    def __init__(self):
        # 连接到 Chrome
        self.ws = websocket.create_connection(
            "ws://localhost:9222/devtools/page/XXX"
        )
        self.msg_id = 0
    
    def run_script(self, js_code):
        """执行 JS 代码"""
        # 构建 CDP 命令
        msg = {
            "id": self.msg_id,
            "method": "Runtime.evaluate",  # CDP 的 Runtime 域
            "params": {
                "expression": js_code,     # 你的 JS 代码
                "returnByValue": True,     # 返回值而不是对象引用
                "awaitPromise": True       # 等待 Promise 完成
            }
        }
        
        # 发送到 Chrome
        self.ws.send(json.dumps(msg))
        self.msg_id += 1
        
        # 等待响应
        while True:
            response = json.loads(self.ws.recv())
            if response.get("id") == msg["id"]:
                # 找到对应的响应
                return response["result"]["result"]["value"]

实际流程

python 复制代码
from DrissionPage import ChromiumPage

page = ChromiumPage()
page.get('https://www.baidu.com')

# 当你执行:
result = page.run_script('navigator.userAgent')

# 底层发生:
# 1. DrissionPage 通过 WebSocket 发送:
#    {"id": 1, "method": "Runtime.evaluate", 
#     "params": {"expression": "navigator.userAgent"}}
#
# 2. Chrome 在真实浏览器中执行 JS
#
# 3. Chrome 返回:
#    {"id": 1, "result": {"result": {"value": "Mozilla/5.0..."}}}
#
# 4. DrissionPage 解析并返回结果

6. CDP 的核心域(Domain)

CDP 分为多个功能域,常用的有:

Runtime 域(执行 JS)

python 复制代码
# Runtime.evaluate - 执行 JS
page.run_cdp('Runtime.evaluate', expression='1+1')

# Runtime.callFunctionOn - 调用对象方法
page.run_cdp('Runtime.callFunctionOn', 
             functionDeclaration='function() { return this.title; }',
             objectId='...')

Page 域(页面控制)

python 复制代码
# Page.navigate - 导航到 URL
page.run_cdp('Page.navigate', url='https://example.com')

# Page.captureScreenshot - 截图
page.run_cdp('Page.captureScreenshot')

Network 域(网络)

python 复制代码
# Network.setCookies - 设置 Cookie
page.run_cdp('Network.setCookies', cookies=[...])

# Network.setExtraHTTPHeaders - 修改请求头
page.run_cdp('Network.setExtraHTTPHeaders', 
             headers={'X-Custom': 'value'})

DOM 域(DOM 操作)

python 复制代码
# DOM.getDocument - 获取 DOM 树
page.run_cdp('DOM.getDocument')

# DOM.querySelector - 查询元素
page.run_cdp('DOM.querySelector', 
             nodeId=1, selector='#id')

更多文章,敬请关注gzh:零基础爬虫第一天

相关推荐
010不二3 小时前
基于Appium爬虫文本导出可话个人动态(环境准备篇)
爬虫·python·appium
硅星企鹅3 小时前
如何使用低代码爬虫工具采集复杂网页数据?
爬虫·python·低代码
010不二3 小时前
基于Appium爬虫文本导出可话个人动态
数据库·爬虫·python·appium
山峰哥4 小时前
Python爬虫实战:从零构建高效数据采集系统
开发语言·数据库·爬虫·python·性能优化·架构
小白学大数据14 小时前
Java 爬虫对百科词条分类信息的抓取与处理
java·开发语言·爬虫
sugar椰子皮16 小时前
【node源码-6】async-hook c层修改以及测试
爬虫
Data_agent21 小时前
OOPBUY模式淘宝1688代购系统搭建指南
开发语言·爬虫·python
乘凉~21 小时前
【Linux作业】Limux下的python多线程爬虫程序设计
linux·爬虫·python
洋生巅峰1 天前
股票爬虫实战解析
爬虫·python·mysql