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,3969 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Download .vtt transcript from Microsoft Teams meetings via CDP.
|
|
3
|
+
|
|
4
|
+
This script automates the entire transcript download process using the
|
|
5
|
+
Chrome DevTools Protocol (CDP). It connects to Teams' WebView2 instance,
|
|
6
|
+
navigates to the specified meeting, and downloads the transcript as a .vtt
|
|
7
|
+
file. An LLM agent (Claude on Vertex AI) assists with the two non-deterministic
|
|
8
|
+
steps: selecting the correct search result and disambiguating iframe targets.
|
|
9
|
+
|
|
10
|
+
Two download approaches are supported:
|
|
11
|
+
A) Native blob interception (when download permission exists)
|
|
12
|
+
B) DOM extraction from the virtualised transcript list (when blocked)
|
|
13
|
+
|
|
14
|
+
The approach is auto-detected based on the Download button state.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import contextlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import signal
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
import typing
|
|
30
|
+
import urllib.request
|
|
31
|
+
from collections import Counter
|
|
32
|
+
from datetime import date
|
|
33
|
+
|
|
34
|
+
import websockets
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Exit codes
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
EXIT_OK = 0
|
|
41
|
+
EXIT_BAD_ARGS = 1 # Invalid CLI arguments or config
|
|
42
|
+
EXIT_CDP_UNAVAILABLE = 2 # Teams/CDP not running or not responding
|
|
43
|
+
EXIT_MEETING_NOT_FOUND = 3 # Meeting search failed after all retries
|
|
44
|
+
EXIT_DOWNLOAD_FAILED = 4 # Transcript download or permission error
|
|
45
|
+
EXIT_VERIFICATION_FAILED = 5 # Output file invalid
|
|
46
|
+
EXIT_INTERRUPTED = 130 # SIGINT (128 + 2)
|
|
47
|
+
EXIT_TERMINATED = 143 # SIGTERM (128 + 15)
|
|
48
|
+
|
|
49
|
+
# Module-level output control flags, set by resolve_config().
|
|
50
|
+
_quiet = False
|
|
51
|
+
_verbose = False
|
|
52
|
+
_format_json = False
|
|
53
|
+
|
|
54
|
+
# Signal handling state. Set by _handle_signal(); checked in loops.
|
|
55
|
+
_shutdown_requested = False
|
|
56
|
+
_shutdown_signal: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _handle_signal(signum: int, _frame: object) -> None:
|
|
60
|
+
"""Handle SIGINT/SIGTERM by setting the shutdown flag.
|
|
61
|
+
|
|
62
|
+
The first signal sets the flag so that the next loop iteration exits
|
|
63
|
+
cleanly. A second signal (e.g. impatient Ctrl+C) terminates
|
|
64
|
+
immediately.
|
|
65
|
+
"""
|
|
66
|
+
global _shutdown_requested, _shutdown_signal
|
|
67
|
+
|
|
68
|
+
if _shutdown_requested:
|
|
69
|
+
# Second signal — force exit.
|
|
70
|
+
sys.exit(128 + signum)
|
|
71
|
+
|
|
72
|
+
_shutdown_requested = True
|
|
73
|
+
_shutdown_signal = signum
|
|
74
|
+
sig_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
|
|
75
|
+
print(
|
|
76
|
+
f"[{sig_name}] Shutdown requested, finishing current step...",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
flush=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_shutdown() -> None:
|
|
83
|
+
"""If a shutdown signal has been received, exit with the appropriate code.
|
|
84
|
+
|
|
85
|
+
Call this at the top of long-running loop iterations and between
|
|
86
|
+
pipeline stages.
|
|
87
|
+
"""
|
|
88
|
+
if not _shutdown_requested:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
signum = _shutdown_signal or signal.SIGINT
|
|
92
|
+
code = EXIT_INTERRUPTED if signum == signal.SIGINT else EXIT_TERMINATED
|
|
93
|
+
error_code = "INTERRUPTED" if signum == signal.SIGINT else "TERMINATED"
|
|
94
|
+
sig_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
|
|
95
|
+
|
|
96
|
+
_exit_error(code, error_code, f"Process terminated by {sig_name}.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _interruptible_sleep(seconds: float) -> None:
|
|
100
|
+
"""Sleep in 0.5 s increments, checking for shutdown between each."""
|
|
101
|
+
remaining = seconds
|
|
102
|
+
while remaining > 0:
|
|
103
|
+
chunk = min(remaining, 0.5)
|
|
104
|
+
await asyncio.sleep(chunk)
|
|
105
|
+
remaining -= chunk
|
|
106
|
+
if _shutdown_requested:
|
|
107
|
+
_check_shutdown()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _interruptible_sleep_sync(seconds: float) -> None:
|
|
111
|
+
"""Synchronous equivalent of :func:`_interruptible_sleep`."""
|
|
112
|
+
remaining = seconds
|
|
113
|
+
while remaining > 0:
|
|
114
|
+
chunk = min(remaining, 0.5)
|
|
115
|
+
time.sleep(chunk)
|
|
116
|
+
remaining -= chunk
|
|
117
|
+
if _shutdown_requested:
|
|
118
|
+
_check_shutdown()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _install_signal_handlers() -> None:
|
|
122
|
+
"""Register signal handlers for SIGINT and SIGTERM."""
|
|
123
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
124
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Argument parsing and config resolution
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_args() -> argparse.Namespace:
|
|
133
|
+
p = argparse.ArgumentParser(
|
|
134
|
+
description=(
|
|
135
|
+
"Download a Microsoft Teams meeting transcript as a WebVTT (.vtt) file.\n"
|
|
136
|
+
"\n"
|
|
137
|
+
"Connects to the Teams desktop app via the Chrome DevTools Protocol (CDP),\n"
|
|
138
|
+
"navigates to the specified meeting's Recap > Transcript tab, and extracts\n"
|
|
139
|
+
"the transcript. An LLM agent (Claude via Vertex AI) assists with\n"
|
|
140
|
+
"non-deterministic UI steps such as search result selection and iframe\n"
|
|
141
|
+
"disambiguation.\n"
|
|
142
|
+
"\n"
|
|
143
|
+
"Two download approaches are supported and auto-detected:\n"
|
|
144
|
+
" A) Native blob interception -- used when you have download permission.\n"
|
|
145
|
+
" The script clicks the Download button and intercepts the resulting\n"
|
|
146
|
+
" blob URL to capture the VTT content byte-for-byte.\n"
|
|
147
|
+
" B) React fibre tree extraction -- used when download is blocked.\n"
|
|
148
|
+
" The script walks the React component tree to extract all transcript\n"
|
|
149
|
+
" entries directly from the in-memory data structure.\n"
|
|
150
|
+
"\n"
|
|
151
|
+
"Speaker names are resolved using the Recap Speakers tab. Vendor-prefixed\n"
|
|
152
|
+
"names (e.g. 'v-Jane Smith (Acme)') and synthetic placeholders (e.g. '@1')\n"
|
|
153
|
+
"are replaced with clean display names (e.g. 'Jane Smith', 'Speaker 1')."
|
|
154
|
+
),
|
|
155
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
156
|
+
epilog=(
|
|
157
|
+
"prerequisites:\n"
|
|
158
|
+
" 1. Microsoft Teams desktop app (macOS) must be running with CDP enabled.\n"
|
|
159
|
+
" The script will set the required flags and relaunch Teams automatically\n"
|
|
160
|
+
" if CDP is not responding on the configured port. To launch manually:\n"
|
|
161
|
+
" export WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="
|
|
162
|
+
"'--remote-debugging-port=9333'\n"
|
|
163
|
+
" open -a 'Microsoft Teams'\n"
|
|
164
|
+
"\n"
|
|
165
|
+
" 2. Google Cloud Application Default Credentials (ADC) must be configured\n"
|
|
166
|
+
" for the Vertex AI project. The simplest way:\n"
|
|
167
|
+
" gcloud auth application-default login\n"
|
|
168
|
+
" Or point --adc / GOOGLE_ADC at a service account key file.\n"
|
|
169
|
+
"\n"
|
|
170
|
+
" 3. Python dependencies (websockets, anthropic[vertex]) are managed\n"
|
|
171
|
+
" by uv. Run 'uv sync' to install from the lockfile.\n"
|
|
172
|
+
"\n"
|
|
173
|
+
"environment variables (used as fallbacks when CLI flags are omitted):\n"
|
|
174
|
+
" GOOGLE_CLOUD_PROJECT GCP project ID for Vertex AI (required if\n"
|
|
175
|
+
" --gcp-project is not provided)\n"
|
|
176
|
+
" CLOUD_ML_REGION Vertex AI region (default: 'global')\n"
|
|
177
|
+
" MODEL_ID Anthropic model ID (default: 'claude-haiku-4-5')\n"
|
|
178
|
+
" GOOGLE_ADC Path to ADC JSON credentials file\n"
|
|
179
|
+
"\n"
|
|
180
|
+
"examples:\n"
|
|
181
|
+
" # Basic usage -- download a single-occurrence meeting transcript\n"
|
|
182
|
+
" %(prog)s \\\n"
|
|
183
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
184
|
+
" --datetime 2026-04-20 \\\n"
|
|
185
|
+
" --output ./transcripts/kickoff.vtt \\\n"
|
|
186
|
+
" --gcp-project my-vertex-project\n"
|
|
187
|
+
"\n"
|
|
188
|
+
" # Recurring meeting -- the script finds the correct occurrence by date\n"
|
|
189
|
+
" %(prog)s \\\n"
|
|
190
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
191
|
+
" --datetime 2026-04-14 \\\n"
|
|
192
|
+
" --output ./transcripts/standup-2026-04-14.vtt\n"
|
|
193
|
+
"\n"
|
|
194
|
+
" # Disambiguate two meetings with the same name on the same day\n"
|
|
195
|
+
" %(prog)s \\\n"
|
|
196
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
197
|
+
" --datetime 2026-04-14T14:00 \\\n"
|
|
198
|
+
" --output ./transcripts/standup-afternoon.vtt\n"
|
|
199
|
+
"\n"
|
|
200
|
+
" # Pipe VTT to stdout (no file written)\n"
|
|
201
|
+
" %(prog)s \\\n"
|
|
202
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
203
|
+
" --datetime 2026-04-20 | less\n"
|
|
204
|
+
"\n"
|
|
205
|
+
" # JSON summary with file path\n"
|
|
206
|
+
" %(prog)s \\\n"
|
|
207
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
208
|
+
" --datetime 2026-04-20 \\\n"
|
|
209
|
+
" --output ./kickoff.vtt --format json\n"
|
|
210
|
+
"\n"
|
|
211
|
+
" # JSON with embedded VTT (no file, agent-friendly)\n"
|
|
212
|
+
" %(prog)s \\\n"
|
|
213
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
214
|
+
" --datetime 2026-04-20 --format json\n"
|
|
215
|
+
"\n"
|
|
216
|
+
" # Dry-run -- check meeting exists without downloading\n"
|
|
217
|
+
" %(prog)s \\\n"
|
|
218
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
219
|
+
" --datetime 2026-04-14 --dry-run --format json\n"
|
|
220
|
+
"\n"
|
|
221
|
+
" # Quiet mode for cron or agent use\n"
|
|
222
|
+
" %(prog)s \\\n"
|
|
223
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
224
|
+
" --datetime 2026-04-14 \\\n"
|
|
225
|
+
" --output ./standup.vtt -q\n"
|
|
226
|
+
"\n"
|
|
227
|
+
" # Use a specific model and region\n"
|
|
228
|
+
" %(prog)s \\\n"
|
|
229
|
+
" --meeting 'Quarterly Review' \\\n"
|
|
230
|
+
" --datetime 2026-03-31 \\\n"
|
|
231
|
+
" --output ./review.vtt \\\n"
|
|
232
|
+
" --model claude-sonnet-4-20250514 \\\n"
|
|
233
|
+
" --gcp-region us-east5\n"
|
|
234
|
+
"\n"
|
|
235
|
+
"how it works:\n"
|
|
236
|
+
" Step 1 Verify Teams is running with CDP enabled (relaunch if needed)\n"
|
|
237
|
+
" Step 2 Confirm CDP is responding on the configured port\n"
|
|
238
|
+
" Step 3 Discover Teams page targets via CDP\n"
|
|
239
|
+
" Step 4 Search for the meeting by name and navigate to its chat\n"
|
|
240
|
+
" Step 5 Open Recap, select the correct date occurrence, extract\n"
|
|
241
|
+
" speaker names, then switch to the Transcript tab\n"
|
|
242
|
+
" Step 6 Locate the transcript iframe target\n"
|
|
243
|
+
" Step 7 Download the transcript (Approach A or B, auto-detected)\n"
|
|
244
|
+
" Step 8 Validate the output VTT file\n"
|
|
245
|
+
"\n"
|
|
246
|
+
"notes:\n"
|
|
247
|
+
" - The meeting name must match what appears in Teams search results.\n"
|
|
248
|
+
" Partial matches work as long as the name is unambiguous.\n"
|
|
249
|
+
" - For recurring meetings, the occurrence dropdown must contain the\n"
|
|
250
|
+
" target date. Teams typically retains the most recent ~5 occurrences.\n"
|
|
251
|
+
" Older occurrences may no longer be available.\n"
|
|
252
|
+
" - The script retries up to 4 times with different search results if\n"
|
|
253
|
+
" the first result does not contain the target date.\n"
|
|
254
|
+
" - Output file is overwritten if it already exists.\n"
|
|
255
|
+
" - --force-restart bypasses the live-call safeguard and may interrupt\n"
|
|
256
|
+
" an active call. Use it only when interruption is explicitly intended.\n"
|
|
257
|
+
" - All diagnostic output goes to stderr. stdout carries only data\n"
|
|
258
|
+
" (VTT content, or JSON when --format json is set).\n"
|
|
259
|
+
" - In JSON mode, the 'file' key is present when --output is given;\n"
|
|
260
|
+
" the 'vtt' key is present when --output is omitted (containing the\n"
|
|
261
|
+
" full VTT content as a string). The two keys are mutually exclusive.\n"
|
|
262
|
+
"\n"
|
|
263
|
+
"exit codes:\n"
|
|
264
|
+
" 0 Success\n"
|
|
265
|
+
" 1 Invalid arguments or missing configuration (BAD_ARGS)\n"
|
|
266
|
+
" 2 Teams/CDP not running or not responding (CDP_UNAVAILABLE)\n"
|
|
267
|
+
" 3 Meeting not found after all retries (MEETING_NOT_FOUND)\n"
|
|
268
|
+
" 4 Transcript download failed (DOWNLOAD_FAILED)\n"
|
|
269
|
+
" 5 Output file verification failed (VERIFICATION_FAILED)\n"
|
|
270
|
+
" 130 Interrupted by SIGINT (Ctrl+C)\n"
|
|
271
|
+
" 143 Terminated by SIGTERM\n"
|
|
272
|
+
"\n"
|
|
273
|
+
"output modes:\n"
|
|
274
|
+
" --output PATH --format text File written, nothing on stdout\n"
|
|
275
|
+
" --output PATH --format json File written, JSON summary on stdout\n"
|
|
276
|
+
" (no --output) --format text VTT content on stdout\n"
|
|
277
|
+
" (no --output) --format json JSON with embedded VTT on stdout\n"
|
|
278
|
+
" --dry-run --format text Summary logged to stderr\n"
|
|
279
|
+
" --dry-run --format json JSON metadata on stdout (no VTT)\n"
|
|
280
|
+
" --dry-run --output PATH Error (incompatible)\n"
|
|
281
|
+
"\n"
|
|
282
|
+
"error codes (machine-stable, used in text prefix and JSON 'code' field):\n"
|
|
283
|
+
" BAD_ARGS Invalid CLI arguments or missing config\n"
|
|
284
|
+
" CDP_UNAVAILABLE Teams/CDP not running or not responding\n"
|
|
285
|
+
" RESTART_BLOCKED Teams restart blocked by active/unknown call state\n"
|
|
286
|
+
" NO_TARGETS No Teams page targets found via CDP\n"
|
|
287
|
+
" SEARCH_FAILED Search results did not appear\n"
|
|
288
|
+
" MEETING_NOT_FOUND Meeting not found in search results\n"
|
|
289
|
+
" RECAP_NOT_FOUND Recap tab not found after navigation\n"
|
|
290
|
+
" TRANSCRIPT_NOT_FOUND Transcript tab not found\n"
|
|
291
|
+
" IFRAME_NOT_FOUND No transcript iframe target\n"
|
|
292
|
+
" DOWNLOAD_FAILED VTT download failed\n"
|
|
293
|
+
" VERIFICATION_FAILED Output file is invalid\n"
|
|
294
|
+
" INTERRUPTED Process interrupted by SIGINT\n"
|
|
295
|
+
" TERMINATED Process terminated by SIGTERM\n"
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
p.add_argument(
|
|
299
|
+
"--meeting",
|
|
300
|
+
required=True,
|
|
301
|
+
metavar="NAME",
|
|
302
|
+
help=(
|
|
303
|
+
"Meeting name to search for in Teams. This is typed into the Teams "
|
|
304
|
+
"search bar, so it should match the meeting title as it appears in the "
|
|
305
|
+
"Teams UI. For best results use the full meeting title."
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
p.add_argument(
|
|
309
|
+
"--datetime",
|
|
310
|
+
required=True,
|
|
311
|
+
metavar="YYYY-MM-DD[THH:MM]",
|
|
312
|
+
dest="datetime",
|
|
313
|
+
help=(
|
|
314
|
+
"Target meeting date, optionally with time, in ISO 8601 format. "
|
|
315
|
+
"Date only (e.g. 2026-04-22) selects the first occurrence on that "
|
|
316
|
+
"date. Date with time (e.g. 2026-04-22T14:00) disambiguates when "
|
|
317
|
+
"there are multiple meetings with the same name on the same day. "
|
|
318
|
+
"The time is matched against the start time shown in the Teams "
|
|
319
|
+
"occurrence dropdown (e.g. '22 April 2026 14:00 - 15:00')."
|
|
320
|
+
),
|
|
321
|
+
)
|
|
322
|
+
p.add_argument(
|
|
323
|
+
"--output",
|
|
324
|
+
default=None,
|
|
325
|
+
metavar="PATH",
|
|
326
|
+
help=(
|
|
327
|
+
"Output file path for the WebVTT (.vtt) transcript. The file is "
|
|
328
|
+
"created (or overwritten) at this path. Parent directories must "
|
|
329
|
+
"already exist. When omitted, the VTT content is written to "
|
|
330
|
+
"stdout (or embedded in the JSON object when --format json)."
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
p.add_argument(
|
|
334
|
+
"--dry-run",
|
|
335
|
+
action="store_true",
|
|
336
|
+
default=False,
|
|
337
|
+
help=(
|
|
338
|
+
"Run steps 1-5 (find meeting, verify transcript exists, extract "
|
|
339
|
+
"roster) then exit without downloading the transcript. Useful "
|
|
340
|
+
"for validating that a meeting can be found. Incompatible with "
|
|
341
|
+
"--output."
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
p.add_argument(
|
|
345
|
+
"--gcp-project",
|
|
346
|
+
default=None,
|
|
347
|
+
metavar="PROJECT_ID",
|
|
348
|
+
help=(
|
|
349
|
+
"Google Cloud project ID for Vertex AI API calls. Falls back to "
|
|
350
|
+
"the GOOGLE_CLOUD_PROJECT environment variable. A value must be "
|
|
351
|
+
"provided via either this flag or the environment variable."
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
p.add_argument(
|
|
355
|
+
"--port",
|
|
356
|
+
type=int,
|
|
357
|
+
default=9333,
|
|
358
|
+
metavar="PORT",
|
|
359
|
+
help=(
|
|
360
|
+
"CDP remote debugging port that Teams is listening on "
|
|
361
|
+
"(default: %(default)s). Must match the value passed to Teams via "
|
|
362
|
+
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS."
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
p.add_argument(
|
|
366
|
+
"--force-restart",
|
|
367
|
+
action="store_true",
|
|
368
|
+
default=False,
|
|
369
|
+
help=(
|
|
370
|
+
"Allow Teams to be killed and relaunched even when an active call is "
|
|
371
|
+
"detected or call state cannot be determined. This may interrupt a live call."
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
p.add_argument(
|
|
375
|
+
"--gcp-region",
|
|
376
|
+
default=None,
|
|
377
|
+
metavar="REGION",
|
|
378
|
+
help=(
|
|
379
|
+
"Vertex AI region for model inference (default: 'global'). Falls "
|
|
380
|
+
"back to the CLOUD_ML_REGION environment variable."
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
p.add_argument(
|
|
384
|
+
"--model",
|
|
385
|
+
default=None,
|
|
386
|
+
metavar="MODEL_ID",
|
|
387
|
+
help=(
|
|
388
|
+
"Anthropic model ID for the LLM agent used in non-deterministic "
|
|
389
|
+
"navigation steps (default: 'claude-haiku-4-5'). Falls back to "
|
|
390
|
+
"the MODEL_ID environment variable. Haiku is recommended for speed "
|
|
391
|
+
"and cost; the agent prompts are simple classification tasks."
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
p.add_argument(
|
|
395
|
+
"--adc",
|
|
396
|
+
default=None,
|
|
397
|
+
metavar="PATH",
|
|
398
|
+
help=(
|
|
399
|
+
"Path to a Google Application Default Credentials JSON file. "
|
|
400
|
+
"Falls back to the GOOGLE_ADC environment variable, then to "
|
|
401
|
+
"~/.config/gcloud/application_default_credentials.json."
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
verbosity = p.add_mutually_exclusive_group()
|
|
406
|
+
verbosity.add_argument(
|
|
407
|
+
"--quiet",
|
|
408
|
+
"-q",
|
|
409
|
+
action="store_true",
|
|
410
|
+
default=False,
|
|
411
|
+
help="Suppress all diagnostic output on stderr. Errors and "
|
|
412
|
+
"structured output (--format json) are still emitted.",
|
|
413
|
+
)
|
|
414
|
+
verbosity.add_argument(
|
|
415
|
+
"--verbose",
|
|
416
|
+
"-v",
|
|
417
|
+
action="store_true",
|
|
418
|
+
default=False,
|
|
419
|
+
help="Emit additional diagnostic detail on stderr (CDP payloads, "
|
|
420
|
+
"agent prompts, React fibre data sizes).",
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
p.add_argument(
|
|
424
|
+
"--format",
|
|
425
|
+
dest="output_format",
|
|
426
|
+
choices=["text", "json"],
|
|
427
|
+
default="text",
|
|
428
|
+
help="Output format on stdout. 'text' (default) emits nothing on "
|
|
429
|
+
"stdout when --output is given, or raw VTT content when --output "
|
|
430
|
+
"is omitted. 'json' emits a structured JSON summary.",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return p.parse_args()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def resolve_config(args: argparse.Namespace) -> dict:
|
|
437
|
+
"""Resolve all configuration from CLI args, env vars, and defaults."""
|
|
438
|
+
global _quiet, _verbose, _format_json
|
|
439
|
+
|
|
440
|
+
_quiet = args.quiet
|
|
441
|
+
_verbose = args.verbose
|
|
442
|
+
_format_json = args.output_format == "json"
|
|
443
|
+
|
|
444
|
+
project = args.gcp_project or os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
445
|
+
if not project:
|
|
446
|
+
_exit_error(
|
|
447
|
+
EXIT_BAD_ARGS,
|
|
448
|
+
"BAD_ARGS",
|
|
449
|
+
"--gcp-project not provided and GOOGLE_CLOUD_PROJECT not set.",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
model = args.model or os.environ.get("MODEL_ID") or "claude-haiku-4-5"
|
|
453
|
+
region = args.gcp_region or os.environ.get("CLOUD_ML_REGION") or "global"
|
|
454
|
+
adc = (
|
|
455
|
+
args.adc
|
|
456
|
+
or os.environ.get("GOOGLE_ADC")
|
|
457
|
+
or os.path.expanduser("~/.config/gcloud/application_default_credentials.json")
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Parse --datetime: accept YYYY-MM-DD or YYYY-MM-DDTHH:MM
|
|
461
|
+
dt_raw = args.datetime
|
|
462
|
+
target_time = None
|
|
463
|
+
if "T" in dt_raw:
|
|
464
|
+
date_part, time_part = dt_raw.split("T", 1)
|
|
465
|
+
# Validate date
|
|
466
|
+
try:
|
|
467
|
+
date.fromisoformat(date_part)
|
|
468
|
+
except ValueError:
|
|
469
|
+
_exit_error(
|
|
470
|
+
EXIT_BAD_ARGS,
|
|
471
|
+
"BAD_ARGS",
|
|
472
|
+
f"Invalid date '{date_part}'. Expected YYYY-MM-DD.",
|
|
473
|
+
)
|
|
474
|
+
# Validate time (HH:MM with valid ranges)
|
|
475
|
+
if not re.match(r"^\d{2}:\d{2}$", time_part):
|
|
476
|
+
_exit_error(
|
|
477
|
+
EXIT_BAD_ARGS,
|
|
478
|
+
"BAD_ARGS",
|
|
479
|
+
f"Invalid time format '{time_part}'. Expected HH:MM.",
|
|
480
|
+
)
|
|
481
|
+
hh, mm = time_part.split(":")
|
|
482
|
+
if not (0 <= int(hh) <= 23 and 0 <= int(mm) <= 59):
|
|
483
|
+
_exit_error(
|
|
484
|
+
EXIT_BAD_ARGS,
|
|
485
|
+
"BAD_ARGS",
|
|
486
|
+
f"Invalid time '{time_part}'. Hours must be 00-23, minutes 00-59.",
|
|
487
|
+
)
|
|
488
|
+
target_time = time_part
|
|
489
|
+
target_date = date_part
|
|
490
|
+
else:
|
|
491
|
+
try:
|
|
492
|
+
date.fromisoformat(dt_raw)
|
|
493
|
+
except ValueError:
|
|
494
|
+
_exit_error(
|
|
495
|
+
EXIT_BAD_ARGS,
|
|
496
|
+
"BAD_ARGS",
|
|
497
|
+
f"Invalid date '{dt_raw}'. Expected YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
|
498
|
+
)
|
|
499
|
+
target_date = dt_raw
|
|
500
|
+
|
|
501
|
+
# Validate flag combinations
|
|
502
|
+
if args.dry_run and args.output:
|
|
503
|
+
_exit_error(
|
|
504
|
+
EXIT_BAD_ARGS,
|
|
505
|
+
"BAD_ARGS",
|
|
506
|
+
"--dry-run and --output are incompatible. Dry-run does not produce a file.",
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# When --output is omitted (and not --dry-run), use a temp file
|
|
510
|
+
# internally so the file-based pipeline works unchanged. The temp
|
|
511
|
+
# file content is written to stdout (or embedded in JSON) at the end.
|
|
512
|
+
output_to_stdout = not args.dry_run and args.output is None
|
|
513
|
+
if output_to_stdout:
|
|
514
|
+
tmp_fd, output_path = tempfile.mkstemp(suffix=".vtt")
|
|
515
|
+
os.close(tmp_fd)
|
|
516
|
+
else:
|
|
517
|
+
output_path = args.output # None when --dry-run
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
"meeting": args.meeting,
|
|
521
|
+
"date": target_date,
|
|
522
|
+
"time": target_time,
|
|
523
|
+
"output": output_path,
|
|
524
|
+
"output_to_stdout": output_to_stdout,
|
|
525
|
+
"dry_run": args.dry_run,
|
|
526
|
+
"port": args.port,
|
|
527
|
+
"force_restart": args.force_restart,
|
|
528
|
+
"gcp_project": project,
|
|
529
|
+
"gcp_region": region,
|
|
530
|
+
"model": model,
|
|
531
|
+
"adc": adc,
|
|
532
|
+
"output_format": args.output_format,
|
|
533
|
+
"quiet": args.quiet,
|
|
534
|
+
"verbose": args.verbose,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
# Logging and structured error output
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def log(step: str, msg: str) -> None:
|
|
544
|
+
"""Print a structured log line to stderr.
|
|
545
|
+
|
|
546
|
+
Suppressed entirely when ``--quiet`` is active. All diagnostic output
|
|
547
|
+
goes to stderr so that stdout is reserved for data (VTT content or JSON).
|
|
548
|
+
"""
|
|
549
|
+
if not _quiet:
|
|
550
|
+
print(f"[{step}] {msg}", file=sys.stderr, flush=True)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _exit_error(
|
|
554
|
+
code: int,
|
|
555
|
+
error_code: str,
|
|
556
|
+
message: str,
|
|
557
|
+
*,
|
|
558
|
+
step: str | None = None,
|
|
559
|
+
) -> typing.NoReturn:
|
|
560
|
+
"""Emit an actionable error and terminate the process.
|
|
561
|
+
|
|
562
|
+
* Always prints a human-readable message to stderr.
|
|
563
|
+
* When ``--format json`` is active, also emits a JSON error object to
|
|
564
|
+
stdout so that calling agents can parse the failure without scraping
|
|
565
|
+
stderr.
|
|
566
|
+
* ``error_code`` is a short, machine-stable identifier (e.g.
|
|
567
|
+
``MEETING_NOT_FOUND``).
|
|
568
|
+
"""
|
|
569
|
+
# Human-readable on stderr
|
|
570
|
+
prefix = f"[{error_code}]"
|
|
571
|
+
print(f"{prefix} {message}", file=sys.stderr, flush=True)
|
|
572
|
+
|
|
573
|
+
# Machine-readable on stdout when JSON output is requested
|
|
574
|
+
if _format_json:
|
|
575
|
+
payload: dict[str, object] = {
|
|
576
|
+
"status": "error",
|
|
577
|
+
"code": error_code,
|
|
578
|
+
"message": message,
|
|
579
|
+
}
|
|
580
|
+
if step:
|
|
581
|
+
payload["step"] = step
|
|
582
|
+
print(json.dumps(payload, ensure_ascii=False), flush=True)
|
|
583
|
+
|
|
584
|
+
sys.exit(code)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# ---------------------------------------------------------------------------
|
|
588
|
+
# CDP HTTP helpers
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def cdp_http_get(port: int, path: str) -> dict | list | None:
|
|
593
|
+
"""GET a CDP HTTP endpoint, return parsed JSON or None on failure."""
|
|
594
|
+
url = f"http://localhost:{port}{path}"
|
|
595
|
+
try:
|
|
596
|
+
with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
|
|
597
|
+
return json.loads(resp.read())
|
|
598
|
+
except Exception:
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
# CDP WebSocket helpers
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
class CDPConnection:
|
|
608
|
+
"""Manages a CDP WebSocket connection with message ID tracking."""
|
|
609
|
+
|
|
610
|
+
def __init__(self, ws):
|
|
611
|
+
self._ws = ws
|
|
612
|
+
self._msg_id = 0
|
|
613
|
+
|
|
614
|
+
async def send(self, method: str, params: dict | None = None, timeout: float = 15):
|
|
615
|
+
"""Send a CDP command and wait for the matching response."""
|
|
616
|
+
self._msg_id += 1
|
|
617
|
+
mid = self._msg_id
|
|
618
|
+
await self._ws.send(
|
|
619
|
+
json.dumps(
|
|
620
|
+
{
|
|
621
|
+
"id": mid,
|
|
622
|
+
"method": method,
|
|
623
|
+
"params": params or {},
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
while True:
|
|
628
|
+
raw = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
|
|
629
|
+
data = json.loads(raw)
|
|
630
|
+
if "id" in data and data["id"] == mid:
|
|
631
|
+
return data
|
|
632
|
+
|
|
633
|
+
async def evaluate(self, expression: str, timeout: float = 15):
|
|
634
|
+
"""Shorthand for Runtime.evaluate, returns the result value."""
|
|
635
|
+
resp = await self.send(
|
|
636
|
+
"Runtime.evaluate",
|
|
637
|
+
{"expression": expression},
|
|
638
|
+
timeout=timeout,
|
|
639
|
+
)
|
|
640
|
+
return resp.get("result", {}).get("result", {}).get("value")
|
|
641
|
+
|
|
642
|
+
async def drain(self, timeout: float = 0.5):
|
|
643
|
+
"""Drain any pending messages from the WebSocket."""
|
|
644
|
+
try:
|
|
645
|
+
while True:
|
|
646
|
+
await asyncio.wait_for(self._ws.recv(), timeout=timeout)
|
|
647
|
+
except (asyncio.TimeoutError, TimeoutError):
|
|
648
|
+
pass
|
|
649
|
+
|
|
650
|
+
async def screenshot(self) -> str:
|
|
651
|
+
"""Take a screenshot via CDP, return base64 PNG data."""
|
|
652
|
+
resp = await self.send("Page.captureScreenshot", {"format": "png"})
|
|
653
|
+
return resp.get("result", {}).get("data", "")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
async def connect_to_target(port: int, target_id: str) -> tuple:
|
|
657
|
+
"""Connect to a CDP target, return (websocket, CDPConnection)."""
|
|
658
|
+
ws_url = f"ws://localhost:{port}/devtools/page/{target_id}"
|
|
659
|
+
ws = await websockets.connect(ws_url, max_size=16 * 1024 * 1024)
|
|
660
|
+
return ws, CDPConnection(ws)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
# Agent (LLM) helpers
|
|
665
|
+
# ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def create_agent_client(config: dict):
|
|
669
|
+
"""Create an AnthropicVertex client from resolved config."""
|
|
670
|
+
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = config["adc"]
|
|
671
|
+
from anthropic import AnthropicVertex
|
|
672
|
+
|
|
673
|
+
return AnthropicVertex(
|
|
674
|
+
project_id=config["gcp_project"],
|
|
675
|
+
region=config["gcp_region"],
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def agent_vision_query(client, model: str, image_b64: str, prompt: str) -> dict | None:
|
|
680
|
+
"""Send a single-turn vision query to the agent. Returns parsed JSON or None."""
|
|
681
|
+
try:
|
|
682
|
+
response = client.messages.create(
|
|
683
|
+
model=model,
|
|
684
|
+
max_tokens=512,
|
|
685
|
+
messages=[
|
|
686
|
+
{
|
|
687
|
+
"role": "user",
|
|
688
|
+
"content": [
|
|
689
|
+
{
|
|
690
|
+
"type": "image",
|
|
691
|
+
"source": {
|
|
692
|
+
"type": "base64",
|
|
693
|
+
"media_type": "image/png",
|
|
694
|
+
"data": image_b64,
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
{"type": "text", "text": prompt},
|
|
698
|
+
],
|
|
699
|
+
}
|
|
700
|
+
],
|
|
701
|
+
)
|
|
702
|
+
text = response.content[0].text.strip()
|
|
703
|
+
# Extract JSON from response (may be wrapped in markdown code fences)
|
|
704
|
+
json_match = re.search(r"\{[^}]+\}", text)
|
|
705
|
+
if json_match:
|
|
706
|
+
return json.loads(json_match.group())
|
|
707
|
+
return None
|
|
708
|
+
except Exception as e:
|
|
709
|
+
log("agent", f"Vision query failed: {e}")
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def agent_text_query(client, model: str, prompt: str) -> dict | None:
|
|
714
|
+
"""Send a single-turn text query to the agent. Returns parsed JSON or None."""
|
|
715
|
+
try:
|
|
716
|
+
response = client.messages.create(
|
|
717
|
+
model=model,
|
|
718
|
+
max_tokens=512,
|
|
719
|
+
messages=[{"role": "user", "content": prompt}],
|
|
720
|
+
)
|
|
721
|
+
text = response.content[0].text.strip()
|
|
722
|
+
json_match = re.search(r"\{[^}]+\}", text)
|
|
723
|
+
if json_match:
|
|
724
|
+
return json.loads(json_match.group())
|
|
725
|
+
return None
|
|
726
|
+
except Exception as e:
|
|
727
|
+
log("agent", f"Text query failed: {e}")
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
# Step 1: Ensure Teams is running with CDP enabled
|
|
733
|
+
# ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def ensure_teams_running(port: int, *, force: bool = False) -> None:
|
|
737
|
+
"""Use the shared guarded restart path when CDP is unavailable."""
|
|
738
|
+
from teams_transcripts.adapters.cdp.teams import ensure_teams_running as guarded_ensure
|
|
739
|
+
|
|
740
|
+
class LegacyLogger:
|
|
741
|
+
@staticmethod
|
|
742
|
+
def log(step: str, msg: str) -> None:
|
|
743
|
+
log(step, msg)
|
|
744
|
+
|
|
745
|
+
class LegacyShutdown:
|
|
746
|
+
interruptible_sleep_sync = staticmethod(_interruptible_sleep_sync)
|
|
747
|
+
|
|
748
|
+
guarded_ensure(
|
|
749
|
+
port,
|
|
750
|
+
logger=LegacyLogger(),
|
|
751
|
+
shutdown=LegacyShutdown(),
|
|
752
|
+
format_json=_format_json,
|
|
753
|
+
force=force,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# ---------------------------------------------------------------------------
|
|
758
|
+
# Step 2: Verify CDP is responding
|
|
759
|
+
# ---------------------------------------------------------------------------
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def verify_cdp(port: int) -> str:
|
|
763
|
+
"""Verify CDP is responding and return the browser version string."""
|
|
764
|
+
for attempt in range(5):
|
|
765
|
+
_check_shutdown()
|
|
766
|
+
version = cdp_http_get(port, "/json/version")
|
|
767
|
+
if isinstance(version, dict) and "Browser" in version:
|
|
768
|
+
browser = version["Browser"]
|
|
769
|
+
log("step2", f"CDP verified: {browser}")
|
|
770
|
+
return browser
|
|
771
|
+
log("step2", f"Attempt {attempt + 1}/5 -- waiting...")
|
|
772
|
+
_interruptible_sleep_sync(3)
|
|
773
|
+
|
|
774
|
+
_exit_error(
|
|
775
|
+
EXIT_CDP_UNAVAILABLE,
|
|
776
|
+
"CDP_UNAVAILABLE",
|
|
777
|
+
"CDP did not respond after 5 attempts. Check Teams is running.",
|
|
778
|
+
step="step2",
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# ---------------------------------------------------------------------------
|
|
783
|
+
# Step 3: Find main Teams CDP target
|
|
784
|
+
# ---------------------------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def find_teams_targets(port: int) -> list[dict]:
|
|
788
|
+
"""Return all CDP targets that are main Teams pages (not workers)."""
|
|
789
|
+
targets = cdp_http_get(port, "/json")
|
|
790
|
+
if not targets:
|
|
791
|
+
_exit_error(
|
|
792
|
+
EXIT_CDP_UNAVAILABLE,
|
|
793
|
+
"CDP_UNAVAILABLE",
|
|
794
|
+
"Could not list CDP targets.",
|
|
795
|
+
step="step3",
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
results = []
|
|
799
|
+
for t in targets:
|
|
800
|
+
url = t.get("url", "")
|
|
801
|
+
if "teams.microsoft.com/v2" in url and "worker" not in url:
|
|
802
|
+
results.append(t)
|
|
803
|
+
return results
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# ---------------------------------------------------------------------------
|
|
807
|
+
# Step 4: Navigate to meeting (agent-assisted)
|
|
808
|
+
# ---------------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
async def find_search_target(port: int, targets: list[dict]) -> str | None:
|
|
812
|
+
"""Find which CDP target has the Teams search input element."""
|
|
813
|
+
for t in targets:
|
|
814
|
+
try:
|
|
815
|
+
ws, cdp = await connect_to_target(port, t["id"])
|
|
816
|
+
async with ws:
|
|
817
|
+
result = await cdp.evaluate("!!document.querySelector('#ms-searchux-input')")
|
|
818
|
+
if result is True:
|
|
819
|
+
log("step4", f"Search input found on target: {t['id'][:20]}...")
|
|
820
|
+
return t["id"]
|
|
821
|
+
except Exception: # noqa: S112, PERF203
|
|
822
|
+
continue
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
async def type_text_via_cdp(cdp: CDPConnection, text: str) -> None:
|
|
827
|
+
"""Type text character by character using Input.dispatchKeyEvent."""
|
|
828
|
+
for char in text:
|
|
829
|
+
await cdp.send(
|
|
830
|
+
"Input.dispatchKeyEvent",
|
|
831
|
+
{
|
|
832
|
+
"type": "keyDown",
|
|
833
|
+
"text": char,
|
|
834
|
+
"key": char,
|
|
835
|
+
"code": f"Key{char.upper()}" if char.isalpha() else "",
|
|
836
|
+
"windowsVirtualKeyCode": ord(char.upper()) if char.isalpha() else ord(char),
|
|
837
|
+
},
|
|
838
|
+
)
|
|
839
|
+
await cdp.send(
|
|
840
|
+
"Input.dispatchKeyEvent",
|
|
841
|
+
{
|
|
842
|
+
"type": "keyUp",
|
|
843
|
+
"key": char,
|
|
844
|
+
"code": f"Key{char.upper()}" if char.isalpha() else "",
|
|
845
|
+
"windowsVirtualKeyCode": ord(char.upper()) if char.isalpha() else ord(char),
|
|
846
|
+
},
|
|
847
|
+
)
|
|
848
|
+
await asyncio.sleep(0.05)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
async def navigate_to_meeting(
|
|
852
|
+
port: int,
|
|
853
|
+
targets: list[dict],
|
|
854
|
+
config: dict,
|
|
855
|
+
agent_client,
|
|
856
|
+
result_index_override: int | None = None,
|
|
857
|
+
) -> str | None:
|
|
858
|
+
"""Navigate to the meeting chat. Returns the target ID used, or None
|
|
859
|
+
if the requested result_index_override is out of range.
|
|
860
|
+
|
|
861
|
+
If *result_index_override* is given, skip the agent call and click
|
|
862
|
+
the search result at that index directly (used for retries when the
|
|
863
|
+
first chat group turns out to be the wrong recurring-meeting instance).
|
|
864
|
+
"""
|
|
865
|
+
# 4a: Find the target with the search input
|
|
866
|
+
search_target_id = await find_search_target(port, targets)
|
|
867
|
+
if not search_target_id:
|
|
868
|
+
_exit_error(
|
|
869
|
+
EXIT_CDP_UNAVAILABLE,
|
|
870
|
+
"NO_TARGETS",
|
|
871
|
+
"Could not find Teams search input on any CDP target.",
|
|
872
|
+
step="step4",
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
ws, cdp = await connect_to_target(port, search_target_id)
|
|
876
|
+
async with ws:
|
|
877
|
+
# 4b: Ensure we are in the Chat view (not Search results or a
|
|
878
|
+
# meeting chat) so the search suggestion popup works correctly.
|
|
879
|
+
# Use the Ctrl+3 keyboard shortcut (Teams: "Chat (Ctrl+3)").
|
|
880
|
+
log("step4", "Switching to Chat view (Ctrl+3)...")
|
|
881
|
+
await cdp.send(
|
|
882
|
+
"Input.dispatchKeyEvent",
|
|
883
|
+
{
|
|
884
|
+
"type": "keyDown",
|
|
885
|
+
"key": "3",
|
|
886
|
+
"code": "Digit3",
|
|
887
|
+
"windowsVirtualKeyCode": 51,
|
|
888
|
+
"modifiers": 2, # Ctrl
|
|
889
|
+
},
|
|
890
|
+
)
|
|
891
|
+
await cdp.send(
|
|
892
|
+
"Input.dispatchKeyEvent",
|
|
893
|
+
{
|
|
894
|
+
"type": "keyUp",
|
|
895
|
+
"key": "3",
|
|
896
|
+
"code": "Digit3",
|
|
897
|
+
"windowsVirtualKeyCode": 51,
|
|
898
|
+
},
|
|
899
|
+
)
|
|
900
|
+
await _interruptible_sleep(2)
|
|
901
|
+
|
|
902
|
+
# Dismiss any open popups/dropdowns
|
|
903
|
+
await cdp.send(
|
|
904
|
+
"Input.dispatchKeyEvent",
|
|
905
|
+
{
|
|
906
|
+
"type": "keyDown",
|
|
907
|
+
"key": "Escape",
|
|
908
|
+
"code": "Escape",
|
|
909
|
+
"windowsVirtualKeyCode": 27,
|
|
910
|
+
},
|
|
911
|
+
)
|
|
912
|
+
await cdp.send(
|
|
913
|
+
"Input.dispatchKeyEvent",
|
|
914
|
+
{
|
|
915
|
+
"type": "keyUp",
|
|
916
|
+
"key": "Escape",
|
|
917
|
+
"code": "Escape",
|
|
918
|
+
"windowsVirtualKeyCode": 27,
|
|
919
|
+
},
|
|
920
|
+
)
|
|
921
|
+
await asyncio.sleep(0.5)
|
|
922
|
+
|
|
923
|
+
# Focus the search input and clear it using the React-compatible
|
|
924
|
+
# nativeInputValueSetter technique (setting .value directly
|
|
925
|
+
# doesn't update React-controlled inputs).
|
|
926
|
+
await cdp.evaluate("""
|
|
927
|
+
(function() {
|
|
928
|
+
var input = document.querySelector('#ms-searchux-input');
|
|
929
|
+
if (!input) return;
|
|
930
|
+
input.focus();
|
|
931
|
+
var setter = Object.getOwnPropertyDescriptor(
|
|
932
|
+
window.HTMLInputElement.prototype, 'value'
|
|
933
|
+
).set;
|
|
934
|
+
setter.call(input, '');
|
|
935
|
+
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
936
|
+
})()
|
|
937
|
+
""")
|
|
938
|
+
await asyncio.sleep(0.5)
|
|
939
|
+
|
|
940
|
+
log("step4", f"Typing meeting name: {config['meeting']}")
|
|
941
|
+
await type_text_via_cdp(cdp, config["meeting"])
|
|
942
|
+
|
|
943
|
+
# 4c: Wait for search results to appear.
|
|
944
|
+
# Teams has TWO search UI modes:
|
|
945
|
+
# Mode A (popup): Quick suggestions as ms-searchux-popup-N elements.
|
|
946
|
+
# Mode B (panel): Expanded search panel with [role="option"] elements
|
|
947
|
+
# that have random IDs (appears after first search or from
|
|
948
|
+
# within a chat context). Shows filter tabs (Messages, Files,
|
|
949
|
+
# etc.) plus result items.
|
|
950
|
+
# We wait until we see either:
|
|
951
|
+
# - ms-searchux-popup-N items that aren't "Search Filters", OR
|
|
952
|
+
# - [role="option"] elements whose text matches the meeting name.
|
|
953
|
+
log("step4", "Waiting for search results...")
|
|
954
|
+
meeting_name_lower = config["meeting"].lower()
|
|
955
|
+
search_mode = None
|
|
956
|
+
for _ in range(20):
|
|
957
|
+
await asyncio.sleep(0.5)
|
|
958
|
+
result = await cdp.evaluate(f"""
|
|
959
|
+
(function() {{
|
|
960
|
+
// Mode A: popup suggestions
|
|
961
|
+
for (var i = 0; i < 10; i++) {{
|
|
962
|
+
var el = document.querySelector('#ms-searchux-popup-' + i);
|
|
963
|
+
if (el) {{
|
|
964
|
+
var text = el.textContent.trim();
|
|
965
|
+
if (text.indexOf('Search Filters') === -1) return 'popup';
|
|
966
|
+
}}
|
|
967
|
+
}}
|
|
968
|
+
// Mode B: expanded search panel option elements
|
|
969
|
+
var opts = Array.from(document.querySelectorAll('[role="option"]'));
|
|
970
|
+
for (var j = 0; j < opts.length; j++) {{
|
|
971
|
+
var t = opts[j].textContent.trim().toLowerCase();
|
|
972
|
+
if (t.indexOf('{meeting_name_lower[:20]}') !== -1) return 'panel';
|
|
973
|
+
}}
|
|
974
|
+
return 'waiting';
|
|
975
|
+
}})()
|
|
976
|
+
""")
|
|
977
|
+
if result in ("popup", "panel"):
|
|
978
|
+
search_mode = result
|
|
979
|
+
break
|
|
980
|
+
|
|
981
|
+
if not search_mode:
|
|
982
|
+
_exit_error(
|
|
983
|
+
EXIT_MEETING_NOT_FOUND,
|
|
984
|
+
"SEARCH_FAILED",
|
|
985
|
+
"Search results did not appear after 10 seconds.",
|
|
986
|
+
step="step4",
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
await _interruptible_sleep(1) # let results fully render
|
|
990
|
+
log("step4", f"Search UI mode: {search_mode}")
|
|
991
|
+
|
|
992
|
+
# 4c-ii: Enumerate available results depending on mode.
|
|
993
|
+
# Build a list of result items as [{id, text, element_selector}].
|
|
994
|
+
if search_mode == "popup":
|
|
995
|
+
results_json = await cdp.evaluate("""
|
|
996
|
+
(function() {
|
|
997
|
+
var items = [];
|
|
998
|
+
for (var i = 0; i < 10; i++) {
|
|
999
|
+
var el = document.querySelector('#ms-searchux-popup-' + i);
|
|
1000
|
+
if (el) {
|
|
1001
|
+
var text = el.textContent.trim().substring(0, 120);
|
|
1002
|
+
if (text.indexOf('Search Filters') === -1) {
|
|
1003
|
+
items.push({
|
|
1004
|
+
id: el.id,
|
|
1005
|
+
text: text,
|
|
1006
|
+
selector: '#' + el.id
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
} else {
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return JSON.stringify(items);
|
|
1014
|
+
})()
|
|
1015
|
+
""")
|
|
1016
|
+
else:
|
|
1017
|
+
# Panel mode: get [role="option"] elements, excluding filter
|
|
1018
|
+
# tabs (Messages, Files, Channels, etc.), "View all results",
|
|
1019
|
+
# and search suggestions (items containing "in:" filter syntax).
|
|
1020
|
+
filter_words = [
|
|
1021
|
+
"Messages",
|
|
1022
|
+
"Files",
|
|
1023
|
+
"Channels",
|
|
1024
|
+
"Group chats",
|
|
1025
|
+
"Meetings",
|
|
1026
|
+
"Images",
|
|
1027
|
+
"View all results",
|
|
1028
|
+
"Search Filters",
|
|
1029
|
+
]
|
|
1030
|
+
results_json = await cdp.evaluate(
|
|
1031
|
+
"""
|
|
1032
|
+
(function() {
|
|
1033
|
+
var filterWords = """
|
|
1034
|
+
+ json.dumps(filter_words)
|
|
1035
|
+
+ """;
|
|
1036
|
+
var opts = Array.from(document.querySelectorAll('[role="option"]'));
|
|
1037
|
+
var items = [];
|
|
1038
|
+
for (var i = 0; i < opts.length; i++) {
|
|
1039
|
+
var text = opts[i].textContent.trim().substring(0, 120);
|
|
1040
|
+
var isFilter = false;
|
|
1041
|
+
for (var f = 0; f < filterWords.length; f++) {
|
|
1042
|
+
if (text === filterWords[f] || text.indexOf('Search Filters') !== -1) {
|
|
1043
|
+
isFilter = true;
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// Detect search suggestions: items that contain "in:"
|
|
1048
|
+
// filter syntax (e.g. "forin:Channel" or "textin:scope").
|
|
1049
|
+
if (!isFilter && /in:/.test(text)) isFilter = true;
|
|
1050
|
+
if (!isFilter && text.length > 0) {
|
|
1051
|
+
var oid = opts[i].id || ('option-' + i);
|
|
1052
|
+
items.push({
|
|
1053
|
+
id: oid,
|
|
1054
|
+
text: text,
|
|
1055
|
+
selector: opts[i].id ? ('#' + opts[i].id) : null,
|
|
1056
|
+
optionIndex: i
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return JSON.stringify(items);
|
|
1061
|
+
})()
|
|
1062
|
+
"""
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
search_results = json.loads(results_json)
|
|
1066
|
+
log("step4", f"Found {len(search_results)} search result(s):")
|
|
1067
|
+
for sr in search_results:
|
|
1068
|
+
log("step4", f" [{sr['id'][:20]}] {sr['text'][:70]}")
|
|
1069
|
+
|
|
1070
|
+
# If panel mode returned no results (all were filter tabs or
|
|
1071
|
+
# search suggestions), click the "Meetings" filter tab to get
|
|
1072
|
+
# meeting-specific results, then re-extract.
|
|
1073
|
+
if search_mode == "panel" and len(search_results) == 0:
|
|
1074
|
+
log("step4", "No chat results in panel. Clicking 'Meetings' filter...")
|
|
1075
|
+
meetings_bbox_json = await cdp.evaluate("""
|
|
1076
|
+
(function() {
|
|
1077
|
+
var opts = Array.from(document.querySelectorAll('[role="option"]'));
|
|
1078
|
+
var meetingsOpt = opts.find(function(o) {
|
|
1079
|
+
return o.textContent.trim() === 'Meetings';
|
|
1080
|
+
});
|
|
1081
|
+
if (!meetingsOpt) return JSON.stringify({error: 'not_found'});
|
|
1082
|
+
var rect = meetingsOpt.getBoundingClientRect();
|
|
1083
|
+
return JSON.stringify({
|
|
1084
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1085
|
+
y: Math.round(rect.top + rect.height / 2)
|
|
1086
|
+
});
|
|
1087
|
+
})()
|
|
1088
|
+
""")
|
|
1089
|
+
meetings_bbox = json.loads(meetings_bbox_json)
|
|
1090
|
+
|
|
1091
|
+
if meetings_bbox.get("error"):
|
|
1092
|
+
log("step4", "Could not find 'Meetings' filter tab.")
|
|
1093
|
+
else:
|
|
1094
|
+
await cdp.send(
|
|
1095
|
+
"Input.dispatchMouseEvent",
|
|
1096
|
+
{
|
|
1097
|
+
"type": "mousePressed",
|
|
1098
|
+
"x": meetings_bbox["x"],
|
|
1099
|
+
"y": meetings_bbox["y"],
|
|
1100
|
+
"button": "left",
|
|
1101
|
+
"clickCount": 1,
|
|
1102
|
+
},
|
|
1103
|
+
)
|
|
1104
|
+
await cdp.send(
|
|
1105
|
+
"Input.dispatchMouseEvent",
|
|
1106
|
+
{
|
|
1107
|
+
"type": "mouseReleased",
|
|
1108
|
+
"x": meetings_bbox["x"],
|
|
1109
|
+
"y": meetings_bbox["y"],
|
|
1110
|
+
"button": "left",
|
|
1111
|
+
"clickCount": 1,
|
|
1112
|
+
},
|
|
1113
|
+
)
|
|
1114
|
+
await _interruptible_sleep(3)
|
|
1115
|
+
|
|
1116
|
+
# Switch to "search_page" mode -- the full search results
|
|
1117
|
+
# page shows results as listitem or article elements.
|
|
1118
|
+
search_mode = "search_page"
|
|
1119
|
+
results_json = await cdp.evaluate("""
|
|
1120
|
+
(function() {
|
|
1121
|
+
// On the search results page, results may be list items,
|
|
1122
|
+
// articles, or divs with data-tid attributes.
|
|
1123
|
+
var candidates = Array.from(document.querySelectorAll(
|
|
1124
|
+
'[role="listitem"], article, '
|
|
1125
|
+
+ '[data-tid*="search-result"], '
|
|
1126
|
+
+ '[data-tid*="meeting-result"]'
|
|
1127
|
+
));
|
|
1128
|
+
// Also try any [role="option"] that appeared after filter
|
|
1129
|
+
if (candidates.length === 0) {
|
|
1130
|
+
candidates = Array.from(
|
|
1131
|
+
document.querySelectorAll('[role="option"]')
|
|
1132
|
+
).filter(function(o) {
|
|
1133
|
+
var t = o.textContent.trim();
|
|
1134
|
+
var filterRe = /^(Messages|Files|Channels|Group chats|Meetings|Images)$/;
|
|
1135
|
+
return t.length > 10
|
|
1136
|
+
&& t !== 'View all results'
|
|
1137
|
+
&& !filterRe.test(t)
|
|
1138
|
+
&& t.indexOf('Search Filters') === -1
|
|
1139
|
+
&& !/in:/.test(t);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
var items = [];
|
|
1143
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
1144
|
+
var text = candidates[i].textContent.trim().substring(0, 120);
|
|
1145
|
+
if (text.length > 5) {
|
|
1146
|
+
items.push({
|
|
1147
|
+
id: candidates[i].id || ('result-' + i),
|
|
1148
|
+
text: text,
|
|
1149
|
+
optionIndex: i
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return JSON.stringify(items);
|
|
1154
|
+
})()
|
|
1155
|
+
""")
|
|
1156
|
+
search_results = json.loads(results_json)
|
|
1157
|
+
log("step4", f"After Meetings filter: {len(search_results)} result(s):")
|
|
1158
|
+
for sr in search_results:
|
|
1159
|
+
log("step4", f" [{sr['id'][:20]}] {sr['text'][:70]}")
|
|
1160
|
+
|
|
1161
|
+
# 4d: Determine which result to click
|
|
1162
|
+
if result_index_override is not None:
|
|
1163
|
+
result_index = result_index_override
|
|
1164
|
+
log("step4", f"Using override result index: {result_index}")
|
|
1165
|
+
elif search_mode == "panel":
|
|
1166
|
+
# In panel mode we already have structured text for each result.
|
|
1167
|
+
# Match by meeting name using word-overlap scoring. Teams'
|
|
1168
|
+
# search panel can inject filter annotations (e.g. "in:") into
|
|
1169
|
+
# the textContent, garbling exact matches. We also use
|
|
1170
|
+
# substring matching to handle concatenated words (e.g.
|
|
1171
|
+
# "CallAdrian" should match the word "Call").
|
|
1172
|
+
name_words = set(re.findall(r"\w+", config["meeting"].lower()))
|
|
1173
|
+
best_idx, best_score = 0, 0
|
|
1174
|
+
for i, sr in enumerate(search_results):
|
|
1175
|
+
result_text_lower = sr["text"].lower()
|
|
1176
|
+
result_words = set(re.findall(r"\w+", result_text_lower))
|
|
1177
|
+
# Exact word overlap
|
|
1178
|
+
overlap = len(name_words & result_words)
|
|
1179
|
+
# Substring match for words not in exact overlap
|
|
1180
|
+
for nw in name_words - result_words:
|
|
1181
|
+
if nw in result_text_lower:
|
|
1182
|
+
overlap += 1
|
|
1183
|
+
# Tiebreaker: prefer results that start with the meeting
|
|
1184
|
+
# name (meeting chat entries) over messages about the
|
|
1185
|
+
# meeting from individuals (which start with "Name:").
|
|
1186
|
+
# Subtract 0.5 if the result text starts with a person's
|
|
1187
|
+
# name followed by a colon (e.g. "Tim: Recap: ...").
|
|
1188
|
+
score = overlap
|
|
1189
|
+
if re.match(r"^[A-Z][a-z]+:", sr["text"]):
|
|
1190
|
+
score -= 0.5
|
|
1191
|
+
if score > best_score:
|
|
1192
|
+
best_score = score
|
|
1193
|
+
best_idx = i
|
|
1194
|
+
result_index = best_idx
|
|
1195
|
+
log(
|
|
1196
|
+
"step4",
|
|
1197
|
+
f"Word-overlap match: index {result_index} (score {best_score}/{len(name_words)})",
|
|
1198
|
+
)
|
|
1199
|
+
else:
|
|
1200
|
+
# Popup mode: use the agent to identify the correct result
|
|
1201
|
+
log("step4", "Taking screenshot of search results...")
|
|
1202
|
+
screenshot_b64 = await cdp.screenshot()
|
|
1203
|
+
|
|
1204
|
+
prompt = (
|
|
1205
|
+
f"This is a screenshot of Microsoft Teams search results. "
|
|
1206
|
+
f"I am looking for the meeting called '{config['meeting']}' "
|
|
1207
|
+
f"from {config['date']}. "
|
|
1208
|
+
f"The search results may include chat entries (showing participant "
|
|
1209
|
+
f"names but not dates) and calendar/meeting entries (showing dates). "
|
|
1210
|
+
f"Chat entries are preferred for transcript downloads. "
|
|
1211
|
+
f"If the exact date is not visible in the results, pick the first "
|
|
1212
|
+
f"chat result whose name matches the meeting name. "
|
|
1213
|
+
f"Count the results from top to bottom starting at 0. "
|
|
1214
|
+
f"Return a JSON object with a single key 'index' set to the 0-based "
|
|
1215
|
+
f"index of the correct result to click, or null if NO result matches "
|
|
1216
|
+
f"the meeting name at all. "
|
|
1217
|
+
f'For example: {{"index": 0}} or {{"index": null}}'
|
|
1218
|
+
)
|
|
1219
|
+
log("step4", "Asking agent to identify correct search result...")
|
|
1220
|
+
agent_result = agent_vision_query(agent_client, config["model"], screenshot_b64, prompt)
|
|
1221
|
+
log("step4", f"Agent response: {agent_result}")
|
|
1222
|
+
|
|
1223
|
+
if not agent_result or agent_result.get("index") is None:
|
|
1224
|
+
_exit_error(
|
|
1225
|
+
EXIT_MEETING_NOT_FOUND,
|
|
1226
|
+
"MEETING_NOT_FOUND",
|
|
1227
|
+
"Agent could not identify the meeting in search results.",
|
|
1228
|
+
step="step4",
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
result_index = agent_result["index"]
|
|
1232
|
+
|
|
1233
|
+
# 4e: Check that the requested index exists
|
|
1234
|
+
if result_index >= len(search_results):
|
|
1235
|
+
if result_index_override is not None:
|
|
1236
|
+
log(
|
|
1237
|
+
"step4",
|
|
1238
|
+
f"Search result index {result_index} out of range "
|
|
1239
|
+
f"(only {len(search_results)} results). No more results to try.",
|
|
1240
|
+
)
|
|
1241
|
+
return None
|
|
1242
|
+
_exit_error(
|
|
1243
|
+
EXIT_MEETING_NOT_FOUND,
|
|
1244
|
+
"MEETING_NOT_FOUND",
|
|
1245
|
+
f"Agent picked index {result_index} but only "
|
|
1246
|
+
f"{len(search_results)} search results exist.",
|
|
1247
|
+
step="step4",
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
target_result = search_results[result_index]
|
|
1251
|
+
target_id_attr = target_result.get("id", "")
|
|
1252
|
+
|
|
1253
|
+
# 4f: Get the bounding box of the result item and click it.
|
|
1254
|
+
# Use getElementById (not querySelector with #id) because some
|
|
1255
|
+
# Teams element IDs start with digits, which breaks CSS selectors.
|
|
1256
|
+
log(
|
|
1257
|
+
"step4",
|
|
1258
|
+
f"Selecting result index {result_index}: {target_result['text'][:60]}...",
|
|
1259
|
+
)
|
|
1260
|
+
if target_id_attr and not target_id_attr.startswith("option-"):
|
|
1261
|
+
# Use getElementById for elements with an actual ID
|
|
1262
|
+
bbox_json = await cdp.evaluate(f"""
|
|
1263
|
+
(function() {{
|
|
1264
|
+
var item = document.getElementById('{target_id_attr}');
|
|
1265
|
+
if (!item) return JSON.stringify({{error: 'not_found'}});
|
|
1266
|
+
var rect = item.getBoundingClientRect();
|
|
1267
|
+
return JSON.stringify({{
|
|
1268
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1269
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1270
|
+
id: item.id
|
|
1271
|
+
}});
|
|
1272
|
+
}})()
|
|
1273
|
+
""")
|
|
1274
|
+
else:
|
|
1275
|
+
# Fallback: use nth [role="option"] by optionIndex
|
|
1276
|
+
oi = target_result.get("optionIndex", 0)
|
|
1277
|
+
bbox_json = await cdp.evaluate(f"""
|
|
1278
|
+
(function() {{
|
|
1279
|
+
var opts = Array.from(document.querySelectorAll('[role="option"]'));
|
|
1280
|
+
if (opts.length <= {oi}) return JSON.stringify({{error: 'not_found'}});
|
|
1281
|
+
var item = opts[{oi}];
|
|
1282
|
+
var rect = item.getBoundingClientRect();
|
|
1283
|
+
return JSON.stringify({{
|
|
1284
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1285
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1286
|
+
id: item.id || 'option-{oi}'
|
|
1287
|
+
}});
|
|
1288
|
+
}})()
|
|
1289
|
+
""")
|
|
1290
|
+
bbox = json.loads(bbox_json)
|
|
1291
|
+
|
|
1292
|
+
if bbox.get("error"):
|
|
1293
|
+
log("step4", f"Could not find element for result at index {result_index}.")
|
|
1294
|
+
return None
|
|
1295
|
+
|
|
1296
|
+
cx, cy = bbox["x"], bbox["y"]
|
|
1297
|
+
log("step4", f"Clicking at ({cx}, {cy}) on {bbox['id']}...")
|
|
1298
|
+
|
|
1299
|
+
# CDP-level mouse click: mousePressed + mouseReleased
|
|
1300
|
+
await cdp.send(
|
|
1301
|
+
"Input.dispatchMouseEvent",
|
|
1302
|
+
{
|
|
1303
|
+
"type": "mousePressed",
|
|
1304
|
+
"x": cx,
|
|
1305
|
+
"y": cy,
|
|
1306
|
+
"button": "left",
|
|
1307
|
+
"clickCount": 1,
|
|
1308
|
+
},
|
|
1309
|
+
)
|
|
1310
|
+
await cdp.send(
|
|
1311
|
+
"Input.dispatchMouseEvent",
|
|
1312
|
+
{
|
|
1313
|
+
"type": "mouseReleased",
|
|
1314
|
+
"x": cx,
|
|
1315
|
+
"y": cy,
|
|
1316
|
+
"button": "left",
|
|
1317
|
+
"clickCount": 1,
|
|
1318
|
+
},
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
# 4g: Wait for navigation to the meeting chat
|
|
1322
|
+
log("step4", "Waiting for meeting chat to load...")
|
|
1323
|
+
await _interruptible_sleep(5)
|
|
1324
|
+
|
|
1325
|
+
# Verify navigation reached a chat (not the Search results page
|
|
1326
|
+
# or some other non-chat view).
|
|
1327
|
+
new_title = await cdp.evaluate("document.title")
|
|
1328
|
+
log("step4", f"Page title after navigation: {new_title}")
|
|
1329
|
+
|
|
1330
|
+
is_chat = (
|
|
1331
|
+
"| Chat" in new_title
|
|
1332
|
+
or "| Copilot" in new_title
|
|
1333
|
+
or "| Communities" in new_title
|
|
1334
|
+
or new_title.startswith(("Chat |", "Copilot |", "Communities"))
|
|
1335
|
+
)
|
|
1336
|
+
if "Search |" in new_title or not is_chat:
|
|
1337
|
+
log(
|
|
1338
|
+
"step4",
|
|
1339
|
+
"Navigation did not reach a meeting chat. "
|
|
1340
|
+
"Result may be a file, channel, or filter -- not a chat entry.",
|
|
1341
|
+
)
|
|
1342
|
+
return None
|
|
1343
|
+
|
|
1344
|
+
return search_target_id
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
# ---------------------------------------------------------------------------
|
|
1348
|
+
# Step 5: Navigate to Recap > Transcript
|
|
1349
|
+
# ---------------------------------------------------------------------------
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _parse_occurrence_duration(text: str) -> int | None:
|
|
1353
|
+
"""Parse meeting duration in seconds from occurrence dropdown text.
|
|
1354
|
+
|
|
1355
|
+
Expected format: '17 April 2026 19:30 - 20:00' or similar.
|
|
1356
|
+
Returns duration in seconds, or None if unparseable.
|
|
1357
|
+
"""
|
|
1358
|
+
m = re.search(r"(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})", text)
|
|
1359
|
+
if not m:
|
|
1360
|
+
return None
|
|
1361
|
+
start_min = int(m.group(1)) * 60 + int(m.group(2))
|
|
1362
|
+
end_min = int(m.group(3)) * 60 + int(m.group(4))
|
|
1363
|
+
if end_min <= start_min:
|
|
1364
|
+
end_min += 24 * 60 # crosses midnight
|
|
1365
|
+
return (end_min - start_min) * 60
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
async def navigate_to_transcript(port: int, target_id: str, config: dict, agent_client) -> bool:
|
|
1369
|
+
"""Navigate from meeting chat to the Transcript tab inside Recap.
|
|
1370
|
+
|
|
1371
|
+
For recurring meetings, uses the agent to select the correct
|
|
1372
|
+
occurrence from the date dropdown.
|
|
1373
|
+
|
|
1374
|
+
Returns True if navigation succeeded, False if the target date
|
|
1375
|
+
occurrence was not found (caller should try a different search result).
|
|
1376
|
+
"""
|
|
1377
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
1378
|
+
async with ws:
|
|
1379
|
+
# Always attempt to click Recap first, even if Speakers tabs are
|
|
1380
|
+
# visible -- they may be stale from a previous meeting. If a Recap
|
|
1381
|
+
# tab exists (meaning we're at the chat-level tabs), click it.
|
|
1382
|
+
# If only Recap-internal tabs are visible (Speakers, Transcript etc.)
|
|
1383
|
+
# and no top-level Recap tab exists, we're already in the right view.
|
|
1384
|
+
recap_result = await cdp.evaluate("""
|
|
1385
|
+
(function() {
|
|
1386
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
1387
|
+
var tabNames = tabs.map(function(t) { return t.textContent.trim(); });
|
|
1388
|
+
// Look for a top-level Recap tab to click
|
|
1389
|
+
var recapTab = tabs.find(function(t) {
|
|
1390
|
+
return t.textContent.indexOf('Recap') !== -1;
|
|
1391
|
+
});
|
|
1392
|
+
if (recapTab) {
|
|
1393
|
+
recapTab.click();
|
|
1394
|
+
return 'clicked_recap';
|
|
1395
|
+
}
|
|
1396
|
+
// No Recap tab -- check if we're already inside Recap view
|
|
1397
|
+
var inRecap = tabNames.some(function(n) {
|
|
1398
|
+
return n.indexOf('Speakers') !== -1;
|
|
1399
|
+
});
|
|
1400
|
+
if (inRecap) return 'already_in_recap';
|
|
1401
|
+
return 'no_recap_tab';
|
|
1402
|
+
})()
|
|
1403
|
+
""")
|
|
1404
|
+
log("step5", f"Recap navigation: {recap_result}")
|
|
1405
|
+
|
|
1406
|
+
if recap_result == "no_recap_tab":
|
|
1407
|
+
_exit_error(
|
|
1408
|
+
EXIT_DOWNLOAD_FAILED,
|
|
1409
|
+
"RECAP_NOT_FOUND",
|
|
1410
|
+
"Recap tab not found. The meeting may not have been recorded.",
|
|
1411
|
+
step="step5",
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
if recap_result == "clicked_recap":
|
|
1415
|
+
log("step5", "Waiting for Recap content to load...")
|
|
1416
|
+
await _interruptible_sleep(3)
|
|
1417
|
+
|
|
1418
|
+
# Check if this is a recurring meeting and select the correct occurrence
|
|
1419
|
+
occurrence_ok = await _select_correct_occurrence(cdp, config, agent_client)
|
|
1420
|
+
if not occurrence_ok:
|
|
1421
|
+
log("step5", "Target date not available in this chat group.")
|
|
1422
|
+
return False
|
|
1423
|
+
|
|
1424
|
+
# Extract meeting date/time from the Recap header.
|
|
1425
|
+
# Both recurring (dropdown button) and single-occurrence meetings
|
|
1426
|
+
# show the date and time range in a leaf SPAN or DIV element
|
|
1427
|
+
# matching "DD Month YYYY HH:MM - HH:MM".
|
|
1428
|
+
meeting_time_text = await cdp.evaluate(r"""
|
|
1429
|
+
(function() {
|
|
1430
|
+
var months = 'January|February|March|April|May|June'
|
|
1431
|
+
+ '|July|August|September|October|November|December';
|
|
1432
|
+
var re = new RegExp(
|
|
1433
|
+
'\\d{1,2}\\s+(' + months + ')\\s+\\d{4}\\s+'
|
|
1434
|
+
+ '\\d{1,2}:\\d{2}\\s*-\\s*\\d{1,2}:\\d{2}', 'i'
|
|
1435
|
+
);
|
|
1436
|
+
var els = document.querySelectorAll('button, span, div');
|
|
1437
|
+
for (var i = 0; i < els.length; i++) {
|
|
1438
|
+
var t = els[i].textContent.trim();
|
|
1439
|
+
if (re.test(t) && t.length < 60
|
|
1440
|
+
&& els[i].children.length === 0) {
|
|
1441
|
+
return t;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return '';
|
|
1445
|
+
})()
|
|
1446
|
+
""")
|
|
1447
|
+
if meeting_time_text:
|
|
1448
|
+
config["meeting_time_text"] = meeting_time_text
|
|
1449
|
+
log("step5", f"Meeting time: {meeting_time_text}")
|
|
1450
|
+
else:
|
|
1451
|
+
log("step5", "Meeting time text not found in Recap header.")
|
|
1452
|
+
|
|
1453
|
+
# Extract speaker mapping from the Recap Speakers section.
|
|
1454
|
+
# This gives us the display names shown in the Teams UI (e.g.
|
|
1455
|
+
# "Speaker 1" instead of the raw "@1" from the transcript data).
|
|
1456
|
+
speaker_map = await _extract_speaker_mapping(cdp)
|
|
1457
|
+
if speaker_map:
|
|
1458
|
+
config["speaker_map"] = speaker_map
|
|
1459
|
+
log("step5", f"Speaker mapping: {len(speaker_map)} speaker(s)")
|
|
1460
|
+
for guid, name in speaker_map.items():
|
|
1461
|
+
log("step5", f" {guid[:12]}... -> {name}")
|
|
1462
|
+
else:
|
|
1463
|
+
log("step5", "No speaker mapping available (Speakers tab missing or empty).")
|
|
1464
|
+
|
|
1465
|
+
# Click the Transcript tab
|
|
1466
|
+
transcript_result = await cdp.evaluate("""
|
|
1467
|
+
(function() {
|
|
1468
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
1469
|
+
var transcriptTab = tabs.find(function(t) {
|
|
1470
|
+
return t.textContent.indexOf('Transcript') !== -1;
|
|
1471
|
+
});
|
|
1472
|
+
if (!transcriptTab) return 'no_transcript_tab';
|
|
1473
|
+
transcriptTab.click();
|
|
1474
|
+
return 'clicked_transcript:' +
|
|
1475
|
+
transcriptTab.getAttribute('aria-selected');
|
|
1476
|
+
})()
|
|
1477
|
+
""")
|
|
1478
|
+
log("step5", f"Transcript navigation: {transcript_result}")
|
|
1479
|
+
|
|
1480
|
+
if transcript_result == "no_transcript_tab":
|
|
1481
|
+
_exit_error(
|
|
1482
|
+
EXIT_DOWNLOAD_FAILED,
|
|
1483
|
+
"TRANSCRIPT_NOT_FOUND",
|
|
1484
|
+
"Transcript tab not found. Transcription may not have been enabled.",
|
|
1485
|
+
step="step5",
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
# Wait for transcript content to load
|
|
1489
|
+
await _interruptible_sleep(3)
|
|
1490
|
+
|
|
1491
|
+
return True
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
async def _extract_speaker_mapping(cdp: CDPConnection) -> dict[str, str]:
|
|
1495
|
+
"""Extract speaker GUID-to-displayName mapping from the Recap Speakers tab.
|
|
1496
|
+
|
|
1497
|
+
Clicks the Speakers sub-tab within Recap, then reads the React props
|
|
1498
|
+
of each ``meeting-recap-speakers-list-item`` element to extract the
|
|
1499
|
+
``userId`` (format ``8:orgid:{GUID}``) and ``displayName``.
|
|
1500
|
+
|
|
1501
|
+
Returns a dict mapping bare GUID -> displayName (e.g.
|
|
1502
|
+
``{"1d678236-a80b-...": "Speaker 1"}``). Returns an empty dict if
|
|
1503
|
+
the Speakers tab is not present or has no items.
|
|
1504
|
+
"""
|
|
1505
|
+
# Click the Speakers sub-tab
|
|
1506
|
+
clicked = await cdp.evaluate(r"""
|
|
1507
|
+
(function() {
|
|
1508
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
1509
|
+
var speakersTab = tabs.find(function(t) {
|
|
1510
|
+
var txt = t.textContent.trim();
|
|
1511
|
+
return txt === 'Speakers' || txt === 'SpeakersSpeakers';
|
|
1512
|
+
});
|
|
1513
|
+
if (!speakersTab) return 'no_tab';
|
|
1514
|
+
speakersTab.click();
|
|
1515
|
+
return 'clicked';
|
|
1516
|
+
})()
|
|
1517
|
+
""")
|
|
1518
|
+
|
|
1519
|
+
if clicked != "clicked":
|
|
1520
|
+
return {}
|
|
1521
|
+
|
|
1522
|
+
await _interruptible_sleep(1.5)
|
|
1523
|
+
|
|
1524
|
+
# Some meetings may show a truncated speaker list with a "Show more"
|
|
1525
|
+
# button. Click it if present so all speakers are rendered.
|
|
1526
|
+
await cdp.evaluate(r"""
|
|
1527
|
+
(function() {
|
|
1528
|
+
var btns = Array.from(document.querySelectorAll('button'));
|
|
1529
|
+
var showMore = btns.find(function(b) {
|
|
1530
|
+
return b.textContent.trim().toLowerCase().indexOf('show more') >= 0
|
|
1531
|
+
|| b.textContent.trim().toLowerCase().indexOf('show all') >= 0;
|
|
1532
|
+
});
|
|
1533
|
+
if (showMore) showMore.click();
|
|
1534
|
+
})()
|
|
1535
|
+
""")
|
|
1536
|
+
await asyncio.sleep(0.5)
|
|
1537
|
+
|
|
1538
|
+
# Extract userId + displayName from React props of each speaker item
|
|
1539
|
+
raw = await cdp.evaluate(r"""
|
|
1540
|
+
(function() {
|
|
1541
|
+
var items = document.querySelectorAll(
|
|
1542
|
+
'[data-tid="meeting-recap-speakers-list-item"]'
|
|
1543
|
+
);
|
|
1544
|
+
if (!items.length) return JSON.stringify([]);
|
|
1545
|
+
|
|
1546
|
+
var results = [];
|
|
1547
|
+
items.forEach(function(item) {
|
|
1548
|
+
var fiberKey = Object.keys(item).find(function(k) {
|
|
1549
|
+
return k.startsWith('__reactFiber$');
|
|
1550
|
+
});
|
|
1551
|
+
if (!fiberKey) return;
|
|
1552
|
+
|
|
1553
|
+
var current = item[fiberKey];
|
|
1554
|
+
var depth = 0;
|
|
1555
|
+
var userId = null;
|
|
1556
|
+
var displayName = null;
|
|
1557
|
+
|
|
1558
|
+
while (current && depth < 15) {
|
|
1559
|
+
var props = current.memoizedProps || {};
|
|
1560
|
+
if (props.userId && !userId) userId = props.userId;
|
|
1561
|
+
if (props.displayName && !displayName) displayName = props.displayName;
|
|
1562
|
+
if (userId && displayName) break;
|
|
1563
|
+
current = current.return;
|
|
1564
|
+
depth++;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
if (userId && displayName) {
|
|
1568
|
+
results.push({userId: userId, displayName: displayName});
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
return JSON.stringify(results);
|
|
1572
|
+
})()
|
|
1573
|
+
""")
|
|
1574
|
+
|
|
1575
|
+
items = json.loads(raw)
|
|
1576
|
+
mapping = {}
|
|
1577
|
+
for item in items:
|
|
1578
|
+
# userId format: "8:orgid:{GUID}" -- extract the GUID
|
|
1579
|
+
uid = item["userId"]
|
|
1580
|
+
guid = uid.split(":")[-1] if ":" in uid else uid
|
|
1581
|
+
mapping[guid] = item["displayName"]
|
|
1582
|
+
|
|
1583
|
+
return mapping
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
async def _select_correct_occurrence(cdp: CDPConnection, config: dict, agent_client) -> bool:
|
|
1587
|
+
"""For recurring meetings, select the correct date occurrence.
|
|
1588
|
+
|
|
1589
|
+
Returns True if the correct occurrence is selected (or it's a single
|
|
1590
|
+
occurrence meeting), False if the target date is not available.
|
|
1591
|
+
"""
|
|
1592
|
+
# Check for the occurrence/date dropdown
|
|
1593
|
+
occurrence_info = await cdp.evaluate("""
|
|
1594
|
+
(function() {
|
|
1595
|
+
var months = 'January|February|March|April|May|June'
|
|
1596
|
+
+ '|July|August|September|October|November|December';
|
|
1597
|
+
var dateRe = new RegExp(
|
|
1598
|
+
'\\\\d{1,2}\\\\s+(' + months + ')\\\\s+\\\\d{4}', 'i'
|
|
1599
|
+
);
|
|
1600
|
+
var dateBtn = null;
|
|
1601
|
+
var allBtns = Array.from(document.querySelectorAll('button'));
|
|
1602
|
+
for (var i = 0; i < allBtns.length; i++) {
|
|
1603
|
+
var text = allBtns[i].textContent.trim();
|
|
1604
|
+
if (dateRe.test(text)) {
|
|
1605
|
+
dateBtn = allBtns[i];
|
|
1606
|
+
break;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (!dateBtn) return JSON.stringify({has_dropdown: false});
|
|
1610
|
+
return JSON.stringify({
|
|
1611
|
+
has_dropdown: true,
|
|
1612
|
+
current_text: dateBtn.textContent.trim().substring(0, 100)
|
|
1613
|
+
});
|
|
1614
|
+
})()
|
|
1615
|
+
""")
|
|
1616
|
+
info = json.loads(occurrence_info)
|
|
1617
|
+
|
|
1618
|
+
if not info.get("has_dropdown"):
|
|
1619
|
+
log("step5", "No occurrence dropdown found (single occurrence meeting).")
|
|
1620
|
+
return True
|
|
1621
|
+
|
|
1622
|
+
current_text = info.get("current_text", "")
|
|
1623
|
+
log("step5", f"Occurrence dropdown shows: {current_text}")
|
|
1624
|
+
|
|
1625
|
+
# Check if the target date is already displayed
|
|
1626
|
+
target_date = config["date"]
|
|
1627
|
+
target_time = config["time"]
|
|
1628
|
+
dt = date.fromisoformat(target_date)
|
|
1629
|
+
day_str = str(dt.day)
|
|
1630
|
+
month_str = dt.strftime("%B")
|
|
1631
|
+
year_str = str(dt.year)
|
|
1632
|
+
|
|
1633
|
+
def _occurrence_matches(text: str) -> bool:
|
|
1634
|
+
"""Check if an occurrence dropdown text matches the target date/time."""
|
|
1635
|
+
if not (day_str in text and month_str in text and year_str in text):
|
|
1636
|
+
return False
|
|
1637
|
+
if target_time is None:
|
|
1638
|
+
return True
|
|
1639
|
+
# Match the start time in text like "22 April 2026 11:15 - 12:00".
|
|
1640
|
+
# The target_time is "HH:MM" and must appear before the separator.
|
|
1641
|
+
sep_idx = text.find("-")
|
|
1642
|
+
if sep_idx == -1:
|
|
1643
|
+
return target_time in text
|
|
1644
|
+
return target_time in text[:sep_idx]
|
|
1645
|
+
|
|
1646
|
+
if _occurrence_matches(current_text):
|
|
1647
|
+
log("step5", "Correct occurrence already selected.")
|
|
1648
|
+
dur = _parse_occurrence_duration(current_text)
|
|
1649
|
+
if dur:
|
|
1650
|
+
log("step5", f"Meeting duration: {dur}s ({dur // 60}min)")
|
|
1651
|
+
return True
|
|
1652
|
+
|
|
1653
|
+
target_label = f"{day_str} {month_str} {year_str}"
|
|
1654
|
+
if target_time:
|
|
1655
|
+
target_label += f" {target_time}"
|
|
1656
|
+
log("step5", f"Need to switch to {target_label}. Opening dropdown...")
|
|
1657
|
+
|
|
1658
|
+
# Click the date dropdown button
|
|
1659
|
+
bbox_json = await cdp.evaluate("""
|
|
1660
|
+
(function() {
|
|
1661
|
+
var months = 'January|February|March|April|May|June'
|
|
1662
|
+
+ '|July|August|September|October|November|December';
|
|
1663
|
+
var dateRe = new RegExp(
|
|
1664
|
+
'\\\\d{1,2}\\\\s+(' + months + ')\\\\s+\\\\d{4}', 'i'
|
|
1665
|
+
);
|
|
1666
|
+
var allBtns = Array.from(document.querySelectorAll('button'));
|
|
1667
|
+
for (var i = 0; i < allBtns.length; i++) {
|
|
1668
|
+
var text = allBtns[i].textContent.trim();
|
|
1669
|
+
if (dateRe.test(text)) {
|
|
1670
|
+
var rect = allBtns[i].getBoundingClientRect();
|
|
1671
|
+
return JSON.stringify({
|
|
1672
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1673
|
+
y: Math.round(rect.top + rect.height / 2)
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return JSON.stringify({error: 'not_found'});
|
|
1678
|
+
})()
|
|
1679
|
+
""")
|
|
1680
|
+
bbox = json.loads(bbox_json)
|
|
1681
|
+
|
|
1682
|
+
if bbox.get("error"):
|
|
1683
|
+
log("step5", "Could not find date dropdown button.")
|
|
1684
|
+
return False
|
|
1685
|
+
|
|
1686
|
+
await cdp.send(
|
|
1687
|
+
"Input.dispatchMouseEvent",
|
|
1688
|
+
{
|
|
1689
|
+
"type": "mousePressed",
|
|
1690
|
+
"x": bbox["x"],
|
|
1691
|
+
"y": bbox["y"],
|
|
1692
|
+
"button": "left",
|
|
1693
|
+
"clickCount": 1,
|
|
1694
|
+
},
|
|
1695
|
+
)
|
|
1696
|
+
await cdp.send(
|
|
1697
|
+
"Input.dispatchMouseEvent",
|
|
1698
|
+
{
|
|
1699
|
+
"type": "mouseReleased",
|
|
1700
|
+
"x": bbox["x"],
|
|
1701
|
+
"y": bbox["y"],
|
|
1702
|
+
"button": "left",
|
|
1703
|
+
"clickCount": 1,
|
|
1704
|
+
},
|
|
1705
|
+
)
|
|
1706
|
+
await _interruptible_sleep(2)
|
|
1707
|
+
|
|
1708
|
+
# Check if the target date is in the dropdown items (text-based check
|
|
1709
|
+
# before bothering the agent)
|
|
1710
|
+
dropdown_check = await cdp.evaluate(f"""
|
|
1711
|
+
(function() {{
|
|
1712
|
+
var items = Array.from(document.querySelectorAll(
|
|
1713
|
+
'[role="menuitem"], [role="option"], '
|
|
1714
|
+
+ '[role="listbox"] > *, [role="menu"] > *'
|
|
1715
|
+
));
|
|
1716
|
+
var allText = items.map(function(el) {{
|
|
1717
|
+
return el.textContent.trim();
|
|
1718
|
+
}});
|
|
1719
|
+
var hasTarget = allText.some(function(t) {{
|
|
1720
|
+
return t.indexOf('{month_str}') !== -1
|
|
1721
|
+
&& t.indexOf('{year_str}') !== -1;
|
|
1722
|
+
}});
|
|
1723
|
+
return JSON.stringify({{
|
|
1724
|
+
count: items.length,
|
|
1725
|
+
has_target_month: hasTarget,
|
|
1726
|
+
items: allText.slice(0, 10)
|
|
1727
|
+
}});
|
|
1728
|
+
}})()
|
|
1729
|
+
""")
|
|
1730
|
+
dropdown_info = json.loads(dropdown_check)
|
|
1731
|
+
log(
|
|
1732
|
+
"step5",
|
|
1733
|
+
f"Dropdown items: {dropdown_info.get('count', 0)}, "
|
|
1734
|
+
f"has target month: {dropdown_info.get('has_target_month', False)}",
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
# Coarse JS check found the target month/year -- now do a precise
|
|
1738
|
+
# Python-side check that also validates day and optional time.
|
|
1739
|
+
dropdown_items = dropdown_info.get("items", [])
|
|
1740
|
+
has_precise_match = dropdown_info.get("has_target_month", False) and any(
|
|
1741
|
+
_occurrence_matches(item) for item in dropdown_items
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
if not has_precise_match:
|
|
1745
|
+
log(
|
|
1746
|
+
"step5",
|
|
1747
|
+
f"Target {target_label} not in dropdown. Available: {dropdown_items[:5]}",
|
|
1748
|
+
)
|
|
1749
|
+
# Close the dropdown
|
|
1750
|
+
await cdp.send(
|
|
1751
|
+
"Input.dispatchKeyEvent",
|
|
1752
|
+
{
|
|
1753
|
+
"type": "keyDown",
|
|
1754
|
+
"key": "Escape",
|
|
1755
|
+
"code": "Escape",
|
|
1756
|
+
"windowsVirtualKeyCode": 27,
|
|
1757
|
+
},
|
|
1758
|
+
)
|
|
1759
|
+
await cdp.send(
|
|
1760
|
+
"Input.dispatchKeyEvent",
|
|
1761
|
+
{
|
|
1762
|
+
"type": "keyUp",
|
|
1763
|
+
"key": "Escape",
|
|
1764
|
+
"code": "Escape",
|
|
1765
|
+
"windowsVirtualKeyCode": 27,
|
|
1766
|
+
},
|
|
1767
|
+
)
|
|
1768
|
+
return False
|
|
1769
|
+
|
|
1770
|
+
# Target month is in the dropdown -- use the agent to pick the exact one
|
|
1771
|
+
screenshot_b64 = await cdp.screenshot()
|
|
1772
|
+
|
|
1773
|
+
time_instruction = ""
|
|
1774
|
+
if target_time:
|
|
1775
|
+
time_instruction = (
|
|
1776
|
+
f" The meeting must start at {target_time} -- match both the date AND the start time."
|
|
1777
|
+
)
|
|
1778
|
+
|
|
1779
|
+
prompt = (
|
|
1780
|
+
f"This is a screenshot of a Microsoft Teams recurring meeting's "
|
|
1781
|
+
f"occurrence selector dropdown. I need to select the meeting "
|
|
1782
|
+
f"occurrence from {config['date']} ({target_label}). "
|
|
1783
|
+
f"Look at all visible dates and find the one matching "
|
|
1784
|
+
f"{target_label}.{time_instruction} "
|
|
1785
|
+
f"Count the dropdown items from top to bottom starting at 0. "
|
|
1786
|
+
f'Return JSON: {{"index": <n>}} for the correct occurrence, '
|
|
1787
|
+
f'or {{"index": null}} if none match.'
|
|
1788
|
+
)
|
|
1789
|
+
|
|
1790
|
+
log("step5", "Asking agent to identify correct occurrence...")
|
|
1791
|
+
agent_result = agent_vision_query(agent_client, config["model"], screenshot_b64, prompt)
|
|
1792
|
+
log("step5", f"Agent response: {agent_result}")
|
|
1793
|
+
|
|
1794
|
+
if not agent_result or agent_result.get("index") is None:
|
|
1795
|
+
log("step5", "Agent could not identify occurrence.")
|
|
1796
|
+
await cdp.send(
|
|
1797
|
+
"Input.dispatchKeyEvent",
|
|
1798
|
+
{
|
|
1799
|
+
"type": "keyDown",
|
|
1800
|
+
"key": "Escape",
|
|
1801
|
+
"code": "Escape",
|
|
1802
|
+
"windowsVirtualKeyCode": 27,
|
|
1803
|
+
},
|
|
1804
|
+
)
|
|
1805
|
+
await cdp.send(
|
|
1806
|
+
"Input.dispatchKeyEvent",
|
|
1807
|
+
{
|
|
1808
|
+
"type": "keyUp",
|
|
1809
|
+
"key": "Escape",
|
|
1810
|
+
"code": "Escape",
|
|
1811
|
+
"windowsVirtualKeyCode": 27,
|
|
1812
|
+
},
|
|
1813
|
+
)
|
|
1814
|
+
return False
|
|
1815
|
+
|
|
1816
|
+
occurrence_index = agent_result["index"]
|
|
1817
|
+
|
|
1818
|
+
# Click the correct dropdown option
|
|
1819
|
+
opt_bbox_json = await cdp.evaluate(f"""
|
|
1820
|
+
(function() {{
|
|
1821
|
+
var items = Array.from(document.querySelectorAll(
|
|
1822
|
+
'[role="menuitem"], [role="option"], '
|
|
1823
|
+
+ '[role="listbox"] > *, [role="menu"] > *'
|
|
1824
|
+
));
|
|
1825
|
+
var dateItems = items.filter(function(el) {{
|
|
1826
|
+
var months = 'January|February|March|April|May|June'
|
|
1827
|
+
+ '|July|August|September|October|November|December';
|
|
1828
|
+
var re = new RegExp('\\\\d{{1,2}}\\\\s+(' + months + ')', 'i');
|
|
1829
|
+
return re.test(el.textContent);
|
|
1830
|
+
}});
|
|
1831
|
+
if (dateItems.length > {occurrence_index}) {{
|
|
1832
|
+
var rect = dateItems[{occurrence_index}].getBoundingClientRect();
|
|
1833
|
+
return JSON.stringify({{
|
|
1834
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1835
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1836
|
+
text: dateItems[{occurrence_index}].textContent.trim().substring(0, 80)
|
|
1837
|
+
}});
|
|
1838
|
+
}}
|
|
1839
|
+
if (items.length > {occurrence_index}) {{
|
|
1840
|
+
var rect = items[{occurrence_index}].getBoundingClientRect();
|
|
1841
|
+
return JSON.stringify({{
|
|
1842
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
1843
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
1844
|
+
text: items[{occurrence_index}].textContent.trim().substring(0, 80)
|
|
1845
|
+
}});
|
|
1846
|
+
}}
|
|
1847
|
+
return JSON.stringify({{error: 'not_found'}});
|
|
1848
|
+
}})()
|
|
1849
|
+
""")
|
|
1850
|
+
opt_bbox = json.loads(opt_bbox_json)
|
|
1851
|
+
|
|
1852
|
+
if opt_bbox.get("error"):
|
|
1853
|
+
log("step5", f"Could not find occurrence option at index {occurrence_index}.")
|
|
1854
|
+
return False
|
|
1855
|
+
|
|
1856
|
+
log("step5", f"Clicking occurrence: {opt_bbox.get('text', '?')}")
|
|
1857
|
+
await cdp.send(
|
|
1858
|
+
"Input.dispatchMouseEvent",
|
|
1859
|
+
{
|
|
1860
|
+
"type": "mousePressed",
|
|
1861
|
+
"x": opt_bbox["x"],
|
|
1862
|
+
"y": opt_bbox["y"],
|
|
1863
|
+
"button": "left",
|
|
1864
|
+
"clickCount": 1,
|
|
1865
|
+
},
|
|
1866
|
+
)
|
|
1867
|
+
await cdp.send(
|
|
1868
|
+
"Input.dispatchMouseEvent",
|
|
1869
|
+
{
|
|
1870
|
+
"type": "mouseReleased",
|
|
1871
|
+
"x": opt_bbox["x"],
|
|
1872
|
+
"y": opt_bbox["y"],
|
|
1873
|
+
"button": "left",
|
|
1874
|
+
"clickCount": 1,
|
|
1875
|
+
},
|
|
1876
|
+
)
|
|
1877
|
+
|
|
1878
|
+
log("step5", "Waiting for occurrence content to load...")
|
|
1879
|
+
await _interruptible_sleep(4)
|
|
1880
|
+
dur = _parse_occurrence_duration(opt_bbox.get("text", ""))
|
|
1881
|
+
if dur:
|
|
1882
|
+
log("step5", f"Meeting duration: {dur}s ({dur // 60}min)")
|
|
1883
|
+
return True
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
# ---------------------------------------------------------------------------
|
|
1887
|
+
# Step 6: Find the transcript iframe's CDP target (agent-assisted)
|
|
1888
|
+
# ---------------------------------------------------------------------------
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
def find_xplatplugins_targets(port: int) -> list[dict]:
|
|
1892
|
+
"""Find all xplatplugins SharePoint iframe CDP targets."""
|
|
1893
|
+
targets = cdp_http_get(port, "/json")
|
|
1894
|
+
if not targets:
|
|
1895
|
+
return []
|
|
1896
|
+
|
|
1897
|
+
matches = []
|
|
1898
|
+
for t in targets:
|
|
1899
|
+
url = t.get("url", "")
|
|
1900
|
+
if "sharepoint" in url and "xplatplugins" in url:
|
|
1901
|
+
matches.append(t)
|
|
1902
|
+
return matches
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def find_iframe_target(
|
|
1906
|
+
port: int, config: dict, agent_client, pre_nav_iframe_ids: set[str] | None = None
|
|
1907
|
+
) -> str:
|
|
1908
|
+
"""Find the correct xplatplugins iframe target. Uses agent if ambiguous.
|
|
1909
|
+
|
|
1910
|
+
If pre_nav_iframe_ids is provided, prefer targets that appeared AFTER
|
|
1911
|
+
navigation (i.e., not in the pre-existing set).
|
|
1912
|
+
"""
|
|
1913
|
+
log("step6", "Looking for transcript iframe target...")
|
|
1914
|
+
pre_nav_iframe_ids = pre_nav_iframe_ids or set()
|
|
1915
|
+
|
|
1916
|
+
# Retry a few times as the iframe may still be loading
|
|
1917
|
+
matches = []
|
|
1918
|
+
new_matches = []
|
|
1919
|
+
for attempt in range(10):
|
|
1920
|
+
_check_shutdown()
|
|
1921
|
+
matches = find_xplatplugins_targets(port)
|
|
1922
|
+
new_matches = [t for t in matches if t["id"] not in pre_nav_iframe_ids]
|
|
1923
|
+
if new_matches:
|
|
1924
|
+
break
|
|
1925
|
+
if matches and attempt >= 5:
|
|
1926
|
+
# After 5 attempts, if we have targets but none are new,
|
|
1927
|
+
# the meeting might reuse an existing iframe
|
|
1928
|
+
log("step6", "No new iframe targets appeared. Using existing targets.")
|
|
1929
|
+
new_matches = matches
|
|
1930
|
+
break
|
|
1931
|
+
label = "new iframe" if matches else "any iframe"
|
|
1932
|
+
log("step6", f"Waiting for {label} target (attempt {attempt + 1}/10)...")
|
|
1933
|
+
_interruptible_sleep_sync(2)
|
|
1934
|
+
|
|
1935
|
+
# Use new targets if available, otherwise fall back to all
|
|
1936
|
+
candidates = new_matches or matches
|
|
1937
|
+
|
|
1938
|
+
if not candidates:
|
|
1939
|
+
_exit_error(
|
|
1940
|
+
EXIT_DOWNLOAD_FAILED,
|
|
1941
|
+
"IFRAME_NOT_FOUND",
|
|
1942
|
+
"No xplatplugins iframe target found. "
|
|
1943
|
+
"Check that Step 5 completed and the Transcript tab loaded.",
|
|
1944
|
+
step="step6",
|
|
1945
|
+
)
|
|
1946
|
+
|
|
1947
|
+
if len(candidates) == 1:
|
|
1948
|
+
tid = candidates[0]["id"]
|
|
1949
|
+
is_new = tid not in pre_nav_iframe_ids
|
|
1950
|
+
log("step6", f"{'New' if is_new else 'Existing'} iframe target: {tid[:20]}...")
|
|
1951
|
+
return tid
|
|
1952
|
+
|
|
1953
|
+
# Multiple targets -- ask the agent
|
|
1954
|
+
log("step6", f"{len(candidates)} iframe targets to choose from. Asking agent...")
|
|
1955
|
+
|
|
1956
|
+
target_list = "\n".join(
|
|
1957
|
+
f" [{i}] ID: {t['id']}, URL: {t['url'][:200]}" for i, t in enumerate(candidates)
|
|
1958
|
+
)
|
|
1959
|
+
prompt = (
|
|
1960
|
+
f"Multiple SharePoint iframe targets were found in a Teams CDP session. "
|
|
1961
|
+
f"I am looking for the transcript of the meeting "
|
|
1962
|
+
f"'{config['meeting']}' on {config['date']}.\n\n"
|
|
1963
|
+
f"Targets:\n{target_list}\n\n"
|
|
1964
|
+
f"Which target most likely corresponds to this meeting? "
|
|
1965
|
+
f"Look at the URL paths for clues (e.g., organiser name, meeting context). "
|
|
1966
|
+
f'Return JSON: {{"target_id": "<full target ID>"}} '
|
|
1967
|
+
f"for the best match."
|
|
1968
|
+
)
|
|
1969
|
+
|
|
1970
|
+
result = agent_text_query(agent_client, config["model"], prompt)
|
|
1971
|
+
log("step6", f"Agent response: {result}")
|
|
1972
|
+
|
|
1973
|
+
if result and result.get("target_id"):
|
|
1974
|
+
# Validate the agent's choice is actually one of our targets
|
|
1975
|
+
for t in candidates:
|
|
1976
|
+
if t["id"] == result["target_id"]:
|
|
1977
|
+
log("step6", f"Agent selected target: {t['id'][:20]}...")
|
|
1978
|
+
return t["id"]
|
|
1979
|
+
# Partial match (agent may have truncated)
|
|
1980
|
+
for t in candidates:
|
|
1981
|
+
if result["target_id"] in t["id"] or t["id"] in result["target_id"]:
|
|
1982
|
+
log("step6", f"Agent selected target (partial match): {t['id'][:20]}...")
|
|
1983
|
+
return t["id"]
|
|
1984
|
+
|
|
1985
|
+
# Fallback: use the last target (most recently created)
|
|
1986
|
+
log("step6", "Agent could not disambiguate. Using most recent target.")
|
|
1987
|
+
return candidates[-1]["id"]
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
# ---------------------------------------------------------------------------
|
|
1991
|
+
# Step 7: Check download permission
|
|
1992
|
+
# ---------------------------------------------------------------------------
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
async def check_permission(port: int, target_id: str) -> str:
|
|
1996
|
+
"""Check the Download button state. Returns 'has_permission',
|
|
1997
|
+
'no_permission', or 'no_button'."""
|
|
1998
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
1999
|
+
async with ws:
|
|
2000
|
+
for attempt in range(5):
|
|
2001
|
+
_check_shutdown()
|
|
2002
|
+
result_json = await cdp.evaluate("""
|
|
2003
|
+
(function() {
|
|
2004
|
+
var btn = document.querySelector('button[aria-haspopup="true"]');
|
|
2005
|
+
if (!btn) return JSON.stringify({state: "no_button"});
|
|
2006
|
+
var text = btn.textContent || "";
|
|
2007
|
+
if (text.indexOf("permission") !== -1)
|
|
2008
|
+
return JSON.stringify({
|
|
2009
|
+
state: "no_permission",
|
|
2010
|
+
message: text.trim().substring(0, 100)
|
|
2011
|
+
});
|
|
2012
|
+
return JSON.stringify({state: "has_permission"});
|
|
2013
|
+
})()
|
|
2014
|
+
""")
|
|
2015
|
+
if result_json:
|
|
2016
|
+
result = json.loads(result_json)
|
|
2017
|
+
log("step7", f"Permission state: {result['state']}")
|
|
2018
|
+
if result.get("message"):
|
|
2019
|
+
log("step7", f"Message: {result['message']}")
|
|
2020
|
+
return result["state"]
|
|
2021
|
+
log("step7", f"Attempt {attempt + 1}/5 -- waiting for button...")
|
|
2022
|
+
await _interruptible_sleep(2)
|
|
2023
|
+
|
|
2024
|
+
return "no_button"
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
# ---------------------------------------------------------------------------
|
|
2028
|
+
# Step 7A: Native download via blob interception
|
|
2029
|
+
# ---------------------------------------------------------------------------
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
async def download_native_vtt(port: int, target_id: str, output_path: str) -> bool:
|
|
2033
|
+
"""Download the transcript via blob interception. Returns True on success."""
|
|
2034
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
2035
|
+
async with ws:
|
|
2036
|
+
# 1. Monkey-patch URL.createObjectURL to capture VTT blob content
|
|
2037
|
+
patch_result = await cdp.evaluate("""
|
|
2038
|
+
(function() {
|
|
2039
|
+
window.__capturedVTT = null;
|
|
2040
|
+
var origCreateObjectURL = URL.createObjectURL;
|
|
2041
|
+
URL.createObjectURL = function(blob) {
|
|
2042
|
+
if (blob instanceof Blob) {
|
|
2043
|
+
blob.text().then(function(text) {
|
|
2044
|
+
if (text.indexOf('WEBVTT') !== -1) {
|
|
2045
|
+
window.__capturedVTT = text;
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
return origCreateObjectURL.call(URL, blob);
|
|
2050
|
+
};
|
|
2051
|
+
return 'interceptor installed';
|
|
2052
|
+
})()
|
|
2053
|
+
""")
|
|
2054
|
+
log("step7a", f"Blob interceptor: {patch_result}")
|
|
2055
|
+
|
|
2056
|
+
# 2. Open the Download dropdown menu
|
|
2057
|
+
menu_result_json = await cdp.evaluate("""
|
|
2058
|
+
(function() {
|
|
2059
|
+
var btn = document.querySelector('button[aria-haspopup="true"]');
|
|
2060
|
+
if (!btn) return JSON.stringify({error: 'Download button not found'});
|
|
2061
|
+
if (btn.getAttribute('aria-expanded') !== 'true') btn.click();
|
|
2062
|
+
return JSON.stringify({ok: true});
|
|
2063
|
+
})()
|
|
2064
|
+
""")
|
|
2065
|
+
menu_result = json.loads(menu_result_json)
|
|
2066
|
+
if menu_result.get("error"):
|
|
2067
|
+
log("step7a", f"Error: {menu_result['error']}")
|
|
2068
|
+
return False
|
|
2069
|
+
log("step7a", "Download menu opened")
|
|
2070
|
+
|
|
2071
|
+
# 3. Wait for menu to render, drain pending messages
|
|
2072
|
+
await _interruptible_sleep(1)
|
|
2073
|
+
await cdp.drain()
|
|
2074
|
+
|
|
2075
|
+
# 4. Click "Download as .vtt"
|
|
2076
|
+
click_result_json = await cdp.evaluate("""
|
|
2077
|
+
(function() {
|
|
2078
|
+
var vttBtn = document.querySelector(
|
|
2079
|
+
'button[aria-label="Download as .vtt"]'
|
|
2080
|
+
);
|
|
2081
|
+
if (!vttBtn) {
|
|
2082
|
+
var allBtns = Array.from(
|
|
2083
|
+
document.querySelectorAll('button[role="menuitem"]')
|
|
2084
|
+
);
|
|
2085
|
+
vttBtn = allBtns.find(function(b) {
|
|
2086
|
+
return b.textContent && b.textContent.indexOf('.vtt') !== -1;
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
if (!vttBtn) return JSON.stringify({error: 'VTT menu item not found'});
|
|
2090
|
+
vttBtn.click();
|
|
2091
|
+
return JSON.stringify({ok: true});
|
|
2092
|
+
})()
|
|
2093
|
+
""")
|
|
2094
|
+
click_result = json.loads(click_result_json)
|
|
2095
|
+
if click_result.get("error"):
|
|
2096
|
+
log("step7a", f"Error: {click_result['error']}")
|
|
2097
|
+
return False
|
|
2098
|
+
log("step7a", "Clicked 'Download as .vtt'")
|
|
2099
|
+
|
|
2100
|
+
# 5. Wait for the blob to be captured
|
|
2101
|
+
log("step7a", "Waiting for VTT blob capture...")
|
|
2102
|
+
await _interruptible_sleep(3)
|
|
2103
|
+
await cdp.drain()
|
|
2104
|
+
|
|
2105
|
+
# 6. Check if the VTT was captured
|
|
2106
|
+
status_json = await cdp.evaluate("""
|
|
2107
|
+
(function() {
|
|
2108
|
+
if (window.__capturedVTT) {
|
|
2109
|
+
return JSON.stringify({
|
|
2110
|
+
captured: true,
|
|
2111
|
+
length: window.__capturedVTT.length
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
return JSON.stringify({captured: false});
|
|
2115
|
+
})()
|
|
2116
|
+
""")
|
|
2117
|
+
status = json.loads(status_json)
|
|
2118
|
+
|
|
2119
|
+
if not status.get("captured"):
|
|
2120
|
+
log("step7a", "VTT blob was not captured.")
|
|
2121
|
+
return False
|
|
2122
|
+
|
|
2123
|
+
length = status["length"]
|
|
2124
|
+
log("step7a", f"Captured {length} chars of VTT content")
|
|
2125
|
+
|
|
2126
|
+
# 7. Retrieve content in chunks (large transcripts may exceed
|
|
2127
|
+
# the single-expression return limit)
|
|
2128
|
+
chunks = []
|
|
2129
|
+
chunk_size = 50000
|
|
2130
|
+
for offset in range(0, length, chunk_size):
|
|
2131
|
+
chunk = await cdp.evaluate(
|
|
2132
|
+
f"window.__capturedVTT.substring({offset}, {offset + chunk_size})"
|
|
2133
|
+
)
|
|
2134
|
+
chunks.append(chunk)
|
|
2135
|
+
|
|
2136
|
+
vtt_content = "".join(chunks)
|
|
2137
|
+
|
|
2138
|
+
# 8. Write to output path
|
|
2139
|
+
with open(output_path, "w") as f:
|
|
2140
|
+
f.write(vtt_content)
|
|
2141
|
+
|
|
2142
|
+
file_size = os.path.getsize(output_path)
|
|
2143
|
+
log("step7a", f"Written {file_size} bytes to {output_path}")
|
|
2144
|
+
return True
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
# ---------------------------------------------------------------------------
|
|
2148
|
+
# Step 7B: DOM extraction (when download is blocked by permissions)
|
|
2149
|
+
# ---------------------------------------------------------------------------
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
async def _extract_via_react_props(
|
|
2153
|
+
port: int,
|
|
2154
|
+
target_id: str,
|
|
2155
|
+
output_path: str,
|
|
2156
|
+
speaker_map: dict[str, str] | None = None,
|
|
2157
|
+
) -> bool | None:
|
|
2158
|
+
"""Try to extract transcript data from the React fibre tree.
|
|
2159
|
+
|
|
2160
|
+
The TranscriptList component holds all entries in its ``entries``
|
|
2161
|
+
prop, bypassing the virtualised list renderer entirely. All Text
|
|
2162
|
+
entries are extracted — no duration filtering is applied.
|
|
2163
|
+
|
|
2164
|
+
If *speaker_map* is provided (GUID -> display name), speaker names
|
|
2165
|
+
are resolved using it before generating VTT.
|
|
2166
|
+
|
|
2167
|
+
Returns True if successful, None if the React props aren't
|
|
2168
|
+
accessible (caller should fall back to DOM scrolling).
|
|
2169
|
+
"""
|
|
2170
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
2171
|
+
async with ws:
|
|
2172
|
+
entries_json = await cdp.evaluate(r"""
|
|
2173
|
+
(function() {
|
|
2174
|
+
var el = document.querySelector('[role="list"]');
|
|
2175
|
+
if (!el) return JSON.stringify({error: 'no list element'});
|
|
2176
|
+
var fiberKey = Object.keys(el).find(function(k) {
|
|
2177
|
+
return k.startsWith('__reactFiber$');
|
|
2178
|
+
});
|
|
2179
|
+
if (!fiberKey) return JSON.stringify({error: 'no fiber key'});
|
|
2180
|
+
|
|
2181
|
+
// Walk up the fibre tree to find the component with
|
|
2182
|
+
// an 'entries' prop (TranscriptList at ~depth 9).
|
|
2183
|
+
var current = el[fiberKey];
|
|
2184
|
+
var depth = 0;
|
|
2185
|
+
var entries = null;
|
|
2186
|
+
while (current && depth < 25) {
|
|
2187
|
+
var props = current.memoizedProps || {};
|
|
2188
|
+
if (props.entries && Array.isArray(props.entries)
|
|
2189
|
+
&& props.entries.length > 0) {
|
|
2190
|
+
entries = props.entries;
|
|
2191
|
+
break;
|
|
2192
|
+
}
|
|
2193
|
+
current = current.return;
|
|
2194
|
+
depth++;
|
|
2195
|
+
}
|
|
2196
|
+
if (!entries)
|
|
2197
|
+
return JSON.stringify({error: 'entries not found'});
|
|
2198
|
+
|
|
2199
|
+
function parseDur(d) {
|
|
2200
|
+
if (!d) return null;
|
|
2201
|
+
var m = d.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/);
|
|
2202
|
+
if (!m) return null;
|
|
2203
|
+
return (parseInt(m[1]||0)*3600)
|
|
2204
|
+
+ (parseInt(m[2]||0)*60)
|
|
2205
|
+
+ parseFloat(m[3]||0);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
var result = [];
|
|
2209
|
+
for (var i = 0; i < entries.length; i++) {
|
|
2210
|
+
var e = entries[i];
|
|
2211
|
+
if (e.type !== 'Text') continue;
|
|
2212
|
+
var ts = parseDur(e.timestamp);
|
|
2213
|
+
result.push({
|
|
2214
|
+
speaker: e.speakerDisplayName || 'Unknown',
|
|
2215
|
+
speakerId: e.speakerId || '',
|
|
2216
|
+
ts: ts,
|
|
2217
|
+
text: e.text || ''
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
return JSON.stringify({ok: true, items: result});
|
|
2221
|
+
})()
|
|
2222
|
+
""")
|
|
2223
|
+
|
|
2224
|
+
data = json.loads(entries_json)
|
|
2225
|
+
|
|
2226
|
+
if data.get("error"):
|
|
2227
|
+
log("step7b", f"React props extraction failed: {data['error']}")
|
|
2228
|
+
return None
|
|
2229
|
+
|
|
2230
|
+
items = data["items"]
|
|
2231
|
+
if not items:
|
|
2232
|
+
log("step7b", "React props returned 0 text entries.")
|
|
2233
|
+
return None
|
|
2234
|
+
|
|
2235
|
+
log("step7b", f"React props: extracted {len(items)} text entries")
|
|
2236
|
+
|
|
2237
|
+
# Resolve speaker names using the Recap Speakers mapping.
|
|
2238
|
+
# Transcript entries have speakerId like "{GUID}@{tenant}" -- extract
|
|
2239
|
+
# the GUID and look it up in the mapping for a better display name.
|
|
2240
|
+
#
|
|
2241
|
+
# The Recap Speakers section shows the canonical display name for
|
|
2242
|
+
# each speaker (e.g. "Gabor Veres"), while the transcript data often
|
|
2243
|
+
# has vendor-prefixed names (e.g. "v-Gabor Veres (Metosin)") or
|
|
2244
|
+
# synthetic placeholders ("@1"). Always prefer the Recap name.
|
|
2245
|
+
if speaker_map:
|
|
2246
|
+
resolved_pairs = {} # old_name -> new_name (for logging)
|
|
2247
|
+
for item in items:
|
|
2248
|
+
sid = item.get("speakerId", "")
|
|
2249
|
+
guid = sid.split("@")[0] if "@" in sid else sid
|
|
2250
|
+
if guid not in speaker_map:
|
|
2251
|
+
continue
|
|
2252
|
+
recap_name = speaker_map[guid]
|
|
2253
|
+
transcript_name = item["speaker"]
|
|
2254
|
+
if recap_name == transcript_name:
|
|
2255
|
+
continue
|
|
2256
|
+
item["speaker"] = recap_name
|
|
2257
|
+
resolved_pairs[transcript_name] = recap_name
|
|
2258
|
+
if resolved_pairs:
|
|
2259
|
+
for old, new in resolved_pairs.items():
|
|
2260
|
+
log("step7b", f" Resolved speaker: {old!r} -> {new!r}")
|
|
2261
|
+
total = sum(1 for it in items if it["speaker"] in resolved_pairs.values())
|
|
2262
|
+
log(
|
|
2263
|
+
"step7b",
|
|
2264
|
+
f"Resolved {len(resolved_pairs)} speaker name(s) ({total} entries affected)",
|
|
2265
|
+
)
|
|
2266
|
+
|
|
2267
|
+
# Filter out meta entries
|
|
2268
|
+
segments = []
|
|
2269
|
+
for item in items:
|
|
2270
|
+
text = item["text"]
|
|
2271
|
+
if "started transcription" in text or "stopped transcription" in text:
|
|
2272
|
+
continue
|
|
2273
|
+
segments.append(item)
|
|
2274
|
+
|
|
2275
|
+
# Generate VTT
|
|
2276
|
+
def sec_to_vtt(s):
|
|
2277
|
+
s = max(0, s)
|
|
2278
|
+
h = int(s) // 3600
|
|
2279
|
+
m = (int(s) % 3600) // 60
|
|
2280
|
+
sec = int(s) % 60
|
|
2281
|
+
ms = int((s % 1) * 1000)
|
|
2282
|
+
return f"{h:02d}:{m:02d}:{sec:02d}.{ms:03d}"
|
|
2283
|
+
|
|
2284
|
+
vtt = ["WEBVTT", ""]
|
|
2285
|
+
for i, seg in enumerate(segments):
|
|
2286
|
+
ss = seg["ts"] if seg["ts"] is not None else 0
|
|
2287
|
+
es = segments[i + 1]["ts"] if i + 1 < len(segments) else ss + 10
|
|
2288
|
+
if es is None or es <= ss:
|
|
2289
|
+
es = ss + 10
|
|
2290
|
+
vtt.extend(
|
|
2291
|
+
[
|
|
2292
|
+
str(i + 1),
|
|
2293
|
+
f"{sec_to_vtt(ss)} --> {sec_to_vtt(es)}",
|
|
2294
|
+
f"<v {seg['speaker']}>{seg['text']}</v>",
|
|
2295
|
+
"",
|
|
2296
|
+
]
|
|
2297
|
+
)
|
|
2298
|
+
|
|
2299
|
+
content = "\n".join(vtt)
|
|
2300
|
+
with open(output_path, "w") as f:
|
|
2301
|
+
f.write(content)
|
|
2302
|
+
|
|
2303
|
+
log("step7b", f"Written {len(content)} bytes, {len(segments)} segments")
|
|
2304
|
+
log("step7b", "Speakers:")
|
|
2305
|
+
for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
|
|
2306
|
+
log("step7b", f" {sp}: {cnt}")
|
|
2307
|
+
|
|
2308
|
+
return True
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
async def extract_dom_vtt(port: int, target_id: str, output_path: str) -> bool:
|
|
2312
|
+
"""Extract transcript from the DOM and generate a .vtt file."""
|
|
2313
|
+
headers = {} # headerNum -> {speaker, timestamp}
|
|
2314
|
+
texts = {} # textNum -> {text, headerNum}
|
|
2315
|
+
header_to_texts = {} # headerNum -> set of textNums
|
|
2316
|
+
orphaned_texts = []
|
|
2317
|
+
|
|
2318
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
2319
|
+
async with ws:
|
|
2320
|
+
# Scroll to top of transcript list
|
|
2321
|
+
await cdp.evaluate("""
|
|
2322
|
+
(function() {
|
|
2323
|
+
var el = document.querySelector('[role="list"]');
|
|
2324
|
+
var p = el;
|
|
2325
|
+
while (p && p.scrollHeight <= p.clientHeight) p = p.parentElement;
|
|
2326
|
+
if (p) p.scrollTop = 0;
|
|
2327
|
+
})()
|
|
2328
|
+
""")
|
|
2329
|
+
await asyncio.sleep(0.5)
|
|
2330
|
+
|
|
2331
|
+
# Scroll through the virtualised list, extracting entries.
|
|
2332
|
+
# Use scrollIntoView on the last visible item to advance.
|
|
2333
|
+
# Use block:'center' to avoid overshooting at the end of the list.
|
|
2334
|
+
consecutive_empty = 0
|
|
2335
|
+
for scroll_pass in range(200):
|
|
2336
|
+
items_json = await cdp.evaluate("""
|
|
2337
|
+
(function() {
|
|
2338
|
+
var items = Array.from(
|
|
2339
|
+
document.querySelectorAll('[role="listitem"]')
|
|
2340
|
+
);
|
|
2341
|
+
var result = [];
|
|
2342
|
+
var currentHeaderNum = null;
|
|
2343
|
+
for (var i = 0; i < items.length; i++) {
|
|
2344
|
+
var id = items[i].id || '';
|
|
2345
|
+
var num = parseInt(id.replace(/[^0-9]/g, ''));
|
|
2346
|
+
if (id.startsWith('itemHeader')) {
|
|
2347
|
+
currentHeaderNum = num;
|
|
2348
|
+
result.push({
|
|
2349
|
+
type: 'h', n: num,
|
|
2350
|
+
s: items[i].querySelector(
|
|
2351
|
+
'[class*="itemDisplayName"]'
|
|
2352
|
+
)?.textContent?.trim(),
|
|
2353
|
+
t: items[i].querySelector(
|
|
2354
|
+
'[class*="baseTimestamp"] span[aria-hidden]'
|
|
2355
|
+
)?.textContent?.trim()
|
|
2356
|
+
});
|
|
2357
|
+
} else if (id.startsWith('sub-entry')) {
|
|
2358
|
+
result.push({
|
|
2359
|
+
type: 'e', n: num, hn: currentHeaderNum,
|
|
2360
|
+
x: items[i].textContent?.trim()
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
// Scroll the last item to viewport centre to advance
|
|
2365
|
+
if (items.length > 0) {
|
|
2366
|
+
items[items.length - 1].scrollIntoView(
|
|
2367
|
+
{behavior: 'instant', block: 'center'}
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
return JSON.stringify(result);
|
|
2371
|
+
})()
|
|
2372
|
+
""")
|
|
2373
|
+
items = json.loads(items_json)
|
|
2374
|
+
|
|
2375
|
+
new_count = 0
|
|
2376
|
+
for item in items:
|
|
2377
|
+
if item["type"] == "h" and item["n"] not in headers:
|
|
2378
|
+
headers[item["n"]] = {
|
|
2379
|
+
"speaker": item["s"],
|
|
2380
|
+
"timestamp": item["t"],
|
|
2381
|
+
}
|
|
2382
|
+
new_count += 1
|
|
2383
|
+
elif item["type"] == "e" and item["n"] not in texts:
|
|
2384
|
+
texts[item["n"]] = {
|
|
2385
|
+
"text": item["x"],
|
|
2386
|
+
"headerNum": item.get("hn"),
|
|
2387
|
+
}
|
|
2388
|
+
new_count += 1
|
|
2389
|
+
hn = item.get("hn")
|
|
2390
|
+
if hn is None:
|
|
2391
|
+
orphaned_texts.append(item["n"])
|
|
2392
|
+
else:
|
|
2393
|
+
if hn not in header_to_texts:
|
|
2394
|
+
header_to_texts[hn] = set()
|
|
2395
|
+
header_to_texts[hn].add(item["n"])
|
|
2396
|
+
|
|
2397
|
+
if scroll_pass % 10 == 0:
|
|
2398
|
+
log(
|
|
2399
|
+
"step7b",
|
|
2400
|
+
f"Pass {scroll_pass}: +{new_count} | headers={len(headers)} texts={len(texts)}",
|
|
2401
|
+
)
|
|
2402
|
+
|
|
2403
|
+
if new_count == 0:
|
|
2404
|
+
consecutive_empty += 1
|
|
2405
|
+
else:
|
|
2406
|
+
consecutive_empty = 0
|
|
2407
|
+
|
|
2408
|
+
if consecutive_empty >= 3 and scroll_pass >= 3:
|
|
2409
|
+
log("step7b", f"Extraction complete at pass {scroll_pass}")
|
|
2410
|
+
break
|
|
2411
|
+
|
|
2412
|
+
await asyncio.sleep(0.25)
|
|
2413
|
+
|
|
2414
|
+
# Diagnostic: log header/text number ranges and gaps
|
|
2415
|
+
if headers:
|
|
2416
|
+
h_nums = sorted(headers.keys())
|
|
2417
|
+
t_nums = sorted(texts.keys())
|
|
2418
|
+
log(
|
|
2419
|
+
"step7b",
|
|
2420
|
+
f"Header IDs: min={h_nums[0]} max={h_nums[-1]} count={len(h_nums)}",
|
|
2421
|
+
)
|
|
2422
|
+
log("step7b", f"Text IDs: min={t_nums[0]} max={t_nums[-1]} count={len(t_nums)}")
|
|
2423
|
+
# Check for gaps in header numbering
|
|
2424
|
+
h_gaps = []
|
|
2425
|
+
for i in range(1, len(h_nums)):
|
|
2426
|
+
gap = h_nums[i] - h_nums[i - 1]
|
|
2427
|
+
if gap > 1:
|
|
2428
|
+
h_gaps.append((h_nums[i - 1], h_nums[i], gap))
|
|
2429
|
+
if h_gaps:
|
|
2430
|
+
log("step7b", f"Header gaps: {h_gaps[:10]}")
|
|
2431
|
+
# Check for gaps in text numbering
|
|
2432
|
+
t_gaps = []
|
|
2433
|
+
for i in range(1, len(t_nums)):
|
|
2434
|
+
gap = t_nums[i] - t_nums[i - 1]
|
|
2435
|
+
if gap > 1:
|
|
2436
|
+
t_gaps.append((t_nums[i - 1], t_nums[i], gap))
|
|
2437
|
+
if t_gaps:
|
|
2438
|
+
log("step7b", f"Text gaps: {t_gaps[:10]}")
|
|
2439
|
+
|
|
2440
|
+
# Sort text lists per header
|
|
2441
|
+
for hn in header_to_texts:
|
|
2442
|
+
header_to_texts[hn] = sorted(header_to_texts[hn])
|
|
2443
|
+
|
|
2444
|
+
if orphaned_texts:
|
|
2445
|
+
log(
|
|
2446
|
+
"step7b",
|
|
2447
|
+
f"WARNING: {len(orphaned_texts)} sub-entries had no header "
|
|
2448
|
+
f"in view: {orphaned_texts[:5]}...",
|
|
2449
|
+
)
|
|
2450
|
+
|
|
2451
|
+
# Convert to VTT
|
|
2452
|
+
def ts_to_sec(ts):
|
|
2453
|
+
if not ts:
|
|
2454
|
+
return None
|
|
2455
|
+
parts = ts.split(":")
|
|
2456
|
+
if len(parts) == 2:
|
|
2457
|
+
return int(parts[0]) * 60 + int(parts[1])
|
|
2458
|
+
if len(parts) == 3:
|
|
2459
|
+
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
|
2460
|
+
return 0
|
|
2461
|
+
|
|
2462
|
+
def sec_to_vtt(s):
|
|
2463
|
+
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}.000"
|
|
2464
|
+
|
|
2465
|
+
# Build ordered segments
|
|
2466
|
+
segments = []
|
|
2467
|
+
null_ts_count = 0
|
|
2468
|
+
for hn in sorted(header_to_texts.keys()):
|
|
2469
|
+
h = headers.get(hn, {})
|
|
2470
|
+
ts_raw = h.get("timestamp")
|
|
2471
|
+
ts_sec = ts_to_sec(ts_raw)
|
|
2472
|
+
if ts_sec is None:
|
|
2473
|
+
null_ts_count += 1
|
|
2474
|
+
for tn in header_to_texts[hn]:
|
|
2475
|
+
t = texts.get(tn, {})
|
|
2476
|
+
text = t.get("text", "")
|
|
2477
|
+
# Skip meta-entries
|
|
2478
|
+
if "started transcription" in text or "stopped transcription" in text:
|
|
2479
|
+
continue
|
|
2480
|
+
segments.append(
|
|
2481
|
+
{
|
|
2482
|
+
"speaker": h.get("speaker", "Unknown"),
|
|
2483
|
+
"ts_sec": ts_sec,
|
|
2484
|
+
"text": text,
|
|
2485
|
+
}
|
|
2486
|
+
)
|
|
2487
|
+
|
|
2488
|
+
if null_ts_count > 0:
|
|
2489
|
+
log(
|
|
2490
|
+
"step7b",
|
|
2491
|
+
f"WARNING: {null_ts_count} headers had null/empty timestamps.",
|
|
2492
|
+
)
|
|
2493
|
+
|
|
2494
|
+
# Forward-fill null timestamps
|
|
2495
|
+
last_good = None
|
|
2496
|
+
for seg in segments:
|
|
2497
|
+
if seg["ts_sec"] is not None:
|
|
2498
|
+
last_good = seg["ts_sec"]
|
|
2499
|
+
elif last_good is not None:
|
|
2500
|
+
seg["ts_sec"] = last_good + 10
|
|
2501
|
+
last_good = seg["ts_sec"]
|
|
2502
|
+
|
|
2503
|
+
# Backfill remaining nulls
|
|
2504
|
+
next_good = None
|
|
2505
|
+
for seg in reversed(segments):
|
|
2506
|
+
if seg["ts_sec"] is not None:
|
|
2507
|
+
next_good = seg["ts_sec"]
|
|
2508
|
+
elif next_good is not None:
|
|
2509
|
+
seg["ts_sec"] = max(0, next_good - 10)
|
|
2510
|
+
next_good = seg["ts_sec"]
|
|
2511
|
+
|
|
2512
|
+
# Final fallback
|
|
2513
|
+
for seg in segments:
|
|
2514
|
+
if seg["ts_sec"] is None:
|
|
2515
|
+
seg["ts_sec"] = 0
|
|
2516
|
+
|
|
2517
|
+
# Detect timestamp discontinuities caused by virtualised scroll
|
|
2518
|
+
# artefacts. If a timestamp jumps by more than 10 minutes from the
|
|
2519
|
+
# previous segment AND the jump is to a timestamp more than 2x the
|
|
2520
|
+
# running max, discard from that point onwards.
|
|
2521
|
+
if len(segments) > 2:
|
|
2522
|
+
running_max = 0
|
|
2523
|
+
cut_at = None
|
|
2524
|
+
for i, seg in enumerate(segments):
|
|
2525
|
+
ts = seg["ts_sec"]
|
|
2526
|
+
if i > 0 and ts is not None and running_max > 0:
|
|
2527
|
+
gap = ts - running_max
|
|
2528
|
+
if gap > 600 and ts > running_max * 2:
|
|
2529
|
+
log(
|
|
2530
|
+
"step7b",
|
|
2531
|
+
f"WARNING: Timestamp discontinuity at segment {i}: "
|
|
2532
|
+
f"{sec_to_vtt(running_max)} -> {sec_to_vtt(ts)} "
|
|
2533
|
+
f"(gap {gap:.0f}s). Truncating {len(segments) - i} "
|
|
2534
|
+
f"segment(s).",
|
|
2535
|
+
)
|
|
2536
|
+
cut_at = i
|
|
2537
|
+
break
|
|
2538
|
+
if ts is not None and ts > running_max:
|
|
2539
|
+
running_max = ts
|
|
2540
|
+
if cut_at is not None:
|
|
2541
|
+
segments = segments[:cut_at]
|
|
2542
|
+
|
|
2543
|
+
# Generate VTT content
|
|
2544
|
+
vtt = ["WEBVTT", ""]
|
|
2545
|
+
for i, seg in enumerate(segments):
|
|
2546
|
+
ss = seg["ts_sec"]
|
|
2547
|
+
es = segments[i + 1]["ts_sec"] if i + 1 < len(segments) else ss + 10
|
|
2548
|
+
if es <= ss:
|
|
2549
|
+
es = ss + 10
|
|
2550
|
+
vtt.extend(
|
|
2551
|
+
[
|
|
2552
|
+
str(i + 1),
|
|
2553
|
+
f"{sec_to_vtt(ss)} --> {sec_to_vtt(es)}",
|
|
2554
|
+
f"<v {seg['speaker']}>{seg['text']}</v>",
|
|
2555
|
+
"",
|
|
2556
|
+
]
|
|
2557
|
+
)
|
|
2558
|
+
|
|
2559
|
+
content = "\n".join(vtt)
|
|
2560
|
+
with open(output_path, "w") as f:
|
|
2561
|
+
f.write(content)
|
|
2562
|
+
|
|
2563
|
+
log("step7b", f"Written {len(content)} bytes, {len(segments)} segments")
|
|
2564
|
+
log("step7b", "Speakers:")
|
|
2565
|
+
for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
|
|
2566
|
+
log("step7b", f" {sp}: {cnt}")
|
|
2567
|
+
|
|
2568
|
+
return True
|
|
2569
|
+
|
|
2570
|
+
|
|
2571
|
+
# ---------------------------------------------------------------------------
|
|
2572
|
+
# Step 8: Verify the output
|
|
2573
|
+
# ---------------------------------------------------------------------------
|
|
2574
|
+
|
|
2575
|
+
|
|
2576
|
+
# ---------------------------------------------------------------------------
|
|
2577
|
+
# Multi-part support helpers
|
|
2578
|
+
# ---------------------------------------------------------------------------
|
|
2579
|
+
|
|
2580
|
+
|
|
2581
|
+
async def _detect_part_tabs(port: int, target_id: str) -> int:
|
|
2582
|
+
"""Detect Part N tabs in the Recap view.
|
|
2583
|
+
|
|
2584
|
+
Returns the number of parts (1 if no Part tabs are present, meaning
|
|
2585
|
+
a single-part meeting).
|
|
2586
|
+
"""
|
|
2587
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
2588
|
+
async with ws:
|
|
2589
|
+
count_json = await cdp.evaluate(r"""
|
|
2590
|
+
(function() {
|
|
2591
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
2592
|
+
var parts = tabs.filter(function(t) {
|
|
2593
|
+
return /^Part\s*\d/i.test(t.textContent.trim());
|
|
2594
|
+
});
|
|
2595
|
+
return JSON.stringify({count: parts.length});
|
|
2596
|
+
})()
|
|
2597
|
+
""")
|
|
2598
|
+
data = json.loads(count_json)
|
|
2599
|
+
return max(1, data.get("count", 0))
|
|
2600
|
+
|
|
2601
|
+
|
|
2602
|
+
async def _click_part_and_transcript(port: int, target_id: str, part_number: int) -> bool:
|
|
2603
|
+
"""Click the Part N tab and then the Transcript sub-tab.
|
|
2604
|
+
|
|
2605
|
+
Returns True if both clicks succeeded.
|
|
2606
|
+
"""
|
|
2607
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
2608
|
+
async with ws:
|
|
2609
|
+
# Click the Part tab
|
|
2610
|
+
part_result = await cdp.evaluate(rf"""
|
|
2611
|
+
(function() {{
|
|
2612
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
2613
|
+
var partTab = tabs.find(function(t) {{
|
|
2614
|
+
return /^Part\s*{part_number}($|Part)/i.test(t.textContent.trim());
|
|
2615
|
+
}});
|
|
2616
|
+
if (!partTab) return 'no_part_tab';
|
|
2617
|
+
partTab.click();
|
|
2618
|
+
return 'clicked_part';
|
|
2619
|
+
}})()
|
|
2620
|
+
""")
|
|
2621
|
+
log("multi-part", f"Part {part_number} tab: {part_result}")
|
|
2622
|
+
|
|
2623
|
+
if part_result == "no_part_tab":
|
|
2624
|
+
return False
|
|
2625
|
+
|
|
2626
|
+
# Wait for the part content to load
|
|
2627
|
+
await _interruptible_sleep(2)
|
|
2628
|
+
|
|
2629
|
+
# Click the Transcript sub-tab
|
|
2630
|
+
transcript_result = await cdp.evaluate("""
|
|
2631
|
+
(function() {
|
|
2632
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
2633
|
+
var transcriptTab = tabs.find(function(t) {
|
|
2634
|
+
return t.textContent.indexOf('Transcript') !== -1;
|
|
2635
|
+
});
|
|
2636
|
+
if (!transcriptTab) return 'no_transcript_tab';
|
|
2637
|
+
transcriptTab.click();
|
|
2638
|
+
return 'clicked_transcript';
|
|
2639
|
+
})()
|
|
2640
|
+
""")
|
|
2641
|
+
log("multi-part", f"Transcript tab for Part {part_number}: {transcript_result}")
|
|
2642
|
+
|
|
2643
|
+
if transcript_result == "no_transcript_tab":
|
|
2644
|
+
return False
|
|
2645
|
+
|
|
2646
|
+
# Wait for transcript iframe to load
|
|
2647
|
+
await _interruptible_sleep(3)
|
|
2648
|
+
|
|
2649
|
+
return True
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
def _parse_vtt_timestamp(ts: str) -> float:
|
|
2653
|
+
"""Parse a VTT timestamp (HH:MM:SS.mmm) to seconds."""
|
|
2654
|
+
parts = ts.split(":")
|
|
2655
|
+
h = int(parts[0])
|
|
2656
|
+
m = int(parts[1])
|
|
2657
|
+
s_ms = parts[2].split(".")
|
|
2658
|
+
s = int(s_ms[0])
|
|
2659
|
+
ms = int(s_ms[1]) if len(s_ms) > 1 else 0
|
|
2660
|
+
return h * 3600 + m * 60 + s + ms / 1000
|
|
2661
|
+
|
|
2662
|
+
|
|
2663
|
+
def _format_vtt_timestamp(seconds: float) -> str:
|
|
2664
|
+
"""Format seconds as a VTT timestamp (HH:MM:SS.mmm)."""
|
|
2665
|
+
seconds = max(0.0, seconds)
|
|
2666
|
+
h = int(seconds) // 3600
|
|
2667
|
+
m = (int(seconds) % 3600) // 60
|
|
2668
|
+
s = int(seconds) % 60
|
|
2669
|
+
ms = int((seconds % 1) * 1000)
|
|
2670
|
+
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
|
2671
|
+
|
|
2672
|
+
|
|
2673
|
+
def _merge_vtt_parts(vtt_contents: list[str]) -> str:
|
|
2674
|
+
"""Merge multiple VTT file contents into a single VTT.
|
|
2675
|
+
|
|
2676
|
+
Each part's timestamps are offset so that Part 2 continues from
|
|
2677
|
+
Part 1's last cue end time, and so on. Cues are renumbered
|
|
2678
|
+
sequentially across all parts.
|
|
2679
|
+
|
|
2680
|
+
Args:
|
|
2681
|
+
vtt_contents: list of VTT file contents (strings), one per part.
|
|
2682
|
+
|
|
2683
|
+
Returns:
|
|
2684
|
+
Merged VTT content as a single string.
|
|
2685
|
+
"""
|
|
2686
|
+
timestamp_re = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})")
|
|
2687
|
+
|
|
2688
|
+
all_cues: list[tuple[float, float, list[str]]] = []
|
|
2689
|
+
cumulative_offset = 0.0
|
|
2690
|
+
|
|
2691
|
+
for part_idx, vtt_text in enumerate(vtt_contents):
|
|
2692
|
+
lines = vtt_text.split("\n")
|
|
2693
|
+
part_cues: list[tuple[float, float, list[str]]] = []
|
|
2694
|
+
|
|
2695
|
+
i = 0
|
|
2696
|
+
while i < len(lines):
|
|
2697
|
+
line = lines[i].strip()
|
|
2698
|
+
ts_match = timestamp_re.match(line)
|
|
2699
|
+
if ts_match:
|
|
2700
|
+
start = _parse_vtt_timestamp(ts_match.group(1))
|
|
2701
|
+
end = _parse_vtt_timestamp(ts_match.group(2))
|
|
2702
|
+
# Collect cue content lines (everything until next blank line)
|
|
2703
|
+
content_lines = []
|
|
2704
|
+
i += 1
|
|
2705
|
+
while i < len(lines) and lines[i].strip():
|
|
2706
|
+
content_lines.append(lines[i])
|
|
2707
|
+
i += 1
|
|
2708
|
+
part_cues.append(
|
|
2709
|
+
(start + cumulative_offset, end + cumulative_offset, content_lines)
|
|
2710
|
+
)
|
|
2711
|
+
else:
|
|
2712
|
+
i += 1
|
|
2713
|
+
|
|
2714
|
+
# Update offset: next part starts after this part's last cue end
|
|
2715
|
+
if part_cues:
|
|
2716
|
+
last_end = max(end for _, end, _ in part_cues)
|
|
2717
|
+
cumulative_offset = last_end
|
|
2718
|
+
log(
|
|
2719
|
+
"multi-part",
|
|
2720
|
+
f"Part {part_idx + 1}: {len(part_cues)} cues, "
|
|
2721
|
+
f"ends at {_format_vtt_timestamp(last_end)}",
|
|
2722
|
+
)
|
|
2723
|
+
|
|
2724
|
+
all_cues.extend(part_cues)
|
|
2725
|
+
|
|
2726
|
+
# Build merged VTT
|
|
2727
|
+
result = ["WEBVTT", ""]
|
|
2728
|
+
for idx, (start, end, content_lines) in enumerate(all_cues, 1):
|
|
2729
|
+
result.append(str(idx))
|
|
2730
|
+
result.append(f"{_format_vtt_timestamp(start)} --> {_format_vtt_timestamp(end)}")
|
|
2731
|
+
result.extend(content_lines)
|
|
2732
|
+
result.append("")
|
|
2733
|
+
|
|
2734
|
+
return "\n".join(result)
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
# ---------------------------------------------------------------------------
|
|
2738
|
+
# Chat message extraction
|
|
2739
|
+
# ---------------------------------------------------------------------------
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
def _strip_html_to_text(html: str) -> str:
|
|
2743
|
+
"""Convert HTML message content to plain text.
|
|
2744
|
+
|
|
2745
|
+
Handles common Teams message markup: ``<p>``, ``<br>``, ``<div>``,
|
|
2746
|
+
``<a href="...">``, ``<span>``, and HTML entities. Strips all other
|
|
2747
|
+
tags. Returns empty string for blank or whitespace-only content.
|
|
2748
|
+
"""
|
|
2749
|
+
import html as html_mod
|
|
2750
|
+
|
|
2751
|
+
if not html:
|
|
2752
|
+
return ""
|
|
2753
|
+
|
|
2754
|
+
text = html
|
|
2755
|
+
|
|
2756
|
+
# Convert block-level elements to newlines
|
|
2757
|
+
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
|
2758
|
+
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
|
|
2759
|
+
text = re.sub(r"</div>", "\n", text, flags=re.IGNORECASE)
|
|
2760
|
+
text = re.sub(r"</li>", "\n", text, flags=re.IGNORECASE)
|
|
2761
|
+
|
|
2762
|
+
# Extract link text with URL: <a href="URL">text</a> -> text (URL)
|
|
2763
|
+
text = re.sub(
|
|
2764
|
+
r'<a\s[^>]*href="([^"]*)"[^>]*>([^<]*)</a>',
|
|
2765
|
+
r"\2 (\1)",
|
|
2766
|
+
text,
|
|
2767
|
+
flags=re.IGNORECASE,
|
|
2768
|
+
)
|
|
2769
|
+
|
|
2770
|
+
# Remove all remaining HTML tags
|
|
2771
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
2772
|
+
|
|
2773
|
+
# Decode HTML entities
|
|
2774
|
+
text = html_mod.unescape(text)
|
|
2775
|
+
|
|
2776
|
+
# Collapse multiple blank lines into a single newline
|
|
2777
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
2778
|
+
|
|
2779
|
+
return text.strip()
|
|
2780
|
+
|
|
2781
|
+
|
|
2782
|
+
def _resolve_chat_author(
|
|
2783
|
+
from_user_id: str,
|
|
2784
|
+
im_display_name: str,
|
|
2785
|
+
speaker_map: dict[str, str] | None,
|
|
2786
|
+
) -> str:
|
|
2787
|
+
"""Resolve a chat message author name using the speaker mapping.
|
|
2788
|
+
|
|
2789
|
+
Extracts the bare GUID from ``fromUserId`` (format ``8:orgid:{GUID}``)
|
|
2790
|
+
and checks the speaker_map for a cleaner display name. Falls back to
|
|
2791
|
+
``imDisplayName`` from the message props.
|
|
2792
|
+
"""
|
|
2793
|
+
if speaker_map and from_user_id and ":" in from_user_id:
|
|
2794
|
+
guid = from_user_id.split(":")[-1]
|
|
2795
|
+
mapped = speaker_map.get(guid)
|
|
2796
|
+
if mapped:
|
|
2797
|
+
return mapped
|
|
2798
|
+
|
|
2799
|
+
return im_display_name or "Unknown"
|
|
2800
|
+
|
|
2801
|
+
|
|
2802
|
+
async def _extract_roster_members(
|
|
2803
|
+
port: int,
|
|
2804
|
+
target_id: str,
|
|
2805
|
+
speaker_map: dict[str, str] | None = None,
|
|
2806
|
+
) -> list[dict]:
|
|
2807
|
+
"""Extract the full meeting chat member list from the roster popover.
|
|
2808
|
+
|
|
2809
|
+
Opens the participant count button, reads member data from the React
|
|
2810
|
+
fibre tree, and closes the popover. Returns a list of dicts with
|
|
2811
|
+
``name`` (cleaned display name), ``email``, and ``role`` (``organiser``
|
|
2812
|
+
if the member is marked as organiser, otherwise ``member``).
|
|
2813
|
+
|
|
2814
|
+
The ``speaker_map`` is used to resolve vendor-prefixed names
|
|
2815
|
+
(e.g. ``v-Gabor Veres (Metosin)`` -> ``Gabor Veres``). Names not
|
|
2816
|
+
in the map have the ``v-`` prefix and parenthesised suffix stripped.
|
|
2817
|
+
"""
|
|
2818
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
2819
|
+
try:
|
|
2820
|
+
# Click the participant count button to open the roster
|
|
2821
|
+
result = await cdp.evaluate("""
|
|
2822
|
+
(function() {
|
|
2823
|
+
var btn = document.querySelector(
|
|
2824
|
+
'[data-tid="chat-header-participant-count"]'
|
|
2825
|
+
);
|
|
2826
|
+
if (!btn) return JSON.stringify({error: 'no_button'});
|
|
2827
|
+
btn.click();
|
|
2828
|
+
return JSON.stringify({ok: true});
|
|
2829
|
+
})()
|
|
2830
|
+
""")
|
|
2831
|
+
data = json.loads(result) if result else {}
|
|
2832
|
+
if data.get("error"):
|
|
2833
|
+
log("roster", f"Could not open roster: {data['error']}")
|
|
2834
|
+
return []
|
|
2835
|
+
|
|
2836
|
+
# Wait for the roster popover to render
|
|
2837
|
+
await _interruptible_sleep(2)
|
|
2838
|
+
|
|
2839
|
+
# Extract member data from the grid
|
|
2840
|
+
raw = await cdp.evaluate("""
|
|
2841
|
+
(function() {
|
|
2842
|
+
var grid = document.querySelector('[aria-label="grid"]');
|
|
2843
|
+
if (!grid) return JSON.stringify({error: 'no_grid'});
|
|
2844
|
+
|
|
2845
|
+
var presentation = grid.querySelector('[role="presentation"]');
|
|
2846
|
+
if (!presentation)
|
|
2847
|
+
return JSON.stringify({error: 'no_presentation'});
|
|
2848
|
+
|
|
2849
|
+
var items = presentation.children;
|
|
2850
|
+
var results = [];
|
|
2851
|
+
|
|
2852
|
+
for (var i = 0; i < items.length; i++) {
|
|
2853
|
+
var item = items[i];
|
|
2854
|
+
var text = item.textContent.trim();
|
|
2855
|
+
|
|
2856
|
+
// Skip the header row ("People (N)")
|
|
2857
|
+
if (/^People\\s*\\(/.test(text)) continue;
|
|
2858
|
+
|
|
2859
|
+
var fk = Object.keys(item).find(
|
|
2860
|
+
function(k) { return k.startsWith('__reactFiber$'); }
|
|
2861
|
+
);
|
|
2862
|
+
if (!fk) continue;
|
|
2863
|
+
|
|
2864
|
+
var fiber = item[fk];
|
|
2865
|
+
var c = fiber;
|
|
2866
|
+
var member = null;
|
|
2867
|
+
for (var d = 0; d < 10 && c; d++) {
|
|
2868
|
+
var p = c.memoizedProps || {};
|
|
2869
|
+
if (p.member && p.member.displayName) {
|
|
2870
|
+
member = {
|
|
2871
|
+
displayName: p.member.displayName,
|
|
2872
|
+
mri: p.member.mri || '',
|
|
2873
|
+
objectId: p.member.objectId || '',
|
|
2874
|
+
email: p.member.email || ''
|
|
2875
|
+
};
|
|
2876
|
+
break;
|
|
2877
|
+
}
|
|
2878
|
+
c = c.return;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
if (member) {
|
|
2882
|
+
member.isOrganiser = /Organiser/i.test(text);
|
|
2883
|
+
results.push(member);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
return JSON.stringify({members: results});
|
|
2888
|
+
})()
|
|
2889
|
+
""")
|
|
2890
|
+
|
|
2891
|
+
# Close the popover with Escape
|
|
2892
|
+
await cdp.send(
|
|
2893
|
+
"Input.dispatchKeyEvent",
|
|
2894
|
+
{
|
|
2895
|
+
"type": "keyDown",
|
|
2896
|
+
"key": "Escape",
|
|
2897
|
+
"code": "Escape",
|
|
2898
|
+
"windowsVirtualKeyCode": 27,
|
|
2899
|
+
},
|
|
2900
|
+
)
|
|
2901
|
+
await cdp.send(
|
|
2902
|
+
"Input.dispatchKeyEvent",
|
|
2903
|
+
{
|
|
2904
|
+
"type": "keyUp",
|
|
2905
|
+
"key": "Escape",
|
|
2906
|
+
"code": "Escape",
|
|
2907
|
+
"windowsVirtualKeyCode": 27,
|
|
2908
|
+
},
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
if not raw:
|
|
2912
|
+
log("roster", "Failed to extract roster data.")
|
|
2913
|
+
return []
|
|
2914
|
+
|
|
2915
|
+
data = json.loads(raw)
|
|
2916
|
+
if data.get("error"):
|
|
2917
|
+
log("roster", f"Roster extraction error: {data['error']}")
|
|
2918
|
+
return []
|
|
2919
|
+
|
|
2920
|
+
raw_members = data.get("members", [])
|
|
2921
|
+
if not raw_members:
|
|
2922
|
+
log("roster", "No members found in roster.")
|
|
2923
|
+
return []
|
|
2924
|
+
|
|
2925
|
+
# Clean names: prefer speaker_map lookup, then strip vendor prefix
|
|
2926
|
+
members = []
|
|
2927
|
+
for m in raw_members:
|
|
2928
|
+
name = m["displayName"]
|
|
2929
|
+
guid = m["objectId"]
|
|
2930
|
+
|
|
2931
|
+
# Try speaker_map first
|
|
2932
|
+
if speaker_map and guid and guid in speaker_map:
|
|
2933
|
+
name = speaker_map[guid]
|
|
2934
|
+
else:
|
|
2935
|
+
# Strip vendor prefix: "v-Name (Company)" -> "Name"
|
|
2936
|
+
cleaned = re.sub(r"^v-", "", name)
|
|
2937
|
+
cleaned = re.sub(r"\s*\([^)]+\)\s*$", "", cleaned)
|
|
2938
|
+
if cleaned:
|
|
2939
|
+
name = cleaned
|
|
2940
|
+
|
|
2941
|
+
role = "organiser" if m.get("isOrganiser") else "member"
|
|
2942
|
+
members.append(
|
|
2943
|
+
{
|
|
2944
|
+
"name": name,
|
|
2945
|
+
"email": m["email"],
|
|
2946
|
+
"objectId": guid,
|
|
2947
|
+
"role": role,
|
|
2948
|
+
}
|
|
2949
|
+
)
|
|
2950
|
+
|
|
2951
|
+
log("roster", f"Extracted {len(members)} member(s)")
|
|
2952
|
+
return members
|
|
2953
|
+
|
|
2954
|
+
finally:
|
|
2955
|
+
await ws.close()
|
|
2956
|
+
|
|
2957
|
+
|
|
2958
|
+
def _utc_to_local_iso(utc_timestamp: str) -> str:
|
|
2959
|
+
"""Convert a UTC ISO 8601 timestamp to local time with UTC offset.
|
|
2960
|
+
|
|
2961
|
+
Accepts strings like ``2026-04-22T10:15:48.462Z`` and returns
|
|
2962
|
+
``2026-04-22T11:15:48.462+01:00`` (for BST). If parsing fails
|
|
2963
|
+
the original string is returned unchanged.
|
|
2964
|
+
"""
|
|
2965
|
+
from datetime import datetime, timedelta
|
|
2966
|
+
|
|
2967
|
+
try:
|
|
2968
|
+
dt = datetime.fromisoformat(utc_timestamp.replace("Z", "+00:00"))
|
|
2969
|
+
local_dt = dt.astimezone(tz=None)
|
|
2970
|
+
offset = local_dt.utcoffset() or timedelta(0)
|
|
2971
|
+
total = int(offset.total_seconds())
|
|
2972
|
+
sign = "+" if total >= 0 else "-"
|
|
2973
|
+
a = abs(total)
|
|
2974
|
+
offset_str = f"{sign}{a // 3600:02d}:{(a % 3600) // 60:02d}"
|
|
2975
|
+
# Format with milliseconds if present in original
|
|
2976
|
+
if "." in utc_timestamp:
|
|
2977
|
+
ms = f"{local_dt.microsecond // 1000:03d}"
|
|
2978
|
+
return f"{local_dt.strftime('%Y-%m-%dT%H:%M:%S')}.{ms}{offset_str}"
|
|
2979
|
+
return local_dt.strftime(f"%Y-%m-%dT%H:%M:%S{offset_str}")
|
|
2980
|
+
except (ValueError, OSError):
|
|
2981
|
+
return utc_timestamp
|
|
2982
|
+
|
|
2983
|
+
|
|
2984
|
+
async def _extract_chat_messages(
|
|
2985
|
+
port: int,
|
|
2986
|
+
target_id: str,
|
|
2987
|
+
config: dict,
|
|
2988
|
+
) -> list[dict]:
|
|
2989
|
+
"""Extract meeting chat messages from the Chat tab via React fibre tree.
|
|
2990
|
+
|
|
2991
|
+
Navigates to the Chat tab, walks the React fibre tree to find the
|
|
2992
|
+
virtualised list's ``items`` array, and extracts user messages that
|
|
2993
|
+
fall within the meeting time window.
|
|
2994
|
+
|
|
2995
|
+
The time window is determined from ``Event/Call`` control messages
|
|
2996
|
+
(meeting start/end signals) which carry actual UTC timestamps. This
|
|
2997
|
+
avoids the ambiguity of the Recap header times which are in local
|
|
2998
|
+
timezone.
|
|
2999
|
+
|
|
3000
|
+
Returns a list of dicts with keys: ``timestamp``, ``author``, ``text``,
|
|
3001
|
+
``reactions``. Returns an empty list if no chat messages are found.
|
|
3002
|
+
"""
|
|
3003
|
+
target_date = config["date"] # YYYY-MM-DD
|
|
3004
|
+
|
|
3005
|
+
ws, cdp = await connect_to_target(port, target_id)
|
|
3006
|
+
try:
|
|
3007
|
+
# Click the Chat tab
|
|
3008
|
+
chat_tab_result = await cdp.evaluate(r"""
|
|
3009
|
+
(function() {
|
|
3010
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
3011
|
+
var chatTab = tabs.find(function(t) {
|
|
3012
|
+
return t.getAttribute('data-tid') ===
|
|
3013
|
+
'tab-item-com.microsoft.chattabs.chat';
|
|
3014
|
+
});
|
|
3015
|
+
if (!chatTab) return 'no_tab';
|
|
3016
|
+
chatTab.click();
|
|
3017
|
+
return 'clicked';
|
|
3018
|
+
})()
|
|
3019
|
+
""")
|
|
3020
|
+
|
|
3021
|
+
if chat_tab_result != "clicked":
|
|
3022
|
+
log("chat", "Chat tab not found -- skipping chat extraction.")
|
|
3023
|
+
return []
|
|
3024
|
+
|
|
3025
|
+
await _interruptible_sleep(2)
|
|
3026
|
+
|
|
3027
|
+
# Extract all messages from the React fibre tree.
|
|
3028
|
+
# The virtualised list component at ~depth 21 (walking up from any
|
|
3029
|
+
# chat-pane-item) holds the complete items array.
|
|
3030
|
+
raw = await cdp.evaluate(r"""
|
|
3031
|
+
(function() {
|
|
3032
|
+
var els = document.querySelectorAll('[data-tid="chat-pane-item"]');
|
|
3033
|
+
if (!els.length) return JSON.stringify({error: 'no_items'});
|
|
3034
|
+
|
|
3035
|
+
// Walk up the fibre tree from the first item to find the
|
|
3036
|
+
// component whose props.items is an array of messages.
|
|
3037
|
+
var el = els[0];
|
|
3038
|
+
var fk = Object.keys(el).find(function(k) {
|
|
3039
|
+
return k.startsWith('__reactFiber$');
|
|
3040
|
+
});
|
|
3041
|
+
if (!fk) return JSON.stringify({error: 'no_fiber'});
|
|
3042
|
+
|
|
3043
|
+
var fiber = el[fk];
|
|
3044
|
+
var c = fiber;
|
|
3045
|
+
var itemsArr = null;
|
|
3046
|
+
var loadPrev = null;
|
|
3047
|
+
var loadNext = null;
|
|
3048
|
+
for (var d = 0; d < 30 && c; d++) {
|
|
3049
|
+
var p = c.memoizedProps || {};
|
|
3050
|
+
if (p.items && Array.isArray(p.items) && p.items.length > 0
|
|
3051
|
+
&& p.items[0] && p.items[0].originalArrivalTime) {
|
|
3052
|
+
itemsArr = p.items;
|
|
3053
|
+
loadPrev = p.loadPrev;
|
|
3054
|
+
loadNext = p.loadNext;
|
|
3055
|
+
break;
|
|
3056
|
+
}
|
|
3057
|
+
c = c.return;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
if (!itemsArr) return JSON.stringify({error: 'no_items_array'});
|
|
3061
|
+
|
|
3062
|
+
// Extract relevant fields from each item
|
|
3063
|
+
var messages = [];
|
|
3064
|
+
for (var i = 0; i < itemsArr.length; i++) {
|
|
3065
|
+
var m = itemsArr[i];
|
|
3066
|
+
messages.push({
|
|
3067
|
+
id: m.id,
|
|
3068
|
+
time: m.originalArrivalTime,
|
|
3069
|
+
itemType: m.itemType,
|
|
3070
|
+
messageType: m.messageType,
|
|
3071
|
+
fromUserId: m.fromUserId || '',
|
|
3072
|
+
displayName: m.imDisplayName || '',
|
|
3073
|
+
content: m.content || '',
|
|
3074
|
+
emotions: m.emotions,
|
|
3075
|
+
emotionsSummary: m.emotionsSummary,
|
|
3076
|
+
files: m.files ? m.files.length : 0,
|
|
3077
|
+
mentions: m.mentions
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
return JSON.stringify({
|
|
3082
|
+
total: itemsArr.length,
|
|
3083
|
+
hasPagination: !!(loadPrev || loadNext),
|
|
3084
|
+
messages: messages
|
|
3085
|
+
});
|
|
3086
|
+
})()
|
|
3087
|
+
""")
|
|
3088
|
+
|
|
3089
|
+
if not raw:
|
|
3090
|
+
log("chat", "Failed to extract chat data from React fibre tree.")
|
|
3091
|
+
return []
|
|
3092
|
+
|
|
3093
|
+
data = json.loads(raw)
|
|
3094
|
+
|
|
3095
|
+
if "error" in data:
|
|
3096
|
+
log("chat", f"Chat extraction error: {data['error']}")
|
|
3097
|
+
return []
|
|
3098
|
+
|
|
3099
|
+
if data.get("hasPagination"):
|
|
3100
|
+
log(
|
|
3101
|
+
"chat",
|
|
3102
|
+
f"WARNING: Chat has pagination ({data['total']} items loaded). "
|
|
3103
|
+
"Some messages may be missing.",
|
|
3104
|
+
)
|
|
3105
|
+
|
|
3106
|
+
log("chat", f"Total items in chat: {data['total']}")
|
|
3107
|
+
|
|
3108
|
+
# Determine the meeting time window from Event/Call control messages.
|
|
3109
|
+
# These are the meeting start/end signals with actual UTC timestamps,
|
|
3110
|
+
# avoiding the ambiguity of the Recap header which shows local time.
|
|
3111
|
+
#
|
|
3112
|
+
# The user-specified --datetime may differ from the actual meeting
|
|
3113
|
+
# date shown in the Recap header (e.g. user says 2026-04-22 but
|
|
3114
|
+
# meeting was 2026-04-21). Try multiple candidate dates.
|
|
3115
|
+
candidate_dates = [target_date]
|
|
3116
|
+
|
|
3117
|
+
# Extract the actual meeting date from the Recap header if available
|
|
3118
|
+
time_text = config.get("meeting_time_text", "")
|
|
3119
|
+
if time_text:
|
|
3120
|
+
recap_start, _ = _parse_meeting_times(time_text, target_date)
|
|
3121
|
+
recap_date = recap_start[:10] # YYYY-MM-DD
|
|
3122
|
+
if recap_date != target_date:
|
|
3123
|
+
candidate_dates.insert(0, recap_date)
|
|
3124
|
+
|
|
3125
|
+
event_calls = []
|
|
3126
|
+
matched_date = None
|
|
3127
|
+
for cdate in candidate_dates:
|
|
3128
|
+
event_calls = [
|
|
3129
|
+
m
|
|
3130
|
+
for m in data["messages"]
|
|
3131
|
+
if m.get("messageType") == "Event/Call" and m.get("time", "").startswith(cdate)
|
|
3132
|
+
]
|
|
3133
|
+
if event_calls:
|
|
3134
|
+
matched_date = cdate
|
|
3135
|
+
break
|
|
3136
|
+
|
|
3137
|
+
if event_calls:
|
|
3138
|
+
# Sort by timestamp; first is meeting start, last is meeting end
|
|
3139
|
+
event_calls.sort(key=lambda m: m["time"])
|
|
3140
|
+
start_iso = event_calls[0]["time"]
|
|
3141
|
+
end_iso = event_calls[-1]["time"]
|
|
3142
|
+
# If only one Event/Call found (start or end only), expand the
|
|
3143
|
+
# window by 2 hours in the missing direction to catch messages.
|
|
3144
|
+
if start_iso == end_iso:
|
|
3145
|
+
from datetime import datetime, timedelta
|
|
3146
|
+
|
|
3147
|
+
dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
|
|
3148
|
+
start_iso = (dt - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
3149
|
+
end_iso = (dt + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
3150
|
+
log(
|
|
3151
|
+
"chat",
|
|
3152
|
+
f"Time window (from Event/Call on {matched_date}): {start_iso} to {end_iso}",
|
|
3153
|
+
)
|
|
3154
|
+
else:
|
|
3155
|
+
# Fallback: use the best available date for a full-day window.
|
|
3156
|
+
# Prefer the Recap header date over the user-specified date.
|
|
3157
|
+
fallback_date = candidate_dates[0]
|
|
3158
|
+
log("chat", f"No Event/Call markers found; falling back to {fallback_date}")
|
|
3159
|
+
start_iso = f"{fallback_date}T00:00:00Z"
|
|
3160
|
+
end_iso = f"{fallback_date}T23:59:59Z"
|
|
3161
|
+
log("chat", f"Time window (full day fallback): {start_iso} to {end_iso}")
|
|
3162
|
+
# Prefer member_map (roster + speakers) for resolving chat authors
|
|
3163
|
+
# and reaction users; fall back to speaker_map if roster unavailable.
|
|
3164
|
+
speaker_map = config.get("member_map") or config.get("speaker_map")
|
|
3165
|
+
results = []
|
|
3166
|
+
|
|
3167
|
+
for msg in data["messages"]:
|
|
3168
|
+
# Only user messages (itemType 3), text type
|
|
3169
|
+
if msg["itemType"] != 3:
|
|
3170
|
+
continue
|
|
3171
|
+
if msg["messageType"] != "RichText/Html":
|
|
3172
|
+
continue
|
|
3173
|
+
# Skip messages with file attachments only (no text)
|
|
3174
|
+
if msg["files"] and msg["files"] > 0:
|
|
3175
|
+
text = _strip_html_to_text(msg["content"])
|
|
3176
|
+
if not text:
|
|
3177
|
+
continue
|
|
3178
|
+
|
|
3179
|
+
# Filter by time window
|
|
3180
|
+
msg_time = msg["time"]
|
|
3181
|
+
if not msg_time:
|
|
3182
|
+
continue
|
|
3183
|
+
if msg_time < start_iso or msg_time > end_iso:
|
|
3184
|
+
continue
|
|
3185
|
+
|
|
3186
|
+
# Build the chat message entry
|
|
3187
|
+
text = _strip_html_to_text(msg["content"])
|
|
3188
|
+
if not text:
|
|
3189
|
+
continue
|
|
3190
|
+
|
|
3191
|
+
author = _resolve_chat_author(msg["fromUserId"], msg["displayName"], speaker_map)
|
|
3192
|
+
|
|
3193
|
+
# Parse reactions — use the detailed ``emotions`` array which
|
|
3194
|
+
# includes per-user data, falling back to ``emotionsSummary``.
|
|
3195
|
+
reactions = None
|
|
3196
|
+
emotions_list = msg.get("emotions") or []
|
|
3197
|
+
if emotions_list:
|
|
3198
|
+
reactions = []
|
|
3199
|
+
for emo in emotions_list:
|
|
3200
|
+
reaction_type = emo.get("key", "")
|
|
3201
|
+
users = []
|
|
3202
|
+
for u in emo.get("users") or []:
|
|
3203
|
+
uid = u.get("userId", "")
|
|
3204
|
+
display = u.get("displayName", "")
|
|
3205
|
+
name = _resolve_chat_author(uid, display, speaker_map)
|
|
3206
|
+
users.append(name)
|
|
3207
|
+
reactions.append({"type": reaction_type, "users": users})
|
|
3208
|
+
elif msg.get("emotionsSummary"):
|
|
3209
|
+
reactions = []
|
|
3210
|
+
for r in msg["emotionsSummary"]:
|
|
3211
|
+
reactions.append(
|
|
3212
|
+
{
|
|
3213
|
+
"type": r.get("key", ""),
|
|
3214
|
+
"users": [],
|
|
3215
|
+
}
|
|
3216
|
+
)
|
|
3217
|
+
|
|
3218
|
+
results.append(
|
|
3219
|
+
{
|
|
3220
|
+
"timestamp": _utc_to_local_iso(msg_time),
|
|
3221
|
+
"author": author,
|
|
3222
|
+
"text": text,
|
|
3223
|
+
"reactions": reactions,
|
|
3224
|
+
}
|
|
3225
|
+
)
|
|
3226
|
+
|
|
3227
|
+
# Sort chronologically (oldest first)
|
|
3228
|
+
results.sort(key=lambda m: m["timestamp"])
|
|
3229
|
+
|
|
3230
|
+
log("chat", f"Extracted {len(results)} chat message(s) in meeting window")
|
|
3231
|
+
return results
|
|
3232
|
+
|
|
3233
|
+
finally:
|
|
3234
|
+
await ws.close()
|
|
3235
|
+
|
|
3236
|
+
|
|
3237
|
+
def _append_chat_to_vtt(output_path: str, messages: list[dict], meeting_start: str) -> None:
|
|
3238
|
+
"""Append meeting chat messages as a NOTE block in the VTT file.
|
|
3239
|
+
|
|
3240
|
+
The NOTE block is inserted after the metadata NOTE block (if present)
|
|
3241
|
+
and before the first cue. Chat messages are stored as a JSON array
|
|
3242
|
+
for machine readability.
|
|
3243
|
+
|
|
3244
|
+
Each message's ``timestamp`` is converted from an absolute ISO 8601
|
|
3245
|
+
datetime to a meeting-start-relative offset in ``HH:MM:SS.mmm``
|
|
3246
|
+
format, matching the cue timestamp convention used elsewhere in the
|
|
3247
|
+
VTT file.
|
|
3248
|
+
|
|
3249
|
+
Args:
|
|
3250
|
+
output_path: Path to the VTT file to modify.
|
|
3251
|
+
messages: Chat messages with absolute ``timestamp`` values.
|
|
3252
|
+
meeting_start: Meeting start time as ISO 8601 (e.g.
|
|
3253
|
+
``2026-04-22T11:15+01:00``).
|
|
3254
|
+
"""
|
|
3255
|
+
if not messages:
|
|
3256
|
+
return
|
|
3257
|
+
|
|
3258
|
+
from datetime import datetime
|
|
3259
|
+
|
|
3260
|
+
try:
|
|
3261
|
+
start_dt = datetime.fromisoformat(meeting_start)
|
|
3262
|
+
except (ValueError, TypeError):
|
|
3263
|
+
# Cannot compute offsets; fall back to absolute timestamps
|
|
3264
|
+
start_dt = None
|
|
3265
|
+
|
|
3266
|
+
offset_messages = []
|
|
3267
|
+
for msg in messages:
|
|
3268
|
+
entry = dict(msg)
|
|
3269
|
+
if start_dt is not None:
|
|
3270
|
+
try:
|
|
3271
|
+
msg_dt = datetime.fromisoformat(entry["timestamp"])
|
|
3272
|
+
delta = msg_dt - start_dt
|
|
3273
|
+
total_ms = int(delta.total_seconds() * 1000)
|
|
3274
|
+
if total_ms < 0:
|
|
3275
|
+
total_ms = 0
|
|
3276
|
+
hours = total_ms // 3_600_000
|
|
3277
|
+
minutes = (total_ms % 3_600_000) // 60_000
|
|
3278
|
+
seconds = (total_ms % 60_000) // 1000
|
|
3279
|
+
millis = total_ms % 1000
|
|
3280
|
+
entry["offset"] = f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
|
|
3281
|
+
except (ValueError, TypeError):
|
|
3282
|
+
entry["offset"] = entry["timestamp"]
|
|
3283
|
+
else:
|
|
3284
|
+
entry["offset"] = entry["timestamp"]
|
|
3285
|
+
del entry["timestamp"]
|
|
3286
|
+
offset_messages.append(entry)
|
|
3287
|
+
|
|
3288
|
+
with open(output_path) as f:
|
|
3289
|
+
content = f.read()
|
|
3290
|
+
|
|
3291
|
+
chat_json = json.dumps(offset_messages, indent=2, ensure_ascii=False)
|
|
3292
|
+
note_block = f"NOTE Chat\n{chat_json}"
|
|
3293
|
+
|
|
3294
|
+
# Insert after the existing NOTE block (metadata) if present.
|
|
3295
|
+
# The metadata NOTE block ends at the first blank line after
|
|
3296
|
+
# "NOTE Meeting Metadata\n". We insert the chat NOTE block after it.
|
|
3297
|
+
#
|
|
3298
|
+
# Pattern: WEBVTT\n\nNOTE Meeting Metadata\n...\n\n
|
|
3299
|
+
# -> WEBVTT\n\nNOTE Meeting Metadata\n...\n\nNOTE Chat\n...\n\n
|
|
3300
|
+
#
|
|
3301
|
+
# Find the end of the first NOTE block.
|
|
3302
|
+
note_end = content.find("\n\nNOTE")
|
|
3303
|
+
if note_end != -1:
|
|
3304
|
+
# Find the blank line after this NOTE block
|
|
3305
|
+
after_note = content.find("\n\n", note_end + 2)
|
|
3306
|
+
if after_note != -1:
|
|
3307
|
+
insert_pos = after_note + 2
|
|
3308
|
+
content = content[:insert_pos] + note_block + "\n\n" + content[insert_pos:]
|
|
3309
|
+
else:
|
|
3310
|
+
content += "\n\n" + note_block + "\n"
|
|
3311
|
+
elif content.startswith("WEBVTT"):
|
|
3312
|
+
# No existing NOTE block -- insert after WEBVTT header
|
|
3313
|
+
content = content.replace("WEBVTT\n\n", f"WEBVTT\n\n{note_block}\n\n", 1)
|
|
3314
|
+
else:
|
|
3315
|
+
content = f"WEBVTT\n\n{note_block}\n\n{content}"
|
|
3316
|
+
|
|
3317
|
+
with open(output_path, "w") as f:
|
|
3318
|
+
f.write(content)
|
|
3319
|
+
|
|
3320
|
+
log("chat", f"Added {len(messages)} chat message(s) to VTT")
|
|
3321
|
+
|
|
3322
|
+
|
|
3323
|
+
def _parse_meeting_times(text: str, fallback_date: str) -> tuple[str, str]:
|
|
3324
|
+
"""Parse occurrence text into ISO 8601 start and end datetime strings.
|
|
3325
|
+
|
|
3326
|
+
*text* has the form ``22 April 2026 11:15 - 12:00``.
|
|
3327
|
+
*fallback_date* is ``YYYY-MM-DD`` used if the text cannot be parsed.
|
|
3328
|
+
|
|
3329
|
+
The Recap header displays times in the user's local timezone. The
|
|
3330
|
+
local UTC offset for the parsed date is appended so the output is a
|
|
3331
|
+
fully-qualified ISO 8601 datetime (e.g. ``2026-04-22T11:15+01:00``).
|
|
3332
|
+
|
|
3333
|
+
Returns ``(start, end)`` where each is ``YYYY-MM-DDTHH:MM+HH:MM`` or
|
|
3334
|
+
just ``YYYY-MM-DD`` if the time component is missing.
|
|
3335
|
+
"""
|
|
3336
|
+
from datetime import datetime, timedelta, timezone
|
|
3337
|
+
|
|
3338
|
+
month_names = {
|
|
3339
|
+
"january": "01",
|
|
3340
|
+
"february": "02",
|
|
3341
|
+
"march": "03",
|
|
3342
|
+
"april": "04",
|
|
3343
|
+
"may": "05",
|
|
3344
|
+
"june": "06",
|
|
3345
|
+
"july": "07",
|
|
3346
|
+
"august": "08",
|
|
3347
|
+
"september": "09",
|
|
3348
|
+
"october": "10",
|
|
3349
|
+
"november": "11",
|
|
3350
|
+
"december": "12",
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
m = re.match(
|
|
3354
|
+
r"(\d{1,2})\s+(\w+)\s+(\d{4})\s+(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})",
|
|
3355
|
+
text.strip(),
|
|
3356
|
+
)
|
|
3357
|
+
if not m:
|
|
3358
|
+
return fallback_date, fallback_date
|
|
3359
|
+
|
|
3360
|
+
day = int(m.group(1))
|
|
3361
|
+
month = month_names.get(m.group(2).lower(), "01")
|
|
3362
|
+
year = m.group(3)
|
|
3363
|
+
start_time = m.group(4).zfill(5) # ensure HH:MM
|
|
3364
|
+
end_time = m.group(5).zfill(5)
|
|
3365
|
+
|
|
3366
|
+
date_str = f"{year}-{month}-{day:02d}"
|
|
3367
|
+
|
|
3368
|
+
# Determine the local UTC offset for this specific date. The offset
|
|
3369
|
+
# may differ from today's offset due to DST transitions, so we
|
|
3370
|
+
# construct a datetime on the meeting date and ask the platform
|
|
3371
|
+
# for the corresponding UTC offset.
|
|
3372
|
+
sh, sm = (int(x) for x in start_time.split(":"))
|
|
3373
|
+
try:
|
|
3374
|
+
naive = datetime(int(year), int(month), day, sh, sm, tzinfo=timezone.utc)
|
|
3375
|
+
local_dt = naive.astimezone(tz=None) # convert to local tz
|
|
3376
|
+
utc_offset = local_dt.utcoffset() or timedelta(0)
|
|
3377
|
+
except (ValueError, OSError):
|
|
3378
|
+
utc_offset = datetime.now(timezone.utc).astimezone().utcoffset() or timedelta(0)
|
|
3379
|
+
|
|
3380
|
+
total_seconds = int(utc_offset.total_seconds())
|
|
3381
|
+
sign = "+" if total_seconds >= 0 else "-"
|
|
3382
|
+
abs_seconds = abs(total_seconds)
|
|
3383
|
+
offset_str = f"{sign}{abs_seconds // 3600:02d}:{(abs_seconds % 3600) // 60:02d}"
|
|
3384
|
+
|
|
3385
|
+
return f"{date_str}T{start_time}{offset_str}", f"{date_str}T{end_time}{offset_str}"
|
|
3386
|
+
|
|
3387
|
+
|
|
3388
|
+
def _prepend_vtt_metadata(output_path: str, config: dict) -> None:
|
|
3389
|
+
"""Insert a VTT NOTE block with meeting metadata after the WEBVTT header.
|
|
3390
|
+
|
|
3391
|
+
The NOTE block contains the meeting title, attendee names (from the
|
|
3392
|
+
speaker mapping), and start/end times (from the Recap header).
|
|
3393
|
+
"""
|
|
3394
|
+
with open(output_path) as f:
|
|
3395
|
+
content = f.read()
|
|
3396
|
+
|
|
3397
|
+
# Build the NOTE block
|
|
3398
|
+
lines = ["NOTE Meeting Metadata"]
|
|
3399
|
+
lines.append(f"Meeting: {config['meeting']}")
|
|
3400
|
+
|
|
3401
|
+
# Members from roster (sorted alphabetically), falling back to speakers
|
|
3402
|
+
roster = config.get("roster_members")
|
|
3403
|
+
if roster:
|
|
3404
|
+
organiser_names = sorted(m["name"] for m in roster if m["role"] == "organiser")
|
|
3405
|
+
member_names = sorted(m["name"] for m in roster)
|
|
3406
|
+
lines.append(f"Members: {', '.join(member_names)}")
|
|
3407
|
+
if organiser_names:
|
|
3408
|
+
lines.append(f"Organiser: {', '.join(organiser_names)}")
|
|
3409
|
+
|
|
3410
|
+
# Email addresses for all members that have one
|
|
3411
|
+
email_entries = sorted((m["name"], m["email"]) for m in roster if m.get("email"))
|
|
3412
|
+
if email_entries:
|
|
3413
|
+
email_strs = [f"{name} <{email}>" for name, email in email_entries]
|
|
3414
|
+
lines.append(f"Emails: {', '.join(email_strs)}")
|
|
3415
|
+
else:
|
|
3416
|
+
# Fallback: speakers only (from Recap)
|
|
3417
|
+
speaker_map = config.get("speaker_map")
|
|
3418
|
+
if speaker_map:
|
|
3419
|
+
names = sorted(speaker_map.values())
|
|
3420
|
+
lines.append(f"Speakers: {', '.join(names)}")
|
|
3421
|
+
|
|
3422
|
+
# Start/end times
|
|
3423
|
+
time_text = config.get("meeting_time_text", "")
|
|
3424
|
+
if time_text:
|
|
3425
|
+
start_dt, end_dt = _parse_meeting_times(time_text, config["date"])
|
|
3426
|
+
else:
|
|
3427
|
+
start_dt = config["date"]
|
|
3428
|
+
if config.get("time"):
|
|
3429
|
+
start_dt = f"{config['date']}T{config['time']}"
|
|
3430
|
+
end_dt = ""
|
|
3431
|
+
|
|
3432
|
+
lines.append(f"Start: {start_dt}")
|
|
3433
|
+
if end_dt:
|
|
3434
|
+
lines.append(f"End: {end_dt}")
|
|
3435
|
+
|
|
3436
|
+
note_block = "\n".join(lines)
|
|
3437
|
+
|
|
3438
|
+
# Insert after the WEBVTT header line (before the first blank line)
|
|
3439
|
+
if content.startswith("WEBVTT"):
|
|
3440
|
+
# Replace "WEBVTT\n\n" with "WEBVTT\n\nNOTE Meeting Metadata\n...\n\n"
|
|
3441
|
+
content = content.replace("WEBVTT\n\n", f"WEBVTT\n\n{note_block}\n\n", 1)
|
|
3442
|
+
else:
|
|
3443
|
+
# Unexpected — prepend both header and note
|
|
3444
|
+
content = f"WEBVTT\n\n{note_block}\n\n{content}"
|
|
3445
|
+
|
|
3446
|
+
with open(output_path, "w") as f:
|
|
3447
|
+
f.write(content)
|
|
3448
|
+
|
|
3449
|
+
log("post", "Added VTT metadata comment block")
|
|
3450
|
+
|
|
3451
|
+
|
|
3452
|
+
def _apply_speaker_mapping_to_vtt(output_path: str, speaker_map: dict[str, str]) -> None:
|
|
3453
|
+
"""Post-process a VTT file to replace speaker names using the mapping.
|
|
3454
|
+
|
|
3455
|
+
Native VTT downloads (Approach A) contain speaker names in
|
|
3456
|
+
``<v SPEAKER_NAME>`` tags. This function matches each VTT speaker
|
|
3457
|
+
name to a Recap display name and replaces it.
|
|
3458
|
+
|
|
3459
|
+
The mapping keys are GUIDs, but VTT files don't contain GUIDs --
|
|
3460
|
+
they contain display names. Matching is done by:
|
|
3461
|
+
|
|
3462
|
+
1. ``@N`` pattern: Recap shows ``Speaker N``.
|
|
3463
|
+
2. Substring match: VTT has ``v-Gabor Veres (Metosin)``, Recap has
|
|
3464
|
+
``Gabor Veres``. The Recap name is a substring of the VTT name.
|
|
3465
|
+
3. Exact match: names are already identical -- no replacement needed.
|
|
3466
|
+
"""
|
|
3467
|
+
import re as _re
|
|
3468
|
+
|
|
3469
|
+
with open(output_path) as f:
|
|
3470
|
+
content = f.read()
|
|
3471
|
+
|
|
3472
|
+
# Extract all unique speaker names from the VTT.
|
|
3473
|
+
vtt_names = set(_re.findall(r"<v ([^>]+)>", content))
|
|
3474
|
+
recap_names = set(speaker_map.values())
|
|
3475
|
+
|
|
3476
|
+
replacements = {} # old_vtt_name -> recap_name
|
|
3477
|
+
|
|
3478
|
+
for vtt_name in vtt_names:
|
|
3479
|
+
if vtt_name in recap_names:
|
|
3480
|
+
# Already matches a Recap name exactly -- no change needed.
|
|
3481
|
+
continue
|
|
3482
|
+
|
|
3483
|
+
# Try to find a matching Recap name.
|
|
3484
|
+
# 1. @N pattern: look for "Speaker N" in Recap names.
|
|
3485
|
+
at_match = _re.match(r"^@(\d+)$", vtt_name)
|
|
3486
|
+
if at_match:
|
|
3487
|
+
speaker_label = f"Speaker {at_match.group(1)}"
|
|
3488
|
+
if speaker_label in recap_names:
|
|
3489
|
+
replacements[vtt_name] = speaker_label
|
|
3490
|
+
continue
|
|
3491
|
+
|
|
3492
|
+
# 2. Substring match: a Recap name is contained in the VTT name.
|
|
3493
|
+
# e.g. "Gabor Veres" in "v-Gabor Veres (Metosin)"
|
|
3494
|
+
# Pick the longest matching Recap name to avoid false positives.
|
|
3495
|
+
best_match = None
|
|
3496
|
+
best_len = 0
|
|
3497
|
+
for recap_name in recap_names:
|
|
3498
|
+
if recap_name in vtt_name and len(recap_name) > best_len:
|
|
3499
|
+
best_match = recap_name
|
|
3500
|
+
best_len = len(recap_name)
|
|
3501
|
+
if best_match:
|
|
3502
|
+
replacements[vtt_name] = best_match
|
|
3503
|
+
|
|
3504
|
+
if not replacements:
|
|
3505
|
+
return
|
|
3506
|
+
|
|
3507
|
+
for old_name, new_name in replacements.items():
|
|
3508
|
+
old_tag = f"<v {old_name}>"
|
|
3509
|
+
new_tag = f"<v {new_name}>"
|
|
3510
|
+
content = content.replace(old_tag, new_tag)
|
|
3511
|
+
log("post", f"Replaced speaker: {old_name!r} -> {new_name!r}")
|
|
3512
|
+
|
|
3513
|
+
with open(output_path, "w") as f:
|
|
3514
|
+
f.write(content)
|
|
3515
|
+
|
|
3516
|
+
|
|
3517
|
+
def _ensure_trailing_newline(output_path: str) -> None:
|
|
3518
|
+
"""Ensure the VTT file ends with a trailing newline."""
|
|
3519
|
+
with open(output_path, "rb") as f:
|
|
3520
|
+
f.seek(-1, 2)
|
|
3521
|
+
if f.read(1) != b"\n":
|
|
3522
|
+
with open(output_path, "a") as fa:
|
|
3523
|
+
fa.write("\n")
|
|
3524
|
+
|
|
3525
|
+
|
|
3526
|
+
def _build_result_summary(
|
|
3527
|
+
config: dict,
|
|
3528
|
+
*,
|
|
3529
|
+
num_parts: int = 1,
|
|
3530
|
+
verification: dict | None = None,
|
|
3531
|
+
chat_count: int = 0,
|
|
3532
|
+
) -> dict:
|
|
3533
|
+
"""Build a structured result summary from config and verification data.
|
|
3534
|
+
|
|
3535
|
+
Used for both ``--format json`` output and ``--dry-run`` summaries.
|
|
3536
|
+
"""
|
|
3537
|
+
summary: dict[str, object] = {"status": "ok"}
|
|
3538
|
+
|
|
3539
|
+
# Meeting metadata
|
|
3540
|
+
summary["meeting"] = config["meeting"]
|
|
3541
|
+
|
|
3542
|
+
time_text = config.get("meeting_time_text", "")
|
|
3543
|
+
if time_text:
|
|
3544
|
+
start_dt, end_dt = _parse_meeting_times(time_text, config["date"])
|
|
3545
|
+
summary["start"] = start_dt
|
|
3546
|
+
if end_dt:
|
|
3547
|
+
summary["end"] = end_dt
|
|
3548
|
+
else:
|
|
3549
|
+
summary["start"] = config["date"]
|
|
3550
|
+
if config.get("time"):
|
|
3551
|
+
summary["start"] = f"{config['date']}T{config['time']}"
|
|
3552
|
+
|
|
3553
|
+
# Members and organiser
|
|
3554
|
+
roster = config.get("roster_members", [])
|
|
3555
|
+
if roster:
|
|
3556
|
+
summary["members"] = sorted(m["name"] for m in roster)
|
|
3557
|
+
organisers = sorted(m["name"] for m in roster if m["role"] == "organiser")
|
|
3558
|
+
if organisers:
|
|
3559
|
+
summary["organiser"] = organisers[0] if len(organisers) == 1 else organisers
|
|
3560
|
+
else:
|
|
3561
|
+
speaker_map = config.get("speaker_map")
|
|
3562
|
+
if speaker_map:
|
|
3563
|
+
summary["members"] = sorted(speaker_map.values())
|
|
3564
|
+
|
|
3565
|
+
summary["parts"] = num_parts
|
|
3566
|
+
|
|
3567
|
+
# Verification data (populated after download, absent for dry-run)
|
|
3568
|
+
if verification and verification.get("valid"):
|
|
3569
|
+
summary["cues"] = verification["cue_count"]
|
|
3570
|
+
summary["speakers"] = verification["speakers"]
|
|
3571
|
+
summary["file_size"] = verification["file_size"]
|
|
3572
|
+
else:
|
|
3573
|
+
# Dry-run: include speaker names with zero counts
|
|
3574
|
+
speaker_map = config.get("speaker_map")
|
|
3575
|
+
if speaker_map:
|
|
3576
|
+
summary["speakers"] = dict.fromkeys(sorted(speaker_map.values()), 0)
|
|
3577
|
+
|
|
3578
|
+
if chat_count:
|
|
3579
|
+
summary["chat_messages"] = chat_count
|
|
3580
|
+
|
|
3581
|
+
return summary
|
|
3582
|
+
|
|
3583
|
+
|
|
3584
|
+
def verify_output(output_path: str) -> dict:
|
|
3585
|
+
"""Verify the downloaded VTT file. Returns a summary dict."""
|
|
3586
|
+
if not os.path.exists(output_path):
|
|
3587
|
+
log("step8", f"ERROR: Output file does not exist: {output_path}")
|
|
3588
|
+
return {"valid": False, "error": "file not found"}
|
|
3589
|
+
|
|
3590
|
+
with open(output_path) as f:
|
|
3591
|
+
content = f.read()
|
|
3592
|
+
|
|
3593
|
+
if not content.startswith("WEBVTT"):
|
|
3594
|
+
log("step8", "ERROR: File does not start with WEBVTT header.")
|
|
3595
|
+
return {"valid": False, "error": "missing WEBVTT header"}
|
|
3596
|
+
|
|
3597
|
+
# Count cues
|
|
3598
|
+
cue_count = len(re.findall(r"\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}", content))
|
|
3599
|
+
|
|
3600
|
+
# Extract speakers
|
|
3601
|
+
speaker_pattern = re.compile(r"^<v ([^>]+)>", re.MULTILINE)
|
|
3602
|
+
speakers = speaker_pattern.findall(content)
|
|
3603
|
+
speaker_counts = Counter(speakers)
|
|
3604
|
+
|
|
3605
|
+
file_size = os.path.getsize(output_path)
|
|
3606
|
+
|
|
3607
|
+
log("step8", f"File: {output_path}")
|
|
3608
|
+
log("step8", f"Size: {file_size} bytes")
|
|
3609
|
+
log("step8", f"Cues: {cue_count}")
|
|
3610
|
+
log("step8", f"Speakers ({len(speaker_counts)}):")
|
|
3611
|
+
for sp, cnt in speaker_counts.most_common():
|
|
3612
|
+
log("step8", f" {sp}: {cnt}")
|
|
3613
|
+
|
|
3614
|
+
return {
|
|
3615
|
+
"valid": True,
|
|
3616
|
+
"file_size": file_size,
|
|
3617
|
+
"cue_count": cue_count,
|
|
3618
|
+
"speakers": dict(speaker_counts),
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
|
|
3622
|
+
# ---------------------------------------------------------------------------
|
|
3623
|
+
# Main orchestration
|
|
3624
|
+
# ---------------------------------------------------------------------------
|
|
3625
|
+
|
|
3626
|
+
MAX_SEARCH_RETRIES = 4
|
|
3627
|
+
|
|
3628
|
+
|
|
3629
|
+
async def main() -> None:
|
|
3630
|
+
_install_signal_handlers()
|
|
3631
|
+
args = parse_args()
|
|
3632
|
+
config = resolve_config(args)
|
|
3633
|
+
|
|
3634
|
+
log("main", f"Meeting: {config['meeting']}")
|
|
3635
|
+
datetime_label = config["date"]
|
|
3636
|
+
if config["time"]:
|
|
3637
|
+
datetime_label += f"T{config['time']}"
|
|
3638
|
+
log("main", f"Date: {datetime_label}")
|
|
3639
|
+
log("main", f"Output: {config['output']}")
|
|
3640
|
+
log("main", f"CDP port: {config['port']}")
|
|
3641
|
+
log("main", f"Model: {config['model']}")
|
|
3642
|
+
log("main", f"GCP project: {config['gcp_project']}")
|
|
3643
|
+
log("main", f"GCP region: {config['gcp_region']}")
|
|
3644
|
+
|
|
3645
|
+
# Step 1: Ensure Teams is running
|
|
3646
|
+
ensure_teams_running(config["port"], force=config["force_restart"])
|
|
3647
|
+
|
|
3648
|
+
# Step 2: Verify CDP
|
|
3649
|
+
verify_cdp(config["port"])
|
|
3650
|
+
|
|
3651
|
+
# Step 3: Find main Teams targets
|
|
3652
|
+
targets = find_teams_targets(config["port"])
|
|
3653
|
+
if not targets:
|
|
3654
|
+
_exit_error(
|
|
3655
|
+
EXIT_CDP_UNAVAILABLE,
|
|
3656
|
+
"NO_TARGETS",
|
|
3657
|
+
"No Teams page targets found via CDP.",
|
|
3658
|
+
step="step3",
|
|
3659
|
+
)
|
|
3660
|
+
log("step3", f"Found {len(targets)} Teams page target(s)")
|
|
3661
|
+
for t in targets:
|
|
3662
|
+
log("step3", f" {t['id'][:20]}... {t.get('title', '')[:60]}")
|
|
3663
|
+
|
|
3664
|
+
# Create agent client (used in Steps 4 and 6)
|
|
3665
|
+
agent_client = create_agent_client(config)
|
|
3666
|
+
|
|
3667
|
+
# Steps 4+5: Navigate to meeting and Recap > Transcript.
|
|
3668
|
+
# For recurring meetings the first search result may be the wrong chat
|
|
3669
|
+
# group (occurrence dropdown doesn't contain the target date). In that
|
|
3670
|
+
# case we retry with the next search result index, up to MAX_RETRIES.
|
|
3671
|
+
search_target_id = None
|
|
3672
|
+
result_index_override: int | None = None
|
|
3673
|
+
|
|
3674
|
+
for attempt in range(MAX_SEARCH_RETRIES):
|
|
3675
|
+
_check_shutdown()
|
|
3676
|
+
# Record existing iframe targets BEFORE each navigation so Step 6
|
|
3677
|
+
# can distinguish new targets from stale ones
|
|
3678
|
+
pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets(config["port"])}
|
|
3679
|
+
if pre_nav_iframe_ids:
|
|
3680
|
+
log("main", f"Pre-existing iframe targets: {len(pre_nav_iframe_ids)}")
|
|
3681
|
+
|
|
3682
|
+
# Step 4: Navigate to meeting
|
|
3683
|
+
search_target_id = await navigate_to_meeting(
|
|
3684
|
+
config["port"],
|
|
3685
|
+
targets,
|
|
3686
|
+
config,
|
|
3687
|
+
agent_client,
|
|
3688
|
+
result_index_override=result_index_override,
|
|
3689
|
+
)
|
|
3690
|
+
|
|
3691
|
+
if search_target_id is None:
|
|
3692
|
+
# Navigation failed (index out of range, or clicked a non-chat
|
|
3693
|
+
# result). Try the next search result index.
|
|
3694
|
+
if result_index_override is None:
|
|
3695
|
+
result_index_override = 1
|
|
3696
|
+
else:
|
|
3697
|
+
result_index_override += 1
|
|
3698
|
+
log(
|
|
3699
|
+
"main",
|
|
3700
|
+
f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed "
|
|
3701
|
+
f"(navigation). Retrying with index {result_index_override}...",
|
|
3702
|
+
)
|
|
3703
|
+
continue
|
|
3704
|
+
|
|
3705
|
+
# Step 5: Navigate to Recap > Transcript
|
|
3706
|
+
transcript_ok = await navigate_to_transcript(
|
|
3707
|
+
config["port"], search_target_id, config, agent_client
|
|
3708
|
+
)
|
|
3709
|
+
|
|
3710
|
+
if transcript_ok:
|
|
3711
|
+
log("main", "Correct occurrence found and transcript tab loaded.")
|
|
3712
|
+
break
|
|
3713
|
+
|
|
3714
|
+
# Occurrence not found -- try the next search result
|
|
3715
|
+
if result_index_override is None:
|
|
3716
|
+
# First attempt used the agent's pick (typically index 0).
|
|
3717
|
+
# Start overriding from index 1.
|
|
3718
|
+
result_index_override = 1
|
|
3719
|
+
else:
|
|
3720
|
+
result_index_override += 1
|
|
3721
|
+
|
|
3722
|
+
log(
|
|
3723
|
+
"main",
|
|
3724
|
+
f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed. "
|
|
3725
|
+
f"Retrying with search result index {result_index_override}...",
|
|
3726
|
+
)
|
|
3727
|
+
else:
|
|
3728
|
+
_exit_error(
|
|
3729
|
+
EXIT_MEETING_NOT_FOUND,
|
|
3730
|
+
"MEETING_NOT_FOUND",
|
|
3731
|
+
f"Could not find the target {datetime_label} in any "
|
|
3732
|
+
f"of the first {MAX_SEARCH_RETRIES} search results.",
|
|
3733
|
+
step="step5",
|
|
3734
|
+
)
|
|
3735
|
+
|
|
3736
|
+
# Extract the full chat member list from the roster popover.
|
|
3737
|
+
# This runs after step 5 so that we have the speaker_map for
|
|
3738
|
+
# name resolution.
|
|
3739
|
+
if search_target_id:
|
|
3740
|
+
roster_members = await _extract_roster_members(
|
|
3741
|
+
config["port"],
|
|
3742
|
+
search_target_id,
|
|
3743
|
+
config.get("speaker_map"),
|
|
3744
|
+
)
|
|
3745
|
+
config["roster_members"] = roster_members
|
|
3746
|
+
|
|
3747
|
+
# Build a combined GUID -> clean name map from both the roster
|
|
3748
|
+
# and the speaker_map. The roster covers all chat members
|
|
3749
|
+
# (including non-speakers), which lets us resolve reaction
|
|
3750
|
+
# authors and chat authors that the speaker_map alone cannot.
|
|
3751
|
+
member_map: dict[str, str] = {}
|
|
3752
|
+
for rm in roster_members:
|
|
3753
|
+
oid = rm.get("objectId", "")
|
|
3754
|
+
if oid and rm.get("name"):
|
|
3755
|
+
member_map[oid] = rm["name"]
|
|
3756
|
+
# Speaker map entries take priority (Recap canonical names).
|
|
3757
|
+
if config.get("speaker_map"):
|
|
3758
|
+
member_map.update(config["speaker_map"])
|
|
3759
|
+
config["member_map"] = member_map
|
|
3760
|
+
|
|
3761
|
+
# Detect multi-part meetings (Part 1, Part 2, etc.)
|
|
3762
|
+
num_parts = await _detect_part_tabs(config["port"], search_target_id)
|
|
3763
|
+
log("main", f"Meeting parts detected: {num_parts}")
|
|
3764
|
+
|
|
3765
|
+
# --dry-run: emit meeting metadata and exit without downloading.
|
|
3766
|
+
if config.get("dry_run"):
|
|
3767
|
+
summary = _build_result_summary(config, num_parts=num_parts)
|
|
3768
|
+
summary["dry_run"] = True
|
|
3769
|
+
if _format_json:
|
|
3770
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
|
|
3771
|
+
else:
|
|
3772
|
+
log("main", f"Dry-run summary: {json.dumps(summary, ensure_ascii=False)}")
|
|
3773
|
+
sys.exit(EXIT_OK)
|
|
3774
|
+
|
|
3775
|
+
# Download each part's transcript.
|
|
3776
|
+
# Part 1 is already loaded (navigate_to_transcript clicked Transcript).
|
|
3777
|
+
# For subsequent parts we click the Part N tab, then Transcript.
|
|
3778
|
+
part_vtt_contents: list[str] = []
|
|
3779
|
+
|
|
3780
|
+
for part_num in range(1, num_parts + 1):
|
|
3781
|
+
_check_shutdown()
|
|
3782
|
+
part_label = f"Part {part_num}/{num_parts}"
|
|
3783
|
+
|
|
3784
|
+
if part_num > 1:
|
|
3785
|
+
# Navigate to Part N and its Transcript sub-tab
|
|
3786
|
+
log("main", f"Navigating to {part_label}...")
|
|
3787
|
+
|
|
3788
|
+
# Record iframe targets BEFORE clicking the new Part tab so
|
|
3789
|
+
# we can distinguish the new iframe from existing ones.
|
|
3790
|
+
pre_part_iframe_ids = {t["id"] for t in find_xplatplugins_targets(config["port"])}
|
|
3791
|
+
|
|
3792
|
+
ok = await _click_part_and_transcript(config["port"], search_target_id, part_num)
|
|
3793
|
+
if not ok:
|
|
3794
|
+
log("main", f"WARNING: Could not navigate to {part_label}. Skipping.")
|
|
3795
|
+
continue
|
|
3796
|
+
|
|
3797
|
+
# Use updated pre-nav set for iframe disambiguation
|
|
3798
|
+
pre_nav_iframe_ids = pre_part_iframe_ids
|
|
3799
|
+
|
|
3800
|
+
log("main", f"Downloading transcript for {part_label}...")
|
|
3801
|
+
|
|
3802
|
+
# Determine output path: use temp files for multi-part, direct for single
|
|
3803
|
+
if num_parts == 1:
|
|
3804
|
+
part_output = config["output"]
|
|
3805
|
+
else:
|
|
3806
|
+
part_output = f"{config['output']}.part{part_num}.tmp"
|
|
3807
|
+
|
|
3808
|
+
# Step 6: Find iframe target (pass pre-existing IDs for disambiguation)
|
|
3809
|
+
iframe_target_id = find_iframe_target(
|
|
3810
|
+
config["port"], config, agent_client, pre_nav_iframe_ids
|
|
3811
|
+
)
|
|
3812
|
+
|
|
3813
|
+
# Step 7: Check permission
|
|
3814
|
+
permission = await check_permission(config["port"], iframe_target_id)
|
|
3815
|
+
|
|
3816
|
+
if permission == "no_button":
|
|
3817
|
+
if num_parts == 1:
|
|
3818
|
+
_exit_error(
|
|
3819
|
+
EXIT_DOWNLOAD_FAILED,
|
|
3820
|
+
"DOWNLOAD_FAILED",
|
|
3821
|
+
"Download button not found. The transcript may not have loaded.",
|
|
3822
|
+
step="step7",
|
|
3823
|
+
)
|
|
3824
|
+
log("main", f"WARNING: No download button for {part_label}. Skipping.")
|
|
3825
|
+
continue
|
|
3826
|
+
|
|
3827
|
+
# Step 7A or 7B based on permission
|
|
3828
|
+
success = False
|
|
3829
|
+
if permission == "has_permission":
|
|
3830
|
+
log("main", f"{part_label}: Using Approach A (native blob interception)")
|
|
3831
|
+
success = await download_native_vtt(config["port"], iframe_target_id, part_output)
|
|
3832
|
+
else:
|
|
3833
|
+
log("main", f"{part_label}: Using Approach B (DOM extraction)")
|
|
3834
|
+
success = await _extract_via_react_props(
|
|
3835
|
+
config["port"],
|
|
3836
|
+
iframe_target_id,
|
|
3837
|
+
part_output,
|
|
3838
|
+
speaker_map=config.get("speaker_map"),
|
|
3839
|
+
)
|
|
3840
|
+
if success is None:
|
|
3841
|
+
log("main", f"{part_label}: React props unavailable, falling back to scroll")
|
|
3842
|
+
success = await extract_dom_vtt(config["port"], iframe_target_id, part_output)
|
|
3843
|
+
|
|
3844
|
+
if not success:
|
|
3845
|
+
if num_parts == 1:
|
|
3846
|
+
_exit_error(
|
|
3847
|
+
EXIT_DOWNLOAD_FAILED,
|
|
3848
|
+
"DOWNLOAD_FAILED",
|
|
3849
|
+
"Transcript download failed.",
|
|
3850
|
+
step="step7",
|
|
3851
|
+
)
|
|
3852
|
+
log("main", f"WARNING: Download failed for {part_label}. Skipping.")
|
|
3853
|
+
continue
|
|
3854
|
+
|
|
3855
|
+
# Apply speaker mapping to this part before merging
|
|
3856
|
+
speaker_map = config.get("speaker_map")
|
|
3857
|
+
if speaker_map:
|
|
3858
|
+
_apply_speaker_mapping_to_vtt(part_output, speaker_map)
|
|
3859
|
+
|
|
3860
|
+
with open(part_output) as f:
|
|
3861
|
+
part_vtt_contents.append(f.read())
|
|
3862
|
+
|
|
3863
|
+
log("main", f"{part_label}: downloaded successfully")
|
|
3864
|
+
|
|
3865
|
+
if not part_vtt_contents:
|
|
3866
|
+
_exit_error(
|
|
3867
|
+
EXIT_DOWNLOAD_FAILED,
|
|
3868
|
+
"DOWNLOAD_FAILED",
|
|
3869
|
+
"No transcript parts were downloaded.",
|
|
3870
|
+
step="step7",
|
|
3871
|
+
)
|
|
3872
|
+
|
|
3873
|
+
# Merge parts if multi-part meeting
|
|
3874
|
+
if num_parts > 1:
|
|
3875
|
+
log("main", f"Merging {len(part_vtt_contents)} parts...")
|
|
3876
|
+
merged = _merge_vtt_parts(part_vtt_contents)
|
|
3877
|
+
with open(config["output"], "w") as f:
|
|
3878
|
+
f.write(merged)
|
|
3879
|
+
log("main", f"Merged VTT written to {config['output']}")
|
|
3880
|
+
|
|
3881
|
+
# Clean up temp files
|
|
3882
|
+
for part_num in range(1, num_parts + 1):
|
|
3883
|
+
tmp_path = f"{config['output']}.part{part_num}.tmp"
|
|
3884
|
+
if os.path.exists(tmp_path):
|
|
3885
|
+
os.remove(tmp_path)
|
|
3886
|
+
|
|
3887
|
+
# Insert VTT metadata comment block (meeting title, attendees, times)
|
|
3888
|
+
_prepend_vtt_metadata(config["output"], config)
|
|
3889
|
+
|
|
3890
|
+
_check_shutdown()
|
|
3891
|
+
|
|
3892
|
+
# Extract meeting chat messages and append to VTT
|
|
3893
|
+
if search_target_id:
|
|
3894
|
+
# Compute meeting start datetime for offset calculation
|
|
3895
|
+
time_text = config.get("meeting_time_text", "")
|
|
3896
|
+
if time_text:
|
|
3897
|
+
meeting_start, _ = _parse_meeting_times(time_text, config["date"])
|
|
3898
|
+
elif config.get("time"):
|
|
3899
|
+
meeting_start = f"{config['date']}T{config['time']}"
|
|
3900
|
+
else:
|
|
3901
|
+
meeting_start = config["date"]
|
|
3902
|
+
|
|
3903
|
+
chat_messages = await _extract_chat_messages(config["port"], search_target_id, config)
|
|
3904
|
+
if chat_messages:
|
|
3905
|
+
_append_chat_to_vtt(config["output"], chat_messages, meeting_start)
|
|
3906
|
+
chat_count = len(chat_messages)
|
|
3907
|
+
else:
|
|
3908
|
+
log("main", "No chat messages found for this meeting occurrence.")
|
|
3909
|
+
chat_count = 0
|
|
3910
|
+
else:
|
|
3911
|
+
chat_count = 0
|
|
3912
|
+
|
|
3913
|
+
# Ensure file ends with a trailing newline (WebVTT spec recommendation).
|
|
3914
|
+
_ensure_trailing_newline(config["output"])
|
|
3915
|
+
|
|
3916
|
+
# Step 8: Verify
|
|
3917
|
+
result = verify_output(config["output"])
|
|
3918
|
+
if result["valid"]:
|
|
3919
|
+
log("main", "Transcript download complete.")
|
|
3920
|
+
else:
|
|
3921
|
+
_exit_error(
|
|
3922
|
+
EXIT_VERIFICATION_FAILED,
|
|
3923
|
+
"VERIFICATION_FAILED",
|
|
3924
|
+
f"Output file verification failed: {result.get('error')}",
|
|
3925
|
+
step="step8",
|
|
3926
|
+
)
|
|
3927
|
+
|
|
3928
|
+
# --- Emit final output ---------------------------------------------------
|
|
3929
|
+
# Behaviour matrix:
|
|
3930
|
+
# --output PATH --format text → file written, nothing on stdout
|
|
3931
|
+
# --output PATH --format json → file written, JSON summary on stdout
|
|
3932
|
+
# (no --output) --format text → VTT content on stdout
|
|
3933
|
+
# (no --output) --format json → JSON summary with embedded VTT on stdout
|
|
3934
|
+
|
|
3935
|
+
output_to_stdout = config.get("output_to_stdout", False)
|
|
3936
|
+
|
|
3937
|
+
if _format_json:
|
|
3938
|
+
summary = _build_result_summary(
|
|
3939
|
+
config,
|
|
3940
|
+
num_parts=num_parts,
|
|
3941
|
+
verification=result,
|
|
3942
|
+
chat_count=chat_count,
|
|
3943
|
+
)
|
|
3944
|
+
if output_to_stdout:
|
|
3945
|
+
# Embed full VTT content in JSON
|
|
3946
|
+
with open(config["output"]) as f:
|
|
3947
|
+
summary["vtt"] = f.read()
|
|
3948
|
+
else:
|
|
3949
|
+
summary["file"] = config["output"]
|
|
3950
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
|
|
3951
|
+
elif output_to_stdout:
|
|
3952
|
+
# Raw VTT to stdout
|
|
3953
|
+
with open(config["output"]) as f:
|
|
3954
|
+
sys.stdout.write(f.read())
|
|
3955
|
+
sys.stdout.flush()
|
|
3956
|
+
|
|
3957
|
+
# Clean up temp file when output was piped to stdout
|
|
3958
|
+
if output_to_stdout:
|
|
3959
|
+
with contextlib.suppress(OSError):
|
|
3960
|
+
os.remove(config["output"])
|
|
3961
|
+
|
|
3962
|
+
|
|
3963
|
+
def main_sync() -> None:
|
|
3964
|
+
"""Synchronous entry point for console_scripts."""
|
|
3965
|
+
asyncio.run(main())
|
|
3966
|
+
|
|
3967
|
+
|
|
3968
|
+
if __name__ == "__main__":
|
|
3969
|
+
main_sync()
|