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,1603 @@
|
|
|
1
|
+
"""Transcript extraction services.
|
|
2
|
+
|
|
3
|
+
Functions that extract transcript data, roster members, and chat messages
|
|
4
|
+
from the Teams browser UI via the :class:`~teams_transcripts.ports.browser.BrowserPort`.
|
|
5
|
+
|
|
6
|
+
Each function takes a connected browser and manages its own connect/disconnect
|
|
7
|
+
lifecycle. Return values are strings or domain objects -- no file I/O is
|
|
8
|
+
performed here.
|
|
9
|
+
|
|
10
|
+
Extracted from ``transcript_download.py`` lines 2001--3240.
|
|
11
|
+
|
|
12
|
+
Schema version: 1.0.0
|
|
13
|
+
Created: 2026-04-25
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import typing
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
|
|
24
|
+
from teams_transcripts.domain.html import strip_html_to_text
|
|
25
|
+
from teams_transcripts.domain.models import ChatMessage, Reaction, RosterMember
|
|
26
|
+
from teams_transcripts.domain.speakers import resolve_chat_author
|
|
27
|
+
from teams_transcripts.domain.timestamps import (
|
|
28
|
+
parse_meeting_times,
|
|
29
|
+
sec_to_vtt_float,
|
|
30
|
+
sec_to_vtt_int,
|
|
31
|
+
ts_to_sec,
|
|
32
|
+
utc_to_local_iso,
|
|
33
|
+
)
|
|
34
|
+
from teams_transcripts.domain.vtt import generate_vtt_from_segments
|
|
35
|
+
|
|
36
|
+
if typing.TYPE_CHECKING:
|
|
37
|
+
from teams_transcripts.infrastructure.logging import Logger
|
|
38
|
+
from teams_transcripts.infrastructure.signals import ShutdownCoordinator
|
|
39
|
+
from teams_transcripts.ports.browser import BrowserPort
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Step 7: Permission check
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def check_permission(
|
|
48
|
+
browser: BrowserPort,
|
|
49
|
+
target_id: str,
|
|
50
|
+
logger: Logger,
|
|
51
|
+
shutdown: ShutdownCoordinator,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Check the Download button state in the transcript iframe.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
browser: Browser port (manages its own connection).
|
|
57
|
+
target_id: CDP target ID for the transcript iframe.
|
|
58
|
+
logger: Logger instance.
|
|
59
|
+
shutdown: Shutdown coordinator.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
``"has_permission"``, ``"no_permission"``, or ``"no_button"``.
|
|
63
|
+
"""
|
|
64
|
+
await browser.connect(target_id)
|
|
65
|
+
try:
|
|
66
|
+
max_attempts = 10
|
|
67
|
+
for attempt in range(max_attempts):
|
|
68
|
+
shutdown.check_shutdown()
|
|
69
|
+
result_json = await browser.evaluate("""
|
|
70
|
+
(function() {
|
|
71
|
+
var btn = document.querySelector('button[aria-haspopup="true"]');
|
|
72
|
+
if (!btn) return JSON.stringify({state: "no_button"});
|
|
73
|
+
var text = btn.textContent || "";
|
|
74
|
+
if (text.indexOf("permission") !== -1)
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
state: "no_permission",
|
|
77
|
+
message: text.trim().substring(0, 100)
|
|
78
|
+
});
|
|
79
|
+
return JSON.stringify({state: "has_permission"});
|
|
80
|
+
})()
|
|
81
|
+
""")
|
|
82
|
+
if result_json:
|
|
83
|
+
result = json.loads(str(result_json))
|
|
84
|
+
logger.log("step7", f"Permission state: {result['state']}")
|
|
85
|
+
if result.get("message"):
|
|
86
|
+
logger.log("step7", f"Message: {result['message']}")
|
|
87
|
+
if result["state"] == "no_button":
|
|
88
|
+
if attempt < max_attempts - 1:
|
|
89
|
+
logger.log(
|
|
90
|
+
"step7",
|
|
91
|
+
f"Attempt {attempt + 1}/{max_attempts} -- waiting for button...",
|
|
92
|
+
)
|
|
93
|
+
await shutdown.interruptible_sleep(2)
|
|
94
|
+
continue
|
|
95
|
+
return "no_button"
|
|
96
|
+
return result["state"]
|
|
97
|
+
logger.log("step7", f"Attempt {attempt + 1}/{max_attempts} -- waiting for button...")
|
|
98
|
+
await shutdown.interruptible_sleep(2)
|
|
99
|
+
finally:
|
|
100
|
+
await browser.disconnect()
|
|
101
|
+
|
|
102
|
+
return "no_button"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Step 7A: Native VTT download (blob interception)
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def download_native_vtt(
|
|
111
|
+
browser: BrowserPort,
|
|
112
|
+
target_id: str,
|
|
113
|
+
logger: Logger,
|
|
114
|
+
shutdown: ShutdownCoordinator,
|
|
115
|
+
) -> str | None:
|
|
116
|
+
"""Download the transcript via blob interception (Approach A).
|
|
117
|
+
|
|
118
|
+
Monkey-patches ``URL.createObjectURL`` to capture VTT blob content,
|
|
119
|
+
then triggers the download via the UI menu.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
browser: Browser port.
|
|
123
|
+
target_id: CDP target ID for the transcript iframe.
|
|
124
|
+
logger: Logger instance.
|
|
125
|
+
shutdown: Shutdown coordinator.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
VTT content as a string, or ``None`` if capture failed.
|
|
129
|
+
"""
|
|
130
|
+
await browser.connect(target_id)
|
|
131
|
+
try:
|
|
132
|
+
# 1. Install blob interceptor
|
|
133
|
+
patch_result = await browser.evaluate("""
|
|
134
|
+
(function() {
|
|
135
|
+
window.__capturedVTT = null;
|
|
136
|
+
if (!window.__teamsTranscriptOrigCreateObjectURL) {
|
|
137
|
+
window.__teamsTranscriptOrigCreateObjectURL = URL.createObjectURL;
|
|
138
|
+
}
|
|
139
|
+
URL.createObjectURL = function(blob) {
|
|
140
|
+
if (blob instanceof Blob) {
|
|
141
|
+
blob.text().then(function(text) {
|
|
142
|
+
if (text.indexOf('WEBVTT') !== -1) {
|
|
143
|
+
window.__capturedVTT = text;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return window.__teamsTranscriptOrigCreateObjectURL.call(URL, blob);
|
|
148
|
+
};
|
|
149
|
+
return 'interceptor installed';
|
|
150
|
+
})()
|
|
151
|
+
""")
|
|
152
|
+
logger.log("step7a", f"Blob interceptor: {patch_result}")
|
|
153
|
+
|
|
154
|
+
# 2. Open the Download dropdown menu
|
|
155
|
+
menu_result_json = await browser.evaluate("""
|
|
156
|
+
(function() {
|
|
157
|
+
var btn = document.querySelector('button[aria-haspopup="true"]');
|
|
158
|
+
if (!btn) return JSON.stringify({error: 'Download button not found'});
|
|
159
|
+
if (btn.getAttribute('aria-expanded') !== 'true') btn.click();
|
|
160
|
+
return JSON.stringify({ok: true});
|
|
161
|
+
})()
|
|
162
|
+
""")
|
|
163
|
+
menu_result = json.loads(str(menu_result_json))
|
|
164
|
+
if menu_result.get("error"):
|
|
165
|
+
logger.log("step7a", f"Error: {menu_result['error']}")
|
|
166
|
+
return None
|
|
167
|
+
logger.log("step7a", "Download menu opened")
|
|
168
|
+
|
|
169
|
+
# 3. Wait for menu to render
|
|
170
|
+
await shutdown.interruptible_sleep(1)
|
|
171
|
+
|
|
172
|
+
# 4. Click "Download as .vtt"
|
|
173
|
+
click_result_json = await browser.evaluate("""
|
|
174
|
+
(function() {
|
|
175
|
+
var vttBtn = document.querySelector(
|
|
176
|
+
'button[aria-label="Download as .vtt"]'
|
|
177
|
+
);
|
|
178
|
+
if (!vttBtn) {
|
|
179
|
+
var allBtns = Array.from(
|
|
180
|
+
document.querySelectorAll('button[role="menuitem"]')
|
|
181
|
+
);
|
|
182
|
+
vttBtn = allBtns.find(function(b) {
|
|
183
|
+
return b.textContent && b.textContent.indexOf('.vtt') !== -1;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (!vttBtn) return JSON.stringify({error: 'VTT menu item not found'});
|
|
187
|
+
vttBtn.click();
|
|
188
|
+
return JSON.stringify({ok: true});
|
|
189
|
+
})()
|
|
190
|
+
""")
|
|
191
|
+
click_result = json.loads(str(click_result_json))
|
|
192
|
+
if click_result.get("error"):
|
|
193
|
+
logger.log("step7a", f"Error: {click_result['error']}")
|
|
194
|
+
return None
|
|
195
|
+
logger.log("step7a", "Clicked 'Download as .vtt'")
|
|
196
|
+
|
|
197
|
+
# 5. Wait for the blob to be captured
|
|
198
|
+
logger.log("step7a", "Waiting for VTT blob capture...")
|
|
199
|
+
await shutdown.interruptible_sleep(3)
|
|
200
|
+
|
|
201
|
+
# 6. Check if the VTT was captured
|
|
202
|
+
status_json = await browser.evaluate("""
|
|
203
|
+
(function() {
|
|
204
|
+
if (window.__capturedVTT) {
|
|
205
|
+
return JSON.stringify({
|
|
206
|
+
captured: true,
|
|
207
|
+
length: window.__capturedVTT.length
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return JSON.stringify({captured: false});
|
|
211
|
+
})()
|
|
212
|
+
""")
|
|
213
|
+
status = json.loads(str(status_json))
|
|
214
|
+
|
|
215
|
+
if not status.get("captured"):
|
|
216
|
+
logger.log("step7a", "VTT blob was not captured.")
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
length = status["length"]
|
|
220
|
+
logger.log("step7a", f"Captured {length} chars of VTT content")
|
|
221
|
+
|
|
222
|
+
# 7. Retrieve content in chunks (large transcripts may exceed
|
|
223
|
+
# the single-expression return limit)
|
|
224
|
+
chunks = []
|
|
225
|
+
chunk_size = 50000
|
|
226
|
+
for offset in range(0, length, chunk_size):
|
|
227
|
+
chunk = await browser.evaluate(
|
|
228
|
+
f"window.__capturedVTT.substring({offset}, {offset + chunk_size})"
|
|
229
|
+
)
|
|
230
|
+
chunks.append(chunk)
|
|
231
|
+
|
|
232
|
+
return "".join(chunks)
|
|
233
|
+
finally:
|
|
234
|
+
await browser.evaluate("""
|
|
235
|
+
(function() {
|
|
236
|
+
if (window.__teamsTranscriptOrigCreateObjectURL) {
|
|
237
|
+
URL.createObjectURL = window.__teamsTranscriptOrigCreateObjectURL;
|
|
238
|
+
delete window.__teamsTranscriptOrigCreateObjectURL;
|
|
239
|
+
}
|
|
240
|
+
return 'interceptor restored';
|
|
241
|
+
})()
|
|
242
|
+
""")
|
|
243
|
+
await browser.disconnect()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Step 7B (React props): Approach A fallback
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def extract_via_react_props(
|
|
252
|
+
browser: BrowserPort,
|
|
253
|
+
target_id: str,
|
|
254
|
+
logger: Logger,
|
|
255
|
+
speaker_map: dict[str, str] | None = None,
|
|
256
|
+
) -> str | None:
|
|
257
|
+
"""Extract transcript data from the React fibre tree (Approach A variant).
|
|
258
|
+
|
|
259
|
+
The TranscriptList component holds all entries in its ``entries``
|
|
260
|
+
prop, bypassing the virtualised list renderer entirely.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
browser: Browser port.
|
|
264
|
+
target_id: CDP target ID for the transcript iframe.
|
|
265
|
+
logger: Logger instance.
|
|
266
|
+
speaker_map: Optional GUID-to-display-name mapping for speaker
|
|
267
|
+
name resolution.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
VTT content as a string, ``None`` if the React props are not
|
|
271
|
+
accessible (caller should fall back to DOM scrolling).
|
|
272
|
+
"""
|
|
273
|
+
await browser.connect(target_id)
|
|
274
|
+
try:
|
|
275
|
+
entries_json = await browser.evaluate(r"""
|
|
276
|
+
(function() {
|
|
277
|
+
var el = document.querySelector('[role="list"]');
|
|
278
|
+
if (!el) return JSON.stringify({error: 'no list element'});
|
|
279
|
+
var fiberKey = Object.keys(el).find(function(k) {
|
|
280
|
+
return k.startsWith('__reactFiber$');
|
|
281
|
+
});
|
|
282
|
+
if (!fiberKey) return JSON.stringify({error: 'no fiber key'});
|
|
283
|
+
|
|
284
|
+
var current = el[fiberKey];
|
|
285
|
+
var depth = 0;
|
|
286
|
+
var entries = null;
|
|
287
|
+
while (current && depth < 25) {
|
|
288
|
+
var props = current.memoizedProps || {};
|
|
289
|
+
if (props.entries && Array.isArray(props.entries)
|
|
290
|
+
&& props.entries.length > 0) {
|
|
291
|
+
entries = props.entries;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
current = current.return;
|
|
295
|
+
depth++;
|
|
296
|
+
}
|
|
297
|
+
if (!entries)
|
|
298
|
+
return JSON.stringify({error: 'entries not found'});
|
|
299
|
+
|
|
300
|
+
function parseDur(d) {
|
|
301
|
+
if (!d) return null;
|
|
302
|
+
var m = d.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/);
|
|
303
|
+
if (!m) return null;
|
|
304
|
+
return (parseInt(m[1]||0)*3600)
|
|
305
|
+
+ (parseInt(m[2]||0)*60)
|
|
306
|
+
+ parseFloat(m[3]||0);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
var result = [];
|
|
310
|
+
for (var i = 0; i < entries.length; i++) {
|
|
311
|
+
var e = entries[i];
|
|
312
|
+
if (e.type !== 'Text') continue;
|
|
313
|
+
var ts = parseDur(e.timestamp);
|
|
314
|
+
result.push({
|
|
315
|
+
speaker: e.speakerDisplayName || 'Unknown',
|
|
316
|
+
speakerId: e.speakerId || '',
|
|
317
|
+
ts: ts,
|
|
318
|
+
text: e.text || ''
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return JSON.stringify({ok: true, items: result});
|
|
322
|
+
})()
|
|
323
|
+
""")
|
|
324
|
+
finally:
|
|
325
|
+
await browser.disconnect()
|
|
326
|
+
|
|
327
|
+
data = json.loads(str(entries_json))
|
|
328
|
+
|
|
329
|
+
if data.get("error"):
|
|
330
|
+
logger.log("step7b", f"React props extraction failed: {data['error']}")
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
items = data["items"]
|
|
334
|
+
if not items:
|
|
335
|
+
logger.log("step7b", "React props returned 0 text entries.")
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
logger.log("step7b", f"React props: extracted {len(items)} text entries")
|
|
339
|
+
|
|
340
|
+
# Resolve speaker names using the Recap Speakers mapping.
|
|
341
|
+
if speaker_map:
|
|
342
|
+
resolved_pairs: dict[str, str] = {}
|
|
343
|
+
for item in items:
|
|
344
|
+
sid = item.get("speakerId", "")
|
|
345
|
+
guid = sid.split("@")[0] if "@" in sid else sid
|
|
346
|
+
if guid not in speaker_map:
|
|
347
|
+
continue
|
|
348
|
+
recap_name = speaker_map[guid]
|
|
349
|
+
transcript_name = item["speaker"]
|
|
350
|
+
if recap_name == transcript_name:
|
|
351
|
+
continue
|
|
352
|
+
item["speaker"] = recap_name
|
|
353
|
+
resolved_pairs[transcript_name] = recap_name
|
|
354
|
+
if resolved_pairs:
|
|
355
|
+
for old, new in resolved_pairs.items():
|
|
356
|
+
logger.log("step7b", f" Resolved speaker: {old!r} -> {new!r}")
|
|
357
|
+
total = sum(1 for it in items if it["speaker"] in resolved_pairs.values())
|
|
358
|
+
logger.log(
|
|
359
|
+
"step7b",
|
|
360
|
+
f"Resolved {len(resolved_pairs)} speaker name(s) ({total} entries affected)",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Filter out meta entries
|
|
364
|
+
segments = []
|
|
365
|
+
for item in items:
|
|
366
|
+
text = item["text"]
|
|
367
|
+
if "started transcription" in text or "stopped transcription" in text:
|
|
368
|
+
continue
|
|
369
|
+
segments.append(item)
|
|
370
|
+
|
|
371
|
+
# Generate VTT using float timestamps (Approach A)
|
|
372
|
+
vtt = ["WEBVTT", ""]
|
|
373
|
+
for i, seg in enumerate(segments):
|
|
374
|
+
ss = seg["ts"] if seg["ts"] is not None else 0
|
|
375
|
+
es = segments[i + 1]["ts"] if i + 1 < len(segments) else ss + 10
|
|
376
|
+
if es is None or es <= ss:
|
|
377
|
+
es = ss + 10
|
|
378
|
+
vtt.extend(
|
|
379
|
+
[
|
|
380
|
+
str(i + 1),
|
|
381
|
+
f"{sec_to_vtt_float(ss)} --> {sec_to_vtt_float(es)}",
|
|
382
|
+
f"<v {seg['speaker']}>{seg['text']}</v>",
|
|
383
|
+
"",
|
|
384
|
+
]
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
content = "\n".join(vtt)
|
|
388
|
+
|
|
389
|
+
logger.log("step7b", f"Generated {len(content)} bytes, {len(segments)} segments")
|
|
390
|
+
logger.log("step7b", "Speakers:")
|
|
391
|
+
for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
|
|
392
|
+
logger.log("step7b", f" {sp}: {cnt}")
|
|
393
|
+
|
|
394
|
+
return content
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# Step 7B (DOM scrolling): Approach B
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def extract_dom_vtt(
|
|
403
|
+
browser: BrowserPort,
|
|
404
|
+
target_id: str,
|
|
405
|
+
logger: Logger,
|
|
406
|
+
) -> str | None:
|
|
407
|
+
"""Extract transcript from the DOM via scrolling (Approach B).
|
|
408
|
+
|
|
409
|
+
Scrolls through the virtualised transcript list, collecting all header
|
|
410
|
+
and sub-entry elements. Applies forward-fill, backfill, and
|
|
411
|
+
discontinuity detection before generating VTT.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
browser: Browser port.
|
|
415
|
+
target_id: CDP target ID for the transcript iframe.
|
|
416
|
+
logger: Logger instance.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
VTT content as a string, or ``None`` on failure.
|
|
420
|
+
"""
|
|
421
|
+
headers: dict[int, dict] = {}
|
|
422
|
+
texts: dict[int, dict] = {}
|
|
423
|
+
header_to_texts: dict[int, set[int]] = {}
|
|
424
|
+
orphaned_texts: list[int] = []
|
|
425
|
+
|
|
426
|
+
await browser.connect(target_id)
|
|
427
|
+
try:
|
|
428
|
+
# Scroll to top
|
|
429
|
+
await browser.evaluate("""
|
|
430
|
+
(function() {
|
|
431
|
+
var el = document.querySelector('[role="list"]');
|
|
432
|
+
var p = el;
|
|
433
|
+
while (p && p.scrollHeight <= p.clientHeight) p = p.parentElement;
|
|
434
|
+
if (p) p.scrollTop = 0;
|
|
435
|
+
})()
|
|
436
|
+
""")
|
|
437
|
+
await _async_sleep(0.5)
|
|
438
|
+
|
|
439
|
+
# Scroll through virtualised list, extracting entries
|
|
440
|
+
consecutive_empty = 0
|
|
441
|
+
for scroll_pass in range(200):
|
|
442
|
+
items_json = await browser.evaluate("""
|
|
443
|
+
(function() {
|
|
444
|
+
var items = Array.from(
|
|
445
|
+
document.querySelectorAll('[role="listitem"]')
|
|
446
|
+
);
|
|
447
|
+
var result = [];
|
|
448
|
+
var currentHeaderNum = null;
|
|
449
|
+
for (var i = 0; i < items.length; i++) {
|
|
450
|
+
var id = items[i].id || '';
|
|
451
|
+
var num = parseInt(id.replace(/[^0-9]/g, ''));
|
|
452
|
+
if (id.startsWith('itemHeader')) {
|
|
453
|
+
currentHeaderNum = num;
|
|
454
|
+
result.push({
|
|
455
|
+
type: 'h', n: num,
|
|
456
|
+
s: items[i].querySelector(
|
|
457
|
+
'[class*="itemDisplayName"]'
|
|
458
|
+
)?.textContent?.trim(),
|
|
459
|
+
t: items[i].querySelector(
|
|
460
|
+
'[class*="baseTimestamp"] span[aria-hidden]'
|
|
461
|
+
)?.textContent?.trim()
|
|
462
|
+
});
|
|
463
|
+
} else if (id.startsWith('sub-entry')) {
|
|
464
|
+
result.push({
|
|
465
|
+
type: 'e', n: num, hn: currentHeaderNum,
|
|
466
|
+
x: items[i].textContent?.trim()
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (items.length > 0) {
|
|
471
|
+
items[items.length - 1].scrollIntoView(
|
|
472
|
+
{behavior: 'instant', block: 'center'}
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
return JSON.stringify(result);
|
|
476
|
+
})()
|
|
477
|
+
""")
|
|
478
|
+
items = json.loads(str(items_json))
|
|
479
|
+
|
|
480
|
+
new_count = 0
|
|
481
|
+
for item in items:
|
|
482
|
+
if item["type"] == "h" and item["n"] not in headers:
|
|
483
|
+
headers[item["n"]] = {
|
|
484
|
+
"speaker": item["s"],
|
|
485
|
+
"timestamp": item["t"],
|
|
486
|
+
}
|
|
487
|
+
new_count += 1
|
|
488
|
+
elif item["type"] == "e" and item["n"] not in texts:
|
|
489
|
+
texts[item["n"]] = {
|
|
490
|
+
"text": item["x"],
|
|
491
|
+
"headerNum": item.get("hn"),
|
|
492
|
+
}
|
|
493
|
+
new_count += 1
|
|
494
|
+
hn = item.get("hn")
|
|
495
|
+
if hn is None:
|
|
496
|
+
orphaned_texts.append(item["n"])
|
|
497
|
+
else:
|
|
498
|
+
if hn not in header_to_texts:
|
|
499
|
+
header_to_texts[hn] = set()
|
|
500
|
+
header_to_texts[hn].add(item["n"])
|
|
501
|
+
|
|
502
|
+
if scroll_pass % 10 == 0:
|
|
503
|
+
logger.log(
|
|
504
|
+
"step7b",
|
|
505
|
+
f"Pass {scroll_pass}: +{new_count} | headers={len(headers)} texts={len(texts)}",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if new_count == 0:
|
|
509
|
+
consecutive_empty += 1
|
|
510
|
+
else:
|
|
511
|
+
consecutive_empty = 0
|
|
512
|
+
|
|
513
|
+
if consecutive_empty >= 3 and scroll_pass >= 3:
|
|
514
|
+
logger.log("step7b", f"Extraction complete at pass {scroll_pass}")
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
await _async_sleep(0.25)
|
|
518
|
+
finally:
|
|
519
|
+
await browser.disconnect()
|
|
520
|
+
|
|
521
|
+
# Sort text lists per header
|
|
522
|
+
sorted_header_texts: dict[int, list[int]] = {hn: sorted(s) for hn, s in header_to_texts.items()}
|
|
523
|
+
|
|
524
|
+
if orphaned_texts:
|
|
525
|
+
logger.log(
|
|
526
|
+
"step7b",
|
|
527
|
+
f"WARNING: {len(orphaned_texts)} sub-entries had no header "
|
|
528
|
+
f"in view: {orphaned_texts[:5]}...",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Build ordered segments
|
|
532
|
+
segments: list[dict] = []
|
|
533
|
+
null_ts_count = 0
|
|
534
|
+
for hn in sorted(sorted_header_texts.keys()):
|
|
535
|
+
h = headers.get(hn, {})
|
|
536
|
+
ts_raw = h.get("timestamp")
|
|
537
|
+
ts_sec = ts_to_sec(ts_raw) if ts_raw else None
|
|
538
|
+
if ts_sec is None:
|
|
539
|
+
null_ts_count += 1
|
|
540
|
+
for tn in sorted_header_texts[hn]:
|
|
541
|
+
t = texts.get(tn, {})
|
|
542
|
+
text = t.get("text", "")
|
|
543
|
+
if "started transcription" in text or "stopped transcription" in text:
|
|
544
|
+
continue
|
|
545
|
+
segments.append(
|
|
546
|
+
{
|
|
547
|
+
"speaker": h.get("speaker", "Unknown"),
|
|
548
|
+
"ts_sec": ts_sec,
|
|
549
|
+
"text": text,
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if null_ts_count > 0:
|
|
554
|
+
logger.log(
|
|
555
|
+
"step7b",
|
|
556
|
+
f"WARNING: {null_ts_count} headers had null/empty timestamps.",
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# Forward-fill null timestamps
|
|
560
|
+
last_good = None
|
|
561
|
+
for seg in segments:
|
|
562
|
+
if seg["ts_sec"] is not None:
|
|
563
|
+
last_good = seg["ts_sec"]
|
|
564
|
+
elif last_good is not None:
|
|
565
|
+
seg["ts_sec"] = last_good + 10
|
|
566
|
+
last_good = seg["ts_sec"]
|
|
567
|
+
|
|
568
|
+
# Backfill remaining nulls
|
|
569
|
+
next_good = None
|
|
570
|
+
for seg in reversed(segments):
|
|
571
|
+
if seg["ts_sec"] is not None:
|
|
572
|
+
next_good = seg["ts_sec"]
|
|
573
|
+
elif next_good is not None:
|
|
574
|
+
seg["ts_sec"] = max(0, next_good - 10)
|
|
575
|
+
next_good = seg["ts_sec"]
|
|
576
|
+
|
|
577
|
+
# Final fallback
|
|
578
|
+
for seg in segments:
|
|
579
|
+
if seg["ts_sec"] is None:
|
|
580
|
+
seg["ts_sec"] = 0
|
|
581
|
+
|
|
582
|
+
# Detect timestamp discontinuities
|
|
583
|
+
if len(segments) > 2:
|
|
584
|
+
running_max = 0
|
|
585
|
+
cut_at = None
|
|
586
|
+
for i, seg in enumerate(segments):
|
|
587
|
+
ts = seg["ts_sec"]
|
|
588
|
+
if i > 0 and ts is not None and running_max > 0:
|
|
589
|
+
gap = ts - running_max
|
|
590
|
+
if gap > 600 and ts > running_max * 2:
|
|
591
|
+
logger.log(
|
|
592
|
+
"step7b",
|
|
593
|
+
f"WARNING: Timestamp discontinuity at segment {i}: "
|
|
594
|
+
f"{sec_to_vtt_int(running_max)} -> {sec_to_vtt_int(ts)} "
|
|
595
|
+
f"(gap {gap:.0f}s). Truncating "
|
|
596
|
+
f"{len(segments) - i} segment(s).",
|
|
597
|
+
)
|
|
598
|
+
cut_at = i
|
|
599
|
+
break
|
|
600
|
+
if ts is not None and ts > running_max:
|
|
601
|
+
running_max = ts
|
|
602
|
+
if cut_at is not None:
|
|
603
|
+
segments = segments[:cut_at]
|
|
604
|
+
|
|
605
|
+
# Generate VTT using integer timestamps (Approach B)
|
|
606
|
+
content = generate_vtt_from_segments(segments)
|
|
607
|
+
|
|
608
|
+
logger.log("step7b", f"Generated {len(content)} bytes, {len(segments)} segments")
|
|
609
|
+
logger.log("step7b", "Speakers:")
|
|
610
|
+
for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
|
|
611
|
+
logger.log("step7b", f" {sp}: {cnt}")
|
|
612
|
+
|
|
613
|
+
return content
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ---------------------------------------------------------------------------
|
|
617
|
+
# Multi-part support
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
async def detect_part_tabs(
|
|
622
|
+
browser: BrowserPort,
|
|
623
|
+
target_id: str,
|
|
624
|
+
) -> int:
|
|
625
|
+
"""Detect Part N tabs in the Recap view.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
browser: Browser port.
|
|
629
|
+
target_id: CDP target ID for the main Teams target.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
Number of parts (1 if no Part tabs are present).
|
|
633
|
+
"""
|
|
634
|
+
await browser.connect(target_id)
|
|
635
|
+
try:
|
|
636
|
+
count_json = await browser.evaluate(r"""
|
|
637
|
+
(function() {
|
|
638
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
639
|
+
var parts = tabs.filter(function(t) {
|
|
640
|
+
return /^Part\s*\d/i.test(t.textContent.trim());
|
|
641
|
+
});
|
|
642
|
+
return JSON.stringify({count: parts.length});
|
|
643
|
+
})()
|
|
644
|
+
""")
|
|
645
|
+
finally:
|
|
646
|
+
await browser.disconnect()
|
|
647
|
+
data = json.loads(str(count_json))
|
|
648
|
+
return max(1, data.get("count", 0))
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
async def click_part_and_transcript(
|
|
652
|
+
browser: BrowserPort,
|
|
653
|
+
target_id: str,
|
|
654
|
+
part_number: int,
|
|
655
|
+
logger: Logger,
|
|
656
|
+
shutdown: ShutdownCoordinator,
|
|
657
|
+
) -> bool:
|
|
658
|
+
"""Navigate to Part N's Transcript via a full Recap re-entry.
|
|
659
|
+
|
|
660
|
+
Teams only creates the xplatplugins iframe when the Transcript tab
|
|
661
|
+
is first activated within a Recap session. Switching parts via tab
|
|
662
|
+
clicks (Part 2 → Transcript) does NOT create a new iframe -- the
|
|
663
|
+
old one is destroyed but no replacement appears.
|
|
664
|
+
|
|
665
|
+
To force iframe creation for Part 2+, this function:
|
|
666
|
+
|
|
667
|
+
1. Exits Recap by clicking the "Chat" tab.
|
|
668
|
+
2. Re-enters Recap by clicking the "Recap" tab.
|
|
669
|
+
3. Waits for Recap content to load.
|
|
670
|
+
4. Clicks the Part N tab.
|
|
671
|
+
5. Clicks the Transcript sub-tab.
|
|
672
|
+
|
|
673
|
+
This triggers a fresh Recap session and reliable iframe creation.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
browser: Browser port.
|
|
677
|
+
target_id: CDP target ID for the main Teams target.
|
|
678
|
+
part_number: 1-based part number.
|
|
679
|
+
logger: Logger instance.
|
|
680
|
+
shutdown: Shutdown coordinator.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
``True`` if navigation succeeded.
|
|
684
|
+
"""
|
|
685
|
+
await browser.connect(target_id)
|
|
686
|
+
try:
|
|
687
|
+
# Step 1: Exit Recap by clicking the Chat tab.
|
|
688
|
+
chat_result = await browser.evaluate("""
|
|
689
|
+
(function() {
|
|
690
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
691
|
+
var chatTab = tabs.find(function(t) {
|
|
692
|
+
return t.textContent.trim() === 'Chat';
|
|
693
|
+
});
|
|
694
|
+
if (!chatTab) return 'no_chat_tab';
|
|
695
|
+
chatTab.click();
|
|
696
|
+
return 'clicked_chat';
|
|
697
|
+
})()
|
|
698
|
+
""")
|
|
699
|
+
logger.log("multi-part", f"Exit Recap (Chat tab): {chat_result}")
|
|
700
|
+
|
|
701
|
+
await shutdown.interruptible_sleep(2)
|
|
702
|
+
|
|
703
|
+
# Step 2: Re-enter Recap.
|
|
704
|
+
recap_result = await browser.evaluate("""
|
|
705
|
+
(function() {
|
|
706
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
707
|
+
var recapTab = tabs.find(function(t) {
|
|
708
|
+
return t.textContent.indexOf('Recap') !== -1;
|
|
709
|
+
});
|
|
710
|
+
if (!recapTab) return 'no_recap_tab';
|
|
711
|
+
recapTab.click();
|
|
712
|
+
return 'clicked_recap';
|
|
713
|
+
})()
|
|
714
|
+
""")
|
|
715
|
+
logger.log("multi-part", f"Re-enter Recap: {recap_result}")
|
|
716
|
+
|
|
717
|
+
if recap_result == "no_recap_tab":
|
|
718
|
+
return False
|
|
719
|
+
|
|
720
|
+
await shutdown.interruptible_sleep(3)
|
|
721
|
+
|
|
722
|
+
# Step 3: Click Part N tab.
|
|
723
|
+
part_result = await browser.evaluate(rf"""
|
|
724
|
+
(function() {{
|
|
725
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
726
|
+
var partTab = tabs.find(function(t) {{
|
|
727
|
+
return /^Part\s*{part_number}($|Part)/i.test(
|
|
728
|
+
t.textContent.trim()
|
|
729
|
+
);
|
|
730
|
+
}});
|
|
731
|
+
if (!partTab) return 'no_part_tab';
|
|
732
|
+
partTab.click();
|
|
733
|
+
return 'clicked_part';
|
|
734
|
+
}})()
|
|
735
|
+
""")
|
|
736
|
+
logger.log("multi-part", f"Part {part_number} tab: {part_result}")
|
|
737
|
+
|
|
738
|
+
if part_result == "no_part_tab":
|
|
739
|
+
return False
|
|
740
|
+
|
|
741
|
+
await shutdown.interruptible_sleep(2)
|
|
742
|
+
|
|
743
|
+
# Step 4: Click Transcript sub-tab.
|
|
744
|
+
transcript_result = await browser.evaluate("""
|
|
745
|
+
(function() {
|
|
746
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
747
|
+
var transcriptTab = tabs.find(function(t) {
|
|
748
|
+
return t.textContent.indexOf('Transcript') !== -1;
|
|
749
|
+
});
|
|
750
|
+
if (!transcriptTab) return 'no_transcript_tab';
|
|
751
|
+
transcriptTab.click();
|
|
752
|
+
return 'clicked_transcript';
|
|
753
|
+
})()
|
|
754
|
+
""")
|
|
755
|
+
logger.log(
|
|
756
|
+
"multi-part",
|
|
757
|
+
f"Transcript tab for Part {part_number}: {transcript_result}",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
if transcript_result == "no_transcript_tab":
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
await shutdown.interruptible_sleep(3)
|
|
764
|
+
finally:
|
|
765
|
+
await browser.disconnect()
|
|
766
|
+
|
|
767
|
+
return True
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
# ---------------------------------------------------------------------------
|
|
771
|
+
# Roster extraction
|
|
772
|
+
# ---------------------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
async def retry_click_transcript(
|
|
776
|
+
browser: BrowserPort,
|
|
777
|
+
target_id: str,
|
|
778
|
+
logger: Logger,
|
|
779
|
+
shutdown: ShutdownCoordinator,
|
|
780
|
+
) -> bool:
|
|
781
|
+
"""Click Speakers then Transcript to force-reload the iframe.
|
|
782
|
+
|
|
783
|
+
Used as a retry mechanism when :func:`find_iframe_target` fails to
|
|
784
|
+
find an xplatplugins iframe for a subsequent part. Cycling through
|
|
785
|
+
the Speakers tab ensures the Transcript click is a genuine state
|
|
786
|
+
transition even if Transcript was already selected.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
browser: Browser port.
|
|
790
|
+
target_id: CDP target ID for the main Teams target.
|
|
791
|
+
logger: Logger instance.
|
|
792
|
+
shutdown: Shutdown coordinator.
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
``True`` if the Transcript tab was found and clicked.
|
|
796
|
+
"""
|
|
797
|
+
await browser.connect(target_id)
|
|
798
|
+
try:
|
|
799
|
+
await browser.evaluate("""
|
|
800
|
+
(function() {
|
|
801
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
802
|
+
var speakersTab = tabs.find(function(t) {
|
|
803
|
+
return /speaker/i.test(t.textContent);
|
|
804
|
+
});
|
|
805
|
+
if (speakersTab) speakersTab.click();
|
|
806
|
+
})()
|
|
807
|
+
""")
|
|
808
|
+
await shutdown.interruptible_sleep(2)
|
|
809
|
+
|
|
810
|
+
result = await browser.evaluate("""
|
|
811
|
+
(function() {
|
|
812
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
813
|
+
var transcriptTab = tabs.find(function(t) {
|
|
814
|
+
return t.textContent.indexOf('Transcript') !== -1;
|
|
815
|
+
});
|
|
816
|
+
if (!transcriptTab) return 'no_transcript_tab';
|
|
817
|
+
transcriptTab.click();
|
|
818
|
+
return 'clicked_transcript';
|
|
819
|
+
})()
|
|
820
|
+
""")
|
|
821
|
+
logger.log("multi-part", f"Transcript tab retry: {result}")
|
|
822
|
+
if result == "no_transcript_tab":
|
|
823
|
+
return False
|
|
824
|
+
|
|
825
|
+
await shutdown.interruptible_sleep(3)
|
|
826
|
+
finally:
|
|
827
|
+
await browser.disconnect()
|
|
828
|
+
|
|
829
|
+
return True
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
async def extract_roster_members(
|
|
833
|
+
browser: BrowserPort,
|
|
834
|
+
target_id: str,
|
|
835
|
+
logger: Logger,
|
|
836
|
+
shutdown: ShutdownCoordinator,
|
|
837
|
+
speaker_map: dict[str, str] | None = None,
|
|
838
|
+
) -> tuple[list[RosterMember], str]:
|
|
839
|
+
"""Extract the full meeting chat member list via the participant popover.
|
|
840
|
+
|
|
841
|
+
Primary approach:
|
|
842
|
+
1. Click ``[data-tid="chat-header-participant-count"]`` to open
|
|
843
|
+
the participant popover.
|
|
844
|
+
2. Read ``props.members`` from the ``chat-header-participant-list``
|
|
845
|
+
element's React fibre tree (level 7). This contains ALL
|
|
846
|
+
members with ``displayName``, ``email``, ``objectId``, etc.
|
|
847
|
+
3. Close the popover with Escape.
|
|
848
|
+
|
|
849
|
+
Fallback (when the popover button is missing or the fibre read fails):
|
|
850
|
+
Read ``chat.membersLimited`` + ``chat.me`` from the
|
|
851
|
+
``chat-topic-menu`` fibre tree. This returns only ~3-4 members
|
|
852
|
+
for large meetings.
|
|
853
|
+
|
|
854
|
+
In both cases, the ``organizerId`` is read from
|
|
855
|
+
``chat.meetingInformation`` on the ``chat-topic-menu`` fibre tree.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
browser: Browser port.
|
|
859
|
+
target_id: CDP target ID for the main Teams target.
|
|
860
|
+
logger: Logger instance.
|
|
861
|
+
shutdown: Shutdown coordinator.
|
|
862
|
+
speaker_map: Optional GUID-to-name mapping for name resolution.
|
|
863
|
+
|
|
864
|
+
Returns:
|
|
865
|
+
Tuple of ``(members, organizer_id)``:
|
|
866
|
+
- ``members``: list of :class:`RosterMember` instances.
|
|
867
|
+
- ``organizer_id``: bare GUID of the meeting organiser,
|
|
868
|
+
or empty string when unavailable.
|
|
869
|
+
"""
|
|
870
|
+
await browser.connect(target_id)
|
|
871
|
+
try:
|
|
872
|
+
# --- Step 1: Read organizerId from chat-topic-menu fibre tree ---
|
|
873
|
+
organizer_id = await _read_organizer_id(browser, logger)
|
|
874
|
+
|
|
875
|
+
# --- Step 2: Try participant popover approach ---
|
|
876
|
+
raw_members = await _try_popover_extraction(browser, logger, shutdown)
|
|
877
|
+
|
|
878
|
+
# --- Step 3: Fallback to membersLimited if popover failed ---
|
|
879
|
+
if raw_members is None:
|
|
880
|
+
logger.log("roster", "Popover unavailable; falling back to membersLimited")
|
|
881
|
+
raw_members = await _read_members_limited(browser, logger)
|
|
882
|
+
finally:
|
|
883
|
+
await browser.disconnect()
|
|
884
|
+
|
|
885
|
+
if not raw_members:
|
|
886
|
+
logger.log("roster", "No members found in roster.")
|
|
887
|
+
return [], organizer_id
|
|
888
|
+
|
|
889
|
+
# Build RosterMember list with name cleaning
|
|
890
|
+
members = _build_roster_members(raw_members, organizer_id, speaker_map)
|
|
891
|
+
logger.log("roster", f"Extracted {len(members)} member(s)")
|
|
892
|
+
return members, organizer_id
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
async def _read_organizer_id(browser: BrowserPort, logger: Logger) -> str:
|
|
896
|
+
"""Read ``meetingInformation.organizerId`` from the chat-topic-menu fibre."""
|
|
897
|
+
raw = await browser.evaluate("""
|
|
898
|
+
(function() {
|
|
899
|
+
var menu = document.querySelector('[data-tid="chat-topic-menu"]');
|
|
900
|
+
if (!menu) return JSON.stringify({error: 'no_menu'});
|
|
901
|
+
|
|
902
|
+
var fiberKey = Object.keys(menu).find(function(k) {
|
|
903
|
+
return k.startsWith('__reactFiber$');
|
|
904
|
+
});
|
|
905
|
+
if (!fiberKey) return JSON.stringify({error: 'no_fiber'});
|
|
906
|
+
|
|
907
|
+
var current = menu[fiberKey];
|
|
908
|
+
for (var i = 0; i < 25 && current; i++) {
|
|
909
|
+
var props = current.memoizedProps;
|
|
910
|
+
if (props && props.chat && props.chat.meetingInformation) {
|
|
911
|
+
var mi = props.chat.meetingInformation;
|
|
912
|
+
return JSON.stringify({
|
|
913
|
+
organizerId: mi.organizerId || ''
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
current = current.return;
|
|
917
|
+
}
|
|
918
|
+
return JSON.stringify({error: 'no_meeting_info'});
|
|
919
|
+
})()
|
|
920
|
+
""")
|
|
921
|
+
if not raw:
|
|
922
|
+
return ""
|
|
923
|
+
data = json.loads(str(raw))
|
|
924
|
+
if data.get("error"):
|
|
925
|
+
logger.log("roster", f"Could not read organizer: {data['error']}")
|
|
926
|
+
return ""
|
|
927
|
+
return data.get("organizerId", "")
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
async def _try_popover_extraction(
|
|
931
|
+
browser: BrowserPort,
|
|
932
|
+
logger: Logger,
|
|
933
|
+
shutdown: ShutdownCoordinator,
|
|
934
|
+
) -> list[dict] | None:
|
|
935
|
+
"""Try to extract all members via the participant popover.
|
|
936
|
+
|
|
937
|
+
Returns a list of member dicts on success, or ``None`` if the popover
|
|
938
|
+
could not be opened or the fibre tree could not be read.
|
|
939
|
+
"""
|
|
940
|
+
# Click the participant count button to open the popover
|
|
941
|
+
click_result = await browser.evaluate("""
|
|
942
|
+
(function() {
|
|
943
|
+
var btn = document.querySelector(
|
|
944
|
+
'[data-tid="chat-header-participant-count"]'
|
|
945
|
+
);
|
|
946
|
+
if (!btn) return JSON.stringify({error: 'no_button'});
|
|
947
|
+
btn.click();
|
|
948
|
+
return JSON.stringify({clicked: true});
|
|
949
|
+
})()
|
|
950
|
+
""")
|
|
951
|
+
if not click_result:
|
|
952
|
+
return None
|
|
953
|
+
click_data = json.loads(str(click_result))
|
|
954
|
+
if click_data.get("error"):
|
|
955
|
+
logger.log("roster", f"Popover button: {click_data['error']}")
|
|
956
|
+
return None
|
|
957
|
+
|
|
958
|
+
logger.log("roster", "Opened participant popover")
|
|
959
|
+
await shutdown.interruptible_sleep(1)
|
|
960
|
+
|
|
961
|
+
# Read props.members from chat-header-participant-list fibre tree
|
|
962
|
+
raw = await browser.evaluate(r"""
|
|
963
|
+
(function() {
|
|
964
|
+
var el = document.querySelector(
|
|
965
|
+
'[data-tid="chat-header-participant-list"]'
|
|
966
|
+
);
|
|
967
|
+
if (!el) return JSON.stringify({error: 'no_list_element'});
|
|
968
|
+
|
|
969
|
+
var fiberKey = Object.keys(el).find(function(k) {
|
|
970
|
+
return k.startsWith('__reactFiber$');
|
|
971
|
+
});
|
|
972
|
+
if (!fiberKey) return JSON.stringify({error: 'no_fiber'});
|
|
973
|
+
|
|
974
|
+
var current = el[fiberKey];
|
|
975
|
+
for (var depth = 0; depth < 15 && current; depth++) {
|
|
976
|
+
var props = current.memoizedProps || {};
|
|
977
|
+
if (props.members && Array.isArray(props.members)
|
|
978
|
+
&& props.members.length > 0
|
|
979
|
+
&& props.members[0].objectId) {
|
|
980
|
+
var result = [];
|
|
981
|
+
for (var i = 0; i < props.members.length; i++) {
|
|
982
|
+
var m = props.members[i];
|
|
983
|
+
result.push({
|
|
984
|
+
displayName: m.displayName || '',
|
|
985
|
+
mri: m.mri || '',
|
|
986
|
+
objectId: m.objectId || '',
|
|
987
|
+
email: m.email || ''
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
return JSON.stringify({
|
|
991
|
+
members: result,
|
|
992
|
+
count: result.length,
|
|
993
|
+
level: depth
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
current = current.return;
|
|
997
|
+
}
|
|
998
|
+
return JSON.stringify({error: 'no_members_in_fiber'});
|
|
999
|
+
})()
|
|
1000
|
+
""")
|
|
1001
|
+
|
|
1002
|
+
# Close the popover (Escape key)
|
|
1003
|
+
await browser.send_command(
|
|
1004
|
+
"Input.dispatchKeyEvent",
|
|
1005
|
+
{"type": "keyDown", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27},
|
|
1006
|
+
)
|
|
1007
|
+
await browser.send_command(
|
|
1008
|
+
"Input.dispatchKeyEvent",
|
|
1009
|
+
{"type": "keyUp", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27},
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
if not raw:
|
|
1013
|
+
logger.log("roster", "Popover fibre read returned nothing")
|
|
1014
|
+
return None
|
|
1015
|
+
|
|
1016
|
+
data = json.loads(str(raw))
|
|
1017
|
+
if data.get("error"):
|
|
1018
|
+
logger.log("roster", f"Popover fibre: {data['error']}")
|
|
1019
|
+
return None
|
|
1020
|
+
|
|
1021
|
+
members = data.get("members", [])
|
|
1022
|
+
logger.log(
|
|
1023
|
+
"roster",
|
|
1024
|
+
f"Popover extraction: {len(members)} members (fibre level {data.get('level', '?')})",
|
|
1025
|
+
)
|
|
1026
|
+
return members
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
async def _read_members_limited(
|
|
1030
|
+
browser: BrowserPort,
|
|
1031
|
+
logger: Logger,
|
|
1032
|
+
) -> list[dict]:
|
|
1033
|
+
"""Fallback: read membersLimited + me from chat-topic-menu fibre tree."""
|
|
1034
|
+
raw = await browser.evaluate("""
|
|
1035
|
+
(function() {
|
|
1036
|
+
var menu = document.querySelector('[data-tid="chat-topic-menu"]');
|
|
1037
|
+
if (!menu) return JSON.stringify({error: 'no_menu'});
|
|
1038
|
+
|
|
1039
|
+
var fiberKey = Object.keys(menu).find(function(k) {
|
|
1040
|
+
return k.startsWith('__reactFiber$');
|
|
1041
|
+
});
|
|
1042
|
+
if (!fiberKey) return JSON.stringify({error: 'no_fiber'});
|
|
1043
|
+
|
|
1044
|
+
var current = menu[fiberKey];
|
|
1045
|
+
for (var i = 0; i < 25 && current; i++) {
|
|
1046
|
+
var props = current.memoizedProps;
|
|
1047
|
+
if (props && props.chat && props.chat.membersLimited) {
|
|
1048
|
+
var chat = props.chat;
|
|
1049
|
+
var members = [];
|
|
1050
|
+
|
|
1051
|
+
var limited = chat.membersLimited || [];
|
|
1052
|
+
for (var j = 0; j < limited.length; j++) {
|
|
1053
|
+
var m = limited[j];
|
|
1054
|
+
members.push({
|
|
1055
|
+
displayName: m.displayName || '',
|
|
1056
|
+
mri: m.mri || '',
|
|
1057
|
+
objectId: m.objectId || '',
|
|
1058
|
+
email: m.email || ''
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (chat.me) {
|
|
1063
|
+
members.push({
|
|
1064
|
+
displayName: chat.me.displayName || '',
|
|
1065
|
+
mri: chat.me.mri || '',
|
|
1066
|
+
objectId: chat.me.objectId || '',
|
|
1067
|
+
email: chat.me.email || ''
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return JSON.stringify({members: members});
|
|
1072
|
+
}
|
|
1073
|
+
current = current.return;
|
|
1074
|
+
}
|
|
1075
|
+
return JSON.stringify({error: 'no_chat_props'});
|
|
1076
|
+
})()
|
|
1077
|
+
""")
|
|
1078
|
+
if not raw:
|
|
1079
|
+
return []
|
|
1080
|
+
data = json.loads(str(raw))
|
|
1081
|
+
if data.get("error"):
|
|
1082
|
+
logger.log("roster", f"membersLimited fallback: {data['error']}")
|
|
1083
|
+
return []
|
|
1084
|
+
return data.get("members", [])
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _build_roster_members(
|
|
1088
|
+
raw_members: list[dict],
|
|
1089
|
+
organizer_id: str,
|
|
1090
|
+
speaker_map: dict[str, str] | None,
|
|
1091
|
+
) -> list[RosterMember]:
|
|
1092
|
+
"""Build :class:`RosterMember` list from raw member dicts.
|
|
1093
|
+
|
|
1094
|
+
Applies name cleaning (``v-`` prefix, ``(...)`` suffix removal)
|
|
1095
|
+
and speaker map resolution.
|
|
1096
|
+
"""
|
|
1097
|
+
members: list[RosterMember] = []
|
|
1098
|
+
for m in raw_members:
|
|
1099
|
+
name = m.get("displayName") or "Unknown"
|
|
1100
|
+
guid = _normalise_object_id(m.get("objectId", ""))
|
|
1101
|
+
organiser_guid = _normalise_object_id(organizer_id)
|
|
1102
|
+
|
|
1103
|
+
if speaker_map and guid and guid in speaker_map:
|
|
1104
|
+
name = speaker_map[guid]
|
|
1105
|
+
else:
|
|
1106
|
+
cleaned = re.sub(r"^v-", "", name)
|
|
1107
|
+
cleaned = re.sub(r"\s*\([^)]+\)\s*$", "", cleaned)
|
|
1108
|
+
if cleaned:
|
|
1109
|
+
name = cleaned
|
|
1110
|
+
|
|
1111
|
+
is_organiser = bool(organiser_guid and guid == organiser_guid)
|
|
1112
|
+
role = "organiser" if is_organiser else "member"
|
|
1113
|
+
members.append(
|
|
1114
|
+
RosterMember(
|
|
1115
|
+
name=name,
|
|
1116
|
+
email=m.get("email", ""),
|
|
1117
|
+
object_id=guid,
|
|
1118
|
+
role=role,
|
|
1119
|
+
)
|
|
1120
|
+
)
|
|
1121
|
+
return members
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _normalise_object_id(value: str) -> str:
|
|
1125
|
+
"""Return a bare object GUID from a Teams MRI-like identifier."""
|
|
1126
|
+
if not value:
|
|
1127
|
+
return ""
|
|
1128
|
+
return value.rsplit(":", 1)[-1]
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
# ---------------------------------------------------------------------------
|
|
1132
|
+
# Chat message extraction
|
|
1133
|
+
# ---------------------------------------------------------------------------
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
async def extract_chat_messages(
|
|
1137
|
+
browser: BrowserPort,
|
|
1138
|
+
target_id: str,
|
|
1139
|
+
target_date: str,
|
|
1140
|
+
logger: Logger,
|
|
1141
|
+
shutdown: ShutdownCoordinator,
|
|
1142
|
+
*,
|
|
1143
|
+
meeting_time_text: str = "",
|
|
1144
|
+
member_map: dict[str, str] | None = None,
|
|
1145
|
+
speaker_map: dict[str, str] | None = None,
|
|
1146
|
+
) -> tuple[list[ChatMessage], list[str]]:
|
|
1147
|
+
"""Extract meeting chat messages and Event/Call content from the Chat tab.
|
|
1148
|
+
|
|
1149
|
+
Navigates to the Chat tab, walks the React fibre tree, and extracts
|
|
1150
|
+
user messages within the meeting time window. Also collects the raw
|
|
1151
|
+
``content`` strings from Event/Call messages (which contain
|
|
1152
|
+
``<partlist>`` attendance data for use by the caller).
|
|
1153
|
+
|
|
1154
|
+
Args:
|
|
1155
|
+
browser: Browser port.
|
|
1156
|
+
target_id: CDP target ID for the main Teams target.
|
|
1157
|
+
target_date: Target date as ``YYYY-MM-DD``.
|
|
1158
|
+
logger: Logger instance.
|
|
1159
|
+
shutdown: Shutdown coordinator.
|
|
1160
|
+
meeting_time_text: Recap header meeting time text (optional).
|
|
1161
|
+
member_map: Combined GUID-to-name mapping (roster + speakers).
|
|
1162
|
+
speaker_map: Recap GUID-to-name mapping (fallback).
|
|
1163
|
+
|
|
1164
|
+
Returns:
|
|
1165
|
+
Tuple of ``(chat_messages, event_call_contents)``:
|
|
1166
|
+
- ``chat_messages``: sorted list of :class:`ChatMessage`.
|
|
1167
|
+
- ``event_call_contents``: raw content strings from Event/Call
|
|
1168
|
+
messages matching the target date (for partlist parsing).
|
|
1169
|
+
"""
|
|
1170
|
+
await browser.connect(target_id)
|
|
1171
|
+
try:
|
|
1172
|
+
# Click the Chat tab
|
|
1173
|
+
chat_tab_result = await browser.evaluate(r"""
|
|
1174
|
+
(function() {
|
|
1175
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
1176
|
+
var chatTab = tabs.find(function(t) {
|
|
1177
|
+
return t.getAttribute('data-tid') ===
|
|
1178
|
+
'tab-item-com.microsoft.chattabs.chat';
|
|
1179
|
+
});
|
|
1180
|
+
if (!chatTab) return 'no_tab';
|
|
1181
|
+
chatTab.click();
|
|
1182
|
+
return 'clicked';
|
|
1183
|
+
})()
|
|
1184
|
+
""")
|
|
1185
|
+
|
|
1186
|
+
if chat_tab_result != "clicked":
|
|
1187
|
+
logger.log("chat", "Chat tab not found -- skipping chat extraction.")
|
|
1188
|
+
return [], []
|
|
1189
|
+
|
|
1190
|
+
await shutdown.interruptible_sleep(2)
|
|
1191
|
+
|
|
1192
|
+
# Extract all messages from the React fibre tree
|
|
1193
|
+
raw = await browser.evaluate(r"""
|
|
1194
|
+
(function() {
|
|
1195
|
+
var els = document.querySelectorAll('[data-tid="chat-pane-item"]');
|
|
1196
|
+
if (!els.length) return JSON.stringify({error: 'no_items'});
|
|
1197
|
+
|
|
1198
|
+
var el = els[0];
|
|
1199
|
+
var fk = Object.keys(el).find(function(k) {
|
|
1200
|
+
return k.startsWith('__reactFiber$');
|
|
1201
|
+
});
|
|
1202
|
+
if (!fk) return JSON.stringify({error: 'no_fiber'});
|
|
1203
|
+
|
|
1204
|
+
var fiber = el[fk];
|
|
1205
|
+
var c = fiber;
|
|
1206
|
+
var itemsArr = null;
|
|
1207
|
+
var loadPrev = null;
|
|
1208
|
+
var loadNext = null;
|
|
1209
|
+
for (var d = 0; d < 30 && c; d++) {
|
|
1210
|
+
var p = c.memoizedProps || {};
|
|
1211
|
+
if (p.items && Array.isArray(p.items) && p.items.length > 0
|
|
1212
|
+
&& p.items[0] && p.items[0].originalArrivalTime) {
|
|
1213
|
+
itemsArr = p.items;
|
|
1214
|
+
loadPrev = p.loadPrev;
|
|
1215
|
+
loadNext = p.loadNext;
|
|
1216
|
+
break;
|
|
1217
|
+
}
|
|
1218
|
+
c = c.return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (!itemsArr) return JSON.stringify({error: 'no_items_array'});
|
|
1222
|
+
|
|
1223
|
+
var messages = [];
|
|
1224
|
+
for (var i = 0; i < itemsArr.length; i++) {
|
|
1225
|
+
var m = itemsArr[i];
|
|
1226
|
+
messages.push({
|
|
1227
|
+
id: m.id,
|
|
1228
|
+
time: m.originalArrivalTime,
|
|
1229
|
+
itemType: m.itemType,
|
|
1230
|
+
messageType: m.messageType,
|
|
1231
|
+
fromUserId: m.fromUserId || '',
|
|
1232
|
+
displayName: m.imDisplayName || '',
|
|
1233
|
+
content: m.content || '',
|
|
1234
|
+
emotions: m.emotions,
|
|
1235
|
+
emotionsSummary: m.emotionsSummary,
|
|
1236
|
+
files: m.files ? m.files.length : 0,
|
|
1237
|
+
mentions: m.mentions
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return JSON.stringify({
|
|
1242
|
+
total: itemsArr.length,
|
|
1243
|
+
hasPagination: !!(loadPrev || loadNext),
|
|
1244
|
+
messages: messages
|
|
1245
|
+
});
|
|
1246
|
+
})()
|
|
1247
|
+
""")
|
|
1248
|
+
|
|
1249
|
+
if not raw:
|
|
1250
|
+
logger.log("chat", "Failed to extract chat data from React fibre tree.")
|
|
1251
|
+
return [], []
|
|
1252
|
+
|
|
1253
|
+
data = json.loads(str(raw))
|
|
1254
|
+
|
|
1255
|
+
if "error" in data:
|
|
1256
|
+
logger.log("chat", f"Chat extraction error: {data['error']}")
|
|
1257
|
+
return [], []
|
|
1258
|
+
|
|
1259
|
+
if data.get("hasPagination"):
|
|
1260
|
+
logger.log(
|
|
1261
|
+
"chat",
|
|
1262
|
+
f"WARNING: Chat has pagination ({data['total']} items loaded). "
|
|
1263
|
+
"Some messages may be missing.",
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
logger.log("chat", f"Total items in chat: {data['total']}")
|
|
1267
|
+
|
|
1268
|
+
finally:
|
|
1269
|
+
await browser.disconnect()
|
|
1270
|
+
|
|
1271
|
+
# Determine meeting time window from Event/Call control messages
|
|
1272
|
+
candidate_dates = [target_date]
|
|
1273
|
+
if meeting_time_text:
|
|
1274
|
+
recap_start, _ = parse_meeting_times(meeting_time_text, target_date)
|
|
1275
|
+
recap_date = recap_start[:10]
|
|
1276
|
+
if recap_date != target_date:
|
|
1277
|
+
candidate_dates.insert(0, recap_date)
|
|
1278
|
+
|
|
1279
|
+
event_calls = []
|
|
1280
|
+
matched_date = None
|
|
1281
|
+
for cdate in candidate_dates:
|
|
1282
|
+
event_calls = [
|
|
1283
|
+
m
|
|
1284
|
+
for m in data["messages"]
|
|
1285
|
+
if m.get("messageType") == "Event/Call" and m.get("time", "").startswith(cdate)
|
|
1286
|
+
]
|
|
1287
|
+
if event_calls:
|
|
1288
|
+
matched_date = cdate
|
|
1289
|
+
break
|
|
1290
|
+
|
|
1291
|
+
if event_calls:
|
|
1292
|
+
event_calls.sort(key=lambda m: m["time"])
|
|
1293
|
+
start_iso = event_calls[0]["time"]
|
|
1294
|
+
end_iso = event_calls[-1]["time"]
|
|
1295
|
+
if start_iso == end_iso:
|
|
1296
|
+
dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
|
|
1297
|
+
start_iso = (dt - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
1298
|
+
end_iso = (dt + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
1299
|
+
logger.log(
|
|
1300
|
+
"chat",
|
|
1301
|
+
f"Time window (from Event/Call on {matched_date}): {start_iso} to {end_iso}",
|
|
1302
|
+
)
|
|
1303
|
+
else:
|
|
1304
|
+
fallback_date = candidate_dates[0]
|
|
1305
|
+
logger.log(
|
|
1306
|
+
"chat",
|
|
1307
|
+
f"No Event/Call markers found; falling back to {fallback_date}",
|
|
1308
|
+
)
|
|
1309
|
+
start_iso = f"{fallback_date}T00:00:00Z"
|
|
1310
|
+
end_iso = f"{fallback_date}T23:59:59Z"
|
|
1311
|
+
logger.log(
|
|
1312
|
+
"chat",
|
|
1313
|
+
f"Time window (full day fallback): {start_iso} to {end_iso}",
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
# Prefer member_map for resolving authors; fall back to speaker_map
|
|
1317
|
+
name_map = member_map or speaker_map
|
|
1318
|
+
|
|
1319
|
+
results: list[ChatMessage] = []
|
|
1320
|
+
for msg in data["messages"]:
|
|
1321
|
+
if msg["itemType"] != 3:
|
|
1322
|
+
continue
|
|
1323
|
+
if msg["messageType"] != "RichText/Html":
|
|
1324
|
+
continue
|
|
1325
|
+
if msg["files"] and msg["files"] > 0:
|
|
1326
|
+
text = strip_html_to_text(msg["content"])
|
|
1327
|
+
if not text:
|
|
1328
|
+
continue
|
|
1329
|
+
|
|
1330
|
+
msg_time = msg["time"]
|
|
1331
|
+
if not msg_time:
|
|
1332
|
+
continue
|
|
1333
|
+
if msg_time < start_iso or msg_time > end_iso:
|
|
1334
|
+
continue
|
|
1335
|
+
|
|
1336
|
+
text = strip_html_to_text(msg["content"])
|
|
1337
|
+
if not text:
|
|
1338
|
+
continue
|
|
1339
|
+
|
|
1340
|
+
author = resolve_chat_author(msg["fromUserId"], msg["displayName"], name_map)
|
|
1341
|
+
|
|
1342
|
+
# Parse reactions
|
|
1343
|
+
reactions: list[Reaction] | None = None
|
|
1344
|
+
emotions_list = msg.get("emotions") or []
|
|
1345
|
+
if emotions_list:
|
|
1346
|
+
reactions = []
|
|
1347
|
+
for emo in emotions_list:
|
|
1348
|
+
reaction_type = emo.get("key", "")
|
|
1349
|
+
users: list[str] = []
|
|
1350
|
+
for u in emo.get("users") or []:
|
|
1351
|
+
uid = u.get("userId", "")
|
|
1352
|
+
display = u.get("displayName", "")
|
|
1353
|
+
name = resolve_chat_author(uid, display, name_map)
|
|
1354
|
+
users.append(name)
|
|
1355
|
+
reactions.append(Reaction(type=reaction_type, users=users))
|
|
1356
|
+
elif msg.get("emotionsSummary"):
|
|
1357
|
+
reactions = []
|
|
1358
|
+
for r in msg["emotionsSummary"]:
|
|
1359
|
+
reactions.append(Reaction(type=r.get("key", ""), users=[]))
|
|
1360
|
+
|
|
1361
|
+
results.append(
|
|
1362
|
+
ChatMessage(
|
|
1363
|
+
timestamp=utc_to_local_iso(msg_time),
|
|
1364
|
+
author=author,
|
|
1365
|
+
text=text,
|
|
1366
|
+
reactions=reactions,
|
|
1367
|
+
)
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
results.sort(key=lambda m: m.timestamp)
|
|
1371
|
+
|
|
1372
|
+
logger.log("chat", f"Extracted {len(results)} chat message(s) in meeting window")
|
|
1373
|
+
|
|
1374
|
+
# Collect Event/Call content strings for attendance extraction.
|
|
1375
|
+
# These are the raw `content` fields from Event/Call messages matching
|
|
1376
|
+
# the target date. The caller can parse <partlist> XML from them.
|
|
1377
|
+
event_call_contents: list[str] = [
|
|
1378
|
+
m["content"]
|
|
1379
|
+
for m in data["messages"]
|
|
1380
|
+
if m.get("messageType") == "Event/Call"
|
|
1381
|
+
and m.get("time", "").startswith(matched_date or target_date)
|
|
1382
|
+
and m.get("content")
|
|
1383
|
+
]
|
|
1384
|
+
if event_call_contents:
|
|
1385
|
+
logger.log("chat", f"Collected {len(event_call_contents)} Event/Call content string(s)")
|
|
1386
|
+
|
|
1387
|
+
return results, event_call_contents
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
# ---------------------------------------------------------------------------
|
|
1391
|
+
# Step 9b: Tracking/RSVP extraction from Details tab
|
|
1392
|
+
# ---------------------------------------------------------------------------
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
async def extract_tracking_data(
|
|
1396
|
+
browser: BrowserPort,
|
|
1397
|
+
target_id: str,
|
|
1398
|
+
logger: Logger,
|
|
1399
|
+
shutdown: ShutdownCoordinator,
|
|
1400
|
+
) -> list[str]:
|
|
1401
|
+
"""Extract RSVP tracking data from the meeting Details tab.
|
|
1402
|
+
|
|
1403
|
+
Navigates to the Details tab, finds the Tracking panel, clicks
|
|
1404
|
+
"Show more attendees" to load all entries, and collects the
|
|
1405
|
+
``aria-label`` strings from each tracking list item.
|
|
1406
|
+
|
|
1407
|
+
This is a **soft-failure** function: if any step fails (no Details
|
|
1408
|
+
tab, no Tracking panel, etc.), it logs the reason and returns an
|
|
1409
|
+
empty list. The pipeline continues without RSVP data.
|
|
1410
|
+
|
|
1411
|
+
Args:
|
|
1412
|
+
browser: Browser port.
|
|
1413
|
+
target_id: CDP target ID for the main Teams target.
|
|
1414
|
+
logger: Logger instance.
|
|
1415
|
+
shutdown: Shutdown coordinator.
|
|
1416
|
+
|
|
1417
|
+
Returns:
|
|
1418
|
+
List of raw aria-label strings from the Tracking panel. Each
|
|
1419
|
+
label has the format:
|
|
1420
|
+
``"Name Status, N of M, in a list of M items, Dialogue Pop-up Button"``
|
|
1421
|
+
"""
|
|
1422
|
+
await browser.connect(target_id)
|
|
1423
|
+
try:
|
|
1424
|
+
return await _do_extract_tracking(browser, logger, shutdown)
|
|
1425
|
+
except Exception as exc: # soft failure by design
|
|
1426
|
+
logger.log("tracking", f"Tracking extraction failed: {exc}")
|
|
1427
|
+
return []
|
|
1428
|
+
finally:
|
|
1429
|
+
await browser.disconnect()
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
async def _do_extract_tracking(
|
|
1433
|
+
browser: BrowserPort,
|
|
1434
|
+
logger: Logger,
|
|
1435
|
+
shutdown: ShutdownCoordinator,
|
|
1436
|
+
) -> list[str]:
|
|
1437
|
+
"""Inner implementation of tracking extraction (connected browser).
|
|
1438
|
+
|
|
1439
|
+
Separated from :func:`extract_tracking_data` to keep the
|
|
1440
|
+
connect/disconnect lifecycle in one place.
|
|
1441
|
+
"""
|
|
1442
|
+
# --- Step 1: Click the Details tab ---
|
|
1443
|
+
tab_result = await browser.evaluate("""
|
|
1444
|
+
(function() {
|
|
1445
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
1446
|
+
var detailsTab = tabs.find(function(t) {
|
|
1447
|
+
return t.textContent.trim() === 'Details';
|
|
1448
|
+
});
|
|
1449
|
+
if (detailsTab) {
|
|
1450
|
+
detailsTab.click();
|
|
1451
|
+
return 'clicked_details';
|
|
1452
|
+
}
|
|
1453
|
+
// Check overflow menu
|
|
1454
|
+
var overflow = document.querySelector('[data-tid="tab-overflow-button"]');
|
|
1455
|
+
if (overflow) return 'check_overflow';
|
|
1456
|
+
return 'no_details_tab';
|
|
1457
|
+
})()
|
|
1458
|
+
""")
|
|
1459
|
+
logger.log("tracking", f"Details tab navigation: {tab_result}")
|
|
1460
|
+
|
|
1461
|
+
if tab_result == "check_overflow":
|
|
1462
|
+
# Click overflow menu and look for Details
|
|
1463
|
+
overflow_rect_raw = await browser.evaluate("""
|
|
1464
|
+
JSON.stringify(
|
|
1465
|
+
document.querySelector('[data-tid="tab-overflow-button"]')
|
|
1466
|
+
.getBoundingClientRect()
|
|
1467
|
+
)
|
|
1468
|
+
""")
|
|
1469
|
+
if overflow_rect_raw:
|
|
1470
|
+
rect = json.loads(str(overflow_rect_raw))
|
|
1471
|
+
ox = int(rect["x"] + rect["width"] / 2)
|
|
1472
|
+
oy = int(rect["y"] + rect["height"] / 2)
|
|
1473
|
+
await browser.send_command(
|
|
1474
|
+
"Input.dispatchMouseEvent",
|
|
1475
|
+
{"type": "mousePressed", "x": ox, "y": oy, "button": "left", "clickCount": 1},
|
|
1476
|
+
)
|
|
1477
|
+
await browser.send_command(
|
|
1478
|
+
"Input.dispatchMouseEvent",
|
|
1479
|
+
{"type": "mouseReleased", "x": ox, "y": oy, "button": "left", "clickCount": 1},
|
|
1480
|
+
)
|
|
1481
|
+
await shutdown.interruptible_sleep(1)
|
|
1482
|
+
|
|
1483
|
+
tab_result = await browser.evaluate("""
|
|
1484
|
+
(function() {
|
|
1485
|
+
var menus = document.querySelectorAll('[role="menu"]');
|
|
1486
|
+
for (var i = 0; i < menus.length; i++) {
|
|
1487
|
+
var items = menus[i].querySelectorAll(
|
|
1488
|
+
'[role="menuitem"], [role="menuitemradio"]'
|
|
1489
|
+
);
|
|
1490
|
+
for (var j = 0; j < items.length; j++) {
|
|
1491
|
+
if (items[j].textContent.trim() === 'Details') {
|
|
1492
|
+
items[j].click();
|
|
1493
|
+
return 'clicked_details';
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return 'no_details_tab';
|
|
1498
|
+
})()
|
|
1499
|
+
""")
|
|
1500
|
+
logger.log("tracking", f"Overflow menu result: {tab_result}")
|
|
1501
|
+
|
|
1502
|
+
if tab_result == "no_details_tab":
|
|
1503
|
+
logger.log("tracking", "No Details tab found; skipping RSVP extraction")
|
|
1504
|
+
return []
|
|
1505
|
+
|
|
1506
|
+
# Wait for Details tab content to load
|
|
1507
|
+
await shutdown.interruptible_sleep(2)
|
|
1508
|
+
shutdown.check_shutdown()
|
|
1509
|
+
|
|
1510
|
+
# --- Step 2: Find the Tracking panel ---
|
|
1511
|
+
tracking_present = await browser.evaluate("""
|
|
1512
|
+
(function() {
|
|
1513
|
+
var tv = document.querySelector('[aria-label*="Tracking view"]');
|
|
1514
|
+
if (tv) return 'found';
|
|
1515
|
+
// Also check for any element with "Tracking" heading
|
|
1516
|
+
var headings = Array.from(document.querySelectorAll('h2, h3, [role="heading"]'));
|
|
1517
|
+
var trackingH = headings.find(function(h) {
|
|
1518
|
+
return h.textContent.trim() === 'Tracking';
|
|
1519
|
+
});
|
|
1520
|
+
return trackingH ? 'found_heading' : 'not_found';
|
|
1521
|
+
})()
|
|
1522
|
+
""")
|
|
1523
|
+
logger.log("tracking", f"Tracking panel: {tracking_present}")
|
|
1524
|
+
|
|
1525
|
+
if tracking_present == "not_found":
|
|
1526
|
+
logger.log("tracking", "No Tracking panel on Details tab; skipping RSVP extraction")
|
|
1527
|
+
return []
|
|
1528
|
+
|
|
1529
|
+
# --- Step 3: Click "Show more attendees" to load all entries ---
|
|
1530
|
+
# There may be multiple "Show more" buttons (one per section).
|
|
1531
|
+
# Click them all, waiting between each.
|
|
1532
|
+
for attempt in range(3):
|
|
1533
|
+
show_more_result = await browser.evaluate("""
|
|
1534
|
+
(function() {
|
|
1535
|
+
var buttons = Array.from(document.querySelectorAll('button'));
|
|
1536
|
+
var showMore = buttons.filter(function(b) {
|
|
1537
|
+
var label = b.getAttribute('aria-label') || b.textContent || '';
|
|
1538
|
+
return label.indexOf('Show more attendees') !== -1
|
|
1539
|
+
|| label.indexOf('Show more') !== -1;
|
|
1540
|
+
});
|
|
1541
|
+
if (showMore.length === 0) return 'none';
|
|
1542
|
+
for (var i = 0; i < showMore.length; i++) {
|
|
1543
|
+
showMore[i].click();
|
|
1544
|
+
}
|
|
1545
|
+
return 'clicked_' + showMore.length;
|
|
1546
|
+
})()
|
|
1547
|
+
""")
|
|
1548
|
+
logger.log("tracking", f"Show more (attempt {attempt + 1}): {show_more_result}")
|
|
1549
|
+
|
|
1550
|
+
if show_more_result == "none":
|
|
1551
|
+
break
|
|
1552
|
+
|
|
1553
|
+
await shutdown.interruptible_sleep(1.5)
|
|
1554
|
+
shutdown.check_shutdown()
|
|
1555
|
+
|
|
1556
|
+
# --- Step 4: Collect all tracking aria-labels ---
|
|
1557
|
+
labels_raw = await browser.evaluate("""
|
|
1558
|
+
(function() {
|
|
1559
|
+
var items = document.querySelectorAll(
|
|
1560
|
+
'[aria-label*="Dialogue Pop-up Button"]'
|
|
1561
|
+
);
|
|
1562
|
+
var labels = [];
|
|
1563
|
+
for (var i = 0; i < items.length; i++) {
|
|
1564
|
+
var label = items[i].getAttribute('aria-label');
|
|
1565
|
+
if (label) labels.push(label);
|
|
1566
|
+
}
|
|
1567
|
+
return JSON.stringify(labels);
|
|
1568
|
+
})()
|
|
1569
|
+
""")
|
|
1570
|
+
|
|
1571
|
+
if not labels_raw:
|
|
1572
|
+
logger.log("tracking", "No tracking labels collected")
|
|
1573
|
+
return []
|
|
1574
|
+
|
|
1575
|
+
labels: list[str] = json.loads(str(labels_raw))
|
|
1576
|
+
logger.log("tracking", f"Collected {len(labels)} tracking label(s)")
|
|
1577
|
+
|
|
1578
|
+
# --- Step 5: Navigate back to Chat tab ---
|
|
1579
|
+
# The pipeline expects to be on the Chat tab for subsequent operations.
|
|
1580
|
+
await browser.evaluate("""
|
|
1581
|
+
(function() {
|
|
1582
|
+
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
|
|
1583
|
+
var chatTab = tabs.find(function(t) {
|
|
1584
|
+
return t.textContent.trim() === 'Chat';
|
|
1585
|
+
});
|
|
1586
|
+
if (chatTab) chatTab.click();
|
|
1587
|
+
})()
|
|
1588
|
+
""")
|
|
1589
|
+
await shutdown.interruptible_sleep(1)
|
|
1590
|
+
|
|
1591
|
+
return labels
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
# ---------------------------------------------------------------------------
|
|
1595
|
+
# Internal helpers
|
|
1596
|
+
# ---------------------------------------------------------------------------
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
async def _async_sleep(seconds: float) -> None:
|
|
1600
|
+
"""Thin wrapper around asyncio.sleep for clarity."""
|
|
1601
|
+
import asyncio
|
|
1602
|
+
|
|
1603
|
+
await asyncio.sleep(seconds)
|