parsek-cdp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parsek_cdp/__init__.py +55 -0
- parsek_cdp/_logging.py +49 -0
- parsek_cdp/cdp/__init__.py +247 -0
- parsek_cdp/cdp/accessibility/__init__.py +15 -0
- parsek_cdp/cdp/accessibility/events.py +35 -0
- parsek_cdp/cdp/accessibility/functions.py +202 -0
- parsek_cdp/cdp/accessibility/types.py +235 -0
- parsek_cdp/cdp/animation/__init__.py +15 -0
- parsek_cdp/cdp/animation/events.py +52 -0
- parsek_cdp/cdp/animation/functions.py +140 -0
- parsek_cdp/cdp/animation/types.py +113 -0
- parsek_cdp/cdp/audits/__init__.py +15 -0
- parsek_cdp/cdp/audits/events.py +21 -0
- parsek_cdp/cdp/audits/functions.py +95 -0
- parsek_cdp/cdp/audits/types.py +1051 -0
- parsek_cdp/cdp/autofill/__init__.py +15 -0
- parsek_cdp/cdp/autofill/events.py +27 -0
- parsek_cdp/cdp/autofill/functions.py +64 -0
- parsek_cdp/cdp/autofill/types.py +108 -0
- parsek_cdp/cdp/backgroundservice/__init__.py +15 -0
- parsek_cdp/cdp/backgroundservice/events.py +40 -0
- parsek_cdp/cdp/backgroundservice/functions.py +59 -0
- parsek_cdp/cdp/backgroundservice/types.py +63 -0
- parsek_cdp/cdp/bluetoothemulation/__init__.py +15 -0
- parsek_cdp/cdp/bluetoothemulation/events.py +70 -0
- parsek_cdp/cdp/bluetoothemulation/functions.py +247 -0
- parsek_cdp/cdp/bluetoothemulation/types.py +120 -0
- parsek_cdp/cdp/browser/__init__.py +15 -0
- parsek_cdp/cdp/browser/events.py +46 -0
- parsek_cdp/cdp/browser/functions.py +350 -0
- parsek_cdp/cdp/browser/types.py +158 -0
- parsek_cdp/cdp/cachestorage/__init__.py +15 -0
- parsek_cdp/cdp/cachestorage/events.py +13 -0
- parsek_cdp/cdp/cachestorage/functions.py +126 -0
- parsek_cdp/cdp/cachestorage/types.py +89 -0
- parsek_cdp/cdp/cast/__init__.py +15 -0
- parsek_cdp/cdp/cast/events.py +38 -0
- parsek_cdp/cdp/cast/functions.py +77 -0
- parsek_cdp/cdp/cast/types.py +23 -0
- parsek_cdp/cdp/console/__init__.py +15 -0
- parsek_cdp/cdp/console/events.py +22 -0
- parsek_cdp/cdp/console/functions.py +36 -0
- parsek_cdp/cdp/console/types.py +30 -0
- parsek_cdp/cdp/css/__init__.py +15 -0
- parsek_cdp/cdp/css/events.py +79 -0
- parsek_cdp/cdp/css/functions.py +788 -0
- parsek_cdp/cdp/css/types.py +742 -0
- parsek_cdp/cdp/debugger/__init__.py +15 -0
- parsek_cdp/cdp/debugger/events.py +164 -0
- parsek_cdp/cdp/debugger/functions.py +663 -0
- parsek_cdp/cdp/debugger/types.py +171 -0
- parsek_cdp/cdp/deviceaccess/__init__.py +15 -0
- parsek_cdp/cdp/deviceaccess/events.py +30 -0
- parsek_cdp/cdp/deviceaccess/functions.py +54 -0
- parsek_cdp/cdp/deviceaccess/types.py +28 -0
- parsek_cdp/cdp/deviceorientation/__init__.py +15 -0
- parsek_cdp/cdp/deviceorientation/events.py +13 -0
- parsek_cdp/cdp/deviceorientation/functions.py +35 -0
- parsek_cdp/cdp/deviceorientation/types.py +10 -0
- parsek_cdp/cdp/dom/__init__.py +15 -0
- parsek_cdp/cdp/dom/events.py +228 -0
- parsek_cdp/cdp/dom/functions.py +1033 -0
- parsek_cdp/cdp/dom/types.py +293 -0
- parsek_cdp/cdp/domdebugger/__init__.py +15 -0
- parsek_cdp/cdp/domdebugger/events.py +13 -0
- parsek_cdp/cdp/domdebugger/functions.py +155 -0
- parsek_cdp/cdp/domdebugger/types.py +57 -0
- parsek_cdp/cdp/domsnapshot/__init__.py +15 -0
- parsek_cdp/cdp/domsnapshot/events.py +13 -0
- parsek_cdp/cdp/domsnapshot/functions.py +111 -0
- parsek_cdp/cdp/domsnapshot/types.py +312 -0
- parsek_cdp/cdp/domstorage/__init__.py +15 -0
- parsek_cdp/cdp/domstorage/events.py +60 -0
- parsek_cdp/cdp/domstorage/functions.py +79 -0
- parsek_cdp/cdp/domstorage/types.py +30 -0
- parsek_cdp/cdp/emulation/__init__.py +15 -0
- parsek_cdp/cdp/emulation/events.py +17 -0
- parsek_cdp/cdp/emulation/functions.py +688 -0
- parsek_cdp/cdp/emulation/types.py +305 -0
- parsek_cdp/cdp/eventbreakpoints/__init__.py +15 -0
- parsek_cdp/cdp/eventbreakpoints/events.py +13 -0
- parsek_cdp/cdp/eventbreakpoints/functions.py +41 -0
- parsek_cdp/cdp/eventbreakpoints/types.py +10 -0
- parsek_cdp/cdp/extensions/__init__.py +15 -0
- parsek_cdp/cdp/extensions/events.py +13 -0
- parsek_cdp/cdp/extensions/functions.py +118 -0
- parsek_cdp/cdp/extensions/types.py +19 -0
- parsek_cdp/cdp/fedcm/__init__.py +15 -0
- parsek_cdp/cdp/fedcm/events.py +45 -0
- parsek_cdp/cdp/fedcm/functions.py +93 -0
- parsek_cdp/cdp/fedcm/types.py +72 -0
- parsek_cdp/cdp/fetch/__init__.py +15 -0
- parsek_cdp/cdp/fetch/events.py +82 -0
- parsek_cdp/cdp/fetch/functions.py +215 -0
- parsek_cdp/cdp/fetch/types.py +81 -0
- parsek_cdp/cdp/filesystem/__init__.py +15 -0
- parsek_cdp/cdp/filesystem/events.py +13 -0
- parsek_cdp/cdp/filesystem/functions.py +36 -0
- parsek_cdp/cdp/filesystem/types.py +54 -0
- parsek_cdp/cdp/headlessexperimental/__init__.py +15 -0
- parsek_cdp/cdp/headlessexperimental/events.py +13 -0
- parsek_cdp/cdp/headlessexperimental/functions.py +77 -0
- parsek_cdp/cdp/headlessexperimental/types.py +24 -0
- parsek_cdp/cdp/heapprofiler/__init__.py +15 -0
- parsek_cdp/cdp/heapprofiler/events.py +65 -0
- parsek_cdp/cdp/heapprofiler/functions.py +193 -0
- parsek_cdp/cdp/heapprofiler/types.py +57 -0
- parsek_cdp/cdp/indexeddb/__init__.py +15 -0
- parsek_cdp/cdp/indexeddb/events.py +13 -0
- parsek_cdp/cdp/indexeddb/functions.py +232 -0
- parsek_cdp/cdp/indexeddb/types.py +120 -0
- parsek_cdp/cdp/input/__init__.py +15 -0
- parsek_cdp/cdp/input/events.py +25 -0
- parsek_cdp/cdp/input/functions.py +361 -0
- parsek_cdp/cdp/input/types.py +87 -0
- parsek_cdp/cdp/inspector/__init__.py +15 -0
- parsek_cdp/cdp/inspector/events.py +41 -0
- parsek_cdp/cdp/inspector/functions.py +27 -0
- parsek_cdp/cdp/inspector/types.py +10 -0
- parsek_cdp/cdp/io/__init__.py +15 -0
- parsek_cdp/cdp/io/events.py +13 -0
- parsek_cdp/cdp/io/functions.py +77 -0
- parsek_cdp/cdp/io/types.py +13 -0
- parsek_cdp/cdp/layertree/__init__.py +15 -0
- parsek_cdp/cdp/layertree/events.py +36 -0
- parsek_cdp/cdp/layertree/functions.py +178 -0
- parsek_cdp/cdp/layertree/types.py +104 -0
- parsek_cdp/cdp/log/__init__.py +15 -0
- parsek_cdp/cdp/log/events.py +22 -0
- parsek_cdp/cdp/log/functions.py +54 -0
- parsek_cdp/cdp/log/types.py +57 -0
- parsek_cdp/cdp/media/__init__.py +15 -0
- parsek_cdp/cdp/media/events.py +87 -0
- parsek_cdp/cdp/media/functions.py +27 -0
- parsek_cdp/cdp/media/types.py +101 -0
- parsek_cdp/cdp/memory/__init__.py +15 -0
- parsek_cdp/cdp/memory/events.py +13 -0
- parsek_cdp/cdp/memory/functions.py +164 -0
- parsek_cdp/cdp/memory/types.py +71 -0
- parsek_cdp/cdp/mixins/__init__.py +21 -0
- parsek_cdp/cdp/mixins/datatype.py +123 -0
- parsek_cdp/cdp/mixins/event.py +78 -0
- parsek_cdp/cdp/mixins/namespace.py +31 -0
- parsek_cdp/cdp/network/__init__.py +15 -0
- parsek_cdp/cdp/network/events.py +726 -0
- parsek_cdp/cdp/network/functions.py +734 -0
- parsek_cdp/cdp/network/types.py +1247 -0
- parsek_cdp/cdp/overlay/__init__.py +15 -0
- parsek_cdp/cdp/overlay/events.py +54 -0
- parsek_cdp/cdp/overlay/functions.py +407 -0
- parsek_cdp/cdp/overlay/types.py +328 -0
- parsek_cdp/cdp/page/__init__.py +15 -0
- parsek_cdp/cdp/page/events.py +416 -0
- parsek_cdp/cdp/page/functions.py +1081 -0
- parsek_cdp/cdp/page/types.py +1088 -0
- parsek_cdp/cdp/performance/__init__.py +15 -0
- parsek_cdp/cdp/performance/events.py +24 -0
- parsek_cdp/cdp/performance/functions.py +63 -0
- parsek_cdp/cdp/performance/types.py +22 -0
- parsek_cdp/cdp/performancetimeline/__init__.py +15 -0
- parsek_cdp/cdp/performancetimeline/events.py +22 -0
- parsek_cdp/cdp/performancetimeline/functions.py +30 -0
- parsek_cdp/cdp/performancetimeline/types.py +85 -0
- parsek_cdp/cdp/preload/__init__.py +15 -0
- parsek_cdp/cdp/preload/events.py +116 -0
- parsek_cdp/cdp/preload/functions.py +25 -0
- parsek_cdp/cdp/preload/types.py +267 -0
- parsek_cdp/cdp/profiler/__init__.py +15 -0
- parsek_cdp/cdp/profiler/events.py +64 -0
- parsek_cdp/cdp/profiler/functions.py +134 -0
- parsek_cdp/cdp/profiler/types.py +105 -0
- parsek_cdp/cdp/pwa/__init__.py +15 -0
- parsek_cdp/cdp/pwa/events.py +13 -0
- parsek_cdp/cdp/pwa/functions.py +196 -0
- parsek_cdp/cdp/pwa/types.py +45 -0
- parsek_cdp/cdp/runtime/__init__.py +15 -0
- parsek_cdp/cdp/runtime/events.py +123 -0
- parsek_cdp/cdp/runtime/functions.py +552 -0
- parsek_cdp/cdp/runtime/types.py +319 -0
- parsek_cdp/cdp/schema/__init__.py +15 -0
- parsek_cdp/cdp/schema/events.py +13 -0
- parsek_cdp/cdp/schema/functions.py +32 -0
- parsek_cdp/cdp/schema/types.py +22 -0
- parsek_cdp/cdp/security/__init__.py +15 -0
- parsek_cdp/cdp/security/events.py +64 -0
- parsek_cdp/cdp/security/functions.py +66 -0
- parsek_cdp/cdp/security/types.py +168 -0
- parsek_cdp/cdp/serviceworker/__init__.py +15 -0
- parsek_cdp/cdp/serviceworker/events.py +43 -0
- parsek_cdp/cdp/serviceworker/functions.py +115 -0
- parsek_cdp/cdp/serviceworker/types.py +95 -0
- parsek_cdp/cdp/storage/__init__.py +15 -0
- parsek_cdp/cdp/storage/events.py +288 -0
- parsek_cdp/cdp/storage/functions.py +571 -0
- parsek_cdp/cdp/storage/types.py +656 -0
- parsek_cdp/cdp/systeminfo/__init__.py +15 -0
- parsek_cdp/cdp/systeminfo/events.py +13 -0
- parsek_cdp/cdp/systeminfo/functions.py +75 -0
- parsek_cdp/cdp/systeminfo/types.py +132 -0
- parsek_cdp/cdp/target/__init__.py +15 -0
- parsek_cdp/cdp/target/events.py +109 -0
- parsek_cdp/cdp/target/functions.py +424 -0
- parsek_cdp/cdp/target/types.py +83 -0
- parsek_cdp/cdp/tethering/__init__.py +15 -0
- parsek_cdp/cdp/tethering/events.py +22 -0
- parsek_cdp/cdp/tethering/functions.py +35 -0
- parsek_cdp/cdp/tethering/types.py +10 -0
- parsek_cdp/cdp/tracing/__init__.py +15 -0
- parsek_cdp/cdp/tracing/events.py +61 -0
- parsek_cdp/cdp/tracing/functions.py +119 -0
- parsek_cdp/cdp/tracing/types.py +81 -0
- parsek_cdp/cdp/webaudio/__init__.py +15 -0
- parsek_cdp/cdp/webaudio/events.py +184 -0
- parsek_cdp/cdp/webaudio/functions.py +51 -0
- parsek_cdp/cdp/webaudio/types.py +155 -0
- parsek_cdp/cdp/webauthn/__init__.py +15 -0
- parsek_cdp/cdp/webauthn/events.py +69 -0
- parsek_cdp/cdp/webauthn/functions.py +214 -0
- parsek_cdp/cdp/webauthn/types.py +96 -0
- parsek_cdp/core/__init__.py +18 -0
- parsek_cdp/core/browser.py +240 -0
- parsek_cdp/core/browser_context.py +58 -0
- parsek_cdp/core/element.py +405 -0
- parsek_cdp/core/feature.py +376 -0
- parsek_cdp/core/frame.py +351 -0
- parsek_cdp/core/pagable.py +175 -0
- parsek_cdp/core/page.py +163 -0
- parsek_cdp/core/target.py +490 -0
- parsek_cdp/features/__init__.py +14 -0
- parsek_cdp/features/request_listener/__init__.py +256 -0
- parsek_cdp/features/request_listener/events.py +48 -0
- parsek_cdp/features/request_listener/types.py +235 -0
- parsek_cdp/generate.py +642 -0
- parsek_cdp/parsek/__init__.py +18 -0
- parsek_cdp/parsek/events.py +38 -0
- parsek_cdp/parsek/types.py +36 -0
- parsek_cdp/py.typed +0 -0
- parsek_cdp-0.1.0.dist-info/METADATA +277 -0
- parsek_cdp-0.1.0.dist-info/RECORD +242 -0
- parsek_cdp-0.1.0.dist-info/WHEEL +5 -0
- parsek_cdp-0.1.0.dist-info/licenses/LICENSE +201 -0
- parsek_cdp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Request listener -- one self-contained feature, two roles.
|
|
2
|
+
|
|
3
|
+
Everything for this feature lives in this package: the wire types and view types
|
|
4
|
+
(:mod:`.types`), the aggregated events (:mod:`.events`) and the feature class
|
|
5
|
+
here. There is no command surface -- the feature is a pure *observer* whose job
|
|
6
|
+
is aggregation; the one thing a client pulls on demand (a response body) is
|
|
7
|
+
fetched with plain CDP (``Network.getResponseBody``), proxied transparently.
|
|
8
|
+
|
|
9
|
+
* **server (producer)** -- owns ``Network``; the ``@on(Network.*)`` handlers
|
|
10
|
+
accumulate the raw burst and emit exactly two aggregated events per request:
|
|
11
|
+
``RequestSent`` (once the request is final) and ``ResponseCompleted`` (at
|
|
12
|
+
completion, with merged headers and total bytes). Per-chunk ``dataReceived``
|
|
13
|
+
never leaves the server.
|
|
14
|
+
* **client (view)** -- the ``@on(Parsek...)`` reducers rebuild ``.requests`` of
|
|
15
|
+
:class:`~.types.Request` / :class:`~.types.Response` from those two events.
|
|
16
|
+
|
|
17
|
+
The reducers run in *both* roles, so the public API is identical client- and
|
|
18
|
+
server-side (and powers the server snapshot for late joiners).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import re
|
|
25
|
+
from typing import Callable, Dict, List, Optional, Union
|
|
26
|
+
|
|
27
|
+
from ...cdp import Network
|
|
28
|
+
from ...cdp.mixins.namespace import attach_namespace
|
|
29
|
+
from ...core.feature import Feature, emit, on
|
|
30
|
+
from . import events as _events
|
|
31
|
+
from . import types as _types
|
|
32
|
+
from .events import RequestSent, ResponseCompleted
|
|
33
|
+
from .types import RedirectEntry, Request, RequestData, Response, ResponseData
|
|
34
|
+
|
|
35
|
+
Matcher = Union[str, "re.Pattern[str]"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RequestListener(Feature):
|
|
39
|
+
"""Aggregated network observation. Server produces, client views.
|
|
40
|
+
|
|
41
|
+
Doubles as this feature's namespace: after ``from parsek_cdp.features import
|
|
42
|
+
RequestListener`` you can reach its wire/view types and events as attributes
|
|
43
|
+
(``RequestListener.RequestSent``, ``RequestListener.RequestData``,
|
|
44
|
+
``RequestListener.Request`` ...), so callers never import them individually.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
domains = (Network,)
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self, host, *, role: str = "client", preserve_log: bool = False
|
|
51
|
+
) -> None:
|
|
52
|
+
self._requests: Dict[str, Request] = {}
|
|
53
|
+
self.preserve_log = preserve_log
|
|
54
|
+
self._waiters: List[tuple] = []
|
|
55
|
+
self._pending: Dict[str, RequestData] = {}
|
|
56
|
+
self._resp: Dict[str, ResponseData] = {}
|
|
57
|
+
self._emitted: set[str] = set()
|
|
58
|
+
#: request ids the browser reported as served from the memory cache
|
|
59
|
+
#: (``requestServedFromCache`` fires before ``responseReceived``).
|
|
60
|
+
self._served_from_cache: set[str] = set()
|
|
61
|
+
super().__init__(host, role=role)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def requests(self) -> List[Request]:
|
|
65
|
+
return list(self._requests.values())
|
|
66
|
+
|
|
67
|
+
def get(self, target: Matcher) -> Optional[Request]:
|
|
68
|
+
pred = self._predicate(target)
|
|
69
|
+
return next(
|
|
70
|
+
(r for r in self._requests.values() if r.response and pred(r)), None
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def wait_for_response(
|
|
74
|
+
self, target: Matcher, *, timeout: float = 60, load_body: bool = False
|
|
75
|
+
) -> Request:
|
|
76
|
+
"""Wait until a request matching ``target`` has a response.
|
|
77
|
+
|
|
78
|
+
``target`` is matched against the request URL: a ``str`` matches as a
|
|
79
|
+
substring (so ``"/api/orders"`` finds ``https://site/api/orders?p=2``);
|
|
80
|
+
a compiled regex is matched with :meth:`re.Pattern.search`.
|
|
81
|
+
"""
|
|
82
|
+
pred = self._predicate(target)
|
|
83
|
+
record = self.get(target)
|
|
84
|
+
if record is None:
|
|
85
|
+
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
86
|
+
self._waiters.append((pred, future))
|
|
87
|
+
record = await asyncio.wait_for(future, timeout)
|
|
88
|
+
if load_body and record.response is not None:
|
|
89
|
+
await record.response.body()
|
|
90
|
+
return record
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _predicate(target: Matcher) -> Callable[[Request], bool]:
|
|
94
|
+
"""Build a URL predicate: regex -> ``search``, str -> substring match."""
|
|
95
|
+
if isinstance(target, re.Pattern):
|
|
96
|
+
return lambda r: target.search(r.url) is not None
|
|
97
|
+
return lambda r: target in r.url
|
|
98
|
+
|
|
99
|
+
def _notify(self) -> None:
|
|
100
|
+
for pred, future in list(self._waiters):
|
|
101
|
+
if future.done():
|
|
102
|
+
self._waiters.remove((pred, future))
|
|
103
|
+
continue
|
|
104
|
+
rec = next(
|
|
105
|
+
(r for r in self._requests.values() if r.response and pred(r)), None
|
|
106
|
+
)
|
|
107
|
+
if rec is not None:
|
|
108
|
+
future.set_result(rec)
|
|
109
|
+
self._waiters.remove((pred, future))
|
|
110
|
+
|
|
111
|
+
async def snapshot(self) -> dict:
|
|
112
|
+
"""State for a late-joining client (the proxy may replay it on connect)."""
|
|
113
|
+
return {"requests": [r._data.to_json() for r in self._requests.values()]}
|
|
114
|
+
|
|
115
|
+
@on(RequestSent)
|
|
116
|
+
def _reduce_request(self, e: RequestSent) -> None:
|
|
117
|
+
data = e.request
|
|
118
|
+
if data.is_navigation and not self.preserve_log:
|
|
119
|
+
# A new document replaces the previous one. Unblock any waiter on a
|
|
120
|
+
# request we are about to drop (so ``join()`` returns instead of
|
|
121
|
+
# hanging until its timeout -- ``finished`` stays False, the caller
|
|
122
|
+
# can tell it was cut short), then reap producer-side state for the
|
|
123
|
+
# old requests. Keep the navigation request's own still-in-flight
|
|
124
|
+
# state: its ``loadingFinished`` is yet to come.
|
|
125
|
+
for old in self._requests.values():
|
|
126
|
+
old._completed.set()
|
|
127
|
+
self._requests.clear()
|
|
128
|
+
keep = data.request_id
|
|
129
|
+
self._pending = (
|
|
130
|
+
{keep: self._pending[keep]} if keep in self._pending else {}
|
|
131
|
+
)
|
|
132
|
+
self._resp = {keep: self._resp[keep]} if keep in self._resp else {}
|
|
133
|
+
self._emitted = {keep} if keep in self._emitted else set()
|
|
134
|
+
self._served_from_cache = (
|
|
135
|
+
{keep} if keep in self._served_from_cache else set()
|
|
136
|
+
)
|
|
137
|
+
self._requests[data.request_id] = Request(data)
|
|
138
|
+
|
|
139
|
+
@on(ResponseCompleted)
|
|
140
|
+
def _reduce_response(self, e: ResponseCompleted) -> None:
|
|
141
|
+
req = self._requests.get(e.response.request_id)
|
|
142
|
+
if req is None:
|
|
143
|
+
return
|
|
144
|
+
req.response = Response(self, req, e.response)
|
|
145
|
+
req.finished = True
|
|
146
|
+
req.succeeded = e.response.succeeded
|
|
147
|
+
req.error_text = e.response.error_text
|
|
148
|
+
req._completed.set()
|
|
149
|
+
self._notify()
|
|
150
|
+
|
|
151
|
+
@on(Network.RequestWillBeSent)
|
|
152
|
+
def _on_request(self, e: "Network.RequestWillBeSent") -> None:
|
|
153
|
+
rd = self._pending.get(e.request_id)
|
|
154
|
+
if rd is None:
|
|
155
|
+
rd = RequestData(request_id=e.request_id, url="", method="")
|
|
156
|
+
self._pending[e.request_id] = rd
|
|
157
|
+
if e.redirect_response is not None: # redirect reuses the id
|
|
158
|
+
rd.redirects.append(
|
|
159
|
+
RedirectEntry(
|
|
160
|
+
url=rd.url or e.redirect_response.url,
|
|
161
|
+
status_code=e.redirect_response.status,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
rd.url = e.request.url
|
|
165
|
+
rd.method = e.request.method
|
|
166
|
+
rd.headers = dict(e.request.headers)
|
|
167
|
+
rd.post_data = e.request.post_data
|
|
168
|
+
rd.resource_type = e.type_.value if e.type_ else None
|
|
169
|
+
rd.frame_id = e.frame_id
|
|
170
|
+
rd.loader_id = e.loader_id
|
|
171
|
+
rd.is_navigation = e.type_ == Network.ResourceType.DOCUMENT
|
|
172
|
+
|
|
173
|
+
@on(Network.RequestServedFromCache)
|
|
174
|
+
def _on_served_from_cache(self, e: "Network.RequestServedFromCache") -> None:
|
|
175
|
+
self._served_from_cache.add(e.request_id)
|
|
176
|
+
|
|
177
|
+
@on(Network.RequestWillBeSentExtraInfo)
|
|
178
|
+
def _on_request_extra(self, e: "Network.RequestWillBeSentExtraInfo") -> None:
|
|
179
|
+
rd = self._pending.get(e.request_id)
|
|
180
|
+
if rd and e.headers:
|
|
181
|
+
rd.headers = {**rd.headers, **e.headers}
|
|
182
|
+
|
|
183
|
+
@on(Network.ResponseReceived)
|
|
184
|
+
@emit(RequestSent)
|
|
185
|
+
def _on_response(self, e: "Network.ResponseReceived") -> Optional[RequestSent]:
|
|
186
|
+
rd = self._pending.get(e.request_id)
|
|
187
|
+
if rd is None:
|
|
188
|
+
return None
|
|
189
|
+
self._resp[e.request_id] = ResponseData(
|
|
190
|
+
request_id=e.request_id,
|
|
191
|
+
status_code=e.response.status,
|
|
192
|
+
status_text=e.response.status_text,
|
|
193
|
+
mime_type=e.response.mime_type,
|
|
194
|
+
headers=dict(e.response.headers),
|
|
195
|
+
from_cache=(
|
|
196
|
+
e.request_id in self._served_from_cache
|
|
197
|
+
or bool(e.response.from_disk_cache)
|
|
198
|
+
or bool(e.response.from_prefetch_cache)
|
|
199
|
+
),
|
|
200
|
+
remote_ip=getattr(e.response, "remote_ip_address", None),
|
|
201
|
+
)
|
|
202
|
+
self._emitted.add(e.request_id)
|
|
203
|
+
return RequestSent(request=rd) # request is now final
|
|
204
|
+
|
|
205
|
+
@on(Network.ResponseReceivedExtraInfo)
|
|
206
|
+
def _on_response_extra(self, e: "Network.ResponseReceivedExtraInfo") -> None:
|
|
207
|
+
resp = self._resp.get(e.request_id)
|
|
208
|
+
if resp and e.headers:
|
|
209
|
+
resp.headers = {**resp.headers, **e.headers}
|
|
210
|
+
|
|
211
|
+
@on(Network.LoadingFinished)
|
|
212
|
+
@emit(ResponseCompleted)
|
|
213
|
+
def _on_finished(self, e: "Network.LoadingFinished"):
|
|
214
|
+
return self._complete(
|
|
215
|
+
e.request_id,
|
|
216
|
+
encoded=float(e.encoded_data_length),
|
|
217
|
+
succeeded=True,
|
|
218
|
+
error=None,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
@on(Network.LoadingFailed)
|
|
222
|
+
@emit(ResponseCompleted)
|
|
223
|
+
def _on_failed(self, e: "Network.LoadingFailed"):
|
|
224
|
+
return self._complete(
|
|
225
|
+
e.request_id, encoded=0.0, succeeded=False, error=e.error_text
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _complete(self, request_id, *, encoded, succeeded, error):
|
|
229
|
+
rd = self._pending.get(request_id)
|
|
230
|
+
if rd is None:
|
|
231
|
+
return None
|
|
232
|
+
resp = self._resp.get(request_id) or ResponseData(
|
|
233
|
+
request_id=request_id, status_code=0
|
|
234
|
+
)
|
|
235
|
+
resp.encoded_data_length = encoded
|
|
236
|
+
resp.succeeded = succeeded
|
|
237
|
+
resp.error_text = error
|
|
238
|
+
# emit RequestSent first if a bodyless failure skipped responseReceived
|
|
239
|
+
out: list = [] if request_id in self._emitted else [RequestSent(request=rd)]
|
|
240
|
+
out.append(ResponseCompleted(response=resp))
|
|
241
|
+
# Terminal for this request: free the producer-side state so a
|
|
242
|
+
# long-lived page can't accumulate it forever. A duplicate terminal
|
|
243
|
+
# (e.g. a stray second loadingFinished) now finds nothing and is
|
|
244
|
+
# dropped, which is the right idempotent behaviour.
|
|
245
|
+
self._pending.pop(request_id, None)
|
|
246
|
+
self._resp.pop(request_id, None)
|
|
247
|
+
self._emitted.discard(request_id)
|
|
248
|
+
self._served_from_cache.discard(request_id)
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Expose this feature's events and types as attributes on the feature class,
|
|
253
|
+
# so it works as a namespace (RequestListener.RequestSent / .RequestData / ...).
|
|
254
|
+
attach_namespace(RequestListener, _types, _events)
|
|
255
|
+
|
|
256
|
+
__all__ = ["RequestListener"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Aggregated wire events for the request-listener feature.
|
|
2
|
+
|
|
3
|
+
Raw CDP emits, per request::
|
|
4
|
+
|
|
5
|
+
requestWillBeSent → requestWillBeSentExtraInfo → responseReceived
|
|
6
|
+
→ responseReceivedExtraInfo → dataReceived × N → loadingFinished
|
|
7
|
+
|
|
8
|
+
collapsed by the producer into exactly two events: :class:`RequestSent` (once the
|
|
9
|
+
request is final) and :class:`ResponseCompleted` (at completion, with merged
|
|
10
|
+
headers and the total byte count). Both carry the page ``sessionId`` so they
|
|
11
|
+
route to the owning page's target on the client.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import ClassVar
|
|
18
|
+
|
|
19
|
+
from ...cdp.mixins.datatype import FieldMeta
|
|
20
|
+
from ...cdp.mixins.event import Event, register_event
|
|
21
|
+
from .types import RequestData, ResponseData
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@register_event("Parsek.RequestListener.request")
|
|
25
|
+
@dataclass
|
|
26
|
+
class RequestSent(Event):
|
|
27
|
+
"""A request went out (folds requestWillBeSent + extraInfo + redirect chain)."""
|
|
28
|
+
|
|
29
|
+
request: RequestData
|
|
30
|
+
|
|
31
|
+
__FIELDS__: ClassVar[tuple] = (
|
|
32
|
+
FieldMeta("request", "request", False, "object",
|
|
33
|
+
ref="Parsek.RequestListener.RequestData"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@register_event("Parsek.RequestListener.response")
|
|
38
|
+
@dataclass
|
|
39
|
+
class ResponseCompleted(Event):
|
|
40
|
+
"""A response fully arrived (folds responseReceived + extraInfo + dataReceived
|
|
41
|
+
+ loadingFinished/Failed into one message at completion)."""
|
|
42
|
+
|
|
43
|
+
response: ResponseData
|
|
44
|
+
|
|
45
|
+
__FIELDS__: ClassVar[tuple] = (
|
|
46
|
+
FieldMeta("response", "response", False, "object",
|
|
47
|
+
ref="Parsek.RequestListener.ResponseData"),
|
|
48
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Types for the request-listener feature -- both the wire shapes and the views.
|
|
2
|
+
|
|
3
|
+
* **wire dataclasses** (:class:`RequestData` / :class:`ResponseData` /
|
|
4
|
+
:class:`RedirectEntry`) -- the aggregated shapes that cross the websocket;
|
|
5
|
+
``__FIELDS__`` are derived from annotations by
|
|
6
|
+
:func:`~parsek_cdp.core.feature.parsek_type`. Each folds several raw
|
|
7
|
+
``Network.*`` payloads into one record.
|
|
8
|
+
* **view types** (:class:`Request` / :class:`Response`) -- behavioural wrappers
|
|
9
|
+
the reducers rebuild from the wire dataclasses; the body stays lazy.
|
|
10
|
+
|
|
11
|
+
Everything for this feature is self-contained in this package; nothing lives in
|
|
12
|
+
a separate protocol module.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import base64
|
|
19
|
+
from dataclasses import field
|
|
20
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Union
|
|
21
|
+
|
|
22
|
+
from ...cdp.mixins.datatype import DataType
|
|
23
|
+
from ...core.feature import parsek_type
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from . import RequestListener
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
# wire dataclasses (aggregated)
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@parsek_type("Parsek.RequestListener.RedirectEntry")
|
|
35
|
+
class RedirectEntry(DataType):
|
|
36
|
+
"""One hop of a redirect chain, folded into the originating request."""
|
|
37
|
+
|
|
38
|
+
url: str
|
|
39
|
+
status_code: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@parsek_type("Parsek.RequestListener.RequestData")
|
|
43
|
+
class RequestData(DataType):
|
|
44
|
+
"""The request side, aggregated.
|
|
45
|
+
|
|
46
|
+
Folds ``requestWillBeSent`` + ``requestWillBeSentExtraInfo`` (merged headers)
|
|
47
|
+
and the whole redirect chain into one record.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
request_id: str
|
|
51
|
+
url: str
|
|
52
|
+
method: str
|
|
53
|
+
headers: dict = field(default_factory=dict)
|
|
54
|
+
post_data: Optional[str] = None
|
|
55
|
+
resource_type: Optional[str] = None
|
|
56
|
+
frame_id: Optional[str] = None
|
|
57
|
+
loader_id: Optional[str] = None
|
|
58
|
+
#: ``True`` for a main-frame document request (resets the current-doc log).
|
|
59
|
+
is_navigation: bool = False
|
|
60
|
+
redirects: List[RedirectEntry] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@parsek_type("Parsek.RequestListener.ResponseData")
|
|
64
|
+
class ResponseData(DataType):
|
|
65
|
+
"""The response side, aggregated and emitted once at completion.
|
|
66
|
+
|
|
67
|
+
Folds ``responseReceived`` + ``responseReceivedExtraInfo`` (merged headers) +
|
|
68
|
+
the terminal ``loadingFinished`` / ``loadingFailed`` (whose
|
|
69
|
+
``encodedDataLength`` is the byte total; per-chunk ``dataReceived`` is ignored).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
request_id: str
|
|
73
|
+
status_code: int
|
|
74
|
+
status_text: str = ""
|
|
75
|
+
mime_type: str = ""
|
|
76
|
+
headers: dict = field(default_factory=dict)
|
|
77
|
+
from_cache: bool = False
|
|
78
|
+
remote_ip: Optional[str] = None
|
|
79
|
+
#: Total bytes over the wire, taken from ``loadingFinished.encodedDataLength``
|
|
80
|
+
#: (the browser's authoritative total -- per-chunk ``dataReceived`` is ignored).
|
|
81
|
+
encoded_data_length: float = 0.0
|
|
82
|
+
succeeded: bool = True
|
|
83
|
+
error_text: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------------------------------- #
|
|
87
|
+
# cookie helpers
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_cookie_header(value: str) -> Dict[str, str]:
|
|
92
|
+
"""Parse a request ``Cookie`` header into ``name -> value``."""
|
|
93
|
+
out: Dict[str, str] = {}
|
|
94
|
+
for part in value.split(";"):
|
|
95
|
+
if "=" in part:
|
|
96
|
+
name, _, val = part.strip().partition("=")
|
|
97
|
+
out[name] = val
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_set_cookie(value: str) -> Dict[str, str]:
|
|
102
|
+
"""Parse one or more ``Set-Cookie`` headers (CDP joins them with newlines)."""
|
|
103
|
+
out: Dict[str, str] = {}
|
|
104
|
+
for line in value.split("\n"):
|
|
105
|
+
head = line.split(";", 1)[0].strip()
|
|
106
|
+
if "=" in head:
|
|
107
|
+
name, _, val = head.partition("=")
|
|
108
|
+
out[name] = val
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- #
|
|
113
|
+
# view types
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Response:
|
|
118
|
+
"""A response rebuilt from :class:`ResponseData`; body fetched lazily."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self, feature: "RequestListener", request: "Request", data: ResponseData
|
|
122
|
+
):
|
|
123
|
+
self._feature = feature
|
|
124
|
+
self.request = request
|
|
125
|
+
self._data = data
|
|
126
|
+
self._body: Optional[Union[str, bytes]] = None
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def url(self) -> str:
|
|
130
|
+
return self.request.url
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def status_code(self) -> int:
|
|
134
|
+
return self._data.status_code
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def headers(self) -> dict:
|
|
138
|
+
return self._data.headers
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def mime_type(self) -> str:
|
|
142
|
+
return self._data.mime_type
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def succeeded(self) -> bool:
|
|
146
|
+
return self._data.succeeded
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def cookies(self) -> Dict[str, str]:
|
|
150
|
+
for key, value in self.headers.items():
|
|
151
|
+
if key.lower() == "set-cookie":
|
|
152
|
+
return parse_set_cookie(value)
|
|
153
|
+
return {}
|
|
154
|
+
|
|
155
|
+
async def body(self) -> Union[str, bytes]:
|
|
156
|
+
"""Fetch the body once with plain CDP (``Network.getResponseBody``).
|
|
157
|
+
|
|
158
|
+
Bodies are never pushed in the aggregated events; the client pulls them
|
|
159
|
+
on demand and the server proxies the call straight to the browser.
|
|
160
|
+
"""
|
|
161
|
+
if self._body is None:
|
|
162
|
+
result = await self._feature.host.send(
|
|
163
|
+
"Network.getResponseBody", {"requestId": self.request.id}
|
|
164
|
+
)
|
|
165
|
+
raw: Union[str, bytes] = result.get("body", "")
|
|
166
|
+
if result.get("base64Encoded"):
|
|
167
|
+
decoded = base64.b64decode(raw)
|
|
168
|
+
try:
|
|
169
|
+
raw = decoded.decode("utf-8")
|
|
170
|
+
except UnicodeDecodeError:
|
|
171
|
+
raw = decoded
|
|
172
|
+
self._body = raw
|
|
173
|
+
return self._body
|
|
174
|
+
|
|
175
|
+
def __repr__(self) -> str:
|
|
176
|
+
return f"<Response [{self.status_code}] {self.url}>"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Request:
|
|
180
|
+
"""A request rebuilt from :class:`RequestData`, completed by a :class:`Response`."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, data: RequestData):
|
|
183
|
+
self._data = data
|
|
184
|
+
self.response: Optional[Response] = None
|
|
185
|
+
self.finished = False
|
|
186
|
+
self.succeeded: Optional[bool] = None
|
|
187
|
+
self.error_text: Optional[str] = None
|
|
188
|
+
self._completed = asyncio.Event()
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def id(self) -> str:
|
|
192
|
+
return self._data.request_id
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def url(self) -> str:
|
|
196
|
+
return self._data.url
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def method(self) -> str:
|
|
200
|
+
return self._data.method
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def headers(self) -> dict:
|
|
204
|
+
return self._data.headers
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def data(self) -> Optional[str]:
|
|
208
|
+
return self._data.post_data
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def resource_type(self) -> Optional[str]:
|
|
212
|
+
return self._data.resource_type
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def redirects(self) -> List[RedirectEntry]:
|
|
216
|
+
return self._data.redirects
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def cookies(self) -> Dict[str, str]:
|
|
220
|
+
for key, value in self.headers.items():
|
|
221
|
+
if key.lower() == "cookie":
|
|
222
|
+
return parse_cookie_header(value)
|
|
223
|
+
return {}
|
|
224
|
+
|
|
225
|
+
async def join(self, *, timeout: Optional[float] = None) -> "Request":
|
|
226
|
+
await asyncio.wait_for(self._completed.wait(), timeout)
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
wait = join
|
|
230
|
+
|
|
231
|
+
def __repr__(self) -> str:
|
|
232
|
+
state = (
|
|
233
|
+
"pending" if not self.finished else ("ok" if self.succeeded else "failed")
|
|
234
|
+
)
|
|
235
|
+
return f"<Request {self.method} [{state}] {self.url:.50}>"
|