This document explains how the project is structured, how the transcript extraction works, and how to develop, test, and release changes.
The project is a single self-contained browser script that scrapes the visible
Microsoft Stream / Teams transcript pane and downloads it as a .txt file. It
ships in two forms, both generated from the same source:
- a bookmarklet (
javascript:URL), and - a DevTools snippet / plain script.
There is no runtime framework and no bundler. The build step is a small Node script that inlines the source into the generated artifacts.
src/ms-stream-transcript-downloader.js Main browser script (the only runtime code)
scripts/build-bookmarklet.mjs Build script: generates dist/ artifacts
test/ Node test runner specs (*.test.mjs)
test/helpers/load.mjs Loads the script in a VM sandbox for tests
test/fixtures/ HTML fixtures mirroring the real Stream DOM
docs/DEVELOPMENT.md This file
dist/ Generated output (gitignored)
.github/workflows/pages.yml Builds and deploys the install page to Pages
The script is an IIFE that attaches a small public API to
window.MSStreamTranscriptDownloader. High-level flow:
- Idempotent load. If the same
SCRIPT_VERSIONis already loaded in the page, the bookmarklet just callsextractTranscript()again instead of re-injecting. A version bump forces a reload of updated code in an open tab. - Find the scroll container.
findScrollableContainer()tries known transcript root selectors, then falls back to locating the nearest scrollable ancestor of a transcript row. - Scroll and harvest. Stream virtualizes the transcript list (only rendering
on-screen rows), so
extractTranscript()scrolls in steps and repeatedly calls the harvester until no new rows appear for several rounds. - Read each row.
readEntry()extracts the speaker, timestamp, and text using the selector lists, andgetEntryIndex()derives a stable order key fromdata-list-index/listItem-<n>ids. Text is read from the dedicated<span id="text-N">element (see "Why the text selector matters" below). - Clean the text (fallback).
stripTranscriptChrome()is a defensive safety net for older/other Stream layouts where chrome leaks into the text element. With the precise#text-Nselector it is effectively a no-op. - Deduplicate and order. The harvester keys entries by list index (or by content when no index exists) and sorts by index then discovery order.
- Format and download.
formatTranscript()builds the text file anddownloadText()triggers a local download via aBlobURL.
Each transcript row wraps the spoken text in a dedicated span:
<div class="baseEntry-695" id="entry-1" ...>
<span id="timestampSpeakerAriaLabel-1" class="screenReaderFriendlyHiddenTag-619">Alex Example 0 minutes 5 seconds</span>
<div id="sub-entry-1" class="entryText-696" role="listitem">
<span id="edit-aria-div-1"><span id="text-1">OK, just give me a second.</span></span>
<!-- on the active row only: -->
<button>…Edit</button>
<span class="screenReaderFriendlyHiddenTag-619">Use enter key to enable transcript edit mode</span>
</div>
</div>The speaker name, the screen-reader spoken-timestamp label, and the inline edit
button are all siblings of #text-1, not inside it. So textSelectors
prefers [id^='text-']: reading that span yields only the spoken text and
avoids the chrome entirely. The visible clock timestamp comes from
[id^='Header-timestamp-']; same-speaker continuation rows omit that element, so
their timestamp is empty by design (and the speaker-grouped output does not print
a header for them anyway).
If #text-N is ever absent and a fallback selector returns text that includes
chrome, stripTranscriptChrome() cleans several patterns observed historically:
- Speaker / timestamp prefixes. Accessibility text such as
"Speaker Name 1 minute 2 secondsYeah."is stripped down to"Yeah.".timestampToSpokenText()converts1:02to the spoken form so it can be matched and removed;parseSpokenTimestampPrefix()recovers a clock timestamp from a leading"1 minute 9 seconds…". - Inline edit button.
stripUiAffordances()cuts everything from the first Private Use Area glyph (e.g.U+E70F) to the end of the string (real transcript text never contains icon-font glyphs), with a regex fallback for theUse enter key to enable transcript edit modetooltip when the glyph is absent.
If you find new chrome leaking into the output, first prefer a more precise selector; only add cleanup cases as a fallback, and cover them with a test that uses the exact observed string.
Microsoft periodically changes the Stream DOM. Selectors are centralized at the
top of src/ms-stream-transcript-downloader.js:
entrySelectors– transcript row containersspeakerSelectors– speaker name elementtimestampSelectors– timestamp elementtextSelectors– the spoken-text element
Prefer adding a new selector to the relevant list rather than replacing an existing one, so older Stream variants keep working.
npm run buildscripts/build-bookmarklet.mjs reads src/ms-stream-transcript-downloader.js,
extracts SCRIPT_VERSION, and writes:
dist/browser-snippet.js– the raw script for DevTools snippetsdist/bookmarklet.txt– thejavascript:URL (URL-encoded payload)dist/index.html/dist/bookmarklet.html– the install pagedist/site.js– the copy-button script for the install page
The bookmarklet payload guards on the loaded version so an already-open tab re-runs the new code after a version bump.
npm run check # node --check on sources + node --test on test/*.test.mjsTests run on the Node test runner. Because the script is a browser IIFE,
test/helpers/load.mjs evaluates it inside a node:vm sandbox with a minimal
fake DOM and sets window.__MS_STREAM_TRANSCRIPT_DOWNLOADER_TEST__ = true. That
flag makes the script expose its pure helpers on _private for unit testing; the
helpers are not exposed in normal browser use.
There are two kinds of specs:
- Unit specs exercise the pure helpers (filename sanitising, timestamp
formatting, text cleanup) directly against
_private. - DOM integration specs (
test/dom-extraction.test.mjs) parse an HTML fixture fromtest/fixtures/withlinkedom(the project's only devDependency) and call_private.readEntry()on real Stream-shaped markup to prove the selectors extract clean text.
- Put new specs in
test/<name>.test.mjs. - Load helpers via
loadScript()and assert against_private. - For DOM-shaped behavior, add/extend a fixture in
test/fixtures/that mirrors the real Stream structure (ids/classes) and parse it withlinkedom. - Use fictional names and data only (e.g.
Alex Example,Alice/Bob). Never include real people's names, secrets, or other real personal data — including in fixtures copied from real pages. - Objects returned from the sandbox come from a different realm, so compare
individual properties instead of using
assert.deepEqualon whole objects.
SCRIPT_VERSION in the source and version in package.json should be kept in
sync. Bump them whenever runtime behavior changes so that:
- the bookmarklet reloads the new code in already-open tabs, and
- the install page reflects the new build after Pages deploys.
After bumping, run npm run check && npm run build and commit the source and
config changes. dist/ is gitignored and rebuilt by CI for Pages.
- The script only reads visible DOM text (
textContent) and writes a local textBlob; it never sends transcript content anywhere. - It has no dependencies and makes no network requests. Keep it that way: bookmarklets run with the host page's privileges.
- Transcript text is treated as plain text, never injected as HTML.
- The generated install page uses a restrictive Content Security Policy and an
external
site.js(no inline event handlers) for the copy button.
See the "Security notes" section of the README for the user-facing summary.