Skip to content

Instantly share code, notes, and snippets.

@LoggeL
Last active October 5, 2026 12:27
Show Gist options
  • Select an option

  • Save LoggeL/56922e18905631bc66a288ef0160102b to your computer and use it in GitHub Desktop.

Select an option

Save LoggeL/56922e18905631bc66a288ef0160102b to your computer and use it in GitHub Desktop.
MS Teams Online Transcript Downloader
// Open the transcript, then paste into DevTools Console.
// In Teams, select "RecapxPlatIframe" (xplatplugins.aspx) in the Console's frame dropdown first.
(async () => {
const DOWNLOAD_JSON = true; // false: TXT only
const info = window.g_fileInfo || {};
const resources = performance.getEntriesByType("resource").map(r => r.name).reverse();
const urls = [...new Set([
...resources.filter(u => /\/media\/transcripts\/[^/?]+\/streamContent(?:\?|$)/i.test(u)),
...resources.filter(u => /\/cdnmedia\/transcripts\/?(?:\?|$)/i.test(u)),
info.spItemTranscriptsContentCdnUrl,
info.spItemTranscriptsContentUrl
].filter(Boolean))];
if (!urls.length) throw new Error(
"Open the transcript first. In Teams, select RecapxPlatIframe in the Console's frame dropdown."
);
const parse = text => {
text = text.replace(/^\uFEFF/, "").trim();
try {
const json = JSON.parse(text);
if (json.entries?.length) return json;
} catch {}
if (!/^WEBVTT(?:\s|$)/i.test(text)) return null;
const entries = [];
for (const block of text.split(/\r?\n\s*\r?\n/)) {
const lines = block.split(/\r?\n/);
const i = lines.findIndex(line => line.includes("-->"));
const timing = lines[i]?.match(/(\S+)\s+-->\s+(\S+)/);
if (!timing) continue;
const cue = lines.slice(i + 1).join(" ");
const voice = cue.match(/<v(?:\.[^\s>]+)?\s+([^>]+)>/i);
const decoded = document.createElement("textarea");
decoded.innerHTML = cue.replace(/<[^>]*>/g, "");
if (decoded.value.trim()) entries.push({
startOffset: timing[1], endOffset: timing[2],
speakerDisplayName: voice?.[1] || "?", text: decoded.value.trim()
});
}
return entries.length ? { entries } : null;
};
const utf8 = new TextDecoder();
const base64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
let transcript;
for (const url of urls) {
try {
const response = await fetch(url, {
credentials: "include", signal: AbortSignal.timeout(15000)
});
if (!response.ok) continue;
const bytes = await response.arrayBuffer();
transcript = parse(utf8.decode(bytes));
if (!transcript && info.EncKey) {
const enc = typeof info.EncKey === "string" ? JSON.parse(info.EncKey) : info.EncKey;
const key = await crypto.subtle.importKey("raw", base64(enc.Key), "AES-CBC", false, ["decrypt"]);
transcript = parse(utf8.decode(await crypto.subtle.decrypt({ name: "AES-CBC", iv: base64(enc.IV) }, key, bytes)));
}
if (transcript) break;
} catch {}
}
if (!transcript) throw new Error("Could not load the transcript. Reload the page, open the transcript and try again.");
const seconds = value => {
if (typeof value === "number") return value;
const iso = String(value || "").match(/^PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/i);
return iso ? (Number(iso[1] || 0) * 3600 + Number(iso[2] || 0) * 60 + Number(iso[3] || 0))
: String(value || "0").replace(",", ".").split(":").reduce((n, part) => n * 60 + Number(part), 0);
};
const time = value => {
const n = Math.round(seconds(value) * 1000);
return [Math.floor(n / 3600000), Math.floor(n / 60000) % 60, Math.floor(n / 1000) % 60]
.map(n => String(n).padStart(2, "0")).join(":") + (n % 1000 ? `.${String(n % 1000).padStart(3, "0")}` : "");
};
const entries = [...transcript.entries].sort((a, b) => seconds(a.startOffset) - seconds(b.startOffset));
const txt = entries.map(e => `[${time(e.startOffset)}] ${e.speakerDisplayName || e.speakerName || e.speaker || "?"}: ${String(e.text || e.eventData?.text || "").replace(/\s+/g, " ").trim()}`).join("\n");
const name = (info.name || document.title || "transcript")
.replace(/\.(mp4|mov|mkv|webm|vtt).*$/i, "").replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 120) || "transcript";
const save = (ext, text, type) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type }));
a.download = `${name}-transcript.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30000);
};
save("txt", txt, "text/plain;charset=utf-8");
if (DOWNLOAD_JSON) {
await new Promise(resolve => setTimeout(resolve, 350));
save("json", JSON.stringify(transcript, null, 2), "application/json");
}
console.info(`Downloaded ${entries.length} transcript entries.`);
})().catch(console.error);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment