【DrissionPage源码-6】dp如何监听network和console

一、Listener:把 Network 事件拼成一条"请求时间线"

源码位置:_units/listener.py,核心类定义:

python 复制代码
class Listener(object):
    """监听器基类"""

1. CDP 连接与基础状态

关键构造:

  • owner:通常是 Page 或 Frame,内部包含 _target_idbrowser._ws_address
  • _driver:通过 Driver(self._target_id, self._address) 建立到当前目标的 CDP 通道。
  • _running_requests / _running_targets
    • 全部 Network 请求计数
    • 命中监听条件的"目标请求"计数
  • _targets / _is_regex / _method / _res_type:监听过滤条件,支持:
    • URL 白名单/关键字匹配/正则
    • 请求方法过滤(GET/POST/...)
    • 响应资源类型过滤(DocumentXHRFetch 等)

2. 配置监听目标:set_targets()

python 复制代码
def set_targets(self, targets=True, is_regex=False, method=('GET', 'POST'), res_type=True):
    传入list或者true 监听所有 

3. 启动监听:start()

python 复制代码
def start(self, targets=None, is_regex=None, method=None, res_type=None):
    ...
    self.clear()

    if self.listening:
        return

    self._driver = Driver(self._target_id, self._address)
    self._driver.session_id = self._driver.run(
        'Target.attachToTarget',
        targetId=self._target_id,
        flatten=True
    )['sessionId']
    self._driver.run('Network.enable')

    self._set_callback()
    self.listening = True

主要动作:

  1. 根据传参更新目标过滤条件(必要时调用 set_targets())。
  2. clear() 清空状态。
  3. 若尚未监听,则:
    • 创建新的 Driver(CDP 会话)
    • Target.attachToTarget 得到 sessionId
    • Network.enable 打开 Network 事件流
    • _set_callback() 注册各类回调
    • 标记 listening=True

4. 注册 CDP 回调:_set_callback()

python 复制代码
def _set_callback(self):
    self._driver.set_callback('Network.requestWillBeSent', self._requestWillBeSent)
    self._driver.set_callback('Network.requestWillBeSentExtraInfo', self._requestWillBeSentExtraInfo)
    self._driver.set_callback('Network.responseReceived', self._response_received)
    self._driver.set_callback('Network.responseReceivedExtraInfo', self._responseReceivedExtraInfo)
    self._driver.set_callback('Network.loadingFinished', self._loading_finished)
    self._driver.set_callback('Network.loadingFailed', self._loading_failed)

可以看到,Listener 关注的是一整条请求生命周期:

  • requestWillBeSent / requestWillBeSentExtraInfo
  • responseReceived / responseReceivedExtraInfo
  • loadingFinished / loadingFailed

这些原始事件会被整理为一个完整的 DataPacket 对象,最后推入 _caught 队列中。

5. 事件拼装:如何变成一个 DataPacket

5.1 请求发出:_requestWillBeSent()

self._request_ids 是一个{}

python 复制代码
def _requestWillBeSent(self, **kwargs):
    self._running_requests += 1
    p = None
    if self._targets is True:
        // 全都要
        if ((self._method is True or kwargs['request']['method'] in self._method)
                and (self._res_type is True or kwargs.get('type', '').upper() in self._res_type)):
            self._running_targets += 1
            rid = kwargs['requestId']
            p = self._request_ids.setdefault(rid, DataPacket(self._owner.tab_id, True))
            p._raw_request = kwargs
            ...
    else:
        rid = kwargs['requestId']
        for target in self._targets:
            // 正则或者判断
            if (((self._is_regex and search(target, kwargs['request']['url']))
                 or (not self._is_regex and target in kwargs['request']['url']))
                    and (self._method is True or kwargs['request']['method'] in self._method)
                    and (self._res_type is True or kwargs.get('type', '').upper() in self._res_type)):
                self._running_targets += 1
                p = self._request_ids.setdefault(rid, DataPacket(self._owner.tab_id, target))
                p._raw_request = kwargs
                break

    self._extra_info_ids.setdefault(kwargs['requestId'], {})['obj'] = p if p else False

关键逻辑:

  • 每次收到请求事件,_running_requests +1。
  • 检查是否命中过滤条件(目标 URL / 方法 / 资源类型)。
    • 如果命中:
      • _running_targets +1
      • 为该 requestId 创建一个 DataPacket(挂在 _request_ids 上)
      • 把原始请求数据记到 DataPacket._raw_request
  • 同时在 _extra_info_ids 中占位,后面额外信息(ExtraInfo)会根据相同 requestId 合并进来。
5.2 响应头:_response_received()
python 复制代码
def _response_received(self, **kwargs):
    request = self._request_ids.get(kwargs['requestId'], None)
    //全局获取到这个对象 设置返回值
    if request:
        request._raw_response = kwargs['response']
        request._resource_type = kwargs['type']

把响应头和资源类型挂到对应 DataPacket 上。

5.3 额外信息:_responseReceivedExtraInfo()
python 复制代码
def _responseReceivedExtraInfo(self, **kwargs):
    self._running_requests -= 1
    r = self._extra_info_ids.get(kwargs['requestId'], None)
    if r:
        obj = r.get('obj', None)
        if obj is False:
            self._extra_info_ids.pop(kwargs['requestId'], None)
        elif isinstance(obj, DataPacket):
            obj._requestExtraInfo = r.get('request', None)
            obj._responseExtraInfo = kwargs
            self._extra_info_ids.pop(kwargs['requestId'], None)
        else:
            r['response'] = kwargs

这里把 request / response 对应的 extraInfo(CDP 提供的更底层网络信息,比如原始头、cookie、认证等)注入到 DataPacket 中。

5.4 请求成功结束:_loading_finished()
python 复制代码
def _loading_finished(self, **kwargs):
    self._running_requests -= 1
    rid = kwargs['requestId']
    packet = self._request_ids.get(rid)
    if packet:
        r = self._driver.run('Network.getResponseBody', requestId=rid)
        if 'body' in r:
            packet._raw_body = r['body']
            packet._base64_body = r['base64Encoded']
        else:
            packet._raw_body = ''
            packet._base64_body = False
        ...
    ...
    self._request_ids.pop(rid, None)

    if packet:
        self._caught.put(packet)  // 是一个queue  。 
        self._running_targets -= 1
  • 通过 Network.getResponseBody(requestId) 获取响应 body:
    • 字段 body:真实内容(字符串)
    • 字段 base64Encoded:是否需要 base64 解码
  • 最后将完整的 DataPacket 投入 _caught,供外部 wait() / steps() 消费。
5.5 请求失败:_loading_failed()

类似逻辑,只是填的是 FailInfo

python 复制代码
data_packet._raw_fail_info = kwargs
data_packet._resource_type = kwargs['type']
data_packet.is_failed = True
...
self._caught.put(data_packet)

二、DataPacket / Request / Response:把 CDP 原始事件"整合成人话"

Listener 本身只负责"抓包 + 拼装",真正暴露给用户的,是三个核心数据对象:

  • DataPacket:一条请求的完整信息
  • Request:请求部分
  • Response:响应部分

1. DataPacket:一条请求的总包装

python 复制代码
class DataPacket(object):

    def __init__(self, tab_id, target):
        self.tab_id = tab_id
        self.target = target
        self.is_failed = False
        ...

常用属性:

  • url / method
python 复制代码
@property
def url(self):
    return self.request.url

@property
def method(self):
    return self.request.method
  • resourceType:来自 Network.responseReceivedtype
  • request / response / fail_info
    • 延迟创建对应对象:
python 复制代码
@property
def request(self):
    if self._request is None:
        self._request = Request(self, self._raw_request['request'], self._raw_post_data)
    return self._request

@property
def response(self):
    if self._response is None:
        self._response = Response(self, self._raw_response, self._raw_body, self._base64_body)
    return self._response
  • wait_extra_info(timeout=None)
    • 等待 extraInfo 注入完成(部分请求 ExtraInfo 会稍后才到)。

2. Request:请求信息解包

python 复制代码
class Request(object):
    def __init__(self, data_packet, raw_request, post_data):
        self._data_packet = data_packet
        self._request = raw_request
        self._raw_post_data = post_data
        self._postData = None
        self._headers = None

