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.
- legacy/__init__.py +0 -0
- legacy/transcript_download.py +3969 -0
- teams_transcripts/__init__.py +1 -0
- teams_transcripts/__main__.py +288 -0
- teams_transcripts/adapters/__init__.py +1 -0
- teams_transcripts/adapters/cdp/__init__.py +1 -0
- teams_transcripts/adapters/cdp/browser.py +196 -0
- teams_transcripts/adapters/cdp/connection.py +168 -0
- teams_transcripts/adapters/cdp/helpers.py +449 -0
- teams_transcripts/adapters/cdp/teams.py +463 -0
- teams_transcripts/adapters/vertex.py +274 -0
- teams_transcripts/domain/__init__.py +1 -0
- teams_transcripts/domain/chat.py +115 -0
- teams_transcripts/domain/html.py +126 -0
- teams_transcripts/domain/mcps.py +343 -0
- teams_transcripts/domain/models.py +531 -0
- teams_transcripts/domain/participants.py +292 -0
- teams_transcripts/domain/scoring.py +106 -0
- teams_transcripts/domain/speakers.py +130 -0
- teams_transcripts/domain/summary.py +266 -0
- teams_transcripts/domain/timestamps.py +350 -0
- teams_transcripts/domain/vtt.py +444 -0
- teams_transcripts/infrastructure/__init__.py +1 -0
- teams_transcripts/infrastructure/config.py +987 -0
- teams_transcripts/infrastructure/errors.py +120 -0
- teams_transcripts/infrastructure/logging.py +69 -0
- teams_transcripts/infrastructure/signals.py +143 -0
- teams_transcripts/mcp_server.py +818 -0
- teams_transcripts/ports/__init__.py +1 -0
- teams_transcripts/ports/agent.py +58 -0
- teams_transcripts/ports/browser.py +126 -0
- teams_transcripts/services/__init__.py +1 -0
- teams_transcripts/services/downloader.py +1283 -0
- teams_transcripts/services/extraction.py +1603 -0
- teams_transcripts/services/listing.py +826 -0
- teams_transcripts/services/navigation.py +1721 -0
- teams_transcripts/services/output.py +84 -0
- teams_transcripts/services/tenant.py +684 -0
- teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
- teams_transcripts-0.5.0.dist-info/RECORD +42 -0
- teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
- 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)
|