teams-transcripts 0.5.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 (42) hide show
  1. legacy/__init__.py +0 -0
  2. legacy/transcript_download.py +3969 -0
  3. teams_transcripts/__init__.py +1 -0
  4. teams_transcripts/__main__.py +288 -0
  5. teams_transcripts/adapters/__init__.py +1 -0
  6. teams_transcripts/adapters/cdp/__init__.py +1 -0
  7. teams_transcripts/adapters/cdp/browser.py +196 -0
  8. teams_transcripts/adapters/cdp/connection.py +168 -0
  9. teams_transcripts/adapters/cdp/helpers.py +449 -0
  10. teams_transcripts/adapters/cdp/teams.py +463 -0
  11. teams_transcripts/adapters/vertex.py +274 -0
  12. teams_transcripts/domain/__init__.py +1 -0
  13. teams_transcripts/domain/chat.py +115 -0
  14. teams_transcripts/domain/html.py +126 -0
  15. teams_transcripts/domain/mcps.py +343 -0
  16. teams_transcripts/domain/models.py +531 -0
  17. teams_transcripts/domain/participants.py +292 -0
  18. teams_transcripts/domain/scoring.py +106 -0
  19. teams_transcripts/domain/speakers.py +130 -0
  20. teams_transcripts/domain/summary.py +266 -0
  21. teams_transcripts/domain/timestamps.py +350 -0
  22. teams_transcripts/domain/vtt.py +444 -0
  23. teams_transcripts/infrastructure/__init__.py +1 -0
  24. teams_transcripts/infrastructure/config.py +987 -0
  25. teams_transcripts/infrastructure/errors.py +120 -0
  26. teams_transcripts/infrastructure/logging.py +69 -0
  27. teams_transcripts/infrastructure/signals.py +143 -0
  28. teams_transcripts/mcp_server.py +818 -0
  29. teams_transcripts/ports/__init__.py +1 -0
  30. teams_transcripts/ports/agent.py +58 -0
  31. teams_transcripts/ports/browser.py +126 -0
  32. teams_transcripts/services/__init__.py +1 -0
  33. teams_transcripts/services/downloader.py +1283 -0
  34. teams_transcripts/services/extraction.py +1603 -0
  35. teams_transcripts/services/listing.py +826 -0
  36. teams_transcripts/services/navigation.py +1721 -0
  37. teams_transcripts/services/output.py +84 -0
  38. teams_transcripts/services/tenant.py +684 -0
  39. teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
  40. teams_transcripts-0.5.0.dist-info/RECORD +42 -0
  41. teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
  42. teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1 @@
