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.
Files changed (242) hide show
  1. parsek_cdp/__init__.py +55 -0
  2. parsek_cdp/_logging.py +49 -0
  3. parsek_cdp/cdp/__init__.py +247 -0
  4. parsek_cdp/cdp/accessibility/__init__.py +15 -0
  5. parsek_cdp/cdp/accessibility/events.py +35 -0
  6. parsek_cdp/cdp/accessibility/functions.py +202 -0
  7. parsek_cdp/cdp/accessibility/types.py +235 -0
  8. parsek_cdp/cdp/animation/__init__.py +15 -0
  9. parsek_cdp/cdp/animation/events.py +52 -0
  10. parsek_cdp/cdp/animation/functions.py +140 -0
  11. parsek_cdp/cdp/animation/types.py +113 -0
  12. parsek_cdp/cdp/audits/__init__.py +15 -0
  13. parsek_cdp/cdp/audits/events.py +21 -0
  14. parsek_cdp/cdp/audits/functions.py +95 -0
  15. parsek_cdp/cdp/audits/types.py +1051 -0
  16. parsek_cdp/cdp/autofill/__init__.py +15 -0
  17. parsek_cdp/cdp/autofill/events.py +27 -0
  18. parsek_cdp/cdp/autofill/functions.py +64 -0
  19. parsek_cdp/cdp/autofill/types.py +108 -0
  20. parsek_cdp/cdp/backgroundservice/__init__.py +15 -0
  21. parsek_cdp/cdp/backgroundservice/events.py +40 -0
  22. parsek_cdp/cdp/backgroundservice/functions.py +59 -0
  23. parsek_cdp/cdp/backgroundservice/types.py +63 -0
  24. parsek_cdp/cdp/bluetoothemulation/__init__.py +15 -0
  25. parsek_cdp/cdp/bluetoothemulation/events.py +70 -0
  26. parsek_cdp/cdp/bluetoothemulation/functions.py +247 -0
  27. parsek_cdp/cdp/bluetoothemulation/types.py +120 -0
  28. parsek_cdp/cdp/browser/__init__.py +15 -0
  29. parsek_cdp/cdp/browser/events.py +46 -0
  30. parsek_cdp/cdp/browser/functions.py +350 -0
  31. parsek_cdp/cdp/browser/types.py +158 -0
  32. parsek_cdp/cdp/cachestorage/__init__.py +15 -0
  33. parsek_cdp/cdp/cachestorage/events.py +13 -0
  34. parsek_cdp/cdp/cachestorage/functions.py +126 -0
  35. parsek_cdp/cdp/cachestorage/types.py +89 -0
  36. parsek_cdp/cdp/cast/__init__.py +15 -0
  37. parsek_cdp/cdp/cast/events.py +38 -0
  38. parsek_cdp/cdp/cast/functions.py +77 -0
  39. parsek_cdp/cdp/cast/types.py +23 -0
  40. parsek_cdp/cdp/console/__init__.py +15 -0
  41. parsek_cdp/cdp/console/events.py +22 -0
  42. parsek_cdp/cdp/console/functions.py +36 -0
  43. parsek_cdp/cdp/console/types.py +30 -0
  44. parsek_cdp/cdp/css/__init__.py +15 -0
  45. parsek_cdp/cdp/css/events.py +79 -0
  46. parsek_cdp/cdp/css/functions.py +788 -0
  47. parsek_cdp/cdp/css/types.py +742 -0
  48. parsek_cdp/cdp/debugger/__init__.py +15 -0
  49. parsek_cdp/cdp/debugger/events.py +164 -0
  50. parsek_cdp/cdp/debugger/functions.py +663 -0
  51. parsek_cdp/cdp/debugger/types.py +171 -0
  52. parsek_cdp/cdp/deviceaccess/__init__.py +15 -0
  53. parsek_cdp/cdp/deviceaccess/events.py +30 -0
  54. parsek_cdp/cdp/deviceaccess/functions.py +54 -0
  55. parsek_cdp/cdp/deviceaccess/types.py +28 -0
  56. parsek_cdp/cdp/deviceorientation/__init__.py +15 -0
  57. parsek_cdp/cdp/deviceorientation/events.py +13 -0
  58. parsek_cdp/cdp/deviceorientation/functions.py +35 -0
  59. parsek_cdp/cdp/deviceorientation/types.py +10 -0
  60. parsek_cdp/cdp/dom/__init__.py +15 -0
  61. parsek_cdp/cdp/dom/events.py +228 -0
  62. parsek_cdp/cdp/dom/functions.py +1033 -0
  63. parsek_cdp/cdp/dom/types.py +293 -0
  64. parsek_cdp/cdp/domdebugger/__init__.py +15 -0
  65. parsek_cdp/cdp/domdebugger/events.py +13 -0
  66. parsek_cdp/cdp/domdebugger/functions.py +155 -0
  67. parsek_cdp/cdp/domdebugger/types.py +57 -0
  68. parsek_cdp/cdp/domsnapshot/__init__.py +15 -0
  69. parsek_cdp/cdp/domsnapshot/events.py +13 -0
  70. parsek_cdp/cdp/domsnapshot/functions.py +111 -0
  71. parsek_cdp/cdp/domsnapshot/types.py +312 -0
  72. parsek_cdp/cdp/domstorage/__init__.py +15 -0
  73. parsek_cdp/cdp/domstorage/events.py +60 -0
  74. parsek_cdp/cdp/domstorage/functions.py +79 -0
  75. parsek_cdp/cdp/domstorage/types.py +30 -0
  76. parsek_cdp/cdp/emulation/__init__.py +15 -0
  77. parsek_cdp/cdp/emulation/events.py +17 -0
  78. parsek_cdp/cdp/emulation/functions.py +688 -0
  79. parsek_cdp/cdp/emulation/types.py +305 -0
  80. parsek_cdp/cdp/eventbreakpoints/__init__.py +15 -0
  81. parsek_cdp/cdp/eventbreakpoints/events.py +13 -0
  82. parsek_cdp/cdp/eventbreakpoints/functions.py +41 -0
  83. parsek_cdp/cdp/eventbreakpoints/types.py +10 -0
  84. parsek_cdp/cdp/extensions/__init__.py +15 -0
  85. parsek_cdp/cdp/extensions/events.py +13 -0
  86. parsek_cdp/cdp/extensions/functions.py +118 -0
  87. parsek_cdp/cdp/extensions/types.py +19 -0
  88. parsek_cdp/cdp/fedcm/__init__.py +15 -0
  89. parsek_cdp/cdp/fedcm/events.py +45 -0
  90. parsek_cdp/cdp/fedcm/functions.py +93 -0
  91. parsek_cdp/cdp/fedcm/types.py +72 -0
  92. parsek_cdp/cdp/fetch/__init__.py +15 -0
  93. parsek_cdp/cdp/fetch/events.py +82 -0
  94. parsek_cdp/cdp/fetch/functions.py +215 -0
  95. parsek_cdp/cdp/fetch/types.py +81 -0
  96. parsek_cdp/cdp/filesystem/__init__.py +15 -0
  97. parsek_cdp/cdp/filesystem/events.py +13 -0
  98. parsek_cdp/cdp/filesystem/functions.py +36 -0
  99. parsek_cdp/cdp/filesystem/types.py +54 -0
  100. parsek_cdp/cdp/headlessexperimental/__init__.py +15 -0
  101. parsek_cdp/cdp/headlessexperimental/events.py +13 -0
  102. parsek_cdp/cdp/headlessexperimental/functions.py +77 -0
  103. parsek_cdp/cdp/headlessexperimental/types.py +24 -0
  104. parsek_cdp/cdp/heapprofiler/__init__.py +15 -0
  105. parsek_cdp/cdp/heapprofiler/events.py +65 -0
  106. parsek_cdp/cdp/heapprofiler/functions.py +193 -0
  107. parsek_cdp/cdp/heapprofiler/types.py +57 -0
  108. parsek_cdp/cdp/indexeddb/__init__.py +15 -0
  109. parsek_cdp/cdp/indexeddb/events.py +13 -0
  110. parsek_cdp/cdp/indexeddb/functions.py +232 -0
  111. parsek_cdp/cdp/indexeddb/types.py +120 -0
  112. parsek_cdp/cdp/input/__init__.py +15 -0
  113. parsek_cdp/cdp/input/events.py +25 -0
  114. parsek_cdp/cdp/input/functions.py +361 -0
  115. parsek_cdp/cdp/input/types.py +87 -0
  116. parsek_cdp/cdp/inspector/__init__.py +15 -0
  117. parsek_cdp/cdp/inspector/events.py +41 -0
  118. parsek_cdp/cdp/inspector/functions.py +27 -0
  119. parsek_cdp/cdp/inspector/types.py +10 -0
  120. parsek_cdp/cdp/io/__init__.py +15 -0
  121. parsek_cdp/cdp/io/events.py +13 -0
  122. parsek_cdp/cdp/io/functions.py +77 -0
  123. parsek_cdp/cdp/io/types.py +13 -0
  124. parsek_cdp/cdp/layertree/__init__.py +15 -0
  125. parsek_cdp/cdp/layertree/events.py +36 -0
  126. parsek_cdp/cdp/layertree/functions.py +178 -0
  127. parsek_cdp/cdp/layertree/types.py +104 -0
  128. parsek_cdp/cdp/log/__init__.py +15 -0
  129. parsek_cdp/cdp/log/events.py +22 -0
  130. parsek_cdp/cdp/log/functions.py +54 -0
  131. parsek_cdp/cdp/log/types.py +57 -0
  132. parsek_cdp/cdp/media/__init__.py +15 -0
  133. parsek_cdp/cdp/media/events.py +87 -0
  134. parsek_cdp/cdp/media/functions.py +27 -0
  135. parsek_cdp/cdp/media/types.py +101 -0
  136. parsek_cdp/cdp/memory/__init__.py +15 -0
  137. parsek_cdp/cdp/memory/events.py +13 -0
  138. parsek_cdp/cdp/memory/functions.py +164 -0
  139. parsek_cdp/cdp/memory/types.py +71 -0
  140. parsek_cdp/cdp/mixins/__init__.py +21 -0
  141. parsek_cdp/cdp/mixins/datatype.py +123 -0
  142. parsek_cdp/cdp/mixins/event.py +78 -0
  143. parsek_cdp/cdp/mixins/namespace.py +31 -0
  144. parsek_cdp/cdp/network/__init__.py +15 -0
  145. parsek_cdp/cdp/network/events.py +726 -0
  146. parsek_cdp/cdp/network/functions.py +734 -0
  147. parsek_cdp/cdp/network/types.py +1247 -0
  148. parsek_cdp/cdp/overlay/__init__.py +15 -0
  149. parsek_cdp/cdp/overlay/events.py +54 -0
  150. parsek_cdp/cdp/overlay/functions.py +407 -0
  151. parsek_cdp/cdp/overlay/types.py +328 -0
  152. parsek_cdp/cdp/page/__init__.py +15 -0
  153. parsek_cdp/cdp/page/events.py +416 -0
  154. parsek_cdp/cdp/page/functions.py +1081 -0
  155. parsek_cdp/cdp/page/types.py +1088 -0
  156. parsek_cdp/cdp/performance/__init__.py +15 -0
  157. parsek_cdp/cdp/performance/events.py +24 -0
  158. parsek_cdp/cdp/performance/functions.py +63 -0
  159. parsek_cdp/cdp/performance/types.py +22 -0
  160. parsek_cdp/cdp/performancetimeline/__init__.py +15 -0
  161. parsek_cdp/cdp/performancetimeline/events.py +22 -0
  162. parsek_cdp/cdp/performancetimeline/functions.py +30 -0
  163. parsek_cdp/cdp/performancetimeline/types.py +85 -0
  164. parsek_cdp/cdp/preload/__init__.py +15 -0
  165. parsek_cdp/cdp/preload/events.py +116 -0
  166. parsek_cdp/cdp/preload/functions.py +25 -0
  167. parsek_cdp/cdp/preload/types.py +267 -0
  168. parsek_cdp/cdp/profiler/__init__.py +15 -0
  169. parsek_cdp/cdp/profiler/events.py +64 -0
  170. parsek_cdp/cdp/profiler/functions.py +134 -0
  171. parsek_cdp/cdp/profiler/types.py +105 -0
  172. parsek_cdp/cdp/pwa/__init__.py +15 -0
  173. parsek_cdp/cdp/pwa/events.py +13 -0
  174. parsek_cdp/cdp/pwa/functions.py +196 -0
  175. parsek_cdp/cdp/pwa/types.py +45 -0
  176. parsek_cdp/cdp/runtime/__init__.py +15 -0
  177. parsek_cdp/cdp/runtime/events.py +123 -0
  178. parsek_cdp/cdp/runtime/functions.py +552 -0
  179. parsek_cdp/cdp/runtime/types.py +319 -0
  180. parsek_cdp/cdp/schema/__init__.py +15 -0
  181. parsek_cdp/cdp/schema/events.py +13 -0
  182. parsek_cdp/cdp/schema/functions.py +32 -0
  183. parsek_cdp/cdp/schema/types.py +22 -0
  184. parsek_cdp/cdp/security/__init__.py +15 -0
  185. parsek_cdp/cdp/security/events.py +64 -0
  186. parsek_cdp/cdp/security/functions.py +66 -0
  187. parsek_cdp/cdp/security/types.py +168 -0
  188. parsek_cdp/cdp/serviceworker/__init__.py +15 -0
  189. parsek_cdp/cdp/serviceworker/events.py +43 -0
  190. parsek_cdp/cdp/serviceworker/functions.py +115 -0
  191. parsek_cdp/cdp/serviceworker/types.py +95 -0
  192. parsek_cdp/cdp/storage/__init__.py +15 -0
  193. parsek_cdp/cdp/storage/events.py +288 -0
  194. parsek_cdp/cdp/storage/functions.py +571 -0
  195. parsek_cdp/cdp/storage/types.py +656 -0
  196. parsek_cdp/cdp/systeminfo/__init__.py +15 -0
  197. parsek_cdp/cdp/systeminfo/events.py +13 -0
  198. parsek_cdp/cdp/systeminfo/functions.py +75 -0
  199. parsek_cdp/cdp/systeminfo/types.py +132 -0
  200. parsek_cdp/cdp/target/__init__.py +15 -0
  201. parsek_cdp/cdp/target/events.py +109 -0
  202. parsek_cdp/cdp/target/functions.py +424 -0
  203. parsek_cdp/cdp/target/types.py +83 -0
  204. parsek_cdp/cdp/tethering/__init__.py +15 -0
  205. parsek_cdp/cdp/tethering/events.py +22 -0
  206. parsek_cdp/cdp/tethering/functions.py +35 -0
  207. parsek_cdp/cdp/tethering/types.py +10 -0
  208. parsek_cdp/cdp/tracing/__init__.py +15 -0
  209. parsek_cdp/cdp/tracing/events.py +61 -0
  210. parsek_cdp/cdp/tracing/functions.py +119 -0
  211. parsek_cdp/cdp/tracing/types.py +81 -0
  212. parsek_cdp/cdp/webaudio/__init__.py +15 -0
  213. parsek_cdp/cdp/webaudio/events.py +184 -0
  214. parsek_cdp/cdp/webaudio/functions.py +51 -0
  215. parsek_cdp/cdp/webaudio/types.py +155 -0
  216. parsek_cdp/cdp/webauthn/__init__.py +15 -0
  217. parsek_cdp/cdp/webauthn/events.py +69 -0
  218. parsek_cdp/cdp/webauthn/functions.py +214 -0
  219. parsek_cdp/cdp/webauthn/types.py +96 -0
  220. parsek_cdp/core/__init__.py +18 -0
  221. parsek_cdp/core/browser.py +240 -0
  222. parsek_cdp/core/browser_context.py +58 -0
  223. parsek_cdp/core/element.py +405 -0
  224. parsek_cdp/core/feature.py +376 -0
  225. parsek_cdp/core/frame.py +351 -0
  226. parsek_cdp/core/pagable.py +175 -0
  227. parsek_cdp/core/page.py +163 -0
  228. parsek_cdp/core/target.py +490 -0
  229. parsek_cdp/features/__init__.py +14 -0
  230. parsek_cdp/features/request_listener/__init__.py +256 -0
  231. parsek_cdp/features/request_listener/events.py +48 -0
  232. parsek_cdp/features/request_listener/types.py +235 -0
  233. parsek_cdp/generate.py +642 -0
  234. parsek_cdp/parsek/__init__.py +18 -0
  235. parsek_cdp/parsek/events.py +38 -0
  236. parsek_cdp/parsek/types.py +36 -0
  237. parsek_cdp/py.typed +0 -0
  238. parsek_cdp-0.1.0.dist-info/METADATA +277 -0
  239. parsek_cdp-0.1.0.dist-info/RECORD +242 -0
  240. parsek_cdp-0.1.0.dist-info/WHEEL +5 -0
  241. parsek_cdp-0.1.0.dist-info/licenses/LICENSE +201 -0
  242. 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}>"