webtap-tool 0.11.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.
- webtap/VISION.md +246 -0
- webtap/__init__.py +84 -0
- webtap/__main__.py +6 -0
- webtap/api/__init__.py +9 -0
- webtap/api/app.py +26 -0
- webtap/api/models.py +69 -0
- webtap/api/server.py +111 -0
- webtap/api/sse.py +182 -0
- webtap/api/state.py +89 -0
- webtap/app.py +79 -0
- webtap/cdp/README.md +275 -0
- webtap/cdp/__init__.py +12 -0
- webtap/cdp/har.py +302 -0
- webtap/cdp/schema/README.md +41 -0
- webtap/cdp/schema/cdp_protocol.json +32785 -0
- webtap/cdp/schema/cdp_version.json +8 -0
- webtap/cdp/session.py +667 -0
- webtap/client.py +81 -0
- webtap/commands/DEVELOPER_GUIDE.md +401 -0
- webtap/commands/TIPS.md +269 -0
- webtap/commands/__init__.py +29 -0
- webtap/commands/_builders.py +331 -0
- webtap/commands/_code_generation.py +110 -0
- webtap/commands/_tips.py +147 -0
- webtap/commands/_utils.py +273 -0
- webtap/commands/connection.py +220 -0
- webtap/commands/console.py +87 -0
- webtap/commands/fetch.py +310 -0
- webtap/commands/filters.py +116 -0
- webtap/commands/javascript.py +73 -0
- webtap/commands/js_export.py +73 -0
- webtap/commands/launch.py +72 -0
- webtap/commands/navigation.py +197 -0
- webtap/commands/network.py +136 -0
- webtap/commands/quicktype.py +306 -0
- webtap/commands/request.py +93 -0
- webtap/commands/selections.py +138 -0
- webtap/commands/setup.py +219 -0
- webtap/commands/to_model.py +163 -0
- webtap/daemon.py +185 -0
- webtap/daemon_state.py +53 -0
- webtap/filters.py +219 -0
- webtap/rpc/__init__.py +14 -0
- webtap/rpc/errors.py +49 -0
- webtap/rpc/framework.py +223 -0
- webtap/rpc/handlers.py +625 -0
- webtap/rpc/machine.py +84 -0
- webtap/services/README.md +83 -0
- webtap/services/__init__.py +15 -0
- webtap/services/console.py +124 -0
- webtap/services/dom.py +547 -0
- webtap/services/fetch.py +415 -0
- webtap/services/main.py +392 -0
- webtap/services/network.py +401 -0
- webtap/services/setup/__init__.py +185 -0
- webtap/services/setup/chrome.py +233 -0
- webtap/services/setup/desktop.py +255 -0
- webtap/services/setup/extension.py +147 -0
- webtap/services/setup/platform.py +162 -0
- webtap/services/state_snapshot.py +86 -0
- webtap_tool-0.11.0.dist-info/METADATA +535 -0
- webtap_tool-0.11.0.dist-info/RECORD +64 -0
- webtap_tool-0.11.0.dist-info/WHEEL +4 -0
- webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
webtap/services/fetch.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Fetch interception service for request/response debugging."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from webtap.cdp import CDPSession
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FetchService:
|
|
15
|
+
"""Fetch interception with explicit actions.
|
|
16
|
+
|
|
17
|
+
Provides request/response interception via CDP Fetch domain.
|
|
18
|
+
Paused requests must be explicitly resumed, failed, or modified.
|
|
19
|
+
State is stored in memory and cleared on disable.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
enabled: Whether fetch interception is currently enabled
|
|
23
|
+
cdp: CDP session for executing commands
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
"""Initialize fetch service."""
|
|
28
|
+
self.enabled = False
|
|
29
|
+
self.enable_response_stage = False # Config option for future
|
|
30
|
+
self.cdp: CDPSession | None = None
|
|
31
|
+
self._broadcast_callback: "Any | None" = None # Callback to service._trigger_broadcast()
|
|
32
|
+
|
|
33
|
+
def set_broadcast_callback(self, callback: "Any") -> None:
|
|
34
|
+
"""Set callback for broadcasting state changes.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
callback: Function to call when state changes (service._trigger_broadcast)
|
|
38
|
+
"""
|
|
39
|
+
self._broadcast_callback = callback
|
|
40
|
+
|
|
41
|
+
def _trigger_broadcast(self) -> None:
|
|
42
|
+
"""Trigger SSE broadcast via service callback (ensures snapshot update)."""
|
|
43
|
+
if self._broadcast_callback:
|
|
44
|
+
try:
|
|
45
|
+
self._broadcast_callback()
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.debug(f"Failed to trigger broadcast: {e}")
|
|
48
|
+
|
|
49
|
+
# ============= Core State Queries =============
|
|
50
|
+
|
|
51
|
+
def get_paused_by_network_id(self, network_id: str) -> dict | None:
|
|
52
|
+
"""Get paused Fetch event by networkId.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
network_id: Network request ID to lookup.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dict with rowid, requestId, stage or None if not found/resolved.
|
|
59
|
+
"""
|
|
60
|
+
if not self.cdp:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
results = self.cdp.query(
|
|
64
|
+
"""
|
|
65
|
+
WITH paused_fetch AS (
|
|
66
|
+
SELECT
|
|
67
|
+
rowid,
|
|
68
|
+
json_extract_string(event, '$.params.requestId') as request_id,
|
|
69
|
+
json_extract_string(event, '$.params.networkId') as network_id,
|
|
70
|
+
json_extract_string(event, '$.params.responseStatusCode') as response_status,
|
|
71
|
+
CASE WHEN json_extract_string(event, '$.params.responseStatusCode') IS NOT NULL
|
|
72
|
+
THEN 'Response' ELSE 'Request' END as stage
|
|
73
|
+
FROM events
|
|
74
|
+
WHERE method = 'Fetch.requestPaused'
|
|
75
|
+
AND json_extract_string(event, '$.params.networkId') = ?
|
|
76
|
+
),
|
|
77
|
+
resolved_fetch AS (
|
|
78
|
+
SELECT DISTINCT json_extract_string(event, '$.params.requestId') as network_id
|
|
79
|
+
FROM events
|
|
80
|
+
WHERE method IN ('Network.loadingFinished', 'Network.loadingFailed')
|
|
81
|
+
)
|
|
82
|
+
SELECT
|
|
83
|
+
pf.rowid,
|
|
84
|
+
pf.request_id,
|
|
85
|
+
pf.stage
|
|
86
|
+
FROM paused_fetch pf
|
|
87
|
+
WHERE pf.network_id NOT IN (SELECT network_id FROM resolved_fetch WHERE network_id IS NOT NULL)
|
|
88
|
+
ORDER BY pf.rowid DESC
|
|
89
|
+
LIMIT 1
|
|
90
|
+
""",
|
|
91
|
+
[network_id],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not results:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
row = results[0]
|
|
98
|
+
return {"rowid": row[0], "requestId": row[1], "stage": row[2]}
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def paused_count(self) -> int:
|
|
102
|
+
"""Count of paused requests from HAR view."""
|
|
103
|
+
if not self.cdp or not self.enabled:
|
|
104
|
+
return 0
|
|
105
|
+
result = self.cdp.query("SELECT COUNT(*) FROM har_summary WHERE state = 'paused'")
|
|
106
|
+
return result[0][0] if result else 0
|
|
107
|
+
|
|
108
|
+
def get_paused_event(self, rowid: int) -> dict | None:
|
|
109
|
+
"""Get full event data for a paused request.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
rowid: Row ID from the database
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Full CDP event data or None if not found
|
|
116
|
+
"""
|
|
117
|
+
if not self.cdp:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
result = self.cdp.query(
|
|
121
|
+
"""
|
|
122
|
+
SELECT event
|
|
123
|
+
FROM events
|
|
124
|
+
WHERE rowid = ?
|
|
125
|
+
AND method = 'Fetch.requestPaused'
|
|
126
|
+
""",
|
|
127
|
+
[rowid],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if result:
|
|
131
|
+
return json.loads(result[0][0])
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# ============= Enable/Disable =============
|
|
135
|
+
|
|
136
|
+
def enable(self, cdp: "CDPSession", response_stage: bool = False) -> dict[str, Any]:
|
|
137
|
+
"""Enable fetch interception.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
cdp: CDP session for executing commands
|
|
141
|
+
response_stage: Whether to also pause at Response stage
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Status dict with enabled state and paused count
|
|
145
|
+
"""
|
|
146
|
+
if self.enabled:
|
|
147
|
+
return {"enabled": True, "message": "Already enabled"}
|
|
148
|
+
|
|
149
|
+
self.cdp = cdp
|
|
150
|
+
self.enable_response_stage = response_stage
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
patterns = [{"urlPattern": "*", "requestStage": "Request"}]
|
|
154
|
+
|
|
155
|
+
if response_stage:
|
|
156
|
+
patterns.append({"urlPattern": "*", "requestStage": "Response"})
|
|
157
|
+
|
|
158
|
+
cdp.execute("Fetch.enable", {"patterns": patterns})
|
|
159
|
+
|
|
160
|
+
self.enabled = True
|
|
161
|
+
stage_msg = "Request and Response stages" if response_stage else "Request stage only"
|
|
162
|
+
logger.info(f"Fetch interception enabled ({stage_msg})")
|
|
163
|
+
|
|
164
|
+
self._trigger_broadcast() # Update snapshot
|
|
165
|
+
return {"enabled": True, "stages": stage_msg, "paused": self.paused_count}
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Failed to enable fetch: {e}")
|
|
169
|
+
return {"enabled": False, "error": str(e)}
|
|
170
|
+
|
|
171
|
+
def disable(self) -> dict[str, Any]:
|
|
172
|
+
"""Disable fetch interception.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Status dict with disabled state
|
|
176
|
+
"""
|
|
177
|
+
if not self.enabled:
|
|
178
|
+
return {"enabled": False, "message": "Already disabled"}
|
|
179
|
+
|
|
180
|
+
if not self.cdp:
|
|
181
|
+
return {"enabled": False, "error": "No CDP session"}
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
self.cdp.execute("Fetch.disable")
|
|
185
|
+
self.enabled = False
|
|
186
|
+
|
|
187
|
+
logger.info("Fetch interception disabled")
|
|
188
|
+
self._trigger_broadcast() # Update snapshot
|
|
189
|
+
return {"enabled": False}
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"Failed to disable fetch: {e}")
|
|
193
|
+
return {"enabled": self.enabled, "error": str(e)}
|
|
194
|
+
|
|
195
|
+
# ============= Explicit Actions =============
|
|
196
|
+
|
|
197
|
+
def continue_request(
|
|
198
|
+
self, rowid: int, modifications: dict[str, Any] | None = None, wait_for_next: float = 0.5
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Continue a specific paused request.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
rowid: Row ID from requests() table
|
|
204
|
+
modifications: Optional modifications to apply
|
|
205
|
+
wait_for_next: Time to wait for follow-up events (0 to disable)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Dict with continuation status and optional next event info
|
|
209
|
+
"""
|
|
210
|
+
if not self.enabled or not self.cdp:
|
|
211
|
+
return {"error": "Fetch not enabled"}
|
|
212
|
+
|
|
213
|
+
# Get the event
|
|
214
|
+
event = self.get_paused_event(rowid)
|
|
215
|
+
if not event:
|
|
216
|
+
return {"error": f"Event {rowid} not found"}
|
|
217
|
+
|
|
218
|
+
params = event["params"]
|
|
219
|
+
request_id = params["requestId"]
|
|
220
|
+
network_id = params.get("networkId")
|
|
221
|
+
|
|
222
|
+
# Determine stage and continue
|
|
223
|
+
if params.get("responseStatusCode"):
|
|
224
|
+
# Response stage
|
|
225
|
+
cdp_params = {"requestId": request_id}
|
|
226
|
+
if modifications:
|
|
227
|
+
cdp_params.update(modifications)
|
|
228
|
+
self.cdp.execute("Fetch.continueResponse", cdp_params)
|
|
229
|
+
stage = "response"
|
|
230
|
+
else:
|
|
231
|
+
# Request stage
|
|
232
|
+
cdp_params = {"requestId": request_id}
|
|
233
|
+
if modifications:
|
|
234
|
+
cdp_params.update(modifications)
|
|
235
|
+
self.cdp.execute("Fetch.continueRequest", cdp_params)
|
|
236
|
+
stage = "request"
|
|
237
|
+
|
|
238
|
+
result = {"resumed_from": stage, "network_id": network_id}
|
|
239
|
+
|
|
240
|
+
# Wait for follow-up if requested
|
|
241
|
+
if wait_for_next > 0 and network_id:
|
|
242
|
+
next_event = self._wait_for_next_event(request_id, network_id, rowid, wait_for_next)
|
|
243
|
+
if next_event:
|
|
244
|
+
result["outcome"] = next_event["type"] # "response", "redirect", or "complete"
|
|
245
|
+
if next_event.get("status"):
|
|
246
|
+
result["status"] = next_event["status"]
|
|
247
|
+
if next_event.get("request_id"):
|
|
248
|
+
result["redirect_request_id"] = next_event["request_id"]
|
|
249
|
+
else:
|
|
250
|
+
result["outcome"] = "complete"
|
|
251
|
+
else:
|
|
252
|
+
result["outcome"] = "unknown"
|
|
253
|
+
|
|
254
|
+
result["remaining"] = self.paused_count
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
def _wait_for_next_event(
|
|
258
|
+
self, request_id: str, network_id: str, after_rowid: int, timeout: float
|
|
259
|
+
) -> dict[str, Any] | None:
|
|
260
|
+
"""Wait for the next event in the chain (response stage or redirect).
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
request_id: The request ID that was just continued
|
|
264
|
+
network_id: The network ID for tracking redirects
|
|
265
|
+
after_rowid: Row ID to search after
|
|
266
|
+
timeout: Maximum time to wait
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Dict with next event info or None if nothing found
|
|
270
|
+
"""
|
|
271
|
+
if not self.cdp:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
start = time.time()
|
|
275
|
+
|
|
276
|
+
while time.time() - start < timeout:
|
|
277
|
+
try:
|
|
278
|
+
# Check for response stage (same requestId)
|
|
279
|
+
response = self.cdp.query(
|
|
280
|
+
"""
|
|
281
|
+
SELECT
|
|
282
|
+
rowid,
|
|
283
|
+
json_extract_string(event, '$.params.responseStatusCode') as status
|
|
284
|
+
FROM events
|
|
285
|
+
WHERE method = 'Fetch.requestPaused'
|
|
286
|
+
AND json_extract_string(event, '$.params.requestId') = ?
|
|
287
|
+
AND json_extract_string(event, '$.params.responseStatusCode') IS NOT NULL
|
|
288
|
+
AND rowid > ?
|
|
289
|
+
LIMIT 1
|
|
290
|
+
""",
|
|
291
|
+
[request_id, after_rowid],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if response and len(response) > 0:
|
|
295
|
+
return {
|
|
296
|
+
"rowid": response[0][0],
|
|
297
|
+
"type": "response",
|
|
298
|
+
"status": response[0][1],
|
|
299
|
+
"description": f"Response stage ready (status {response[0][1]})",
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# Check for redirect (new requestId, same networkId)
|
|
303
|
+
redirect = self.cdp.query(
|
|
304
|
+
"""
|
|
305
|
+
SELECT
|
|
306
|
+
rowid,
|
|
307
|
+
json_extract_string(event, '$.params.requestId') as new_request_id,
|
|
308
|
+
json_extract_string(event, '$.params.request.url') as url
|
|
309
|
+
FROM events
|
|
310
|
+
WHERE method = 'Fetch.requestPaused'
|
|
311
|
+
AND json_extract_string(event, '$.params.networkId') = ?
|
|
312
|
+
AND json_extract_string(event, '$.params.redirectedRequestId') = ?
|
|
313
|
+
AND rowid > ?
|
|
314
|
+
LIMIT 1
|
|
315
|
+
""",
|
|
316
|
+
[network_id, request_id, after_rowid],
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if redirect and len(redirect) > 0:
|
|
320
|
+
url = redirect[0][2]
|
|
321
|
+
return {
|
|
322
|
+
"rowid": redirect[0][0],
|
|
323
|
+
"type": "redirect",
|
|
324
|
+
"request_id": redirect[0][1],
|
|
325
|
+
"url": url[:60] if url else None,
|
|
326
|
+
"description": f"Redirected to {url[:40]}..." if url else "Redirected",
|
|
327
|
+
}
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.debug(f"Error during polling: {e}")
|
|
330
|
+
# Continue polling on transient errors
|
|
331
|
+
|
|
332
|
+
time.sleep(0.05) # 50ms polling
|
|
333
|
+
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def fail_request(self, rowid: int, reason: str = "BlockedByClient") -> dict[str, Any]:
|
|
337
|
+
"""Explicitly fail a request.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
rowid: Row ID from requests() table
|
|
341
|
+
reason: CDP error reason
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Dict with failure status
|
|
345
|
+
"""
|
|
346
|
+
if not self.enabled or not self.cdp:
|
|
347
|
+
return {"error": "Fetch not enabled"}
|
|
348
|
+
|
|
349
|
+
event = self.get_paused_event(rowid)
|
|
350
|
+
if not event:
|
|
351
|
+
return {"error": f"Event {rowid} not found"}
|
|
352
|
+
|
|
353
|
+
request_id = event["params"]["requestId"]
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
self.cdp.execute("Fetch.failRequest", {"requestId": request_id, "errorReason": reason})
|
|
357
|
+
|
|
358
|
+
return {"failed": rowid, "reason": reason, "remaining": self.paused_count - 1}
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
logger.error(f"Failed to fail request {rowid}: {e}")
|
|
362
|
+
return {"error": str(e)}
|
|
363
|
+
|
|
364
|
+
def fulfill_request(
|
|
365
|
+
self,
|
|
366
|
+
rowid: int,
|
|
367
|
+
response_code: int = 200,
|
|
368
|
+
response_headers: list[dict[str, str]] | None = None,
|
|
369
|
+
body: str = "",
|
|
370
|
+
) -> dict[str, Any]:
|
|
371
|
+
"""Fulfill a request with a custom response.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
rowid: Row ID from requests() table
|
|
375
|
+
response_code: HTTP response code
|
|
376
|
+
response_headers: Response headers
|
|
377
|
+
body: Response body
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Dict with fulfillment status
|
|
381
|
+
"""
|
|
382
|
+
if not self.enabled or not self.cdp:
|
|
383
|
+
return {"error": "Fetch not enabled"}
|
|
384
|
+
|
|
385
|
+
event = self.get_paused_event(rowid)
|
|
386
|
+
if not event:
|
|
387
|
+
return {"error": f"Event {rowid} not found"}
|
|
388
|
+
|
|
389
|
+
request_id = event["params"]["requestId"]
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
import base64
|
|
393
|
+
|
|
394
|
+
# Encode body to base64
|
|
395
|
+
body_base64 = base64.b64encode(body.encode()).decode()
|
|
396
|
+
|
|
397
|
+
params = {
|
|
398
|
+
"requestId": request_id,
|
|
399
|
+
"responseCode": response_code,
|
|
400
|
+
"body": body_base64,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if response_headers:
|
|
404
|
+
params["responseHeaders"] = response_headers
|
|
405
|
+
|
|
406
|
+
self.cdp.execute("Fetch.fulfillRequest", params)
|
|
407
|
+
|
|
408
|
+
return {"fulfilled": rowid, "response_code": response_code, "remaining": self.paused_count - 1}
|
|
409
|
+
|
|
410
|
+
except Exception as e:
|
|
411
|
+
logger.error(f"Failed to fulfill request {rowid}: {e}")
|
|
412
|
+
return {"error": str(e)}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# No exports - internal service only
|