【DrissionPage源码-2】dp如何控制浏览器

接上篇,来实验一下只用python+cdp 启动操作浏览器

一、python 实现cdp控制浏览器

plain 复制代码
 --remote-debugging-port=9222 --remote-allow-origins=*
 // 必须添加 --remote-allow-origins=* 参数(或者指定具体来源),否则 Python 脚本通过 WebSocket 连接会被直接拒绝(返回 403)
 

见证奇迹的时刻

plain 复制代码
import json
import time
import requests
from websocket import create_connection

# 获取所有标签页列表,并取第一个标签页的 WebSocket URL
response = requests.get("http://localhost:9222/json")
tabs = response.json()
if not tabs:
    print("没有找到打开的标签页,请确保 Chrome 已启动并有标签页。")
    exit(1)

ws_url = tabs[0]["webSocketDebuggerUrl"]
print(f"连接到标签页: {tabs[0]['title']} ({tabs[0]['url']})")
print(f"WebSocket URL: {ws_url}")

# 连接 WebSocket
ws = create_connection(ws_url)

# 启用 Page 域(必须先启用才能接收事件和使用 navigate)
enable_page = {"id": 1, "method": "Page.enable"}
ws.send(json.dumps(enable_page))
print("启用 Page 域:", json.loads(ws.recv()))

# 导航到百度
navigate_cmd = {
    "id": 2,
    "method": "Page.navigate",
    "params": {"url": "https://www.baidu.com"}
}
ws.send(json.dumps(navigate_cmd))
response = json.loads(ws.recv())
print("导航响应:", response)

# 等待页面加载完成(监听 loadEventFired 事件)
print("等待页面加载完成...")
while True:
    msg = json.loads(ws.recv())
    print('msg=======',msg)
    if "method" in msg and msg["method"] == "Page.loadEventFired":
        print("页面加载完成!")
        break
    # 可以打印其他事件(可选)
    # print("收到事件:", msg)

# 等待几秒,让你看到百度页面
time.sleep(5)

# 关闭当前标签页
close_cmd = {"id": 3, "method": "Page.close"}
ws.send(json.dumps(close_cmd))
print("关闭标签页响应:", json.loads(ws.recv()))

# 关闭 WebSocket 连接
ws.close()
print("完成!标签页已关闭。")

当然,也有一个三方库:

plain 复制代码
import PyChromeDevTools

# 连接到 Chrome (需提前启动 Chrome 调试端口)
chrome = PyChromeDevTools.ChromeInterface()

# 启用网络和页面功能
chrome.Network.enable()
chrome.Page.enable()

# 导航到网页
chrome.Page.navigate(url="https://www.baidu.com/")

# 等待页面加载完成
chrome.wait_event("Page.loadEventFired", timeout=60)

我们对这个逻辑,按dp 的逻辑封装一下:

先来分析一下dp 的deriver 相比上面两种,

二、driver 类详细讲解

1. 三线程异步架构

  • 接收线程:专门负责从 WebSocket 接收消息,不被任何业务逻辑阻塞
plain 复制代码
  def _recv_loop(self):
        while self.is_running:
            try:
                # self._ws.settimeout(1)
                msg_json = self._ws.recv()
                msg = loads(msg_json)
            except WebSocketTimeoutException:
                continue
            except (WebSocketException, OSError, WebSocketConnectionClosedException, JSONDecodeError):
                self._stop()
                return

            if 'method' in msg:
                if msg['method'].startswith('Page.javascriptDialog'):
                    self.alert_flag = msg['method'].endswith('Opening')
                function = self.immediate_event_handlers.get(msg['method'])
                if function:
                    self._handle_immediate_event(function, msg['params'])
                else:
                    self.event_queue.put(msg)

            elif msg.get('id') in self.method_results:
                self.method_results[msg['id']].put(msg)
  • 普通事件处理线程:后台持续处理网络请求、页面事件等
  • 即时事件处理线程:按需启动,处理需要立即响应的事件(如 dialog)
plain 复制代码
    def _handle_event_loop(self):
        while self.is_running:
            try:
                event = self.event_queue.get(timeout=1)
            except Empty:
                continue

            function = self.event_handlers.get(event['method'])
            if function:
                function(**event['params'])

            self.event_queue.task_done()
    def _handle_immediate_event_loop(self):
        while not self.immediate_event_queue.empty():
            function, kwargs = self.immediate_event_queue.get(timeout=1)
            try:
                function(**kwargs)
            except PageDisconnectedError:
                pass
  //两个队列分别处理不同的事务 

2. 消息分类路由

python 复制代码
def _recv_loop(self):
    # ... 接收消息 ...
    
    if 'method' in msg:  # 判断:这是事件
        if msg['method'].startswith('Page.javascriptDialog'):
            self.alert_flag = msg['method'].endswith('Opening')  # 特殊处理 Alert
        
        function = self.immediate_event_handlers.get(msg['method'])
        if function:
            self._handle_immediate_event(function, msg['params'])  # 即时事件
        else:
            self.event_queue.put(msg)  # 普通事件
    
    elif msg.get('id') in self.method_results:  # 判断:这是命令响应
        self.method_results[msg['id']].put(msg)  # 精确投递到等待的命令

先看一下上面执行的日志

plain 复制代码
连接到标签页: WeTab 新标签页 (chrome-extension://aikflfpejipbpjdlfabpgclhblkpaafo/index.html)
WebSocket URL: ws://localhost:9222/devtools/page/AB460ED6AE423BE07C5E5429395C95E8
启用 Page 域: {'id': 1, 'result': {}}
导航响应: {'method': 'Page.frameStartedLoading', 'params': {'frameId': 'AB460ED6AE423BE07C5E5429395C95E8'}}
等待页面加载完成...
msg======= {'id': 2, 'result': {'frameId': 'AB460ED6AE423BE07C5E5429395C95E8', 'loaderId': '52220A990F1BFF283061E5299D598198'}}
msg======= {'method': 'Page.frameNavigated', 'params': {'frame': {'id': 'AB460ED6AE423BE07C5E5429395C95E8', 'loaderId': '52220A990F1BFF283061E5299D598198', 'url': 'https://www.baidu.com/', 'domainAndRegistry': 'baidu.com', 'securityOrigin': 'https://www.baidu.com', 'mimeType': 'text/html', 'adFrameStatus': {'adFrameType': 'none'}, 'secureContextType': 'Secure', 'crossOriginIsolatedContextType': 'NotIsolated', 'gatedAPIFeatures': []}, 'type': 'Navigation'}}
msg======= {'method': 'Page.domContentEventFired', 'params': {'timestamp': 751485.511995}}
msg======= {'method': 'Page.loadEventFired', 'params': {'timestamp': 751485.835044}}
页面加载完成!
关闭标签页响应: {'method': 'Page.frameStoppedLoading', 'params': {'frameId': 'AB460ED6AE423BE07C5E5429395C95E8'}}
完成!标签页已关闭。

路由逻辑:

plain 复制代码
WebSocket 消息到达
         |
         v
  _recv_loop (接收线程) 立即处理
         |
         +---> 检查是否有紧急处理器?
         |
         +---YES---> _handle_immediate_event()
         |               |
         |               v
         |          立即创建/启动 immediate 线程处理
         |               |
         |               v
         |          直接执行 function(**kwargs)
         |               (毫秒级响应)
         |
         +---NO----> event_queue.put(msg)
                        |
                        v
                   _handle_event_loop (事件处理线程)
                        |
                        v
                   轮询取事件,处理
                   (可能被阻塞)

** 1. 有 **method** 字段** → 事件 (这是浏览器主动推送的事件通知。表示页面中发生了某件事,不需要发送命令,它会自动返回(前提是已启用对应域)。)

复制代码
- **是 **`**javascriptDialog**`** → 设置 **`**alert_flag**`
- **有即时处理器 → 即时队列**
- **否则 → 普通队列**

** 2. 有 **id** 字段且在等待列表** → 命令响应 (这是主动发送的命令的回复。发送了一个带 id 的命令,浏览器处理完后用相同的 id 回复,表示"这个命令的结果出来了"。)

复制代码
- 投递到 `method_results[id]` 队列

3.还有一种alert ,会阻塞页面:点击、输入等操作无法执行 ,后面是进行处理:

plain 复制代码
//1. 监听 Page.javascriptDialogOpening 和 Page.javascriptDialogClosed 事件
//2. 发送 Page.handleJavaScriptDialog CDP 命令去除 alert
if msg['method'].startswith('Page.javascriptDialog'):
      self.alert_flag = msg['method'].endswith('Opening')
 

处理事件循环的伪代码:

plain 复制代码
    def _immediate_loop(self):
        """紧急事件处理循环"""
        while not self.immediate_event_queue.empty():
            msg = self.immediate_event_queue.get()
            handler = self.immediate_event_handlers.get(msg['method'])
            # {'method': 'Page.frameStartedLoading', 'params': {'frameId': 'AB460ED6AE423BE07C5E5429395C95E8'}}
            if handler:
                handler(msg.get('params', {}))

    def _event_loop(self):
        """普通事件处理循环"""
        while True:
            try:
                event = self.event_queue.get(timeout=1)
            except self.event_queue.empty():
                time.sleep(0.1)
                continue

            handler = self.event_handlers.get(event['method'])
            if handler:
                handler(event.get('params', {}))

这两个都会调用回调函数,回调函数会在初始化被设置

plain 复制代码
    
    def set_callback(self, event, callback, immediate=False):
        handler = self.immediate_event_handlers if immediate else self.event_handlers
        if callback:
            handler[event] = callback
        else:
            handler.pop(event, None)

还有发送cdp 命令的接口

plain 复制代码
  def run(self, _method, **kwargs):
        if not self.is_running:
            return {'error': 'connection disconnected', 'type': 'connection_error'}

        timeout = kwargs.pop('_timeout', _S.cdp_timeout)
        if self.session_id:
            result = self._send({'method': _method, 'params': kwargs, 'sessionId': self.session_id}, timeout=timeout)
        else:
            result = self._send({'method': _method, 'params': kwargs}, timeout=timeout)
        if 'result' not in result and 'error' in result:
            kwargs['_timeout'] = timeout
            return {'error': result['error']['message'], 'type': result.get('type', 'call_method_error'),
                    'method': _method, 'args': kwargs, 'data': result['error'].get('data')}
        else:
            return result['result']

  def _send(self, message, timeout=None):
        self._cur_id += 1
        ws_id = self._cur_id
        message['id'] = ws_id
        message_json = dumps(message)

        end_time = perf_counter() + timeout if timeout is not None else None
        self.method_results[ws_id] = Queue()
        try:
            self._ws.send(message_json)
            if timeout == 0:
                self.method_results.pop(ws_id, None)
                return {'id': ws_id, 'result': {}}

        except (OSError, WebSocketConnectionClosedException):
            self.method_results.pop(ws_id, None)
            return {'error': {'message': 'connection disconnected'}, 'type': 'connection_error'}

        while self.is_running:
            try:
                result = self.method_results[ws_id].get(timeout=.2)
                self.method_results.pop(ws_id, None)
                return result

            except Empty:
                if self.alert_flag and message['method'].startswith(('Input.', 'Runtime.')):
                    return {'error': {'message': 'alert exists.'}, 'type': 'alert_exists'}

                if timeout is not None and perf_counter() > end_time:
                    self.method_results.pop(ws_id, None)
                    return {'error': {'message': 'alert exists.'}, 'type': 'alert_exists'} \
                        if self.alert_flag else {'error': {'message': 'timeout'}, 'type': 'timeout'}

                continue

        return {'error': {'message': 'connection disconnected'}, 'type': 'connection_error'}

然后开始 chromium 对driver的封装,包括设置ua 等,

我来给你详细讲解 Chromium 类的作用和核心功能。

好的,现在我来给你详细讲解 Chromium 类的整体设计和核心功能。

三、Chromium 类详细讲解

Chromium 类是 DrissionPage 框架中最核心的类之一,它代表整个浏览器进程的管理器。

核心职责

1. 浏览器进程的生命周期管理

启动阶段 (line 39-54):

python 复制代码
def __new__(cls, addr_or_opts=None, session_options=None):
    # 第 1 步:处理参数
    opt = handle_options(addr_or_opts)
    
    # 第 2 步:启动浏览器进程或连接到已有浏览器
    is_headless, browser_id, is_exists, ws_only = run_browser(opt)
    # 这个就是  ws = s.get(f'http://{chromium_options.address}/json/version', headers={'Connection': 'close'}, timeout=2) 
    # 获取browser_id 
    
    # 第 3 步:单例模式,避免重复创建同一浏览器
    with cls._lock:
        if browser_id in cls._BROWSERS:
            return cls._BROWSERS[browser_id]  # 浏览器已存在,直接返回
    
    # 第 4 步:新建浏览器对象
    r = object.__new__(cls)
    # 省略其他配置
    cls._BROWSERS[browser_id] = r  # 注册到全局浏览器字典
    return r

初始化阶段 (line 57-121):

python 复制代码
def __init__(self, addr_or_opts=None, session_options=None):
    # 防止重复初始化
    if hasattr(self, '_created'):
        return
    self._created = True
    
    # 初始化浏览器驱动
    self._driver = BrowserDriver(self.id, self._ws_address, self)
    
    # 监听标签页创建和销毁事件
    self._run_cdp('Target.setDiscoverTargets', discover=True)
    ##  discover=True 表示启用目标发现 向浏览器发送 CDP 命令 Target.setDiscoverTargets   ,浏览器会开始主动推送标签页相关的事件消息
    ## 新开的标签页都会给回复

    self._driver.set_callback('Target.targetDestroyed', self._onTargetDestroyed)
    # 清理驱动引用 等缓存 
    self._driver.set_callback('Target.targetCreated', self._onTargetCreated)
    
    # 初始化下载管理器
    self._dl_mgr = DownloadManager(self)


"""
一个简单的伪代码
class MyBrowser:
    def __init__(self):
        self._drivers = {}
        self._driver = WebSocketDriver()
    
    def _init_target_monitor(self):
        # 第 1 步:启用目标发现
        self._driver.run('Target.setDiscoverTargets', discover=True)
        # 如果不注册这个,即使有下面两个也不会 触发回调。
        
        # 第 2 步:注册销毁回调
        self._driver.set_callback('Target.targetDestroyed', self._on_tab_destroyed)
        
        # 第 3 步:注册创建回调
        self._driver.set_callback('Target.targetCreated', self._on_tab_created)
    
    def _on_tab_created(self, targetInfo, **kwargs):
        """新标签页创建时的处理"""
        target_id = targetInfo['targetId']
        
        # 为新标签页创建驱动
        driver = TabDriver(target_id)
        self._drivers[target_id] = driver
        
        print(f"新标签页创建: {target_id}")
    
    def _on_tab_destroyed(self, targetId, **kwargs):
        """标签页销毁时的处理"""
        # 清理驱动
        driver = self._drivers.pop(targetId, None)
        if driver:
            driver.stop()
        
        print(f"标签页销毁: {targetId}")

"""

关闭阶段 (line 253-303):

python 复制代码
def quit(self, timeout=5, force=False, del_data=False):
    # 发送 CDP 命令关闭浏览器
    self._run_cdp('Browser.close')
    
    # 停止所有驱动
    self._driver.stop()
    
    # 强制杀死进程
    if force:
        Process(pid).kill()
    
    # 删除用户数据
    if del_data:
        rmtree(path, True)
2. 标签页(Tab)的管理

获取标签页

python 复制代码
@property
def tab_ids(self):
    """获取所有标签页 ID"""
    return [i['id'] for i in self._driver.get(f'http://{self.address}/json').json()]

@property
def latest_tab(self):
    """获取最新创建的标签页"""
    return self._get_tab(id_or_num=self.tab_ids[0])

创建标签页 (line 188-189, 305-336):

python 复制代码
def new_tab(self, url=None, new_window=False, background=False):
    return self._new_tab(True, url=url, new_window=new_window)

def _new_tab(self, mix=True, url=None, new_window=False):
    # 通过 CDP 命令创建新标签页
    tab = self._run_cdp('Target.createTarget', newWindow=new_window)['targetId']
    
    # 创建标签页对象
    tab = MixTab(self, tab) if mix else ChromiumTab(self, tab)
    
    if url:
        tab.get(url)
    return tab

查找标签页 (line 338-391):

python 复制代码
def _get_tab(self, id_or_num=None, title=None, url=None, **kwargs):
    """支持多种查找方式:ID、序号、标题、URL"""
    if id_or_num is not None:
        if isinstance(id_or_num, int):
            return self.tab_ids[id_or_num]  # 按序号
        else:
            return id_or_num  # 按 ID
    elif title:
        return [t for t in tabs if title in t['title']]  # 按标题
    elif url:
        return [t for t in tabs if url in t['url']]  # 按 URL

关闭标签页 (line 200-226):

python 复制代码
def close_tabs(self, tabs_or_ids, others=False):
    # 支持关闭指定标签页或其他标签页
    for tab in tabs:
        self._run_cdp('Target.closeTarget', targetId=tab.tab_id)
3. Frame(iframe)的管理
python 复制代码
self._frames = {}  # 存储所有 frame 的映射关系

当新标签页创建时,自动跟踪其包含的所有 frame:

python 复制代码
def _onTargetCreated(self, **kwargs):
    tab_id = kwargs['targetInfo']['targetId']
    self._frames[tab_id] = tab_id  # 记录 frame 所属的标签页
4. 驱动管理
python 复制代码
self._drivers = {}        # 待分配的驱动
self._all_drivers = {}    # 所有活跃的驱动

为每个标签页创建独立的驱动:

python 复制代码
def _get_driver(self, tab_id, owner=None):
    """为标签页获取或创建驱动"""
    d = self._drivers.pop(tab_id, None)
    if not d:
        d = Driver(tab_id, self._ws_address)  # 创建新驱动
    d.owner = owner
    self._all_drivers.setdefault(tab_id, set()).add(d)
    return d

四、使用流程日志

python 复制代码
# 1. 创建或连接浏览器
browser = Chromium()  # 自动启动浏览器或自动分配端口

# 2. 创建新标签页
tab = browser.new_tab('https://example.com')
"""

def new_tab(xxx):
    ## ...省略很多代码:
    tab = self._run_cdp('Target.createTarget', **kwargs)['targetId']
    tab = tab_type(self, tab)
# 输出 如下,即先返回 id ,再包装对象 ,优雅
0ED87E5977F65C3A170E9CC794439AE0
<MixTab browser_id=9027e9ce-c259-4e38-8e6a-604cbe5d92c3 tab_id=0ED87E5977F65C3A170E9CC794439AE0>
        
"""
# 3. 获取现有标签页
tab = browser.get_tab(url='example')  # 按 URL 查找
tab = browser.latest_tab  # 最新标签页

# 4. 关闭标签页
browser.close_tabs(tab)

# 5. 关闭浏览器
browser.quit()

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

相关推荐
深蓝电商API1 天前
Scrapy日志系统详解与生产环境配置
爬虫·python·scrapy
海天一色y2 天前
python--爬虫入门
爬虫
Delroy2 天前
Vercel 凌晨突发:agent-browser 来了,减少 93% 上下文!AI 终于有了“操纵现实”的手! 🚀
人工智能·爬虫·机器学习
程序员agions2 天前
Node.js 爬虫实战指南(三):分布式爬虫架构,让你的爬虫飞起来
分布式·爬虫·node.js
上海云盾-高防顾问2 天前
防CC攻击不止限速:智能指纹识别如何精准抵御恶意爬虫
爬虫·安全·web安全
特行独立的猫2 天前
python+Proxifier+mitmproxy实现监听本地网路所有的http请求
开发语言·爬虫·python·http
深蓝电商API2 天前
Scrapy Spider 参数化:动态传入 start_urls 和自定义设置
爬虫·python·scrapy
CCPC不拿奖不改名2 天前
基于FastAPI的API开发(爬虫的工作原理):从设计到部署详解+面试习题
爬虫·python·网络协议·tcp/ip·http·postman·fastapi
小白学大数据2 天前
某程旅行小程序爬虫技术解析与实战案例
爬虫·小程序
程序员agions2 天前
Node.js 爬虫实战指南(四):反反爬策略大全,和网站斗智斗勇
爬虫·node.js