Skip to content

Instantly share code, notes, and snippets.

@elliotboney
Last active July 14, 2026 19:02
Show Gist options
  • Select an option

  • Save elliotboney/e961da975027c83c095fb902e9a3dcf9 to your computer and use it in GitHub Desktop.

Select an option

Save elliotboney/e961da975027c83c095fb902e9a3dcf9 to your computer and use it in GitHub Desktop.
Download Zoom Transcript as Markdown Bookmarklet

Zoom Transcript → Markdown

A bookmarklet that pulls the transcript off a Zoom notes page and downloads it as a clean markdown file.

One click. No extension, no login, no data leaves your browser.

What you get

A transcript.md file formatted like this:

# Transcript

**Garry Creath** [12:02:07]
And do you need to communicate anything with Next Step?

**Elliot Boney** [12:02:19]
Not yet. Waiting on the pipeline numbers first.

Speaker names, timestamps, and message text. Ready to drop into notes, feed to an LLM, or diff against a summary.

How it works

Zoom renders each transcript turn as a row: a header with the speaker name and a timestamp, followed by the message text.

The script finds every element on the page whose entire text is a bare timestamp, then walks up from there to find the row it belongs to. Speaker name comes from the row's aria-label, with a fallback that strips the timestamp out of the header text. Everything in the row that isn't the header gets treated as message content.

Anchoring on timestamps matters. Zoom's class names are generated hashes that change on every deploy, so anything that targets them breaks within weeks. Timestamps are stable because they're content, not styling.

Rows with no body text get skipped, which filters out stray timestamps elsewhere on the page. Turns are assembled into markdown and handed to the browser as a Blob download.

Install

  1. Create a new bookmark in your browser. Name it whatever you want.
  2. Paste this as the URL:
javascript:(function(){var R=/^\d{1,2}:\d{2}(:\d{2})?$/;var S=[].slice.call(document.querySelectorAll('*')).filter(function(e){return e.children.length===0&&R.test(e.textContent.trim())});var seen=new Set();var T=[];S.forEach(function(s){var h=s.parentElement;if(!h)return;var r=h.parentElement;if(!r||seen.has(r))return;seen.add(r);var tm=s.textContent.trim();var n='';var ne=h.querySelector('span[aria-label]');if(ne)n=ne.getAttribute('aria-label').trim();if(!n)n=h.textContent.trim().replace(tm,'').trim();var m=[];[].slice.call(r.children).forEach(function(c){if(c===h)return;var x=c.textContent.trim();if(x)m.push(x)});if(!m.length)return;T.push('**'+n+'**'+(tm?' ['+tm+']':'')+'\n'+m.join(' '))});if(!T.length){alert('No transcript turns found. Make sure the transcript is fully loaded on screen.');return}var md='# Transcript\n\n'+T.join('\n\n')+'\n';var b=new Blob([md],{type:'text/markdown'});var u=URL.createObjectURL(b);var d=document.createElement('a');d.href=u;d.download='transcript.md';document.body.appendChild(d);d.click();d.remove();URL.revokeObjectURL(u)})();

Paste it exactly. Don't let anything re-encode the spaces or quotes. A %20 where a space belongs will break the script on parse.

Use

  1. Open your Zoom notes page and find the transcript.
  2. Scroll through the entire transcript, top to bottom. This is the step people skip.
  3. Click the bookmark.
  4. transcript.md lands in your downloads.

Why the scrolling matters

Zoom virtualizes long transcripts. Turns that haven't been scrolled into view don't exist in the page yet, so there's nothing to scrape. Scroll all the way down, then all the way back up, then click.

If your output is missing the first half of the meeting, this is why.

Troubleshooting

"No transcript turns found." The transcript isn't loaded, or you clicked it on the wrong page. Scroll the transcript into view and try again.

Output is missing turns. Virtualization. See above.

A speaker name looks wrong or shows up as a blank. Zoom occasionally drops the aria-label on a row. The fallback should catch it. If it doesn't, the name is probably rendering somewhere unexpected on that page.

Nothing happens at all, not even the alert. The bookmarklet got mangled on paste. Re-copy it and check that there are no %20 or %22 sequences in the bookmark URL.

Limits

  • Built against the Zoom notes page layout. Zoom changes their DOM without warning, and a big enough change will break this.
  • Reads only what's rendered in the page. It has no API access and can't pull anything you can't already see.
  • Long messages that Zoom splits across multiple elements get joined with a space.