主要能力:

  • __getattr__ 直接透传原始字段,例如 request.url / request.method 等。
  • headers:合并 ExtraInfo 头(有些头只在 extraInfo 提供):
python 复制代码
@property
def headers(self):
    if self._headers is None:
        self._headers = CaseInsensitiveDict(self._request['headers'])
        if self.extra_info.headers:
            h = CaseInsensitiveDict(self.extra_info.headers)
            for k, v in h.items():
                if k not in self._headers:
                    self._headers[k] = v
    return self._headers
  • params:URL 查询参数解析为 dict。
  • postData
    • 优先使用 _raw_post_dataNetwork.getRequestPostData 补回来的)。
    • 其次使用原始 postData 字段。
    • 尝试 json.loads(),失败则原样返回。
  • cookies:从 ExtraInfo 的 associatedCookies 里筛选未被阻止的 cookie。

3. Response:响应信息解包

python 复制代码
class Response(object):
    def __init__(self, data_packet, raw_response, raw_body, base64_body):
        self._data_packet = data_packet
        self._response = raw_response
        self._raw_body = raw_body
        self._is_base64_body = base64_body
        self._body = None
        self._headers = None

关键属性:

  • headers:同样合并 ExtraInfo 的 header。
  • raw_body:原始字符串/body。
  • body
    • 如果是 base64,则解码为 bytes。
    • 否则尝试当 JSON 解析,不行就原样返回字符串。

这一层把底层 CDP 返回的结构改成了"用起来顺手"的 Python 对象。


三、Listener 的等待与遍历:同步消费异步事件

有了 _caught 这个队列,Listener 提供了两种消费方式:一次性等待和逐步遍历。

1. wait(count=1, timeout=None, fit_count=True, raise_err=None)

python 复制代码
def wait(self, count=1, timeout=None, fit_count=True, raise_err=None):
    if not self.listening:
        raise RuntimeError(_S._lang.join(_S._lang.NOT_LISTENING))
    ...

行为说明:

  • count=1
    • 默认等待至少 1 条数据包,返回 DataPacket 对象。
  • count>1
    • 在超时时间内等到 至少 count 条,返回列表。
  • fit_count=False
    • 超时但队列里已经有部分数据时,直接返回已有的全部数据。
  • timeout=None
    • 一直等到条件满足或 driver 不再运行。

当配合网络断言、接口抓包等使用时,这个接口非常直观。

2. steps(count=None, timeout=None, gap=1)

python 复制代码
def steps(self, count=None, timeout=None, gap=1):
    ...
    while self._driver.is_running and self.listening:
        if self._caught.qsize() >= gap:
            yield self._caught.get_nowait() if gap == 1 else [ ... ]
            ...
  • 这是一个生成器接口,可以逐步迭代抓到的请求:
    • gap=1:每次 yield 1 条
    • gap=N:每次 yield N 条
  • count 控制总共 yield 的数量。
  • timeout 表示"每一步之间最长等待多久",超时返回 False 结束。

适合写这种模式:

python 复制代码
for pkt in listener.steps(count=10, timeout=5):
    print(pkt.url, pkt.response.status)

四、FrameListener:专注某个 Frame 的网络请求

FrameListener 继承自 Listener,只改了两处方法:

python 复制代码
class FrameListener(Listener):
    def _requestWillBeSent(self, **kwargs):
        if not self._owner._is_diff_domain and kwargs.get('frameId', None) != self._owner._frame_id:
            return
        super()._requestWillBeSent(**kwargs)

    def _response_received(self, **kwargs):
        if not self._owner._is_diff_domain and kwargs.get('frameId', None) != self._owner._frame_id:
            return
        super()._response_received(**kwargs)

含义很简单:

  • 如果 frame 没有跨域(_is_diff_domain=False),就只处理 frameId == 当前 frame 的 _frame_id 的请求。
  • 这样可以把一个页面中不同 iframe 的请求区分开,便于精准监听子页面的网络行为。

五、Console:监听浏览器控制台输出

再看 Console,定位在 _units/console.py 第 14 行:

