NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Teams Transcript Extractor
// @name:en Teams Transcript Extractor
// @name:ja Teams 文字起こし抽出ツール
// @name:zh-CN Teams 会议转录提取器
// @name:zh-TW Teams 會議轉錄提取器
// @name:ko Teams 회의 대화록 추출기
// @namespace https://github.com/CheerChen
// @version 1.1.2
// @description Extract the full meeting transcript from a Teams recording's SharePoint Stream page via the page's own OneDrive transcript API. Works even when the download button is blocked.
// @description:en Extract the full meeting transcript from a Teams recording's SharePoint Stream page via the page's own OneDrive transcript API. Works even when the download button is blocked.
// @description:ja Teams録画のSharePoint Streamページから、ページ自身のOneDrive文字起こしAPI経由で全文を抽出します。ダウンロードボタンがブロックされていても動作します。
// @description:zh-CN 通过页面自身的 OneDrive 转录 API,从 Teams 录像的 SharePoint Stream 页面提取完整会议转录。即使下载按钮被禁用也可用。
// @description:zh-TW 透過頁面自身的 OneDrive 轉錄 API,從 Teams 錄影的 SharePoint Stream 頁面提取完整會議轉錄。即使下載按鈕被停用也可用。
// @description:ko 페이지 자체의 OneDrive 대화록 API를 통해 Teams 녹화의 SharePoint Stream 페이지에서 전체 회의 대화록을 추출합니다. 다운로드 버튼이 차단되어 있어도 작동합니다.
// @author cheerchen37
// @match *://*/_layouts/15/stream.aspx*
// @match *://*/*/_layouts/15/stream.aspx*
// @match *://*/_layouts/15/streamembed.aspx*
// @match *://*/*/_layouts/15/streamembed.aspx*
// @match *://*/_layouts/15/xplatplugins.aspx*
// @match *://*/*/_layouts/15/xplatplugins.aspx*
// @grant unsafeWindow
// @run-at document-idle
// @icon https://www.google.com/s2/favicons?domain=teams.microsoft.com
// @license MIT
// @homepage https://github.com/CheerChen/userscripts
// @supportURL https://github.com/CheerChen/userscripts/issues
// @updateURL https://raw.githubusercontent.com/CheerChen/userscripts/master/teams-transcript-extractor.user.js
// ==/UserScript==
// The transcript lives in SharePoint-hosted documents: stream.aspx (recording page),
// streamembed.aspx + xplatplugins.aspx (the two OOPIFs inside a Teams recap).
// Verified 2026-09: the recap iframes load from <tenant>-my.sharepoint.com under a
// /personal/<user>/ prefix, and the Teams top page cannot read the iframe URL
// (src attr unset, contentWindow cross-origin) — so matching the _layouts path on
// any host / any prefix is required. The g_fileInfo guard no-ops elsewhere.
(function () {
'use strict';
const PAGE = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const TAG = '[Teams Transcript]';
const log = (...a) => console.log(TAG, ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ---------- extraction method 1: OneDrive transcript API ----------
function itemBase() {
const fi = PAGE.g_fileInfo;
const u = fi && fi['.spItemUrl'];
return u ? u.replace('/_api/v2.0/', '/_api/v2.1/').split('?')[0] : null;
}
// "00:12:34.5678" -> seconds
function offsetToSec(s) {
const m = /^(\d+):(\d+):(\d+(?:\.\d+)?)$/.exec(s || '');
return m ? +m[1] * 3600 + +m[2] * 60 + +m[3] : null;
}
async function extractViaApi() {
const base = itemBase();
if (!base) throw new Error('g_fileInfo.spItemUrl not found (not a Stream recording page)');
const r1 = await fetch(base + '?select=media/transcripts&$expand=media/transcripts');
if (!r1.ok) throw new Error('transcript list HTTP ' + r1.status);
const list = ((await r1.json()).media || {}).transcripts || [];
if (!list.length) throw new Error('recording has no transcripts');
const t = list.find(x => x.isDefault) || list[0];
const r2 = await fetch(base + '/media/transcripts/' + t.id + '/streamContent?format=json');
if (!r2.ok) throw new Error('streamContent HTTP ' + r2.status);
const data = await r2.json();
const speech = (data.entries || []).map(e => ({
kind: 'speech',
sec: offsetToSec(e.startOffset),
speaker: e.speakerDisplayName || '',
text: e.text || '',
speakerId: e.speakerId || null,
roomId: e.roomId || null,
}));
const events = (data.events || []).map(e => ({
kind: 'system',
sec: offsetToSec(e.startOffset),
speaker: '',
text: `${e.eventType}${e.userDisplayName ? ' by ' + e.userDisplayName : ''}`,
eventType: e.eventType,
user: e.userDisplayName || null,
}));
// API entries are already in display order; events slot in by offset.
const entries = [...speech, ...events]
.map((e, i) => ({ ...e, i }))
.sort((a, b) => (a.sec ?? 0) - (b.sec ?? 0) || a.i - b.i)
.map(({ i, ...e }) => e);
log(`api: ${speech.length} utterances, ${events.length} events (transcript ${t.id}, ${list.length} available)`);
return {
method: 'api', entries, complete: true,
transcripts: list.map(x => ({ id: x.id, displayName: x.displayName, languageTag: x.languageTag, isDefault: x.isDefault, source: x.source })),
used: t.id,
};
}
// ---------- extraction method 2: DOM scroll harvest (fallback) ----------
// aria-label = "<Speaker> <time>" where <time> follows the UI language:
// ja "1 時間 2 分間 3 秒間", zh "1 小时 2 分钟 3 秒", en "1 hour 2 minutes 3 seconds".
const H = '(?:時間|小时|小時|hours?|hrs?)';
const M = '(?:分間|分钟|分鐘|分|minutes?|mins?)';
const S = '(?:秒間|秒|seconds?|secs?)';
const TIME_RE = new RegExp(`\\s*(?:(\\d+)\\s*${H})?\\s*(?:(\\d+)\\s*${M})?\\s*(?:(\\d+)\\s*${S})?\\s*$`, 'i');
function parseAriaLabel(s) {
if (!s || !s.trim()) return { speaker: '', sec: null };
const m = s.match(TIME_RE);
const speaker = s.slice(0, m.index).trim();
if (!m[1] && !m[2] && !m[3]) return { speaker, sec: null };
return { speaker, sec: +(m[1] || 0) * 3600 + +(m[2] || 0) * 60 + +(m[3] || 0) };
}
async function extractViaDom({ step = 300 } = {}) {
const scroller = document.getElementById('scrollToTargetTargetedFocusZone');
if (!scroller) throw new Error('dom: no transcript scroller (#scrollToTargetTargetedFocusZone) — is the transcript panel open?');
const harvest = new Map();
const collect = () => {
let setsize = 0;
for (const el of document.querySelectorAll('[aria-posinset]')) {
setsize = +el.getAttribute('aria-setsize') || setsize;
const pos = +el.getAttribute('aria-posinset');
if (harvest.has(pos)) continue;
let p = el.parentElement, group = null;
for (let i = 0; i < 5 && p; i++) {
if (p.getAttribute && p.getAttribute('role') === 'group') { group = p; break; }
p = p.parentElement;
}
harvest.set(pos, {
pos,
ariaLabel: group ? (group.getAttribute('aria-label') || '') : '',
text: (el.textContent || '').trim(),
});
}
return { collected: harvest.size, setsize };
};
scroller.scrollTop = 0;
await sleep(400);
let st = collect();
if (!st.setsize) throw new Error('dom: aria-setsize is 0 — is the transcript panel expanded?');
log(`dom: setsize=${st.setsize}`);
let still = 0;
for (let i = 0; i < 5000 && st.collected < st.setsize; i++) {
const b = scroller.scrollTop;
scroller.scrollTop = b + step;
await sleep(150);
st = collect();
if (scroller.scrollTop === b) { if (++still >= 3) break; await sleep(400); } else still = 0;
}
const items = [...harvest.values()].sort((a, b) => a.pos - b.pos);
const have = new Set(items.map(e => e.pos));
const missing = [];
for (let i = 1; i <= st.setsize; i++) if (!have.has(i)) missing.push(i);
const entries = items.map(e => {
const { speaker, sec } = parseAriaLabel(e.ariaLabel);
// No speaker AND no timestamp -> system line (e.g. "X started transcription").
return { kind: !speaker && sec == null ? 'system' : 'speech', sec, speaker, text: e.text, pos: e.pos, ariaLabel: e.ariaLabel };
});
log(`dom: collected ${items.length}/${st.setsize}, missing ${missing.length}`);
return { method: 'dom', entries, setsize: st.setsize, missing, complete: missing.length === 0 };
}
// ---------- formatting ----------
function cleanRecordingName(name) {
if (!name) return null;
return name
.replace(/-\d{8}_\d{6}.*$/, '')
.replace(/-(Meeting Recording|会議の録音|会议录制).*$/i, '')
.replace(/\.(mp4|vtt|txt|json)$/i, '')
.trim() || null;
}
function fmtTime(sec) {
if (sec == null) return '--:--';
const t = Math.floor(sec);
const hh = Math.floor(t / 3600), mm = Math.floor((t % 3600) / 60), ss = t % 60;
const p = (n) => String(n).padStart(2, '0');
return hh > 0 ? `${hh}:${p(mm)}:${p(ss)}` : `${p(mm)}:${p(ss)}`;
}
function formatText(result, title) {
const out = [];
let cur = null;
const flush = () => { if (cur) { out.push(`[${fmtTime(cur.sec)}] ${cur.speaker} / ${cur.texts.join(' ')}`); cur = null; } };
for (const e of result.entries) {
if (e.kind === 'system') { flush(); out.push(`[${fmtTime(e.sec)}] (system) / ${e.text}`); continue; }
// Empty speaker on a real utterance = Teams could not attribute it (shared room mic).
const label = e.speaker || '(話者不明)';
if (cur && cur.speaker === label) cur.texts.push(e.text);
else { flush(); cur = { speaker: label, sec: e.sec, texts: [e.text] }; }
}
flush();
const speech = result.entries.filter(e => e.kind === 'speech').length;
const cov = result.method === 'dom'
? `${result.entries.length}/${result.setsize} list items, missing ${result.missing.length}`
: `${speech} utterances (complete, via API)`;
const header =
`# Teams meeting transcript\n` +
`# Title: ${title}\n` +
`# Method: ${result.method} — ${cov}\n` +
`# Timestamps relative to recording start (mm:ss / h:mm:ss)\n` +
`# Speaker "(話者不明)" = utterance Teams could not attribute (typically same-room attendees)\n` +
`# Consecutive same-speaker entries merged onto one line\n\n`;
return header + out.join('\n') + '\n';
}
// Real "姓 名(Romaji)" names, to help map anonymized @1..@N / (話者不明) to people.
const NAME_RE = /([^\s、。()()]+(?:\s+[^\s、。()()]+)?([A-Za-z][A-Za-z .'-]*))/g;
function identityHints(result) {
const names = new Set();
for (const e of result.entries) {
if (e.speaker && !/^@\d+$/.test(e.speaker)) names.add(e.speaker);
if (e.user) names.add(e.user);
for (const m of (e.text || '').matchAll(NAME_RE)) names.add(m[1].trim());
}
return {
note: 'Best-effort, NOT a full attendee list. Anonymized @1..@N and (話者不明) are usually people sharing a room mic; roomAccountHolders is whoever joined the room device.',
namesSeen: [...names],
roomUtterances: result.entries.filter(e => e.roomId).length,
roomAccountHolders: [...new Set(result.entries.filter(e => /^RoomAttribution/.test(e.eventType || '')).map(e => e.user).filter(Boolean))],
};
}
// ---------- UI (Fluent-styled to match the Stream page) ----------
const UI_FONT = '"Segoe UI","Segoe UI Web (West European)",-apple-system,BlinkMacSystemFont,"Yu Gothic UI","Microsoft YaHei",sans-serif';
const PRIMARY = '#5b5fc7'; // Stream/Teams theme purple
const PRIMARY_HOVER = '#4f52b2';
const TRIGGER_BG = '#d83b01'; // Fluent orange — stands out next to the purple Share button
const TRIGGER_BG_HOVER = '#b33401';
function fluentBtn(text, primary) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = text;
b.style.cssText =
'min-width:96px;height:32px;padding:0 16px;border-radius:4px;cursor:pointer;' +
`font:600 14px ${UI_FONT};` +
(primary
? `background:${PRIMARY};color:#fff;border:1px solid transparent;`
: 'background:#fff;color:#242424;border:1px solid #d1d1d1;');
const normal = b.style.background, hover = primary ? PRIMARY_HOVER : '#f5f5f5';
b.addEventListener('mouseenter', () => (b.style.background = hover));
b.addEventListener('mouseleave', () => (b.style.background = normal));
return b;
}
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
async function copyText(text, btn) {
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard API can be blocked inside the Teams recap OOPIF.
const ta = document.createElement('textarea');
ta.value = text;
document.body.append(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
const prev = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => (btn.textContent = prev), 1500);
}
function safeFilename(s) {
return (s || 'teams-transcript').replace(/[\\/:*?"<>|]/g, '_');
}
function showResult(result, title) {
const txt = formatText(result, title);
const json = JSON.stringify({
title,
recordingFile: (PAGE.g_fileInfo || {}).name || null,
method: result.method,
apiError: result.apiError || null,
transcripts: result.transcripts || null,
setsize: result.setsize,
missing: result.missing,
identityHints: identityHints(result),
entries: result.entries,
}, null, 2);
const base = safeFilename(title);
const overlay = document.createElement('div');
overlay.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:2147483646;' +
'display:flex;align-items:center;justify-content:center;';
const dlg = document.createElement('div');
dlg.setAttribute('role', 'dialog');
dlg.style.cssText =
'background:#fff;border-radius:8px;overflow:hidden;display:flex;flex-direction:column;' +
'width:min(760px,92vw);max-height:82vh;' +
'box-shadow:0 14px 28px rgba(0,0,0,.24),0 0 8px rgba(0,0,0,.12);' +
`font-family:${UI_FONT};color:#242424;`;
// Header: dialog title + coverage subtitle + close icon button
const head = document.createElement('div');
head.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;padding:18px 24px 4px;gap:16px;';
const headText = document.createElement('div');
headText.style.minWidth = '0';
const hTitle = document.createElement('div');
hTitle.style.cssText = 'font-size:20px;font-weight:600;line-height:28px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
hTitle.textContent = title;
const speech = result.entries.filter(e => e.kind === 'speech').length;
const meta = result.method === 'dom'
? `${result.entries.length}/${result.setsize} items, missing ${result.missing.length}`
: `${speech} utterances`;
const hMeta = document.createElement('div');
hMeta.style.cssText = 'font-size:12px;color:#605e5c;margin-top:2px;';
hMeta.textContent = `Teams transcript — ${result.method} · ${meta}` +
(result.apiError ? ` · api failed: ${result.apiError}` : '');
headText.append(hTitle, hMeta);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.style.cssText =
'background:transparent;border:none;cursor:pointer;width:32px;height:32px;' +
'border-radius:4px;font-size:16px;color:#605e5c;flex:none;line-height:1;';
closeBtn.textContent = '✕';
closeBtn.addEventListener('mouseenter', () => (closeBtn.style.background = '#f5f5f5'));
closeBtn.addEventListener('mouseleave', () => (closeBtn.style.background = 'transparent'));
head.append(headText, closeBtn);
// Body: transcript text
const content = document.createElement('pre');
content.style.cssText =
'margin:12px 24px;padding:14px 16px;flex:1;overflow:auto;' +
'border:1px solid #edebe9;border-radius:6px;background:#faf9f8;' +
'font:12px/1.55 Consolas,Monaco,"Courier New",monospace;color:#323130;' +
'white-space:pre-wrap;word-break:break-word;';
content.textContent = txt;
// Footer: Fluent action buttons
const foot = document.createElement('div');
foot.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;padding:0 24px 20px;';
const copyBtn = fluentBtn('Copy TXT', true);
copyBtn.addEventListener('click', () => copyText(txt, copyBtn));
const dlTxtBtn = fluentBtn('Download TXT');
dlTxtBtn.addEventListener('click', () => downloadFile(txt, `${base}.transcript.txt`, 'text/plain'));
const dlJsonBtn = fluentBtn('Download JSON');
dlJsonBtn.addEventListener('click', () => downloadFile(json, `${base}.raw.json`, 'application/json'));
foot.append(copyBtn, dlTxtBtn, dlJsonBtn);
dlg.append(head, content, foot);
overlay.append(dlg);
document.body.append(overlay);
const close = () => overlay.remove();
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function showError(err) {
console.error(TAG, err);
const div = document.createElement('div');
div.style.cssText =
'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;' +
'padding:10px 16px;max-width:560px;background:#fff;color:#a4262c;' +
'border:1px solid #edebe9;border-left:4px solid #a4262c;border-radius:4px;' +
`box-shadow:0 4px 12px rgba(0,0,0,.18);font:13px ${UI_FONT};`;
div.textContent = 'Transcript extraction failed: ' + err.message;
document.body.append(div);
setTimeout(() => div.remove(), 8000);
}
// ---------- trigger button ----------
const LABEL = (() => {
const lang = (document.documentElement.lang || navigator.language || '').toLowerCase();
if (lang.startsWith('ja')) return { idle: '文字起こし', busy: '抽出中…' };
if (lang.startsWith('zh')) return lang.includes('tw') || lang.includes('hk')
? { idle: '提取轉錄', busy: '擷取中…' }
: { idle: '提取转录', busy: '提取中…' };
return { idle: 'Transcript', busy: 'Extracting…' };
})();
async function run(labelEl, btn) {
btn.disabled = true;
labelEl.textContent = LABEL.busy;
try {
let result, apiError = null;
try {
result = await extractViaApi();
} catch (e) {
apiError = e.message;
log('api failed:', e.message, '— falling back to DOM harvest');
result = await extractViaDom();
}
const fi = PAGE.g_fileInfo || {};
const title = fi.title || cleanRecordingName(fi.name) || 'teams-transcript';
showResult({ ...result, apiError }, title);
} catch (e) {
showError(e);
}
btn.disabled = false;
labelEl.textContent = LABEL.idle;
}
const TRANSCRIPT_SVG =
'<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
'<rect x="2" y="3" width="9" height="1.5" rx="0.75"/>' +
'<rect x="2" y="6.5" width="12" height="1.5" rx="0.75"/>' +
'<rect x="2" y="10" width="11" height="1.5" rx="0.75"/>' +
'<rect x="2" y="13.5" width="6" height="1.5" rx="0.75"/></svg>';
// Insert into the SharePoint command bar, before Microsoft's own button group
// (Teams / Share). Clone the primary ("Share") button's ms-OverflowSet-item —
// falling back to the first item — so the generated root-XX classes come
// along; cloneNode does not copy listeners, so only our own handler is
// attached. Falls back to a floating button when the command bar never
// appears (e.g. inside the recap xplatplugins.aspx iframe).
function addTriggerButton() {
const group = document.querySelector('.ms-CommandBar-secondaryCommand');
const primaryBtn = group && group.querySelector('.ms-Button--primary');
const donorItem = group && (primaryBtn
? primaryBtn.closest('.ms-OverflowSet-item')
: group.querySelector('.ms-OverflowSet-item'));
if (donorItem) {
const item = donorItem.cloneNode(true);
item.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
const btn = item.querySelector('button');
// Strip donor identity: keep class/type/tabindex/style, drop
// name, role, aria-*, data-automationid.
for (const a of [...btn.attributes]) {
if (!['class', 'type', 'tabindex', 'data-is-focusable', 'style'].includes(a.name)) {
btn.removeAttribute(a.name);
}
}
btn.classList.remove('ms-Button--hasMenu');
btn.title = 'Extract transcript';
btn.setAttribute('aria-label', 'Extract transcript');
// Repaint the (primary) donor to orange with white text. Inline
// background beats the generated :hover rule, so hover is manual.
btn.style.background = TRIGGER_BG;
btn.style.color = '#fff';
btn.addEventListener('mouseenter', () => (btn.style.background = TRIGGER_BG_HOVER));
btn.addEventListener('mouseleave', () => (btn.style.background = TRIGGER_BG));
item.querySelector('.ms-Button-menuIcon')?.remove();
const iconSlot = item.querySelector('.ms-Button-icon');
if (iconSlot) {
// The donor glyph is a font ::before; clear classes so it
// disappears, then drop in the SVG.
iconSlot.className = '';
iconSlot.removeAttribute('style');
iconSlot.style.cssText = 'display:flex;align-items:center;margin-right:4px;';
iconSlot.innerHTML = TRANSCRIPT_SVG;
}
const labelEl = item.querySelector('.ms-Button-label');
if (labelEl) labelEl.textContent = LABEL.idle;
btn.addEventListener('click', () => run(labelEl || btn, btn));
group.prepend(item);
return;
}
// No command bar (e.g. recap OOPIF) — floating fallback.
const btn = document.createElement('button');
btn.type = 'button';
const labelEl = document.createElement('span');
labelEl.textContent = LABEL.idle;
const icon = document.createElement('i');
icon.style.cssText = 'display:flex;align-items:center;';
icon.innerHTML = TRANSCRIPT_SVG;
btn.append(icon, labelEl);
btn.style.cssText =
'position:fixed;bottom:20px;right:20px;z-index:2147483646;padding:10px 18px;' +
`background:${TRIGGER_BG};color:#fff;border:none;border-radius:6px;cursor:pointer;` +
`font:600 14px ${UI_FONT};box-shadow:0 2px 8px rgba(0,0,0,.25);` +
'display:flex;align-items:center;gap:8px;';
btn.addEventListener('click', () => run(labelEl, btn));
document.body.append(btn);
}
// xplatplugins.aspx hosts many recap plugins; only pages where SharePoint
// actually exposes g_fileInfo + .spItemUrl are real recording players.
// The command bar also renders late, so poll for both before injecting.
(async () => {
let haveFile = false;
for (let i = 0; i < 240; i++) {
const file = itemBase();
if (file) haveFile = true;
if (file && document.querySelector('.ms-CommandBar-secondaryCommand')) { addTriggerButton(); return; }
// g_fileInfo present but command bar absent for ~30s -> floating fallback
if (file && i > 60) { addTriggerButton(); return; }
await sleep(500);
}
if (!haveFile) log('g_fileInfo.spItemUrl not found after 120s — no trigger button added');
})();
})();