1
+ """teams_transcripts -- hexagonal architecture for Teams transcript download."""
@@ -0,0 +1,288 @@
1
+ """CLI entry point for the teams_transcripts package.
2
+
3
+ Usage::
4
+
5
+ python -m teams_transcripts --meeting "Weekly Standup" --datetime 2026-04-25
6
+ python -m teams_transcripts --meeting-id UUID
7
+ python -m teams_transcripts --list-tenants
8
+ python -m teams_transcripts --list-transcripts
9
+ python -m teams_transcripts --list-transcripts 7 --format json
10
+
11
+ This module is the thin entry point that wires infrastructure (config,
12
+ logging, signals) to the downloader service, the list-tenants command,
13
+ or the list-transcripts command.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ import sys
21
+ import typing
22
+
23
+ from teams_transcripts.infrastructure.config import (
24
+ _format_explicitly_set,
25
+ parse_args,
26
+ resolve_config,
27
+ )
28
+ from teams_transcripts.infrastructure.errors import EXIT_BAD_ARGS, exit_error
29
+ from teams_transcripts.infrastructure.logging import Logger
30
+ from teams_transcripts.infrastructure.signals import ShutdownCoordinator
31
+ from teams_transcripts.services.downloader import run_download
32
+
33
+ if typing.TYPE_CHECKING:
34
+ from teams_transcripts.adapters.cdp.browser import CdpBrowserAdapter
35
+ from teams_transcripts.services.tenant import TenantVerification
36
+
37
+ # Flags that are only relevant in download mode and must be rejected in list modes.
38
+ _DOWNLOAD_ONLY_FLAGS: dict[str, str] = {
39
+ "--gcp-project": "--gcp-project",
40
+ "--model": "--model",
41
+ "--gcp-region": "--gcp-region",
42
+ "--adc": "--adc",
43
+ "--dry-run": "--dry-run",
44
+ "--transcript": "--transcript",
45
+ "--chat": "--chat",
46
+ "--metadata": "--metadata",
47
+ }
48
+
49
+
50
+ def _check_download_only_flags(mode_flag: str, format_json: bool) -> None:
51
+ """Reject download-only flags that have no effect in list modes."""
52
+ for argv_flag, display_name in _DOWNLOAD_ONLY_FLAGS.items():
53
+ if any(arg == argv_flag or arg.startswith(f"{argv_flag}=") for arg in sys.argv):
54
+ exit_error(
55
+ EXIT_BAD_ARGS,
56
+ "BAD_ARGS",
57
+ f"{display_name} is incompatible with {mode_flag}.",
58
+ format_json=format_json,
59
+ )
60
+
61
+
62
+ def main() -> None:
63
+ """Synchronous entry point for ``python -m teams_transcripts``."""
64
+ # Show help when invoked with no arguments.
65
+ if len(sys.argv) <= 1:
66
+ sys.argv.append("--help")
67
+
68
+ args = parse_args()
69
+
70
+ # --list-tenants and --list-transcripts are mutually exclusive.
71
+ if args.list_tenants and args.list_transcripts is not None:
72
+ exit_error(
73
+ EXIT_BAD_ARGS,
74
+ "BAD_ARGS",
75
+ "--list-tenants and --list-transcripts are mutually exclusive.",
76
+ format_json=args.output_format == "json",
77
+ )
78
+
79
+ # --list-tenants: separate command path (no MeetingConfig needed)
80
+ if args.list_tenants:
81
+ _run_list_tenants(args)
82
+ return
83
+
84
+ # --list-transcripts: separate command path (no MeetingConfig needed)
85
+ if args.list_transcripts is not None:
86
+ _run_list_transcripts(args)
87
+ return
88
+
89
+ config = resolve_config(args)
90
+
91
+ logger = Logger(quiet=config.quiet, verbose=config.verbose)
92
+ shutdown = ShutdownCoordinator(format_json=config.format_json)
93
+ shutdown.install_signal_handlers()
94
+
95
+ asyncio.run(run_download(config, logger, shutdown))
96
+
97
+
98
+ def _run_list_tenants(args: object) -> None:
99
+ """Handle the --list-tenants command path.
100
+
101
+ Validates flag combinations, then delegates to
102
+ ``services.tenant.run_list_tenants``.
103
+ """
104
+ from teams_transcripts.services.tenant import run_list_tenants
105
+
106
+ format_json = args.output_format == "json" # type: ignore[attr-defined]
107
+
108
+ # Incompatible flags
109
+ if args.meeting is not None: # type: ignore[attr-defined]
110
+ exit_error(
111
+ EXIT_BAD_ARGS,
112
+ "BAD_ARGS",
113
+ "--meeting is incompatible with --list-tenants.",
114
+ format_json=format_json,
115
+ )
116
+ if args.meeting_id is not None: # type: ignore[attr-defined]
117
+ exit_error(
118
+ EXIT_BAD_ARGS,
119
+ "BAD_ARGS",
120
+ "--meeting-id is incompatible with --list-tenants.",
121
+ format_json=format_json,
122
+ )
123
+ if args.datetime is not None: # type: ignore[attr-defined]
124
+ exit_error(
125
+ EXIT_BAD_ARGS,
126
+ "BAD_ARGS",
127
+ "--datetime is incompatible with --list-tenants.",
128
+ format_json=format_json,
129
+ )
130
+ _check_download_only_flags("--list-tenants", format_json)
131
+
132
+ logger = Logger(
133
+ quiet=args.quiet, # type: ignore[attr-defined]
134
+ verbose=args.verbose, # type: ignore[attr-defined]
135
+ )
136
+ shutdown = ShutdownCoordinator(format_json=format_json)
137
+ shutdown.install_signal_handlers()
138
+
139
+ asyncio.run(
140
+ run_list_tenants(
141
+ args.port, # type: ignore[attr-defined]
142
+ logger=logger,
143
+ shutdown=shutdown,
144
+ format_json=format_json,
145
+ output_path=args.output, # type: ignore[attr-defined]
146
+ force_restart=getattr(args, "force_restart", False),
147
+ )
148
+ )
149
+
150
+
151
+ def _run_list_transcripts(args: object) -> None:
152
+ """Handle the --list-transcripts command path.
153
+
154
+ Validates flag combinations, then delegates to
155
+ ``services.listing.run_list_transcripts``.
156
+ """
157
+ from teams_transcripts.adapters.cdp.browser import CdpBrowserAdapter
158
+ from teams_transcripts.adapters.cdp.helpers import find_teams_targets, verify_cdp
159
+ from teams_transcripts.adapters.cdp.teams import ensure_teams_running
160
+ from teams_transcripts.infrastructure.errors import EXIT_CDP_UNAVAILABLE
161
+ from teams_transcripts.services.listing import run_list_transcripts
162
+ from teams_transcripts.services.tenant import pick_detection_target
163
+
164
+ format_json = args.output_format == "json" # type: ignore[attr-defined]
165
+
166
+ # --format vtt is not valid with --list-transcripts
167
+ # Only reject if user explicitly passed --format vtt (the argparse default is vtt)
168
+ if args.output_format == "vtt" and _format_explicitly_set(args): # type: ignore[attr-defined]
169
+ exit_error(
170
+ EXIT_BAD_ARGS,
171
+ "BAD_ARGS",
172
+ "--format vtt is not valid with --list-transcripts. "
173
+ "Use --format json or omit --format for a table.",
174
+ format_json=False,
175
+ )
176
+
177
+ # Incompatible flags
178
+ if args.meeting is not None: # type: ignore[attr-defined]
179
+ exit_error(
180
+ EXIT_BAD_ARGS,
181
+ "BAD_ARGS",
182
+ "--meeting is incompatible with --list-transcripts.",
183
+ format_json=format_json,
184
+ )
185
+ if args.meeting_id is not None: # type: ignore[attr-defined]
186
+ exit_error(
187
+ EXIT_BAD_ARGS,
188
+ "BAD_ARGS",
189
+ "--meeting-id is incompatible with --list-transcripts.",
190
+ format_json=format_json,
191
+ )
192
+ if args.datetime is not None: # type: ignore[attr-defined]
193
+ exit_error(
194
+ EXIT_BAD_ARGS,
195
+ "BAD_ARGS",
196
+ "--datetime is incompatible with --list-transcripts.",
197
+ format_json=format_json,
198
+ )
199
+ _check_download_only_flags("--list-transcripts", format_json)
200
+
201
+ days = args.list_transcripts # type: ignore[attr-defined]
202
+ if days < 1:
203
+ exit_error(
204
+ EXIT_BAD_ARGS,
205
+ "BAD_ARGS",
206
+ "--list-transcripts DAYS must be 1 or greater.",
207
+ format_json=format_json,
208
+ )
209
+
210
+ if not 1 <= args.port <= 65535: # type: ignore[attr-defined]
211
+ exit_error(
212
+ EXIT_BAD_ARGS,
213
+ "BAD_ARGS",
214
+ f"Invalid port '{args.port}'. Expected a value from 1 to 65535.", # type: ignore[attr-defined]
215
+ format_json=format_json,
216
+ )
217
+
218
+ logger = Logger(
219
+ quiet=args.quiet, # type: ignore[attr-defined]
220
+ verbose=args.verbose, # type: ignore[attr-defined]
221
+ )
222
+ shutdown = ShutdownCoordinator(format_json=format_json)
223
+ shutdown.install_signal_handlers()
224
+
225
+ tenant = args.tenant or os.environ.get("TEAMS_TENANT") or None # type: ignore[attr-defined]
226
+ tenant_id: str | None = None
227
+ if tenant:
228
+ ensure_teams_running(
229
+ args.port, # type: ignore[attr-defined]
230
+ logger=logger,
231
+ shutdown=shutdown,
232
+ format_json=format_json,
233
+ force=getattr(args, "force_restart", False),
234
+ )
235
+ verify_cdp(args.port, logger=logger, shutdown=shutdown, format_json=format_json) # type: ignore[attr-defined]
236
+ targets = find_teams_targets(args.port, logger=logger, format_json=format_json) # type: ignore[attr-defined]
237
+ if not targets:
238
+ exit_error(
239
+ EXIT_CDP_UNAVAILABLE,
240
+ "NO_TARGETS",
241
+ "No Teams page targets found via CDP.",
242
+ step="list-transcripts",
243
+ format_json=format_json,
244
+ )
245
+ browser = CdpBrowserAdapter(port=args.port) # type: ignore[attr-defined]
246
+ awaitable = _resolve_tenant_for_list(
247
+ browser,
248
+ pick_detection_target(targets),
249
+ tenant,
250
+ logger,
251
+ format_json,
252
+ )
253
+ resolved = asyncio.run(awaitable)
254
+ tenant_id = resolved.tenant_id
255
+
256
+ asyncio.run(
257
+ run_list_transcripts(
258
+ args.port, # type: ignore[attr-defined]
259
+ days=days,
260
+ logger=logger,
261
+ shutdown=shutdown,
262
+ format_json=format_json,
263
+ output_path=args.output, # type: ignore[attr-defined]
264
+ tenant_id=tenant_id,
265
+ force_restart=getattr(args, "force_restart", False),
266
+ )
267
+ )
268
+
269
+
270
+ async def _resolve_tenant_for_list(
271
+ browser: CdpBrowserAdapter,
272
+ target_id: str,
273
+ tenant: str,
274
+ logger: Logger,
275
+ format_json: bool,
276
+ ) -> TenantVerification:
277
+ """Resolve a list-mode tenant domain to a canonical tenant ID."""
278
+ from teams_transcripts.services.tenant import resolve_requested_tenant
279
+
280
+ await browser.connect(target_id)
281
+ try:
282
+ return await resolve_requested_tenant(browser, tenant, logger, format_json=format_json)
283
+ finally:
284
+ await browser.disconnect()
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
@@ -0,0 +1 @@
1
+ """Adapter implementations -- concrete implementations of port interfaces."""
@@ -0,0 +1 @@
1
+ """CDP adapter -- Chrome DevTools Protocol implementation of BrowserPort."""
@@ -0,0 +1,196 @@
1
+ """CDP browser adapter -- implements BrowserPort using CDP WebSocket.
2
+
3
+ Wraps low-level CDP operations (navigate, click, evaluate, screenshot,
4
+ type_text) into the BrowserPort protocol interface.
5
+
6
+ Extracted from ``transcript_download.py`` line 832 (type_text_via_cdp).
7
+ Higher-level operations (navigate_to_meeting, etc.) live in services.
8
+
9
+ Schema version: 1.0.0
10
+ Created: 2026-04-25
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import base64
17
+
18
+ from teams_transcripts.adapters.cdp.connection import (
19
+ CDPConnection,
20
+ WebSocketLike,
21
+ connect_to_target,
22
+ )
23
+
24
+
25
+ class CdpBrowserAdapter:
26
+ """BrowserPort implementation using Chrome DevTools Protocol.
27
+
28
+ Manages a single CDP connection to a browser target. Methods
29
+ correspond to the BrowserPort protocol operations.
30
+
31
+ Args:
32
+ port: CDP debugging port number.
33
+ """
34
+
35
+ def __init__(self, port: int) -> None:
36
+ self._port = port
37
+ self._ws: WebSocketLike | None = None
38
+ self._cdp: CDPConnection | None = None
39
+
40
+ @property
41
+ def cdp(self) -> CDPConnection:
42
+ """The underlying CDPConnection (for direct CDP access in services)."""
43
+ if self._cdp is None:
44
+ msg = "Not connected -- call connect() first"
45
+ raise RuntimeError(msg)
46
+ return self._cdp
47
+
48
+ async def connect(self, target_id: str) -> None:
49
+ """Open a CDP WebSocket connection to the specified target.
50
+
51
+ Args:
52
+ target_id: CDP target identifier.
53
+ """
54
+ self._ws, self._cdp = await connect_to_target(self._port, target_id)
55
+
56
+ async def disconnect(self) -> None:
57
+ """Close the current WebSocket connection."""
58
+ if self._ws is not None:
59
+ await self._ws.close()
60
+ self._ws = None
61
+ self._cdp = None
62
+
63
+ async def evaluate(self, expression: str, *, timeout: float = 15) -> object:
64
+ """Evaluate JavaScript in the connected target.
65
+
66
+ Args:
67
+ expression: JavaScript source to evaluate.
68
+ timeout: Maximum seconds to wait.
69
+
70
+ Returns:
71
+ The evaluated value.
72
+ """
73
+ return await self.cdp.evaluate(expression, timeout=timeout)
74
+
75
+ async def send_command(
76
+ self,
77
+ method: str,
78
+ params: dict | None = None,
79
+ *,
80
+ timeout: float = 15,
81
+ ) -> dict:
82
+ """Send a raw CDP command.
83
+
84
+ Args:
85
+ method: CDP method name.
86
+ params: CDP method parameters.
87
+ timeout: Maximum seconds to wait.
88
+
89
+ Returns:
90
+ The full CDP response dict.
91
+ """
92
+ return await self.cdp.send(method, params, timeout=timeout)
93
+
94
+ async def navigate(self, url: str, *, timeout: float = 30) -> None:
95
+ """Navigate to a URL.
96
+
97
+ Args:
98
+ url: Destination URL.
99
+ timeout: Maximum seconds to wait for navigation.
100
+ """
101
+ await self.cdp.send("Page.navigate", {"url": url}, timeout=timeout)
102
+
103
+ async def click(self, selector: str, *, timeout: float = 10) -> bool:
104
+ """Click the first element matching a CSS selector.
105
+
106
+ Uses JavaScript ``querySelector`` + ``click()`` rather than
107
+ CDP Input events for reliability.
108
+
109
+ Args:
110
+ selector: CSS selector string.
111
+ timeout: Maximum seconds to wait.
112
+
113
+ Returns:
114
+ ``True`` if the click succeeded.
115
+ """
116
+ js = f"""
117
+ (function() {{
118
+ var el = document.querySelector({selector!r});
119
+ if (!el) return false;
120
+ el.click();
121
+ return true;
122
+ }})()
123
+ """
124
+ result = await self.cdp.evaluate(js, timeout=timeout)
125
+ return result is True
126
+
127
+ async def type_text(self, text: str, *, delay_ms: int = 50) -> None:
128
+ """Type text character by character using CDP Input events.
129
+
130
+ Simulates keyDown/keyUp events for each character.
131
+
132
+ Args:
133
+ text: Text to type.
134
+ delay_ms: Milliseconds between keystrokes.
135
+ """
136
+ delay_sec = delay_ms / 1000.0
137
+ for char in text:
138
+ await self.cdp.send(
139
+ "Input.dispatchKeyEvent",
140
+ {
141
+ "type": "keyDown",
142
+ "text": char,
143
+ "key": char,
144
+ "code": f"Key{char.upper()}" if char.isalpha() else "",
145
+ "windowsVirtualKeyCode": (ord(char.upper()) if char.isalpha() else ord(char)),
146
+ },
147
+ )
148
+ await self.cdp.send(
149
+ "Input.dispatchKeyEvent",
150
+ {
151
+ "type": "keyUp",
152
+ "key": char,
153
+ "code": f"Key{char.upper()}" if char.isalpha() else "",
154
+ "windowsVirtualKeyCode": (ord(char.upper()) if char.isalpha() else ord(char)),
155
+ },
156
+ )
157
+ await asyncio.sleep(delay_sec)
158
+
159
+ async def screenshot(self, *, image_format: str = "png") -> bytes:
160
+ """Capture a screenshot.
161
+
162
+ Args:
163
+ image_format: Image format (``"png"`` or ``"jpeg"``).
164
+
165
+ Returns:
166
+ Raw image bytes.
167
+ """
168
+ b64_data = await self.cdp.screenshot()
169
+ return base64.b64decode(b64_data)
170
+
171
+ async def wait_for_selector(
172
+ self,
173
+ selector: str,
174
+ *,
175
+ timeout: float = 10,
176
+ ) -> bool:
177
+ """Wait for an element to appear in the DOM.
178
+
179
+ Polls every 200ms until the selector matches or timeout expires.
180
+
181
+ Args:
182
+ selector: CSS selector string.
183
+ timeout: Maximum seconds to wait.
184
+
185
+ Returns:
186
+ ``True`` if found, ``False`` on timeout.
187
+ """
188
+ elapsed = 0.0
189
+ interval = 0.2
190
+ while elapsed < timeout:
191
+ result = await self.cdp.evaluate(f"!!document.querySelector({selector!r})")
192
+ if result is True:
193
+ return True
194
+ await asyncio.sleep(interval)
195
+ elapsed += interval
196
+ return False
@@ -0,0 +1,168 @@
1
+ """CDP WebSocket connection management.
2
+
3
+ Provides the :class:`CDPConnection` class for sending CDP commands over
4
+ a WebSocket, and the :func:`connect_to_target` helper for establishing
5
+ new connections.
6
+
7
+ Extracted from ``transcript_download.py`` lines 595-648.
8
+
9
+ Schema version: 1.0.0
10
+ Created: 2026-04-25
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import typing
18
+
19
+ import websockets
20
+
21
+ if typing.TYPE_CHECKING:
22
+ import types
23
+
24
+
25
+ class CDPError(RuntimeError):
26
+ """Raised when CDP returns a command error or JavaScript exception."""
27
+
28
+ def __init__(self, details: object) -> None:
29
+ super().__init__(f"CDP command failed: {details}")
30
+ self.details = details
31
+
32
+
33
+ @typing.runtime_checkable
34
+ class WebSocketLike(typing.Protocol):
35
+ """Minimal WebSocket interface used by CDPConnection.
36
+
37
+ Both ``websockets.ClientConnection`` and test fakes satisfy this
38
+ protocol structurally -- no inheritance required.
39
+
40
+ Parameter names use positional-only syntax so the protocol does
41
+ not enforce specific parameter names on implementations.
42
+ """
43
+
44
+ async def send(self, message: str, /) -> None: ...
45
+ async def recv(self, /) -> str | bytes: ...
46
+ async def close(self, /) -> None: ...
47
+
48
+ async def __aenter__(self) -> WebSocketLike: ...
49
+ async def __aexit__(
50
+ self,
51
+ exc_type: type[BaseException] | None,
52
+ exc_val: BaseException | None,
53
+ exc_tb: types.TracebackType | None,
54
+ /,
55
+ ) -> None: ...
56
+
57
+
58
+ class CDPConnection:
59
+ """Manages a CDP WebSocket connection with message ID tracking.
60
+
61
+ Wraps a WebSocket instance satisfying :class:`WebSocketLike`, providing:
62
+
63
+ * Auto-incrementing message IDs for request/response correlation.
64
+ * Convenience methods for common CDP operations (evaluate, screenshot).
65
+ * Message draining to clear stale events from the receive buffer.
66
+
67
+ Args:
68
+ ws: An open WebSocket connection.
69
+ """
70
+
71
+ def __init__(self, ws: WebSocketLike) -> None:
72
+ self._ws = ws
73
+ self._msg_id = 0
74
+
75
+ async def send(
76
+ self,
77
+ method: str,
78
+ params: dict | None = None,
79
+ timeout: float = 15,
80
+ ) -> dict:
81
+ """Send a CDP command and wait for the matching response.
82
+
83
+ Args:
84
+ method: CDP method name (e.g. ``"Runtime.evaluate"``).
85
+ params: CDP method parameters. Defaults to empty dict.
86
+ timeout: Maximum seconds to wait for the response.
87
+
88
+ Returns:
89
+ The full CDP response dict (contains ``id`` and ``result``
90
+ or ``error``).
91
+ """
92
+ self._msg_id += 1
93
+ mid = self._msg_id
94
+ await self._ws.send(
95
+ json.dumps(
96
+ {
97
+ "id": mid,
98
+ "method": method,
99
+ "params": params or {},
100
+ }
101
+ )
102
+ )
103
+ while True:
104
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
105
+ data = json.loads(raw)
106
+ if "id" in data and data["id"] == mid:
107
+ if "error" in data:
108
+ raise CDPError(data["error"])
109
+ return data
110
+
111
+ async def evaluate(self, expression: str, timeout: float = 15) -> object:
112
+ """Evaluate a JavaScript expression via ``Runtime.evaluate``.
113
+
114
+ Args:
115
+ expression: JavaScript source to evaluate.
116
+ timeout: Maximum seconds to wait for a result.
117
+
118
+ Returns:
119
+ The evaluated value (primitive, dict, list, or ``None``).
120
+ """
121
+ resp = await self.send(
122
+ "Runtime.evaluate",
123
+ {"expression": expression},
124
+ timeout=timeout,
125
+ )
126
+ if resp.get("result", {}).get("exceptionDetails"):
127
+ raise CDPError(resp["result"]["exceptionDetails"])
128
+ return resp.get("result", {}).get("result", {}).get("value")
129
+
130
+ async def drain(self, timeout: float = 0.5) -> None:
131
+ """Drain any pending messages from the WebSocket.
132
+
133
+ Useful after navigation or other operations that produce
134
+ unsolicited CDP events.
135
+
136
+ Args:
137
+ timeout: Seconds to wait for each message before assuming
138
+ the buffer is empty.
139
+ """
140
+ try:
141
+ while True:
142
+ await asyncio.wait_for(self._ws.recv(), timeout=timeout)
143
+ except (asyncio.TimeoutError, TimeoutError):
144
+ pass
145
+
146
+ async def screenshot(self) -> str:
147
+ """Take a screenshot via CDP.
148
+
149
+ Returns:
150
+ Base64-encoded PNG image data.
151
+ """
152
+ resp = await self.send("Page.captureScreenshot", {"format": "png"})
153
+ return resp.get("result", {}).get("data", "")
154
+
155
+
156
+ async def connect_to_target(port: int, target_id: str) -> tuple[WebSocketLike, CDPConnection]:
157
+ """Connect to a CDP target via WebSocket.
158
+
159
+ Args:
160
+ port: CDP debugging port number.
161
+ target_id: CDP target identifier.
162
+
163
+ Returns:
164
+ A tuple of ``(websocket, CDPConnection)``.
165
+ """
166
+ ws_url = f"ws://127.0.0.1:{port}/devtools/page/{target_id}"
167
+ ws = await websockets.connect(ws_url, max_size=16 * 1024 * 1024)
168
+ return ws, CDPConnection(ws)