python 复制代码
class Console(object):
    def __init__(self, owner):
        self._owner = owner
        self._caught = None
        self._not_enabled = True
        self.listening = False

角色很清晰:监听 Console.messageAdded 事件,并把每一条 console 消息封装成 ConsoleData

1. 启动与停止

python 复制代码
def start(self):
    self._caught = Queue(maxsize=0)
    self._owner._driver.set_callback("Console.messageAdded", self._console)
    if self._not_enabled:
        self._owner._run_cdp("Console.enable")
        self._not_enabled = False
    self.listening = True
def _console(self, **kwargs):
    self._caught.put(ConsoleData(kwargs['message']))
def stop(self):
    if self.listening:
        self._owner._driver.set_callback('Console.messageAdded', None)
        self.listening = False
  • start()
    • 新建队列 _caught
    • 注册回调 _console
    • 若首次开启,则调用 Console.enable 打开 CDP 的 Console 域
  • stop()
    • 取消回调,标记停止

清空消息也很简单:

python 复制代码
def clear(self):
    self._caught = Queue(maxsize=0)

2. 同步等待:wait(timeout=None)

python 复制代码
def wait(self, timeout=None):
    if not self.listening:
        raise RuntimeError(_S._lang.join(_S._lang.NOT_LISTENING))
    if timeout is None:
        while self._owner._driver.is_running and self.listening and not self._caught.qsize():
            sleep(.03)
        return self._caught.get_nowait() if self._caught.qsize() else None

    else:
        end = perf_counter() + timeout
        while self._owner._driver.is_running and self.listening and perf_counter() < end:
            if self._caught.qsize():
                return self._caught.get_nowait()
            sleep(0.05)
        return False

行为:

  • timeout=None
    • 一直等到出现一条 console 消息,然后返回一个 ConsoleData
    • 若中途 driver 停止或监听被关,可能返回 None
  • timeout>0
    • 在指定时间内等一条消息,超时返回 False

3. 迭代消费:steps(timeout=None)

python 复制代码
def steps(self, timeout=None):
    if timeout is None:
        while self._owner._driver.is_running and self.listening:
            if self._caught.qsize():
                yield self._caught.get_nowait()
            sleep(0.05)

    else:
        end = perf_counter() + timeout
        while self._owner._driver.is_running and self.listening and perf_counter() < end:
            if self._caught.qsize():
                yield self._caught.get_nowait()
                end = perf_counter() + timeout
            sleep(0.05)
        return False

Listener.steps() 类似,只不过每次 yield 的是单条 ConsoleData

其实和listener 思路差不多,简化了一点,优雅。

六、结语

这一轮把 DrissionPage 里几块重点基本看了一遍:从Driver 到 元素对象的构造(ChromiumElement、三类 ID 的互转和懒加载),到网络层监听(Listener / FrameListener / DataPacket),再到控制台输出监听(Console / ConsoleData),基本把页面结构、网络流量、前端日志这三条主线都串了起来。

如果跟着源码走了一遍,现在对 CDP 大概会有这样几层认识:

  • DOM 域(DOM.*)负责节点的查找、描述、属性与结构;
  • Runtime 域(Runtime.*)负责在具体对象上执行 JS,并把返回值再包装回 Python;
  • Network 域(Network.*)把一次 HTTP 请求拆成多个事件,DrissionPage 再拼成可用的数据对象;
  • 其它域(Console.*Page.*IO.* 等)则分别承担日志、资源获取、截图等更上层的能力。

DrissionPage 做的事情,是在这些零散的 CDP 能力之上,再加了一层 稳定、可组合、语义化 的封装。在代码里只看到 .ele().click()listener.wait()console.messages 这样的调用,而不是满屏的 Runtime.callFunctionOnNetwork.getResponseBody

如果还想继续往下,其实还有不少有意思的模块可以研究,比如:

  • 行为类:ClickerElementScrollerElementWaiter以及对应的cdp。
  • 状态与几何:ElementStatesElementRect以及对应的cdp。
  • 页面级工具:下载器、 screencast、cookies 管理等,如何在多 target、多 frame 场景下保持一致行为。

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

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