You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
370 lines
19 KiB
370 lines
19 KiB
|
2 months ago
|
---
|
||
|
|
name: "get-meeting-transcript"
|
||
|
|
description: "Grab a Teams/Stream meeting transcript by driving a logged-in browser when the Graph download is blocked. Captures the underlying .vtt from network traffic, or scrapes the recap transcript pane. Accepts a Teams recap, Stream/SharePoint, or meet.microsoft.com URL, or a calendar reference it resolves to one. Saves .vtt + .md + the meeting chat into the Obsidian vault. Triggers: get transcript, scrape transcript, grab the transcript, meeting chat, transcript for my meeting."
|
||
|
|
---
|
||
|
|
|
||
|
|
## get-meeting-transcript
|
||
|
|
|
||
|
|
When the user wants a transcript for a meeting they can view in the UI but
|
||
|
|
can't pull via Graph (`/me/onlineMeetings/.../transcripts` returns nothing or
|
||
|
|
403s), use this skill instead of giving up.
|
||
|
|
|
||
|
|
**Before scraping, try the official download.** The scraper is the third
|
||
|
|
choice, not the first — see "Choosing a capture path" below. A downloaded
|
||
|
|
`.vtt` is strictly better than anything this script synthesizes, and getting
|
||
|
|
one costs the user about ten seconds.
|
||
|
|
|
||
|
|
### Choosing a capture path
|
||
|
|
|
||
|
|
Try these in order. Stop at the first that works.
|
||
|
|
|
||
|
|
**1. Native Graph.** (`m365_*` does not currently expose transcripts; the EOD
|
||
|
|
automation uses an internal Stream call.) If it succeeds, you don't need this
|
||
|
|
skill at all.
|
||
|
|
|
||
|
|
**2. Official `.vtt` download from the UI — prefer this over scraping.**
|
||
|
|
In the Teams recap (or the Stream/SharePoint player), the transcript pane has
|
||
|
|
a **download** action that yields the real `.vtt`: every cue at full
|
||
|
|
millisecond precision, proper `<v Speaker>` tags, and cue identifiers. That is
|
||
|
|
strictly better than the synthesized file this script produces from the DOM,
|
||
|
|
which is lossy in timing (see "Fidelity of the DOM scrape").
|
||
|
|
|
||
|
|
If the meeting is one the user can open right now, **ask them to download it**
|
||
|
|
before reaching for the scraper — it is faster than a manual-mode walk (which
|
||
|
|
already requires them to navigate to the same pane) and higher fidelity. Have
|
||
|
|
them save it into the meeting's output dir, then:
|
||
|
|
|
||
|
|
- adopt it as `transcript.vtt` (replacing any scraped copy), and
|
||
|
|
- render `transcript.md` from it using the documented format —
|
||
|
|
`### HH:MM:SS — Speaker` headings, consecutive same-speaker cues collapsed
|
||
|
|
into paragraphs — **skipping the GUID cue-identifier lines**, which are not
|
||
|
|
transcript text.
|
||
|
|
|
||
|
|
Record `source: "official Teams transcript download (.vtt)"` in the markdown
|
||
|
|
front matter so later readers know the timings are exact.
|
||
|
|
|
||
|
|
**3. This scraper.** Use it when the download action is unavailable, blocked
|
||
|
|
by tenant policy, or the user isn't around to click it. Common cases: meetings
|
||
|
|
the user attended but didn't organize, restrictive transcript-export policy,
|
||
|
|
externally-organized meetings. Content and speaker attribution are reliable;
|
||
|
|
timings are not.
|
||
|
|
|
||
|
|
If a scrape has already run and a download later becomes available, prefer the
|
||
|
|
download and regenerate `transcript.md` from it — the scraped `.vtt` should be
|
||
|
|
replaced, not kept alongside.
|
||
|
|
|
||
|
|
### How it works
|
||
|
|
|
||
|
|
A bundled Playwright script (`scrape-transcript.mjs`) drives Edge against a
|
||
|
|
persistent profile (`.pw-profile/`) so the user's Teams/Stream/SharePoint
|
||
|
|
sign-in carries over.
|
||
|
|
|
||
|
|
**Two capture paths** (auto-detected, network first, DOM fallback):
|
||
|
|
|
||
|
|
1. **Network interception** (rare — Teams encrypts transcript blobs in
|
||
|
|
transit, so this usually fails for Teams recap). Listens on every network
|
||
|
|
response for either a WEBVTT signature or a JSON transcript shape with
|
||
|
|
recognizable cue arrays (`{ text, startTime/startOffset, speaker }`).
|
||
|
|
2. **DOM scrape of the recap iframe** (the usual path for Teams meetings the
|
||
|
|
user doesn't organize). The transcript pane lives inside the
|
||
|
|
`RecapxPlatIframe` SharePoint iframe at
|
||
|
|
`*_layouts/15/xplatplugins.aspx?...hv=Recap*` inside a region with
|
||
|
|
`aria-label="Transcript"`. Cue entries are `[id^="entry-"]` with
|
||
|
|
`aria-label="@N M minutes S seconds"`. Speaker headers
|
||
|
|
(`.itemDisplayName-*`) only appear when the speaker changes — those get
|
||
|
|
forward-filled across following cues.
|
||
|
|
|
||
|
|
**Virtualization handling.** The recap iframe uses a virtual list (only ~10
|
||
|
|
cues rendered at once). To materialize every cue, the script focuses the
|
||
|
|
first entry then drives **PageDown + ArrowDown keypresses** to walk through
|
||
|
|
the entire list (`aria-setsize` tells us the total; normal-zone pacing is
|
||
|
|
PageDown+5×ArrowDown per pass with a 150ms settle wait). Cues are accumulated
|
||
|
|
by their stable `entry-N` id, so re-renders don't cause duplicates.
|
||
|
|
|
||
|
|
**Tail resilience.** Big `PageDown` jumps are more likely to overshoot the
|
||
|
|
render boundary near the *end* of the list than in the middle — observed
|
||
|
|
failure mode: a run plateaus short of `aria-setsize` (e.g. 307/329, 369/402,
|
||
|
|
355/366) because the walk's stuck-counter trips right at the tail. Once
|
||
|
|
collection reaches 85% of the reported total, the script automatically
|
||
|
|
switches to gentler single `ArrowDown` steps with a 275ms settle wait, and
|
||
|
|
tolerates 3x more "stuck" passes before giving up. Keyboard nav only moves
|
||
|
|
list *focus*, though — some virtualized-list implementations render more
|
||
|
|
rows off a real, trusted scroll/wheel input rather than focus changes alone,
|
||
|
|
so a stall that survives repeated keyboard nudges can still respond to an
|
||
|
|
actual scroll. **Note:** a JS-dispatched synthetic `WheelEvent` +
|
||
|
|
manual `scrollTop` bump was tried first and confirmed *not* to work here —
|
||
|
|
diagnostics showed the transcript region has no CSS overflow-scroll
|
||
|
|
container at all (`scrollHeight === clientHeight` wherever probed), so those
|
||
|
|
synthetic events landed on nothing. The script instead uses Playwright's
|
||
|
|
`page.mouse.wheel()`: a trusted, OS-level input event dispatched at the
|
||
|
|
transcript pane's real screen coordinates, which can reach handlers that
|
||
|
|
reject untrusted/synthetic events or rely on the browser's native
|
||
|
|
wheel-to-scroll pipeline. Every 4th stuck pass in the tail zone, and
|
||
|
|
throughout the dedicated tail-recovery phase below, the script fires a mouse
|
||
|
|
wheel nudge as a supplement to keyboard nudges. If it still falls short
|
||
|
|
after the main loop, a dedicated tail-recovery phase spends an extra bounded
|
||
|
|
budget (`--tail-wait`, default 45s) alternating keyboard (`End`+`ArrowDown`)
|
||
|
|
and mouse-wheel nudges. The result JSON reports `cueCount`, `targetCueCount`,
|
||
|
|
and `truncated` so you can tell at a glance whether anything was missed — no
|
||
|
|
need to hand-write a gap analysis script. When truncated, the missing cues
|
||
|
|
are virtually always a contiguous block at the point the walk stalled
|
||
|
|
(usually the tail), not scattered through the transcript.
|
||
|
|
|
||
|
|
If `--headed`, the user can click into the transcript pane manually; the
|
||
|
|
DOM scrape still works because it's keyed off the iframe structure, not on
|
||
|
|
the navigation path used to reach it.
|
||
|
|
|
||
|
|
### Input modes
|
||
|
|
|
||
|
|
**1. Direct URL.** Anything that resolves to a page showing the transcript:
|
||
|
|
|
||
|
|
- Teams recap URL: `https://teams.microsoft.com/.../recap/...`
|
||
|
|
- Stream / SharePoint video page: `*.sharepoint.com/.../stream.aspx?id=...`
|
||
|
|
or `*.sharepoint.com/personal/.../_layouts/15/stream.aspx?...`
|
||
|
|
|
||
|
|
**Live-join links auto-fallback to manual.** `onlineMeeting.joinUrl` /
|
||
|
|
`onlineMeetingUrl` values — `teams.microsoft.com/meet/...`,
|
||
|
|
`teams.microsoft.com/l/meetup-join/...`, `meet.microsoft.com/...` — always
|
||
|
|
open the pre-join/lobby screen, **never** the recap, even for meetings that
|
||
|
|
already ended. (Earlier revisions of this skill assumed Teams would redirect
|
||
|
|
these into the recap; it doesn't.) The script detects this pattern itself
|
||
|
|
(`isLiveJoinUrl()`) and immediately switches into the manual walk instead of
|
||
|
|
burning the `--wait` timeout on a dead-end screen — you don't need to
|
||
|
|
pre-filter these URLs or retry with `--manual` yourself; just pass whatever
|
||
|
|
URL you have and let the script decide. Don't waste a turn calling it with
|
||
|
|
the join URL expecting a direct hit — assume it'll need the manual walk and
|
||
|
|
tell the user up front so they're ready to navigate to Recap → Transcript.
|
||
|
|
|
||
|
|
**2. Calendar reference.** User says "transcript from yesterday's 1:1 with
|
||
|
|
Alex" or names a meeting. The agent (you) must resolve this to a URL before
|
||
|
|
invoking the scraper:
|
||
|
|
|
||
|
|
1. Call `m365_list_events` with an appropriate `startDate`/`endDate` window.
|
||
|
|
2. Match by subject / attendee. Confirm with the user via `m_ask_user` if
|
||
|
|
multiple events match.
|
||
|
|
3. Pull the transcript URL from the event:
|
||
|
|
- any SharePoint stream.aspx URL in the event body (often present after
|
||
|
|
recording is auto-saved to OneDrive) — try this first, it can go
|
||
|
|
straight to a direct-URL capture.
|
||
|
|
- `onlineMeeting.joinUrl` / a `teams.microsoft.com/meet/...` link in the
|
||
|
|
body — this will auto-fallback to the manual walk (see above), so treat
|
||
|
|
it as "go straight to manual" rather than a URL worth waiting on.
|
||
|
|
4. Hand the URL to `scrape-transcript.mjs`.
|
||
|
|
|
||
|
|
If neither lookup succeeds, ask the user to paste the URL directly. If you
|
||
|
|
already know the only URL available is a join link, you can skip straight to
|
||
|
|
`--manual` yourself and tell the user to expect the manual walk — no need to
|
||
|
|
round-trip through a direct-URL attempt first.
|
||
|
|
|
||
|
|
### Invocation
|
||
|
|
|
||
|
|
```pwsh
|
||
|
|
node "C:\Users\dkucinski\.scout\m-skills\get-meeting-transcript\scrape-transcript.mjs" `
|
||
|
|
--url "<meeting or stream URL>" `
|
||
|
|
[--out "<output dir>"] `
|
||
|
|
[--subject "<filename slug>"] `
|
||
|
|
[--headed] `
|
||
|
|
[--manual] `
|
||
|
|
[--wait 60] `
|
||
|
|
[--tail-wait 45] `
|
||
|
|
[--debug]
|
||
|
|
```
|
||
|
|
|
||
|
|
- `--url` is the page to land on. Skip when using `--manual`. If `--url` is
|
||
|
|
a live-join link (`teams.microsoft.com/meet/...`, `/l/meetup-join/...`,
|
||
|
|
`meet.microsoft.com/...`), the script auto-detects it and behaves as if
|
||
|
|
`--manual` were passed (overrides the URL to `teams.microsoft.com/v2/`,
|
||
|
|
extends the wait to 300s) — printing a note to stderr explaining why.
|
||
|
|
- `--manual` opens `teams.microsoft.com` and waits while the user navigates
|
||
|
|
to the recap themselves (use this directly when you already know the only
|
||
|
|
URL you have is a join link — no need to let the script rediscover that).
|
||
|
|
Implies `--headed`.
|
||
|
|
Default wait jumps to 300s in manual mode. Extraction starts
|
||
|
|
**automatically** as soon as the transcript pane is detected — you just
|
||
|
|
navigate to Recap → Transcript and the scraper proceeds on its own. After
|
||
|
|
reaching the pane, clicking one cue is recommended (gives the list keyboard
|
||
|
|
focus for the PageDown/ArrowDown walk), but the scraper also self-focuses
|
||
|
|
the first cue. Pressing Enter in the terminal is an optional override that
|
||
|
|
forces extraction to start immediately. The old behavior — blocking until
|
||
|
|
you typed something — has been removed, so manual mode works whether or not
|
||
|
|
stdin is interactive.
|
||
|
|
- `--out` defaults to `D:\Repos\Obsidian\02 - Meetings\YYYY-MM-DD - <subject>\`
|
||
|
|
using today's date and `--subject` (falls back to `meeting`). Pass an
|
||
|
|
explicit path with the meeting's own date when scraping past meetings.
|
||
|
|
- `--headed` opens a visible browser window. Required on first run so the
|
||
|
|
user can sign in.
|
||
|
|
- `--wait N` overrides the seconds to wait for transcript readiness/capture
|
||
|
|
after the page loads (default 45; 300 in `--manual`). Bump higher for very
|
||
|
|
long meetings (the keyboard walk takes ~1 ArrowDown per cue).
|
||
|
|
- `--tail-wait N` extra seconds (default 45) spent on gentle End+ArrowDown
|
||
|
|
recovery nudges if the DOM-scrape walk plateaus short of the transcript's
|
||
|
|
reported total (`aria-setsize`) — see "Tail resilience" above. Set to `0`
|
||
|
|
to disable and fail fast instead.
|
||
|
|
- `--debug` logs every response URL + content-type the listener sees plus
|
||
|
|
scroll-progress dumps, for diagnosing capture failures.
|
||
|
|
|
||
|
|
stdout is a single JSON object:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{ "ok": true, "vtt": "C:\\...\\transcript.vtt", "md": "C:\\...\\transcript.md", "cueCount": 785, "targetCueCount": 785, "truncated": false, "bytes": 100976, "source": "dom" }
|
||
|
|
```
|
||
|
|
|
||
|
|
`source` is `"network"` when captured from a WEBVTT/JSON response, or
|
||
|
|
`"dom"` when extracted from the recap iframe DOM. For Teams meetings, expect
|
||
|
|
`"dom"` — the transcript blob is encrypted in transit. `targetCueCount` is
|
||
|
|
only present for DOM-scrape captures (the iframe's `aria-setsize`); if
|
||
|
|
`truncated` is `true`, `cueCount` is short of it — check stderr for the
|
||
|
|
`[warn] transcript is likely INCOMPLETE` line, and consider re-running with
|
||
|
|
a larger `--tail-wait` if it keeps happening.
|
||
|
|
|
||
|
|
Exit codes: `0` ok, `2` bad args, `3` no transcript captured before
|
||
|
|
timeout, `4` page never loaded.
|
||
|
|
|
||
|
|
### One-time setup
|
||
|
|
|
||
|
|
First run, always pass `--headed`. The script opens Edge against
|
||
|
|
`.pw-profile/`. Sign in to teams.microsoft.com and/or the SharePoint tenant
|
||
|
|
when prompted. The profile persists; subsequent runs reuse it silently.
|
||
|
|
|
||
|
|
### Output
|
||
|
|
|
||
|
|
Files written into the output dir:
|
||
|
|
|
||
|
|
- `transcript.vtt` — WEBVTT. When `source` is `"network"`, this is byte-for-byte
|
||
|
|
what the UI player consumes. When `source` is `"dom"` (the usual case for
|
||
|
|
Teams), it is a **synthesized** WEBVTT and is **lossy in timing** — see
|
||
|
|
"Fidelity of the DOM scrape" below.
|
||
|
|
- `transcript.md` — same content as readable markdown with
|
||
|
|
`### HH:MM:SS — Speaker` headings and consecutive same-speaker cues
|
||
|
|
collapsed into single paragraphs. Easy to read in Obsidian.
|
||
|
|
- `chat.md` — the meeting chat, when one exists. Written by the agent, not
|
||
|
|
the script. See "Meeting chat capture" below.
|
||
|
|
|
||
|
|
Default output dir matches the existing EOD meeting-archive layout under
|
||
|
|
`D:\Repos\Obsidian\02 - Meetings\` so transcripts grabbed this way slot
|
||
|
|
into the same vault structure as the automated ones.
|
||
|
|
|
||
|
|
### Fidelity of the DOM scrape
|
||
|
|
|
||
|
|
Measured 2026-08-14 against an official `.vtt` download of the same 57-minute
|
||
|
|
meeting (Astra Analytics walkthrough), the DOM scrape was:
|
||
|
|
|
||
|
|
- **Faithful on text** — 10,507 words vs 10,503 in the official file. The 4
|
||
|
|
extra are the "X started/stopped transcription" system lines, which the
|
||
|
|
recap DOM exposes and the official export omits.
|
||
|
|
- **Faithful on speaker attribution** — per-speaker word counts matched within
|
||
|
|
±4 words across all five participants.
|
||
|
|
- **Lossy on timing.** 433 captured entries vs 1,091 real cues. The recap DOM
|
||
|
|
exposes *grouped* entries, and the script rounds each to whole seconds, so
|
||
|
|
a synthesized cue spans several real ones (e.g. `00:00:03.000 --> 00:00:24.000`
|
||
|
|
covers five cues running `00:00:03.288 --> 00:00:24.168`).
|
||
|
|
|
||
|
|
So: **the DOM scrape is trustworthy for content and attribution, and should
|
||
|
|
not be trusted for precise timings.** Don't cite DOM-scraped timestamps as
|
||
|
|
exact, and don't use them to align against recordings or other time-series.
|
||
|
|
|
||
|
|
Note that `cueCount` counts *recap entries*, not VTT cues — `433/433` with
|
||
|
|
`truncated: false` is a complete capture even though the real transcript has
|
||
|
|
1,091 cues. The two numbers are not comparable, and a mismatch is not a bug.
|
||
|
|
|
||
|
|
**If you verify a capture against a reference `.vtt`, strip the cue-identifier
|
||
|
|
lines first.** Official Teams exports put a GUID id line (e.g.
|
||
|
|
`63fe10ca-…-152225ba9c70/7-1`) before each timestamp; a naive parser counts
|
||
|
|
those as transcript text and will report a large phantom word deficit
|
||
|
|
(~7 tokens × cue count). This has already produced one false "41% of content
|
||
|
|
is missing" alarm.
|
||
|
|
|
||
|
|
### Meeting chat capture (`chat.md`)
|
||
|
|
|
||
|
|
After a successful transcript capture, also grab the **meeting chat** — the
|
||
|
|
Teams conversation attached to the same meeting. It routinely carries what the
|
||
|
|
transcript can't: links pasted mid-call, files shared, and the follow-up
|
||
|
|
traffic that lands in the hours or days afterwards.
|
||
|
|
|
||
|
|
This is an **agent** step. `scrape-transcript.mjs` does not do it — the script
|
||
|
|
only handles the transcript pane. Do it after the script returns `ok: true`.
|
||
|
|
|
||
|
|
**1. Resolve the meeting chat id.**
|
||
|
|
|
||
|
|
- `workiq_search_chats` with the meeting subject is usually enough. Meeting
|
||
|
|
chats come back with `chatType: "meeting"` and an id shaped
|
||
|
|
`19:meeting_<base64>@thread.v2`.
|
||
|
|
- Sanity-check the returned `members` against the calendar event's attendees
|
||
|
|
before using it. If the subject is generic and several chats match, confirm
|
||
|
|
via `m_ask_user` — never guess.
|
||
|
|
- If no chat exists (common for meetings nobody typed in), say so and move on.
|
||
|
|
A missing chat is not a failure of the run.
|
||
|
|
|
||
|
|
**2. Export it to `<out>/chat.md`.**
|
||
|
|
|
||
|
|
Follow the `/teams-export` rendering conventions so it reads identically to
|
||
|
|
everything under `00 - Chats\`: `##` for the date (YYYY-MM-DD), `###` for each
|
||
|
|
message (`HH:mm — Author`), replies as nested blockquotes, attachments
|
||
|
|
collapsed to a single `**Attachments:**` line (never download binaries),
|
||
|
|
message HTML converted to markdown, timestamps in America/Los_Angeles 24-hour,
|
||
|
|
and `---` between root messages.
|
||
|
|
|
||
|
|
Front matter:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
---
|
||
|
|
type: reference
|
||
|
|
subtype: meeting-chat
|
||
|
|
date: <meeting date, YYYY-MM-DD>
|
||
|
|
chat_id: "19:meeting_...@thread.v2"
|
||
|
|
topic: <meeting subject>
|
||
|
|
captured: <YYYY-MM-DD>
|
||
|
|
---
|
||
|
|
```
|
||
|
|
|
||
|
|
Export the **whole** chat rather than a date window — meeting chats are small,
|
||
|
|
and the valuable part is often the post-meeting tail.
|
||
|
|
|
||
|
|
**3. Emit a `sources.yaml` suggestion — do NOT write it.**
|
||
|
|
|
||
|
|
Most meeting chats are dead within a day, and `chat.md` is the right home for
|
||
|
|
those: self-contained, sitting next to the transcript it belongs to. But some
|
||
|
|
keep producing follow-up traffic for days, and those are worth promoting to a
|
||
|
|
standing source so `/teams-export` keeps pulling them.
|
||
|
|
|
||
|
|
That promotion is the user's call, not this skill's. `sources.yaml` is
|
||
|
|
hand-maintained and `/teams-export` is explicitly forbidden from writing to it;
|
||
|
|
this skill must not write to it either. Instead, print a ready-to-paste block
|
||
|
|
and let the user decide:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
# Suggested addition to D:\Repos\Obsidian\00 - Chats\sources.yaml (groups:)
|
||
|
|
- alias: <kebab-case-slug>
|
||
|
|
type: group
|
||
|
|
chat_id: "19:meeting_...@thread.v2"
|
||
|
|
topic: <meeting subject>
|
||
|
|
notes: >-
|
||
|
|
Added <YYYY-MM-DD>. Meeting chat for the <date> <subject> session.
|
||
|
|
Transcript captured under "02 - Meetings/<date> - <subject>".
|
||
|
|
Retire with enabled:false once follow-up traffic stops.
|
||
|
|
```
|
||
|
|
|
||
|
|
Recommend promotion when the meeting has a live follow-up thread — handoffs,
|
||
|
|
access grants, artifacts still to be shared, or a recurring series. Recommend
|
||
|
|
against it for one-off sessions that concluded in the room; `chat.md` already
|
||
|
|
covers those, and every dead entry left in `sources.yaml` is re-polled on every
|
||
|
|
future export run.
|
||
|
|
|
||
|
|
### Guardrails
|
||
|
|
|
||
|
|
- `.pw-profile/` and any captured cookies are credentials. Never echo them,
|
||
|
|
never copy them outside the skill dir.
|
||
|
|
- Transcripts may contain sensitive content (MIP labels are not surfaced via
|
||
|
|
the UI capture). Treat any transcript saved this way as at least as
|
||
|
|
sensitive as the calendar event itself. Warn the user before any outbound
|
||
|
|
share.
|
||
|
|
- Only write inside the `--out` dir. Do not modify anything else in the
|
||
|
|
Obsidian vault. This includes `00 - Chats\sources.yaml` — that file is
|
||
|
|
hand-maintained by the user; suggest additions, never apply them.
|
||
|
|
- `chat.md` inherits the same sensitivity treatment as the transcript, and can
|
||
|
|
be worse — chats carry pasted links, file names, and customer detail that
|
||
|
|
never got said out loud. Same rule: warn before any outbound share.
|
||
|
|
- If the user supplies a calendar reference that resolves to multiple
|
||
|
|
events, always confirm via `m_ask_user` — never guess. Same for a meeting
|
||
|
|
subject that matches multiple Teams chats.
|