Last active
October 5, 2026 12:27
-
-
Save LoggeL/56922e18905631bc66a288ef0160102b to your computer and use it in GitHub Desktop.
MS Teams Online Transcript Downloader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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