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,1283 @@
|
|
|
1
|
+
"""Main transcript download orchestration.
|
|
2
|
+
|
|
3
|
+
Coordinates the full pipeline: CDP target discovery, meeting navigation,
|
|
4
|
+
transcript extraction, VTT post-processing, and output emission.
|
|
5
|
+
|
|
6
|
+
This module is the composition root -- it depends on adapters (CDP helpers,
|
|
7
|
+
Vertex agent), service functions (navigation, extraction), and domain
|
|
8
|
+
functions (VTT processing, summary building). It also performs all file
|
|
9
|
+
I/O.
|
|
10
|
+
|
|
11
|
+
Extracted from ``transcript_download.py`` ``main()`` (lines 3635-3967).
|
|
12
|
+
|
|
13
|
+
Schema version: 2.0.0
|
|
14
|
+
Created: 2026-04-25
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as dt_module
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import typing
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import websockets.exceptions
|
|
26
|
+
|
|
27
|
+
from teams_transcripts.adapters.cdp.browser import CdpBrowserAdapter
|
|
28
|
+
from teams_transcripts.adapters.cdp.helpers import (
|
|
29
|
+
find_iframe_target,
|
|
30
|
+
find_search_target,
|
|
31
|
+
find_teams_targets,
|
|
32
|
+
find_xplatplugins_targets_io,
|
|
33
|
+
verify_cdp,
|
|
34
|
+
)
|
|
35
|
+
from teams_transcripts.adapters.cdp.teams import ensure_teams_running
|
|
36
|
+
from teams_transcripts.adapters.vertex import VertexAgentAdapter, create_agent_client
|
|
37
|
+
from teams_transcripts.domain.chat import build_chat_note_block, insert_chat_block
|
|
38
|
+
from teams_transcripts.domain.models import ChatMessage, MeetingParticipants, RosterMember
|
|
39
|
+
from teams_transcripts.domain.participants import (
|
|
40
|
+
build_meeting_participants,
|
|
41
|
+
parse_partlist_xml,
|
|
42
|
+
parse_tracking_entries,
|
|
43
|
+
)
|
|
44
|
+
from teams_transcripts.domain.speakers import (
|
|
45
|
+
apply_speaker_mapping,
|
|
46
|
+
build_speaker_map_from_roster,
|
|
47
|
+
)
|
|
48
|
+
from teams_transcripts.domain.summary import build_result_summary, verify_vtt_content
|
|
49
|
+
from teams_transcripts.domain.timestamps import parse_meeting_times
|
|
50
|
+
from teams_transcripts.domain.vtt import (
|
|
51
|
+
ensure_trailing_newline,
|
|
52
|
+
merge_parts,
|
|
53
|
+
prepend_metadata,
|
|
54
|
+
)
|
|
55
|
+
from teams_transcripts.infrastructure.errors import (
|
|
56
|
+
EXIT_CDP_UNAVAILABLE,
|
|
57
|
+
EXIT_DOWNLOAD_FAILED,
|
|
58
|
+
EXIT_MEETING_NOT_FOUND,
|
|
59
|
+
EXIT_OK,
|
|
60
|
+
EXIT_VERIFICATION_FAILED,
|
|
61
|
+
exit_error,
|
|
62
|
+
)
|
|
63
|
+
from teams_transcripts.services.extraction import (
|
|
64
|
+
check_permission,
|
|
65
|
+
click_part_and_transcript,
|
|
66
|
+
detect_part_tabs,
|
|
67
|
+
download_native_vtt,
|
|
68
|
+
extract_chat_messages,
|
|
69
|
+
extract_dom_vtt,
|
|
70
|
+
extract_roster_members,
|
|
71
|
+
extract_tracking_data,
|
|
72
|
+
extract_via_react_props,
|
|
73
|
+
retry_click_transcript,
|
|
74
|
+
)
|
|
75
|
+
from teams_transcripts.services.navigation import (
|
|
76
|
+
navigate_to_meeting,
|
|
77
|
+
navigate_to_thread_direct,
|
|
78
|
+
navigate_to_transcript,
|
|
79
|
+
verify_meeting_thread_id,
|
|
80
|
+
)
|
|
81
|
+
from teams_transcripts.services.output import (
|
|
82
|
+
_build_config_dict,
|
|
83
|
+
_emit_output,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if typing.TYPE_CHECKING:
|
|
87
|
+
from teams_transcripts.domain.models import MeetingConfig
|
|
88
|
+
from teams_transcripts.infrastructure.logging import Logger
|
|
89
|
+
from teams_transcripts.infrastructure.signals import ShutdownCoordinator
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Constants
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
MAX_SEARCH_RETRIES: int = 4
|
|
96
|
+
"""Maximum number of search result indices to try before giving up."""
|
|
97
|
+
|
|
98
|
+
_MONTH_NAMES: tuple[str, ...] = (
|
|
99
|
+
"January",
|
|
100
|
+
"February",
|
|
101
|
+
"March",
|
|
102
|
+
"April",
|
|
103
|
+
"May",
|
|
104
|
+
"June",
|
|
105
|
+
"July",
|
|
106
|
+
"August",
|
|
107
|
+
"September",
|
|
108
|
+
"October",
|
|
109
|
+
"November",
|
|
110
|
+
"December",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Internal helpers
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_date_from_meeting_time(meeting_time_text: str) -> str:
|
|
120
|
+
"""Extract ISO date from meeting time text.
|
|
121
|
+
|
|
122
|
+
Teams Recap headers use the format ``"5 January 2026 09:00 - 09:30"``.
|
|
123
|
+
Parses the day/month/year and returns an ISO ``YYYY-MM-DD`` string.
|
|
124
|
+
|
|
125
|
+
Returns an empty string if parsing fails.
|
|
126
|
+
"""
|
|
127
|
+
import re
|
|
128
|
+
|
|
129
|
+
match = re.match(r"(\d{1,2})\s+(\w+)\s+(\d{4})", meeting_time_text.strip())
|
|
130
|
+
if not match:
|
|
131
|
+
return ""
|
|
132
|
+
day, month_name, year = match.groups()
|
|
133
|
+
try:
|
|
134
|
+
parsed = dt_module.datetime.strptime(f"{day} {month_name} {year}", "%d %B %Y").replace(
|
|
135
|
+
tzinfo=dt_module.timezone.utc,
|
|
136
|
+
)
|
|
137
|
+
return parsed.date().isoformat()
|
|
138
|
+
except ValueError:
|
|
139
|
+
return ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Internal data: navigation result threading
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class _NavMetadata:
|
|
149
|
+
"""Metadata returned from the Step 4+5 retry loop.
|
|
150
|
+
|
|
151
|
+
Avoids stashing temporary data on the browser adapter.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
search_target_id: str = ""
|
|
155
|
+
speaker_map: dict[str, str] = field(default_factory=dict)
|
|
156
|
+
meeting_time_text: str = ""
|
|
157
|
+
pre_nav_iframe_ids: set[str] = field(default_factory=set)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Main orchestration
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def run_download(
|
|
166
|
+
config: MeetingConfig,
|
|
167
|
+
logger: Logger,
|
|
168
|
+
shutdown: ShutdownCoordinator,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Execute the full transcript download pipeline.
|
|
171
|
+
|
|
172
|
+
Steps:
|
|
173
|
+
1. Ensure Teams is running with CDP enabled.
|
|
174
|
+
2. Verify CDP is responding.
|
|
175
|
+
3. Discover Teams page targets.
|
|
176
|
+
4-5. Navigate to meeting and transcript (with retry loop).
|
|
177
|
+
6. Find the transcript iframe target.
|
|
178
|
+
7. Download VTT (native blob or DOM extraction).
|
|
179
|
+
8. Verify the output.
|
|
180
|
+
|
|
181
|
+
Output is emitted according to the ``--output`` / ``--format``
|
|
182
|
+
combination on the config.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
config: Resolved meeting configuration.
|
|
186
|
+
logger: Logger instance.
|
|
187
|
+
shutdown: Shutdown coordinator.
|
|
188
|
+
"""
|
|
189
|
+
# Log configuration
|
|
190
|
+
datetime_label = config.date
|
|
191
|
+
if config.time:
|
|
192
|
+
datetime_label += f"T{config.time}"
|
|
193
|
+
if config.meeting_id:
|
|
194
|
+
logger.log("main", f"Meeting ID: {config.meeting_id}")
|
|
195
|
+
else:
|
|
196
|
+
logger.log("main", f"Meeting: {config.meeting}")
|
|
197
|
+
logger.log("main", f"Date: {datetime_label}")
|
|
198
|
+
logger.log("main", f"Output: {config.output}")
|
|
199
|
+
logger.log("main", f"CDP port: {config.port}")
|
|
200
|
+
logger.log("main", f"Model: {config.model}")
|
|
201
|
+
logger.log("main", f"GCP project: {config.gcp_project}")
|
|
202
|
+
logger.log("main", f"GCP region: {config.gcp_region}")
|
|
203
|
+
|
|
204
|
+
# Step 1: Ensure Teams is running
|
|
205
|
+
ensure_teams_running(
|
|
206
|
+
config.port,
|
|
207
|
+
logger=logger,
|
|
208
|
+
shutdown=shutdown,
|
|
209
|
+
format_json=config.format_json,
|
|
210
|
+
force=config.force_restart,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Step 2: Verify CDP
|
|
214
|
+
verify_cdp(
|
|
215
|
+
config.port,
|
|
216
|
+
logger=logger,
|
|
217
|
+
shutdown=shutdown,
|
|
218
|
+
format_json=config.format_json,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Step 3: Find main Teams targets
|
|
222
|
+
targets = find_teams_targets(
|
|
223
|
+
config.port,
|
|
224
|
+
logger=logger,
|
|
225
|
+
format_json=config.format_json,
|
|
226
|
+
)
|
|
227
|
+
if not targets:
|
|
228
|
+
exit_error(
|
|
229
|
+
EXIT_CDP_UNAVAILABLE,
|
|
230
|
+
"NO_TARGETS",
|
|
231
|
+
"No Teams page targets found via CDP.",
|
|
232
|
+
step="step3",
|
|
233
|
+
format_json=config.format_json,
|
|
234
|
+
)
|
|
235
|
+
logger.log("step3", f"Found {len(targets)} Teams page target(s)")
|
|
236
|
+
for t in targets:
|
|
237
|
+
logger.log("step3", f" {t['id'][:20]}... {t.get('title', '')[:60]}")
|
|
238
|
+
|
|
239
|
+
# Create agent client and adapter
|
|
240
|
+
agent_client = create_agent_client(config)
|
|
241
|
+
agent = VertexAgentAdapter(client=agent_client, model=config.model, logger=logger)
|
|
242
|
+
browser = CdpBrowserAdapter(port=config.port)
|
|
243
|
+
|
|
244
|
+
# Step 3b: Verify tenant (when --tenant is specified)
|
|
245
|
+
tenant_domain: str | None = None
|
|
246
|
+
tenant_id: str | None = None
|
|
247
|
+
if config.tenant:
|
|
248
|
+
from teams_transcripts.adapters.cdp.teams import relaunch_teams_on_tenant
|
|
249
|
+
from teams_transcripts.services.tenant import (
|
|
250
|
+
TenantSwitchNeededError,
|
|
251
|
+
TenantVerification,
|
|
252
|
+
ensure_tenant,
|
|
253
|
+
pick_detection_target,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Pick the best target for tenant detection. Prefer a target with
|
|
257
|
+
# a descriptive title (e.g. "Chat | ... | TenantName | ...") over
|
|
258
|
+
# a generic "Microsoft Teams" shell page.
|
|
259
|
+
detection_target_id = pick_detection_target(targets)
|
|
260
|
+
|
|
261
|
+
await browser.connect(detection_target_id)
|
|
262
|
+
try:
|
|
263
|
+
verified_tenant = await ensure_tenant(browser, config, logger)
|
|
264
|
+
except TenantSwitchNeededError as switch:
|
|
265
|
+
await browser.disconnect()
|
|
266
|
+
# Kill Teams and relaunch on the correct tenant
|
|
267
|
+
relaunch_teams_on_tenant(
|
|
268
|
+
config.port,
|
|
269
|
+
switch.tenant_id,
|
|
270
|
+
logger=logger,
|
|
271
|
+
shutdown=shutdown,
|
|
272
|
+
format_json=config.format_json,
|
|
273
|
+
force=config.force_restart,
|
|
274
|
+
)
|
|
275
|
+
# Re-verify CDP after relaunch
|
|
276
|
+
verify_cdp(
|
|
277
|
+
config.port,
|
|
278
|
+
logger=logger,
|
|
279
|
+
shutdown=shutdown,
|
|
280
|
+
format_json=config.format_json,
|
|
281
|
+
)
|
|
282
|
+
# Re-discover targets on the new tenant
|
|
283
|
+
targets = find_teams_targets(
|
|
284
|
+
config.port,
|
|
285
|
+
logger=logger,
|
|
286
|
+
format_json=config.format_json,
|
|
287
|
+
)
|
|
288
|
+
if not targets:
|
|
289
|
+
exit_error(
|
|
290
|
+
EXIT_CDP_UNAVAILABLE,
|
|
291
|
+
"NO_TARGETS",
|
|
292
|
+
"No Teams page targets found after tenant switch.",
|
|
293
|
+
step="step3b",
|
|
294
|
+
format_json=config.format_json,
|
|
295
|
+
)
|
|
296
|
+
logger.log("step3b", f"Found {len(targets)} target(s) after switch")
|
|
297
|
+
for t in targets:
|
|
298
|
+
logger.log("step3b", f" {t['id'][:20]}... {t.get('title', '')[:60]}")
|
|
299
|
+
|
|
300
|
+
# Re-create browser adapter (old websocket is dead)
|
|
301
|
+
browser = CdpBrowserAdapter(port=config.port)
|
|
302
|
+
|
|
303
|
+
# Re-verify tenant on the new targets
|
|
304
|
+
detection_target_id = pick_detection_target(targets)
|
|
305
|
+
await browser.connect(detection_target_id)
|
|
306
|
+
try:
|
|
307
|
+
verified_tenant = await ensure_tenant(browser, config, logger)
|
|
308
|
+
except TenantSwitchNeededError as retry_switch:
|
|
309
|
+
# Switch failed -- the relaunch didn't land on the right tenant.
|
|
310
|
+
exit_error(
|
|
311
|
+
EXIT_CDP_UNAVAILABLE,
|
|
312
|
+
"TENANT_SWITCH_FAILED",
|
|
313
|
+
f"Tenant switch failed: Teams opened on the wrong tenant "
|
|
314
|
+
f"after relaunch. Expected '{config.tenant}', "
|
|
315
|
+
f"got a different tenant. Target was "
|
|
316
|
+
f"'{retry_switch.domain}' ({retry_switch.tenant_id[:8]}...).",
|
|
317
|
+
step="step3b",
|
|
318
|
+
format_json=config.format_json,
|
|
319
|
+
)
|
|
320
|
+
finally:
|
|
321
|
+
await browser.disconnect()
|
|
322
|
+
else:
|
|
323
|
+
await browser.disconnect()
|
|
324
|
+
if isinstance(verified_tenant, TenantVerification):
|
|
325
|
+
tenant_domain = verified_tenant.domain
|
|
326
|
+
tenant_id = verified_tenant.tenant_id
|
|
327
|
+
|
|
328
|
+
# Steps 4+5: Navigate to meeting and Recap > Transcript (with retries)
|
|
329
|
+
# When --call-id is used, always resolve via MCPS (string match required).
|
|
330
|
+
# When --meeting-id is used, try direct navigation first (skips the MCPS
|
|
331
|
+
# scan entirely for non-recurring meetings). Only fall back to MCPS if
|
|
332
|
+
# direct navigation fails or the meeting is recurring.
|
|
333
|
+
resolved_thread_id: str | None = None
|
|
334
|
+
nav_meta: _NavMetadata | None = None
|
|
335
|
+
if config.call_id and not config.meeting:
|
|
336
|
+
from teams_transcripts.services.listing import resolve_call_id
|
|
337
|
+
|
|
338
|
+
resolved_subject, resolved_date, resolved_thread_id = await resolve_call_id(
|
|
339
|
+
config.port,
|
|
340
|
+
config.call_id,
|
|
341
|
+
logger=logger,
|
|
342
|
+
shutdown=shutdown,
|
|
343
|
+
format_json=config.format_json,
|
|
344
|
+
tenant_id=tenant_id,
|
|
345
|
+
force_restart=config.force_restart,
|
|
346
|
+
)
|
|
347
|
+
config = config.model_copy(
|
|
348
|
+
update={"meeting": resolved_subject, "date": resolved_date},
|
|
349
|
+
)
|
|
350
|
+
logger.log("main", f"Resolved call ID to: {config.meeting}")
|
|
351
|
+
logger.log("main", f"Meeting date: {config.date}")
|
|
352
|
+
|
|
353
|
+
elif config.meeting_id and not config.meeting:
|
|
354
|
+
# OPTIMISATION: Attempt direct navigation without MCPS scan.
|
|
355
|
+
# For non-recurring meetings (the common case), this saves 10-20s
|
|
356
|
+
# by building thread_id from the UUID and navigating directly.
|
|
357
|
+
from teams_transcripts.domain.mcps import build_thread_id
|
|
358
|
+
|
|
359
|
+
speculative_thread_id = build_thread_id(config.meeting_id)
|
|
360
|
+
logger.log(
|
|
361
|
+
"main",
|
|
362
|
+
f"Attempting direct navigation for meeting ID {config.meeting_id[:12]}...",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Discover search target for navigation
|
|
366
|
+
search_target_id_spec = await find_search_target(
|
|
367
|
+
config.port,
|
|
368
|
+
targets,
|
|
369
|
+
logger=logger,
|
|
370
|
+
)
|
|
371
|
+
speculative_nav_title: str | None = None
|
|
372
|
+
if search_target_id_spec:
|
|
373
|
+
speculative_nav_title = await navigate_to_thread_direct(
|
|
374
|
+
browser,
|
|
375
|
+
search_target_id_spec,
|
|
376
|
+
speculative_thread_id,
|
|
377
|
+
logger,
|
|
378
|
+
shutdown,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if speculative_nav_title and search_target_id_spec:
|
|
382
|
+
# Sidebar found the chat. Now try Recap/Transcript.
|
|
383
|
+
# config.date is "" here — _select_correct_occurrence handles
|
|
384
|
+
# empty date by returning False for recurring meetings (dropdown
|
|
385
|
+
# present). Non-recurring meetings have no dropdown → success.
|
|
386
|
+
logger.log("main", "Direct thread navigation succeeded (speculative).")
|
|
387
|
+
pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(config.port)}
|
|
388
|
+
|
|
389
|
+
nav_result = await navigate_to_transcript(
|
|
390
|
+
browser,
|
|
391
|
+
agent,
|
|
392
|
+
search_target_id_spec,
|
|
393
|
+
config.date, # "" for speculative path
|
|
394
|
+
config.time,
|
|
395
|
+
config.model,
|
|
396
|
+
logger,
|
|
397
|
+
shutdown,
|
|
398
|
+
format_json=config.format_json,
|
|
399
|
+
require_recap=config.include_transcript or config.include_metadata,
|
|
400
|
+
require_transcript=config.include_transcript,
|
|
401
|
+
)
|
|
402
|
+
if nav_result.success:
|
|
403
|
+
# Non-recurring meeting — speculative path succeeded!
|
|
404
|
+
# Derive subject from sidebar title, date from meeting time.
|
|
405
|
+
resolved_thread_id = speculative_thread_id
|
|
406
|
+
derived_date = _parse_date_from_meeting_time(nav_result.meeting_time_text)
|
|
407
|
+
config = config.model_copy(
|
|
408
|
+
update={
|
|
409
|
+
"meeting": speculative_nav_title,
|
|
410
|
+
"date": derived_date or config.date,
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
logger.log(
|
|
414
|
+
"main",
|
|
415
|
+
f"Speculative navigation succeeded: '{config.meeting}' on {config.date}",
|
|
416
|
+
)
|
|
417
|
+
nav_meta = _NavMetadata(
|
|
418
|
+
search_target_id=search_target_id_spec,
|
|
419
|
+
speaker_map=nav_result.speaker_map,
|
|
420
|
+
meeting_time_text=nav_result.meeting_time_text,
|
|
421
|
+
pre_nav_iframe_ids=pre_nav_iframe_ids,
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
# Recurring meeting — occurrence selection failed (no date).
|
|
425
|
+
# Fall back to MCPS to get the date, then retry navigation.
|
|
426
|
+
logger.log(
|
|
427
|
+
"main",
|
|
428
|
+
"Speculative navigation failed (recurring meeting likely). "
|
|
429
|
+
"Falling back to MCPS resolution.",
|
|
430
|
+
)
|
|
431
|
+
nav_meta = None
|
|
432
|
+
# Resolve via MCPS (the slow path)
|
|
433
|
+
from teams_transcripts.services.listing import resolve_meeting_id
|
|
434
|
+
|
|
435
|
+
resolved_subject, resolved_date, resolved_thread_id = await resolve_meeting_id(
|
|
436
|
+
config.port,
|
|
437
|
+
config.meeting_id,
|
|
438
|
+
logger=logger,
|
|
439
|
+
shutdown=shutdown,
|
|
440
|
+
format_json=config.format_json,
|
|
441
|
+
tenant_id=tenant_id,
|
|
442
|
+
force_restart=config.force_restart,
|
|
443
|
+
)
|
|
444
|
+
config = config.model_copy(
|
|
445
|
+
update={"meeting": resolved_subject, "date": resolved_date},
|
|
446
|
+
)
|
|
447
|
+
logger.log("main", f"Resolved meeting ID to: {config.meeting}")
|
|
448
|
+
logger.log("main", f"Meeting date: {config.date}")
|
|
449
|
+
else:
|
|
450
|
+
# Sidebar miss — fall back to MCPS for full resolution
|
|
451
|
+
logger.log(
|
|
452
|
+
"main",
|
|
453
|
+
"Speculative sidebar navigation failed. Falling back to MCPS resolution.",
|
|
454
|
+
)
|
|
455
|
+
from teams_transcripts.services.listing import resolve_meeting_id
|
|
456
|
+
|
|
457
|
+
resolved_subject, resolved_date, resolved_thread_id = await resolve_meeting_id(
|
|
458
|
+
config.port,
|
|
459
|
+
config.meeting_id,
|
|
460
|
+
logger=logger,
|
|
461
|
+
shutdown=shutdown,
|
|
462
|
+
format_json=config.format_json,
|
|
463
|
+
tenant_id=tenant_id,
|
|
464
|
+
force_restart=config.force_restart,
|
|
465
|
+
)
|
|
466
|
+
config = config.model_copy(
|
|
467
|
+
update={"meeting": resolved_subject, "date": resolved_date},
|
|
468
|
+
)
|
|
469
|
+
logger.log("main", f"Resolved meeting ID to: {config.meeting}")
|
|
470
|
+
logger.log("main", f"Meeting date: {config.date}")
|
|
471
|
+
|
|
472
|
+
# When we have a thread_id from resolution (MCPS or speculative) and
|
|
473
|
+
# nav_meta hasn't been set yet, attempt direct navigation via the
|
|
474
|
+
# sidebar fibre walk.
|
|
475
|
+
direct_nav_ok = False
|
|
476
|
+
search_target_id: str | None = None
|
|
477
|
+
if nav_meta is None and resolved_thread_id:
|
|
478
|
+
# We need a search target for Step 5 (navigate_to_transcript).
|
|
479
|
+
# Discover it first so we can use it regardless of nav path.
|
|
480
|
+
search_target_id = await find_search_target(
|
|
481
|
+
config.port,
|
|
482
|
+
targets,
|
|
483
|
+
logger=logger,
|
|
484
|
+
)
|
|
485
|
+
if search_target_id:
|
|
486
|
+
direct_nav_title = await navigate_to_thread_direct(
|
|
487
|
+
browser,
|
|
488
|
+
search_target_id,
|
|
489
|
+
resolved_thread_id,
|
|
490
|
+
logger,
|
|
491
|
+
shutdown,
|
|
492
|
+
)
|
|
493
|
+
direct_nav_ok = direct_nav_title is not None
|
|
494
|
+
if direct_nav_ok:
|
|
495
|
+
logger.log("main", "Direct thread navigation succeeded.")
|
|
496
|
+
|
|
497
|
+
if direct_nav_ok and search_target_id:
|
|
498
|
+
# Direct navigation landed on the correct chat.
|
|
499
|
+
# Record pre-navigation iframe IDs, then proceed to Step 5
|
|
500
|
+
# (Recap > Transcript).
|
|
501
|
+
pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(config.port)}
|
|
502
|
+
|
|
503
|
+
nav_result = await navigate_to_transcript(
|
|
504
|
+
browser,
|
|
505
|
+
agent,
|
|
506
|
+
search_target_id,
|
|
507
|
+
config.date,
|
|
508
|
+
config.time,
|
|
509
|
+
config.model,
|
|
510
|
+
logger,
|
|
511
|
+
shutdown,
|
|
512
|
+
format_json=config.format_json,
|
|
513
|
+
require_recap=config.include_transcript or config.include_metadata,
|
|
514
|
+
require_transcript=config.include_transcript,
|
|
515
|
+
)
|
|
516
|
+
if nav_result.success:
|
|
517
|
+
nav_message = (
|
|
518
|
+
"Correct occurrence found and transcript tab loaded."
|
|
519
|
+
if config.include_transcript
|
|
520
|
+
else "Correct occurrence found. Transcript tab was not required."
|
|
521
|
+
)
|
|
522
|
+
logger.log("main", nav_message)
|
|
523
|
+
nav_meta = _NavMetadata(
|
|
524
|
+
search_target_id=search_target_id,
|
|
525
|
+
speaker_map=nav_result.speaker_map,
|
|
526
|
+
meeting_time_text=nav_result.meeting_time_text,
|
|
527
|
+
pre_nav_iframe_ids=pre_nav_iframe_ids,
|
|
528
|
+
)
|
|
529
|
+
else:
|
|
530
|
+
# Occurrence not found via direct nav — fall back to full retry loop
|
|
531
|
+
logger.log(
|
|
532
|
+
"main",
|
|
533
|
+
"Direct navigation reached a chat but occurrence mismatch. "
|
|
534
|
+
"Falling back to search-based navigation.",
|
|
535
|
+
)
|
|
536
|
+
nav_meta = None
|
|
537
|
+
|
|
538
|
+
if nav_meta is None:
|
|
539
|
+
nav_meta = await _navigate_with_retries(
|
|
540
|
+
browser,
|
|
541
|
+
agent,
|
|
542
|
+
targets,
|
|
543
|
+
config,
|
|
544
|
+
logger,
|
|
545
|
+
shutdown,
|
|
546
|
+
)
|
|
547
|
+
search_target_id = nav_meta.search_target_id
|
|
548
|
+
speaker_map = nav_meta.speaker_map
|
|
549
|
+
|
|
550
|
+
# Detect multi-part meetings (quick DOM check, no navigation).
|
|
551
|
+
# Always runs (even for dry-run) so the summary can report the
|
|
552
|
+
# actual part count. Navigation to the Transcript tab has already
|
|
553
|
+
# completed by this point.
|
|
554
|
+
num_parts = await detect_part_tabs(browser, search_target_id)
|
|
555
|
+
logger.log("main", f"Meeting parts detected: {num_parts}")
|
|
556
|
+
|
|
557
|
+
# --- Dry-run exit ---
|
|
558
|
+
# Must happen before download (matching legacy behaviour). Dry-run
|
|
559
|
+
# should be non-destructive: no iframe discovery, no chat tab
|
|
560
|
+
# navigation, no Details tab navigation. Roster extraction is kept
|
|
561
|
+
# because it only opens the participant popover and is part of the
|
|
562
|
+
# documented dry-run metadata contract.
|
|
563
|
+
if config.dry_run:
|
|
564
|
+
roster_members_raw: list[dict] = []
|
|
565
|
+
organizer_id = ""
|
|
566
|
+
if config.include_metadata or config.include_chat:
|
|
567
|
+
(
|
|
568
|
+
roster_members_raw,
|
|
569
|
+
speaker_map,
|
|
570
|
+
_member_map,
|
|
571
|
+
organizer_id,
|
|
572
|
+
) = await _extract_roster_and_maps(
|
|
573
|
+
browser, search_target_id, nav_meta.speaker_map, logger, shutdown
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
participants: MeetingParticipants | None = None
|
|
577
|
+
if config.include_metadata:
|
|
578
|
+
roster_as_members = [
|
|
579
|
+
RosterMember(
|
|
580
|
+
name=m["name"],
|
|
581
|
+
email=m.get("email", ""),
|
|
582
|
+
object_id=m.get("objectId", ""),
|
|
583
|
+
role=m.get("role", "member"),
|
|
584
|
+
)
|
|
585
|
+
for m in roster_members_raw
|
|
586
|
+
]
|
|
587
|
+
participants = build_meeting_participants(
|
|
588
|
+
all_members=roster_as_members,
|
|
589
|
+
speaker_map=speaker_map,
|
|
590
|
+
attendance=[],
|
|
591
|
+
organizer_id=organizer_id,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
config_dict = _build_config_dict(
|
|
595
|
+
config,
|
|
596
|
+
speaker_map,
|
|
597
|
+
roster_members_raw,
|
|
598
|
+
nav_meta.meeting_time_text,
|
|
599
|
+
tenant_domain=tenant_domain,
|
|
600
|
+
)
|
|
601
|
+
summary = build_result_summary(
|
|
602
|
+
config_dict,
|
|
603
|
+
num_parts=num_parts,
|
|
604
|
+
chat_count=0,
|
|
605
|
+
participants=participants,
|
|
606
|
+
chat_messages=None,
|
|
607
|
+
include_transcript=config.include_transcript,
|
|
608
|
+
include_chat=config.include_chat,
|
|
609
|
+
include_metadata=config.include_metadata,
|
|
610
|
+
)
|
|
611
|
+
summary["dry_run"] = True
|
|
612
|
+
if config.format_json:
|
|
613
|
+
print(
|
|
614
|
+
json.dumps(summary, ensure_ascii=False, indent=2),
|
|
615
|
+
flush=True,
|
|
616
|
+
)
|
|
617
|
+
else:
|
|
618
|
+
logger.log(
|
|
619
|
+
"main",
|
|
620
|
+
f"Dry-run summary: {json.dumps(summary, ensure_ascii=False)}",
|
|
621
|
+
)
|
|
622
|
+
sys.exit(EXIT_OK)
|
|
623
|
+
|
|
624
|
+
# --- Transcript download (time-critical: iframe is ephemeral) ---
|
|
625
|
+
# The xplatplugins iframe target only exists for ~8 seconds after
|
|
626
|
+
# the Recap/Transcript tab loads. Download MUST happen immediately
|
|
627
|
+
# before any roster/chat/metadata extraction that might navigate
|
|
628
|
+
# away or cause the SPA to unmount the Recap panel.
|
|
629
|
+
part_vtt_contents: list[str] = []
|
|
630
|
+
if config.include_transcript:
|
|
631
|
+
part_vtt_contents = await _download_parts(
|
|
632
|
+
browser,
|
|
633
|
+
agent,
|
|
634
|
+
search_target_id,
|
|
635
|
+
config,
|
|
636
|
+
logger,
|
|
637
|
+
shutdown,
|
|
638
|
+
num_parts=num_parts,
|
|
639
|
+
speaker_map=speaker_map,
|
|
640
|
+
pre_nav_iframe_ids=nav_meta.pre_nav_iframe_ids,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
if not part_vtt_contents:
|
|
644
|
+
exit_error(
|
|
645
|
+
EXIT_DOWNLOAD_FAILED,
|
|
646
|
+
"DOWNLOAD_FAILED",
|
|
647
|
+
"No transcript parts were downloaded.",
|
|
648
|
+
step="step7",
|
|
649
|
+
format_json=config.format_json,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# --- Metadata extraction (safe to navigate away now) ---
|
|
653
|
+
# Roster extraction (after steps 4+5 so speaker_map is available)
|
|
654
|
+
# Run roster extraction when metadata or chat is needed (chat uses member_map
|
|
655
|
+
# for author name resolution).
|
|
656
|
+
roster_members_raw: list[dict] = []
|
|
657
|
+
member_map: dict[str, str] | None = None
|
|
658
|
+
organizer_id = ""
|
|
659
|
+
if config.include_metadata or config.include_chat:
|
|
660
|
+
roster_members_raw, speaker_map, member_map, organizer_id = await _extract_roster_and_maps(
|
|
661
|
+
browser,
|
|
662
|
+
search_target_id,
|
|
663
|
+
nav_meta.speaker_map,
|
|
664
|
+
logger,
|
|
665
|
+
shutdown,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# Build config dict for domain functions (they expect plain dicts)
|
|
669
|
+
config_dict = _build_config_dict(
|
|
670
|
+
config,
|
|
671
|
+
speaker_map,
|
|
672
|
+
roster_members_raw,
|
|
673
|
+
nav_meta.meeting_time_text,
|
|
674
|
+
tenant_domain=tenant_domain,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# --- Extract non-transcript components (chat + metadata) ---
|
|
678
|
+
# These are extracted regardless of --dry-run when requested.
|
|
679
|
+
chat_messages: list[ChatMessage] = []
|
|
680
|
+
event_call_contents: list[str] = []
|
|
681
|
+
participants: MeetingParticipants | None = None
|
|
682
|
+
chat_count = 0
|
|
683
|
+
|
|
684
|
+
# Chat extraction: needed when include_chat OR include_metadata (for attendance data)
|
|
685
|
+
if config.include_chat or config.include_metadata:
|
|
686
|
+
chat_messages, event_call_contents = await extract_chat_messages(
|
|
687
|
+
browser,
|
|
688
|
+
search_target_id,
|
|
689
|
+
config.date,
|
|
690
|
+
logger,
|
|
691
|
+
shutdown,
|
|
692
|
+
meeting_time_text=config_dict.get("meeting_time_text", ""),
|
|
693
|
+
member_map=member_map,
|
|
694
|
+
speaker_map=speaker_map,
|
|
695
|
+
)
|
|
696
|
+
if config.include_chat:
|
|
697
|
+
chat_count = len(chat_messages)
|
|
698
|
+
|
|
699
|
+
# Build categorised participant lists
|
|
700
|
+
if config.include_metadata:
|
|
701
|
+
roster_as_members = [
|
|
702
|
+
RosterMember(
|
|
703
|
+
name=m["name"],
|
|
704
|
+
email=m.get("email", ""),
|
|
705
|
+
object_id=m.get("objectId", ""),
|
|
706
|
+
role=m.get("role", "member"),
|
|
707
|
+
)
|
|
708
|
+
for m in roster_members_raw
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
attendance: list[dict] = []
|
|
712
|
+
for ec_content in event_call_contents:
|
|
713
|
+
attendance.extend(parse_partlist_xml(ec_content))
|
|
714
|
+
if attendance:
|
|
715
|
+
logger.log("main", f"Parsed {len(attendance)} attendance record(s) from partlists")
|
|
716
|
+
|
|
717
|
+
# Extract tracking/RSVP data from the Details tab
|
|
718
|
+
shutdown.check_shutdown()
|
|
719
|
+
tracking_labels = await extract_tracking_data(
|
|
720
|
+
browser,
|
|
721
|
+
search_target_id,
|
|
722
|
+
logger,
|
|
723
|
+
shutdown,
|
|
724
|
+
)
|
|
725
|
+
tracking_entries = parse_tracking_entries(tracking_labels) if tracking_labels else []
|
|
726
|
+
if tracking_entries:
|
|
727
|
+
logger.log("main", f"Parsed {len(tracking_entries)} tracking/RSVP entries")
|
|
728
|
+
|
|
729
|
+
participants = build_meeting_participants(
|
|
730
|
+
all_members=roster_as_members,
|
|
731
|
+
speaker_map=speaker_map,
|
|
732
|
+
attendance=attendance,
|
|
733
|
+
organizer_id=organizer_id,
|
|
734
|
+
tracking=tracking_entries or None,
|
|
735
|
+
)
|
|
736
|
+
rsvp_count = sum(1 for m in participants.invited if m.rsvp_status)
|
|
737
|
+
logger.log(
|
|
738
|
+
"main",
|
|
739
|
+
f"Participants: organiser={len(participants.organiser)}, "
|
|
740
|
+
f"invited={len(participants.invited)}, "
|
|
741
|
+
f"speakers={len(participants.speakers)}, "
|
|
742
|
+
f"attendees={len(participants.attendees)}"
|
|
743
|
+
+ (f", rsvp_enriched={rsvp_count}" if rsvp_count else ""),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# --- Transcript excluded: JSON-only output path ---
|
|
747
|
+
if not config.include_transcript:
|
|
748
|
+
summary = build_result_summary(
|
|
749
|
+
config_dict,
|
|
750
|
+
num_parts=num_parts,
|
|
751
|
+
chat_count=chat_count,
|
|
752
|
+
participants=participants,
|
|
753
|
+
chat_messages=chat_messages if config.include_chat else None,
|
|
754
|
+
include_transcript=False,
|
|
755
|
+
include_chat=config.include_chat,
|
|
756
|
+
include_metadata=config.include_metadata,
|
|
757
|
+
)
|
|
758
|
+
if config.output:
|
|
759
|
+
# Write JSON to file
|
|
760
|
+
with open(config.output, "w") as f:
|
|
761
|
+
json.dump(summary, f, ensure_ascii=False, indent=2)
|
|
762
|
+
f.write("\n")
|
|
763
|
+
logger.log("main", f"JSON output written to {config.output}")
|
|
764
|
+
else:
|
|
765
|
+
# JSON to stdout
|
|
766
|
+
print(
|
|
767
|
+
json.dumps(summary, ensure_ascii=False, indent=2),
|
|
768
|
+
flush=True,
|
|
769
|
+
)
|
|
770
|
+
sys.exit(EXIT_OK)
|
|
771
|
+
|
|
772
|
+
# --- Transcript included: full download path ---
|
|
773
|
+
|
|
774
|
+
# Assemble final VTT content
|
|
775
|
+
if num_parts > 1:
|
|
776
|
+
logger.log("main", f"Merging {len(part_vtt_contents)} parts...")
|
|
777
|
+
content = merge_parts(part_vtt_contents)
|
|
778
|
+
else:
|
|
779
|
+
content = part_vtt_contents[0]
|
|
780
|
+
|
|
781
|
+
shutdown.check_shutdown()
|
|
782
|
+
|
|
783
|
+
# If we haven't done chat extraction yet (e.g. include_chat=True but
|
|
784
|
+
# extraction was deferred), it's already done above. The extraction
|
|
785
|
+
# now happens BEFORE the transcript download for the component-flag flow.
|
|
786
|
+
|
|
787
|
+
# Insert metadata (when metadata is included)
|
|
788
|
+
if config.include_metadata and participants is not None:
|
|
789
|
+
content = prepend_metadata(content, config_dict, participants=participants)
|
|
790
|
+
elif config.include_metadata:
|
|
791
|
+
# Fallback: legacy metadata from roster (no MeetingParticipants)
|
|
792
|
+
content = prepend_metadata(content, config_dict)
|
|
793
|
+
|
|
794
|
+
# Append chat messages (when chat is included)
|
|
795
|
+
if config.include_chat and chat_messages:
|
|
796
|
+
time_text = config_dict.get("meeting_time_text", "")
|
|
797
|
+
if time_text:
|
|
798
|
+
meeting_start, _ = parse_meeting_times(time_text, config.date)
|
|
799
|
+
elif config.time:
|
|
800
|
+
meeting_start = f"{config.date}T{config.time}"
|
|
801
|
+
else:
|
|
802
|
+
meeting_start = config.date
|
|
803
|
+
|
|
804
|
+
chat_dicts = [
|
|
805
|
+
{
|
|
806
|
+
"timestamp": m.timestamp,
|
|
807
|
+
"author": m.author,
|
|
808
|
+
"text": m.text,
|
|
809
|
+
"reactions": (
|
|
810
|
+
[{"type": r.type, "users": r.users} for r in m.reactions]
|
|
811
|
+
if m.reactions
|
|
812
|
+
else None
|
|
813
|
+
),
|
|
814
|
+
}
|
|
815
|
+
for m in chat_messages
|
|
816
|
+
]
|
|
817
|
+
chat_block = build_chat_note_block(chat_dicts, meeting_start)
|
|
818
|
+
content = insert_chat_block(content, chat_block)
|
|
819
|
+
elif not config.include_chat:
|
|
820
|
+
logger.log("main", "Chat excluded by --chat no.")
|
|
821
|
+
else:
|
|
822
|
+
logger.log("main", "No chat messages found for this meeting occurrence.")
|
|
823
|
+
|
|
824
|
+
# Ensure trailing newline
|
|
825
|
+
content = ensure_trailing_newline(content)
|
|
826
|
+
|
|
827
|
+
# Write VTT to output file (only when format is VTT;
|
|
828
|
+
# JSON format writes structured JSON later in _emit_output)
|
|
829
|
+
if not config.format_json:
|
|
830
|
+
assert config.output is not None
|
|
831
|
+
with open(config.output, "w") as f:
|
|
832
|
+
f.write(content)
|
|
833
|
+
|
|
834
|
+
# Step 8: Verify
|
|
835
|
+
verification = verify_vtt_content(content, require_cues=config.include_transcript)
|
|
836
|
+
if verification["valid"]:
|
|
837
|
+
logger.log("step8", f"Cue count: {verification['cue_count']}")
|
|
838
|
+
for sp, cnt in sorted(verification["speakers"].items()):
|
|
839
|
+
logger.log("step8", f" {sp}: {cnt}")
|
|
840
|
+
logger.log("main", "Transcript download complete.")
|
|
841
|
+
else:
|
|
842
|
+
exit_error(
|
|
843
|
+
EXIT_VERIFICATION_FAILED,
|
|
844
|
+
"VERIFICATION_FAILED",
|
|
845
|
+
f"Output file verification failed: {verification.get('error')}",
|
|
846
|
+
step="step8",
|
|
847
|
+
format_json=config.format_json,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
# Emit final output
|
|
851
|
+
_emit_output(
|
|
852
|
+
config,
|
|
853
|
+
config_dict,
|
|
854
|
+
content,
|
|
855
|
+
num_parts,
|
|
856
|
+
verification,
|
|
857
|
+
chat_count,
|
|
858
|
+
participants,
|
|
859
|
+
chat_messages=chat_messages if config.include_chat else None,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
# Internal: navigation retry loop (Steps 4+5)
|
|
865
|
+
# ---------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
async def _navigate_with_retries(
|
|
869
|
+
browser: CdpBrowserAdapter,
|
|
870
|
+
agent: VertexAgentAdapter,
|
|
871
|
+
targets: list[dict],
|
|
872
|
+
config: MeetingConfig,
|
|
873
|
+
logger: Logger,
|
|
874
|
+
shutdown: ShutdownCoordinator,
|
|
875
|
+
) -> _NavMetadata:
|
|
876
|
+
"""Run the Step 4+5 retry loop, returning navigation metadata.
|
|
877
|
+
|
|
878
|
+
Exits the process with ``EXIT_MEETING_NOT_FOUND`` if all retries
|
|
879
|
+
are exhausted.
|
|
880
|
+
"""
|
|
881
|
+
port = config.port
|
|
882
|
+
result_index_override: int | None = None
|
|
883
|
+
search_target_id: str | None = None
|
|
884
|
+
|
|
885
|
+
datetime_label = config.date
|
|
886
|
+
if config.time:
|
|
887
|
+
datetime_label += f"T{config.time}"
|
|
888
|
+
|
|
889
|
+
for attempt in range(MAX_SEARCH_RETRIES):
|
|
890
|
+
shutdown.check_shutdown()
|
|
891
|
+
|
|
892
|
+
# Record existing iframe targets before navigation
|
|
893
|
+
pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
|
|
894
|
+
if pre_nav_iframe_ids:
|
|
895
|
+
logger.log("main", f"Pre-existing iframe targets: {len(pre_nav_iframe_ids)}")
|
|
896
|
+
|
|
897
|
+
try:
|
|
898
|
+
# Step 4: Find search target and navigate to meeting.
|
|
899
|
+
# After a fresh relaunch, Teams may take time to load content
|
|
900
|
+
# targets -- the shell target appears first but the page
|
|
901
|
+
# targets with the search input load later. Retry with
|
|
902
|
+
# target re-discovery to handle this.
|
|
903
|
+
search_target_id = await find_search_target(
|
|
904
|
+
port,
|
|
905
|
+
targets,
|
|
906
|
+
logger=logger,
|
|
907
|
+
)
|
|
908
|
+
if not search_target_id:
|
|
909
|
+
for search_wait in range(3):
|
|
910
|
+
wait_secs = 5 + search_wait * 5
|
|
911
|
+
logger.log(
|
|
912
|
+
"step4",
|
|
913
|
+
f"Search input not found. Waiting {wait_secs}s "
|
|
914
|
+
f"for Teams to finish loading "
|
|
915
|
+
f"(attempt {search_wait + 1}/3)...",
|
|
916
|
+
)
|
|
917
|
+
await shutdown.interruptible_sleep(wait_secs)
|
|
918
|
+
# Re-discover targets -- new ones may have appeared
|
|
919
|
+
targets = find_teams_targets(
|
|
920
|
+
port,
|
|
921
|
+
logger=logger,
|
|
922
|
+
format_json=config.format_json,
|
|
923
|
+
)
|
|
924
|
+
if targets:
|
|
925
|
+
search_target_id = await find_search_target(
|
|
926
|
+
port,
|
|
927
|
+
targets,
|
|
928
|
+
logger=logger,
|
|
929
|
+
)
|
|
930
|
+
if search_target_id:
|
|
931
|
+
break
|
|
932
|
+
|
|
933
|
+
if not search_target_id:
|
|
934
|
+
exit_error(
|
|
935
|
+
EXIT_CDP_UNAVAILABLE,
|
|
936
|
+
"NO_TARGETS",
|
|
937
|
+
"Could not find Teams search input on any CDP target.",
|
|
938
|
+
step="step4",
|
|
939
|
+
format_json=config.format_json,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
nav_ok = await navigate_to_meeting(
|
|
943
|
+
browser,
|
|
944
|
+
agent,
|
|
945
|
+
search_target_id,
|
|
946
|
+
config.meeting,
|
|
947
|
+
config.date,
|
|
948
|
+
logger,
|
|
949
|
+
shutdown,
|
|
950
|
+
format_json=config.format_json,
|
|
951
|
+
result_index_override=result_index_override,
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
if not nav_ok:
|
|
955
|
+
if result_index_override is None:
|
|
956
|
+
result_index_override = 1
|
|
957
|
+
else:
|
|
958
|
+
result_index_override += 1
|
|
959
|
+
logger.log(
|
|
960
|
+
"main",
|
|
961
|
+
f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed "
|
|
962
|
+
f"(navigation). Retrying with index {result_index_override}...",
|
|
963
|
+
)
|
|
964
|
+
continue
|
|
965
|
+
|
|
966
|
+
# Step 4b: Verify thread_id when --meeting-id was used
|
|
967
|
+
if config.meeting_id:
|
|
968
|
+
from teams_transcripts.domain.mcps import build_thread_id
|
|
969
|
+
|
|
970
|
+
expected_tid = build_thread_id(config.meeting_id)
|
|
971
|
+
tid_ok = await verify_meeting_thread_id(
|
|
972
|
+
browser,
|
|
973
|
+
search_target_id,
|
|
974
|
+
expected_tid,
|
|
975
|
+
logger,
|
|
976
|
+
)
|
|
977
|
+
if not tid_ok:
|
|
978
|
+
if result_index_override is None:
|
|
979
|
+
result_index_override = 1
|
|
980
|
+
else:
|
|
981
|
+
result_index_override += 1
|
|
982
|
+
logger.log(
|
|
983
|
+
"main",
|
|
984
|
+
f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed "
|
|
985
|
+
f"(thread_id mismatch). "
|
|
986
|
+
f"Retrying with index {result_index_override}...",
|
|
987
|
+
)
|
|
988
|
+
continue
|
|
989
|
+
|
|
990
|
+
# Step 5: Navigate to Recap > Transcript
|
|
991
|
+
nav_result = await navigate_to_transcript(
|
|
992
|
+
browser,
|
|
993
|
+
agent,
|
|
994
|
+
search_target_id,
|
|
995
|
+
config.date,
|
|
996
|
+
config.time,
|
|
997
|
+
config.model,
|
|
998
|
+
logger,
|
|
999
|
+
shutdown,
|
|
1000
|
+
format_json=config.format_json,
|
|
1001
|
+
require_recap=config.include_transcript or config.include_metadata,
|
|
1002
|
+
require_transcript=config.include_transcript,
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
if nav_result.success:
|
|
1006
|
+
nav_message = (
|
|
1007
|
+
"Correct occurrence found and transcript tab loaded."
|
|
1008
|
+
if config.include_transcript
|
|
1009
|
+
else "Correct occurrence found. Transcript tab was not required."
|
|
1010
|
+
)
|
|
1011
|
+
logger.log("main", nav_message)
|
|
1012
|
+
return _NavMetadata(
|
|
1013
|
+
search_target_id=search_target_id,
|
|
1014
|
+
speaker_map=nav_result.speaker_map,
|
|
1015
|
+
meeting_time_text=nav_result.meeting_time_text,
|
|
1016
|
+
pre_nav_iframe_ids=pre_nav_iframe_ids,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
# Occurrence not found -- try the next search result
|
|
1020
|
+
if result_index_override is None:
|
|
1021
|
+
result_index_override = 1
|
|
1022
|
+
else:
|
|
1023
|
+
result_index_override += 1
|
|
1024
|
+
logger.log(
|
|
1025
|
+
"main",
|
|
1026
|
+
f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed. "
|
|
1027
|
+
f"Retrying with search result index {result_index_override}...",
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
except websockets.exceptions.ConnectionClosed as exc:
|
|
1031
|
+
# The CDP WebSocket was dropped -- typically because Teams
|
|
1032
|
+
# navigated away or the target was destroyed. Treat as a
|
|
1033
|
+
# retriable failure: re-discover targets on the next attempt.
|
|
1034
|
+
logger.log(
|
|
1035
|
+
"main",
|
|
1036
|
+
f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} lost "
|
|
1037
|
+
f"WebSocket connection: {exc}. Re-discovering targets...",
|
|
1038
|
+
)
|
|
1039
|
+
targets = find_teams_targets(
|
|
1040
|
+
port,
|
|
1041
|
+
logger=logger,
|
|
1042
|
+
format_json=config.format_json,
|
|
1043
|
+
)
|
|
1044
|
+
search_target_id = None
|
|
1045
|
+
else:
|
|
1046
|
+
# Exhausted all retries
|
|
1047
|
+
exit_error(
|
|
1048
|
+
EXIT_MEETING_NOT_FOUND,
|
|
1049
|
+
"MEETING_NOT_FOUND",
|
|
1050
|
+
f"Could not find the target {datetime_label} in any "
|
|
1051
|
+
f"of the first {MAX_SEARCH_RETRIES} search results.",
|
|
1052
|
+
step="step5",
|
|
1053
|
+
format_json=config.format_json,
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
# Unreachable: either the loop returned above or exit_error terminated.
|
|
1057
|
+
# Required by ruff RET503 since exit_error's NoReturn isn't visible
|
|
1058
|
+
# through the for/else structure.
|
|
1059
|
+
msg = "Unreachable: retry loop exhausted without exit"
|
|
1060
|
+
raise AssertionError(msg)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# ---------------------------------------------------------------------------
|
|
1064
|
+
# Internal: roster and member map extraction
|
|
1065
|
+
# ---------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
async def _extract_roster_and_maps(
|
|
1069
|
+
browser: CdpBrowserAdapter,
|
|
1070
|
+
search_target_id: str,
|
|
1071
|
+
speaker_map: dict[str, str],
|
|
1072
|
+
logger: Logger,
|
|
1073
|
+
shutdown: ShutdownCoordinator,
|
|
1074
|
+
) -> tuple[list[dict], dict[str, str], dict[str, str], str]:
|
|
1075
|
+
"""Extract roster members and build speaker/member maps.
|
|
1076
|
+
|
|
1077
|
+
Returns a tuple of ``(roster_members_raw, speaker_map, member_map, organizer_id)``.
|
|
1078
|
+
"""
|
|
1079
|
+
# Extract roster
|
|
1080
|
+
roster_members, organizer_id = await extract_roster_members(
|
|
1081
|
+
browser,
|
|
1082
|
+
search_target_id,
|
|
1083
|
+
logger,
|
|
1084
|
+
shutdown,
|
|
1085
|
+
speaker_map=speaker_map or None,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
# Convert to plain dicts for domain functions
|
|
1089
|
+
roster_members_raw = [
|
|
1090
|
+
{
|
|
1091
|
+
"name": m.name,
|
|
1092
|
+
"email": m.email,
|
|
1093
|
+
"objectId": m.object_id,
|
|
1094
|
+
"role": m.role,
|
|
1095
|
+
}
|
|
1096
|
+
for m in roster_members
|
|
1097
|
+
]
|
|
1098
|
+
|
|
1099
|
+
# Build combined member map
|
|
1100
|
+
member_map = build_speaker_map_from_roster(roster_members_raw, speaker_map or None)
|
|
1101
|
+
|
|
1102
|
+
return roster_members_raw, speaker_map, member_map, organizer_id
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# ---------------------------------------------------------------------------
|
|
1106
|
+
# Internal: per-part download loop
|
|
1107
|
+
# ---------------------------------------------------------------------------
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
async def _download_parts(
|
|
1111
|
+
browser: CdpBrowserAdapter,
|
|
1112
|
+
agent: VertexAgentAdapter,
|
|
1113
|
+
search_target_id: str,
|
|
1114
|
+
config: MeetingConfig,
|
|
1115
|
+
logger: Logger,
|
|
1116
|
+
shutdown: ShutdownCoordinator,
|
|
1117
|
+
*,
|
|
1118
|
+
num_parts: int,
|
|
1119
|
+
speaker_map: dict[str, str],
|
|
1120
|
+
pre_nav_iframe_ids: set[str] | None = None,
|
|
1121
|
+
) -> list[str]:
|
|
1122
|
+
"""Download all transcript parts and return their VTT contents.
|
|
1123
|
+
|
|
1124
|
+
For multi-part meetings, clicks each Part tab and downloads
|
|
1125
|
+
separately.
|
|
1126
|
+
|
|
1127
|
+
Args:
|
|
1128
|
+
pre_nav_iframe_ids: Set of iframe target IDs that existed
|
|
1129
|
+
*before* the Step 4+5 navigation. For part 1, using this
|
|
1130
|
+
set lets us detect the xplatplugins target that appeared
|
|
1131
|
+
during Recap/Transcript tab load (which may be ephemeral).
|
|
1132
|
+
If not supplied, the current state is captured instead.
|
|
1133
|
+
"""
|
|
1134
|
+
port = config.port
|
|
1135
|
+
part_vtt_contents: list[str] = []
|
|
1136
|
+
# For part 1: use the pre-navigation set captured before step 5,
|
|
1137
|
+
# so the xplatplugins target that appeared during Recap/Transcript
|
|
1138
|
+
# load is detected as "new". Falls back to current state if not
|
|
1139
|
+
# provided (backward compatibility).
|
|
1140
|
+
if pre_nav_iframe_ids is None:
|
|
1141
|
+
pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
|
|
1142
|
+
|
|
1143
|
+
for part_num in range(1, num_parts + 1):
|
|
1144
|
+
shutdown.check_shutdown()
|
|
1145
|
+
part_label = f"Part {part_num}/{num_parts}"
|
|
1146
|
+
|
|
1147
|
+
if part_num > 1:
|
|
1148
|
+
logger.log("main", f"Navigating to {part_label}...")
|
|
1149
|
+
pre_part_iframe_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
|
|
1150
|
+
ok = await click_part_and_transcript(
|
|
1151
|
+
browser,
|
|
1152
|
+
search_target_id,
|
|
1153
|
+
part_num,
|
|
1154
|
+
logger,
|
|
1155
|
+
shutdown,
|
|
1156
|
+
)
|
|
1157
|
+
if not ok:
|
|
1158
|
+
logger.log("main", f"WARNING: Could not navigate to {part_label}. Skipping.")
|
|
1159
|
+
continue
|
|
1160
|
+
pre_nav_iframe_ids = pre_part_iframe_ids
|
|
1161
|
+
|
|
1162
|
+
logger.log("main", f"Downloading transcript for {part_label}...")
|
|
1163
|
+
|
|
1164
|
+
# Step 6: Find iframe target. For parts > 1, use allow_missing
|
|
1165
|
+
# so that we can retry by re-clicking the Transcript tab rather
|
|
1166
|
+
# than hard-exiting.
|
|
1167
|
+
is_subsequent_part = part_num > 1
|
|
1168
|
+
iframe_target_id = find_iframe_target(
|
|
1169
|
+
port,
|
|
1170
|
+
config=config,
|
|
1171
|
+
agent_client=agent.client,
|
|
1172
|
+
logger=logger,
|
|
1173
|
+
shutdown=shutdown,
|
|
1174
|
+
pre_nav_iframe_ids=pre_nav_iframe_ids,
|
|
1175
|
+
allow_missing=is_subsequent_part,
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
# Retry once for subsequent parts: re-click Transcript and poll
|
|
1179
|
+
# again. The initial click may have been a no-op if the tab was
|
|
1180
|
+
# already selected.
|
|
1181
|
+
if iframe_target_id is None and is_subsequent_part:
|
|
1182
|
+
logger.log("step6", "Retrying: re-clicking Transcript tab...")
|
|
1183
|
+
pre_retry_ids = {t["id"] for t in find_xplatplugins_targets_io(port)}
|
|
1184
|
+
ok = await retry_click_transcript(browser, search_target_id, logger, shutdown)
|
|
1185
|
+
if ok:
|
|
1186
|
+
iframe_target_id = find_iframe_target(
|
|
1187
|
+
port,
|
|
1188
|
+
config=config,
|
|
1189
|
+
agent_client=agent.client,
|
|
1190
|
+
logger=logger,
|
|
1191
|
+
shutdown=shutdown,
|
|
1192
|
+
pre_nav_iframe_ids=pre_retry_ids,
|
|
1193
|
+
allow_missing=True,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
if iframe_target_id is None:
|
|
1197
|
+
if num_parts == 1:
|
|
1198
|
+
exit_error(
|
|
1199
|
+
EXIT_DOWNLOAD_FAILED,
|
|
1200
|
+
"IFRAME_NOT_FOUND",
|
|
1201
|
+
"No xplatplugins iframe target found. "
|
|
1202
|
+
"Check that Step 5 completed and the Transcript tab loaded.",
|
|
1203
|
+
step="step6",
|
|
1204
|
+
format_json=config.format_json,
|
|
1205
|
+
)
|
|
1206
|
+
logger.log("main", f"WARNING: No iframe target for {part_label}. Skipping.")
|
|
1207
|
+
continue
|
|
1208
|
+
|
|
1209
|
+
# Step 7: Check permission.
|
|
1210
|
+
permission_state = await check_permission(
|
|
1211
|
+
browser,
|
|
1212
|
+
iframe_target_id,
|
|
1213
|
+
logger,
|
|
1214
|
+
shutdown,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
if permission_state == "no_button":
|
|
1218
|
+
if num_parts == 1:
|
|
1219
|
+
exit_error(
|
|
1220
|
+
EXIT_DOWNLOAD_FAILED,
|
|
1221
|
+
"DOWNLOAD_FAILED",
|
|
1222
|
+
"Download button not found. The transcript may not have loaded.",
|
|
1223
|
+
step="step7",
|
|
1224
|
+
format_json=config.format_json,
|
|
1225
|
+
)
|
|
1226
|
+
logger.log("main", f"WARNING: No download button for {part_label}. Skipping.")
|
|
1227
|
+
continue
|
|
1228
|
+
|
|
1229
|
+
# Step 7A or 7B based on permission
|
|
1230
|
+
vtt_content: str | None = None
|
|
1231
|
+
if permission_state == "has_permission":
|
|
1232
|
+
logger.log("main", f"{part_label}: Using Approach A (native blob interception)")
|
|
1233
|
+
vtt_content = await download_native_vtt(
|
|
1234
|
+
browser,
|
|
1235
|
+
iframe_target_id,
|
|
1236
|
+
logger,
|
|
1237
|
+
shutdown,
|
|
1238
|
+
)
|
|
1239
|
+
else:
|
|
1240
|
+
logger.log("main", f"{part_label}: Using Approach B (DOM extraction)")
|
|
1241
|
+
vtt_content = await extract_via_react_props(
|
|
1242
|
+
browser,
|
|
1243
|
+
iframe_target_id,
|
|
1244
|
+
logger,
|
|
1245
|
+
speaker_map=speaker_map or None,
|
|
1246
|
+
)
|
|
1247
|
+
if vtt_content is None:
|
|
1248
|
+
logger.log(
|
|
1249
|
+
"main",
|
|
1250
|
+
f"{part_label}: React props unavailable, falling back to scroll",
|
|
1251
|
+
)
|
|
1252
|
+
vtt_content = await extract_dom_vtt(
|
|
1253
|
+
browser,
|
|
1254
|
+
iframe_target_id,
|
|
1255
|
+
logger,
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
if not vtt_content:
|
|
1259
|
+
if num_parts == 1:
|
|
1260
|
+
exit_error(
|
|
1261
|
+
EXIT_DOWNLOAD_FAILED,
|
|
1262
|
+
"DOWNLOAD_FAILED",
|
|
1263
|
+
"Transcript download failed.",
|
|
1264
|
+
step="step7",
|
|
1265
|
+
format_json=config.format_json,
|
|
1266
|
+
)
|
|
1267
|
+
logger.log("main", f"WARNING: Download failed for {part_label}. Skipping.")
|
|
1268
|
+
continue
|
|
1269
|
+
|
|
1270
|
+
# Normalise line endings: Teams blob downloads use CRLF (\r\n).
|
|
1271
|
+
# Domain functions and the VTT spec expect LF (\n). The old
|
|
1272
|
+
# monolith normalised this implicitly via file I/O round-trips
|
|
1273
|
+
# (Python's universal newlines mode).
|
|
1274
|
+
vtt_content = vtt_content.replace("\r\n", "\n")
|
|
1275
|
+
|
|
1276
|
+
# Apply speaker mapping
|
|
1277
|
+
if speaker_map:
|
|
1278
|
+
vtt_content = apply_speaker_mapping(vtt_content, speaker_map)
|
|
1279
|
+
|
|
1280
|
+
part_vtt_contents.append(vtt_content)
|
|
1281
|
+
logger.log("main", f"{part_label}: downloaded successfully")
|
|
1282
|
+
|
|
1283
|
+
return part_vtt_contents
|