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.
Files changed (64) hide show
  1. webtap/VISION.md +246 -0
  2. webtap/__init__.py +84 -0
  3. webtap/__main__.py +6 -0
  4. webtap/api/__init__.py +9 -0
  5. webtap/api/app.py +26 -0
  6. webtap/api/models.py +69 -0
  7. webtap/api/server.py +111 -0
  8. webtap/api/sse.py +182 -0
  9. webtap/api/state.py +89 -0
  10. webtap/app.py +79 -0
  11. webtap/cdp/README.md +275 -0
  12. webtap/cdp/__init__.py +12 -0
  13. webtap/cdp/har.py +302 -0
  14. webtap/cdp/schema/README.md +41 -0
  15. webtap/cdp/schema/cdp_protocol.json +32785 -0
  16. webtap/cdp/schema/cdp_version.json +8 -0
  17. webtap/cdp/session.py +667 -0
  18. webtap/client.py +81 -0
  19. webtap/commands/DEVELOPER_GUIDE.md +401 -0
  20. webtap/commands/TIPS.md +269 -0
  21. webtap/commands/__init__.py +29 -0
  22. webtap/commands/_builders.py +331 -0
  23. webtap/commands/_code_generation.py +110 -0
  24. webtap/commands/_tips.py +147 -0
  25. webtap/commands/_utils.py +273 -0
  26. webtap/commands/connection.py +220 -0
  27. webtap/commands/console.py +87 -0
  28. webtap/commands/fetch.py +310 -0
  29. webtap/commands/filters.py +116 -0
  30. webtap/commands/javascript.py +73 -0
  31. webtap/commands/js_export.py +73 -0
  32. webtap/commands/launch.py +72 -0
  33. webtap/commands/navigation.py +197 -0
  34. webtap/commands/network.py +136 -0
  35. webtap/commands/quicktype.py +306 -0
  36. webtap/commands/request.py +93 -0
  37. webtap/commands/selections.py +138 -0
  38. webtap/commands/setup.py +219 -0
  39. webtap/commands/to_model.py +163 -0
  40. webtap/daemon.py +185 -0
  41. webtap/daemon_state.py +53 -0
  42. webtap/filters.py +219 -0
  43. webtap/rpc/__init__.py +14 -0
  44. webtap/rpc/errors.py +49 -0
  45. webtap/rpc/framework.py +223 -0
  46. webtap/rpc/handlers.py +625 -0
  47. webtap/rpc/machine.py +84 -0
  48. webtap/services/README.md +83 -0
  49. webtap/services/__init__.py +15 -0
  50. webtap/services/console.py +124 -0
  51. webtap/services/dom.py +547 -0
  52. webtap/services/fetch.py +415 -0
  53. webtap/services/main.py +392 -0
  54. webtap/services/network.py +401 -0
  55. webtap/services/setup/__init__.py +185 -0
  56. webtap/services/setup/chrome.py +233 -0
  57. webtap/services/setup/desktop.py +255 -0
  58. webtap/services/setup/extension.py +147 -0
  59. webtap/services/setup/platform.py +162 -0
  60. webtap/services/state_snapshot.py +86 -0
  61. webtap_tool-0.11.0.dist-info/METADATA +535 -0
  62. webtap_tool-0.11.0.dist-info/RECORD +64 -0
  63. webtap_tool-0.11.0.dist-info/WHEEL +4 -0
  64. webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
@@ -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