(function () {
// Matches a bare timestamp: 12:02:07 or 1:02 etc.
// Anchoring on these instead of avatars is what makes this work at all.
const TIMESTAMP_PATTERN = /^\d{1,2}:\d{2}(:\d{2})?$/;
/**
* Find every leaf element on the page whose entire text is a timestamp.
* Leaf-only (no children) keeps us from matching wrapper divs that happen
* to contain a timestamp somewhere inside them.
*/
function findTimestampNodes() {
const allElements = Array.from(document.querySelectorAll('*'));
return allElements.filter(function (element) {
const isLeaf = element.children.length === 0;
return isLeaf && TIMESTAMP_PATTERN.test(element.textContent.trim());
});
}
/**
* Pull the speaker name out of a turn's header.
* Preferred source is the aria-label, since it's the cleanest string.
* Fallback strips the timestamp out of the header's raw text, which handles
* the case where Zoom drops the aria-label on some rows.
*/
function extractSpeakerName(headerElement, timestampText) {
const labelledSpan = headerElement.querySelector('span[aria-label]');
if (labelledSpan) {
const label = labelledSpan.getAttribute('aria-label').trim();
if (label) return label;
}
return headerElement.textContent.trim().replace(timestampText, '').trim();
}
/**
* Everything in the row that isn't the header is message content.
* Long messages sometimes split across sibling divs, so collect them all
* and join with a space rather than assuming a single node.
*/
function extractMessageText(rowElement, headerElement) {
const parts = [];
Array.from(rowElement.children).forEach(function (child) {
if (child === headerElement) return;
const text = child.textContent.trim();
if (text) parts.push(text);
});
return parts.join(' ');
}
/** Build one markdown block: **Name** [12:02:07] \n message */
function formatTurn(speakerName, timestampText, messageText) {
const timeSuffix = timestampText ? ' [' + timestampText + ']' : '';
return '**' + speakerName + '**' + timeSuffix + '\n' + messageText;
}
/** Trigger a browser download without leaving the page. */
function downloadMarkdown(markdown, filename) {
const blob = new Blob([markdown], { type: 'text/markdown' });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
}
// --- main ---
const timestampNodes = findTimestampNodes();
const processedRows = new Set(); // guards against two timestamps in one row
const turns = [];
timestampNodes.forEach(function (timestampNode) {
const header = timestampNode.parentElement;
if (!header) return;
const row = header.parentElement;
if (!row) return;
if (processedRows.has(row)) return;
processedRows.add(row);
const timestampText = timestampNode.textContent.trim();
const speakerName = extractSpeakerName(header, timestampText);
const messageText = extractMessageText(row, header);
// Skip rows with no body text. Filters out stray timestamps elsewhere
// on the page that aren't actually transcript turns.
if (!messageText) return;
turns.push(formatTurn(speakerName, timestampText, messageText));
});
if (!turns.length) {
alert('No transcript turns found. Make sure the transcript is fully loaded on screen.');
return;
}
downloadMarkdown('# Transcript\n\n' + turns.join('\n\n') + '\n', 'transcript.md');
})();
javascript:(function(){var R=/^\d{1,2}:\d{2}(:\d{2})?$/;var S=[].slice.call(document.querySelectorAll('*')).filter(function(e){return e.children.length===0&&R.test(e.textContent.trim())});var seen=new Set();var T=[];S.forEach(function(s){var h=s.parentElement;if(!h)return;var r=h.parentElement;if(!r||seen.has(r))return;seen.add(r);var tm=s.textContent.trim();var n='';var ne=h.querySelector('span[aria-label]');if(ne)n=ne.getAttribute('aria-label').trim();if(!n)n=h.textContent.trim().replace(tm,'').trim();var m=[];[].slice.call(r.children).forEach(function(c){if(c===h)return;var x=c.textContent.trim();if(x)m.push(x)});if(!m.length)return;T.push('**'+n+'**'+(tm?' ['+tm+']':'')+'\n'+m.join(' '))});if(!T.length){alert('Still no turns found.');return}var md='#%20Transcript\n\n'+T.join('\n\n')+'\n';var%20b=new%20Blob([md],{type:'text/markdown'});var%20u=URL.createObjectURL(b);var%20d=document.createElement('a');d.href=u;d.download='transcript.md';document.body.appendChild(d);d.click();d.remove();URL.revokeObjectURL(u)})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment