bookmarklets
Bookmarklets
Small single-purpose JavaScript tools you drag onto your bookmark bar.
A bookmarklet is a bookmark whose address is a small JavaScript program instead of a URL. Clicking it runs that program against the page you are currently looking at — no extension to install, no permissions to grant, nothing that keeps running in the background.
How to install one
- Make sure your bookmarks bar is visible (Ctrl+Shift+B, or Cmd+Shift+B on a Mac).
- Drag the button below onto that bar.
- Open the conversation you want and click the new bookmark.
If dragging is awkward, use Copy and paste the result into the URL field of a manually created bookmark.
-
Export Claude Conversation
Downloads the claude.ai conversation you are viewing as a Markdown file, including thinking blocks, tool calls and attachments.
Export Claude Conversation Drag this onto your bookmarks bar.
Source — claude-export.js
/** * Claude.ai conversation → Markdown export bookmarklet * * Run it while viewing https://claude.ai/chat/<uuid>. * It uses Claude's own internal JSON API (same session cookies the page * uses), so it captures the full conversation without DOM scraping — * including messages scrolled out of view / virtualized away. * * Output: a downloaded .md file. Filename defaults to * claude_<slugified-title>_<short-id>.md * and a prompt() lets you edit it before saving. * * @title Export Claude Conversation * @description Downloads the claude.ai conversation you are viewing as a Markdown * file, including thinking blocks, tool calls and attachments. * @order 10 */ (async () => { try { // 1. Conversation id from the URL const m = location.pathname.match(/\/chat\/([0-9a-f-]{36})/i); if (!m) { alert('Open a conversation first (URL must look like /chat/<uuid>).'); return; } const convId = m[1]; // 2. Organization id: prefer the lastActiveOrg cookie, fall back to API let orgId = (document.cookie.match(/(?:^|;\s*)lastActiveOrg=([^;]+)/) || [])[1]; if (!orgId) { const orgs = await (await fetch('/api/organizations', { credentials: 'include' })).json(); const org = orgs.find(o => (o.capabilities || []).includes('chat')) || orgs[0]; orgId = org && org.uuid; } if (!orgId) { alert('Could not determine organization id — are you logged in?'); return; } // 3. Fetch the full conversation tree const url = `/api/organizations/${orgId}/chat_conversations/${convId}` + `?tree=True&rendering_mode=messages&render_all_tools=true`; const res = await fetch(url, { credentials: 'include' }); if (!res.ok) { alert(`API request failed (${res.status}). Claude may have changed its API.`); return; } const conv = await res.json(); // 4. Render one message's content blocks as markdown const renderBlock = (b) => { switch (b.type) { case 'text': return b.text || ''; case 'thinking': return b.thinking ? `<details><summary>Thinking</summary>\n\n${b.thinking}\n\n</details>` : ''; case 'tool_use': return '```json\n' + `// tool_use: ${b.name || 'unknown'}\n` + JSON.stringify(b.input || {}, null, 2) + '\n```'; case 'tool_result': { const txt = Array.isArray(b.content) ? b.content.map(c => c.text || '').join('\n') : (typeof b.content === 'string' ? b.content : JSON.stringify(b.content)); return '```\n' + `[tool_result${b.is_error ? ' (error)' : ''}]\n` + txt + '\n```'; } default: return b.text || ''; } }; const renderMsg = (msg) => { const role = msg.sender === 'human' ? 'Human' : 'Assistant'; const parts = (msg.content && msg.content.length) ? msg.content.map(renderBlock).filter(Boolean) : [msg.text || '']; // Attachments / uploaded files, if any const extras = []; (msg.attachments || []).forEach(a => { extras.push(`> **Attachment:** ${a.file_name || 'file'}` + (a.extracted_content ? `\n\n\`\`\`\n${a.extracted_content}\n\`\`\`` : '')); }); (msg.files || []).forEach(f => extras.push(`> **File:** ${f.file_name || f.file_kind || 'file'}`)); return `## ${role}\n\n${[...extras, ...parts].join('\n\n')}`; }; const msgs = (conv.chat_messages || []) .slice() .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const title = conv.name || 'Untitled conversation'; const md = [ `# ${title}`, '', `- **Conversation ID:** ${convId}`, `- **URL:** ${location.origin}/chat/${convId}`, `- **Created:** ${conv.created_at || 'unknown'}`, `- **Exported:** ${new Date().toISOString()}`, `- **Messages:** ${msgs.length}`, '', '---', '', msgs.map(renderMsg).join('\n\n---\n\n'), '' ].join('\n'); // 5. Filename: derived from title + short id, editable via prompt() const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); const suggested = `claude_${slug || 'conversation'}_${convId.slice(0, 8)}.md`; const filename = prompt('Save conversation as:', suggested); if (!filename) return; // user cancelled // 6. Download const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename.endsWith('.md') ? filename : filename + '.md'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } catch (e) { alert('Export failed: ' + e.message); console.error(e); } })(); -
Export ChatGPT Conversation
Downloads the chatgpt.com conversation you are viewing as a Markdown file, including reasoning summaries, tool calls and attachments.
Export ChatGPT Conversation Drag this onto your bookmarks bar.
Source — chatgpt-export.js
/** * ChatGPT conversation → Markdown export bookmarklet * * Run it while viewing https://chatgpt.com/c/<uuid> (also works inside * projects / custom GPTs: /g/<gizmo>/c/<uuid>). * It uses ChatGPT's own internal JSON API (the page's session is exchanged * for a bearer token via /api/auth/session), so it captures the full * conversation without DOM scraping. * * ChatGPT stores a conversation as a tree (every edit / regenerate forks it). * The export follows the branch currently shown on screen: from * `current_node` back up to the root. * * Output: a downloaded .md file. Filename defaults to * chatgpt_<slugified-title>_<short-id>.md * and a prompt() lets you edit it before saving. * * @title Export ChatGPT Conversation * @description Downloads the chatgpt.com conversation you are viewing as a Markdown * file, including reasoning summaries, tool calls and attachments. * @order 20 */ (async () => { try { // 1. Conversation id from the URL const m = location.pathname.match(/\/c\/([0-9a-f-]{36})/i); if (!m) { alert('Open a conversation first (URL must look like /c/<uuid>).'); return; } const convId = m[1]; // 2. Access token from the logged-in session const session = await (await fetch('/api/auth/session', { credentials: 'include' })).json(); const token = session && session.accessToken; if (!token) { alert('Could not get an access token — are you logged in?'); return; } // 3. Fetch the full conversation tree const res = await fetch(`/backend-api/conversation/${convId}`, { credentials: 'include', headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) { alert(`API request failed (${res.status}). ChatGPT may have changed its API.`); return; } const conv = await res.json(); // 4. Walk the visible branch: current_node → root, then reverse const mapping = conv.mapping || {}; const chain = []; for (let id = conv.current_node; id && mapping[id]; id = mapping[id].parent) { chain.push(mapping[id]); } chain.reverse(); // Inline citation markers look like \ue200cite\ue202turn0search0\ue201; // replace them with the rendered link text where the API provides one. // Only references whose matched_text contains such a marker are applied: // some (e.g. the sources footnote) match a plain ' ', which would // otherwise strip every space from the message. const MARKER = /[\ue200-\ue2ff]/; const cleanText = (text, meta) => { let t = text || ''; ((meta && meta.content_references) || []).forEach(r => { if (r.matched_text && MARKER.test(r.matched_text)) t = t.split(r.matched_text).join(r.alt || ''); }); return t.replace(/[\ue200-\ue2ff]/g, ''); }; const fence = (lang, body) => '```' + (lang || '') + '\n' + (body || '').replace(/\n$/, '') + '\n```'; // 5. Render one message as markdown (or '' to skip it) const renderMsg = (msg) => { const meta = msg.metadata || {}; if (meta.is_visually_hidden_from_conversation) return ''; const role = msg.author && msg.author.role; if (role === 'system') return ''; const c = msg.content || {}; let body = ''; switch (c.content_type) { case 'text': body = cleanText((c.parts || []).join('\n'), meta); break; case 'multimodal_text': body = (c.parts || []).map(p => { if (typeof p === 'string') return cleanText(p, meta); if (p.content_type === 'image_asset_pointer') return `> **Image:** ${p.asset_pointer || 'image'}`; if (p.content_type === 'audio_transcription') return p.text || ''; return p.text || ''; }).filter(Boolean).join('\n\n'); break; case 'code': // Assistant calling a tool (python, web search, canvas, …) body = `*Tool call → ${msg.recipient || 'tool'}*\n\n` + fence(c.language === 'unknown' ? '' : c.language, c.text); break; case 'execution_output': body = fence('', `[output]\n${c.text || ''}`); break; case 'thoughts': body = (c.thoughts || []).length ? '<details><summary>Thinking</summary>\n\n' + c.thoughts.map(t => (t.summary ? `**${t.summary}**\n\n` : '') + (t.content || '')).join('\n\n') + '\n\n</details>' : ''; break; case 'reasoning_recap': body = c.content ? `*${c.content}*` : ''; break; case 'tether_quote': body = `> **Quote from [${c.title || c.domain || c.url}](${c.url})**\n>\n> ` + (c.text || '').split('\n').join('\n> '); break; case 'tether_browsing_display': body = c.result ? fence('', `[browsing]\n${c.result}`) : ''; break; case 'system_error': body = `> **Error:** ${c.name || ''} ${c.text || ''}`.trim(); break; case 'user_editable_context': case 'model_editable_context': return ''; // custom instructions / memory — not part of the dialogue default: body = c.text || (Array.isArray(c.parts) ? c.parts.filter(p => typeof p === 'string').join('\n') : ''); } const extras = (meta.attachments || []).map(a => `> **Attachment:** ${a.name || a.id || 'file'}`); const parts = [...extras, body].filter(s => s && s.trim()); if (!parts.length) return ''; const heading = role === 'user' ? 'User' : role === 'tool' ? `Tool${msg.author.name ? ` (${msg.author.name})` : ''}` : `Assistant${meta.model_slug ? ` (${meta.model_slug})` : ''}`; return `## ${heading}\n\n${parts.join('\n\n')}`; }; const rendered = chain.map(n => n.message).filter(Boolean).map(renderMsg).filter(Boolean); const title = conv.title || 'Untitled conversation'; const iso = (s) => (s ? new Date(s * 1000).toISOString() : 'unknown'); const md = [ `# ${title}`, '', `- **Conversation ID:** ${convId}`, `- **URL:** ${location.origin}/c/${convId}`, `- **Created:** ${iso(conv.create_time)}`, `- **Updated:** ${iso(conv.update_time)}`, `- **Exported:** ${new Date().toISOString()}`, `- **Messages:** ${rendered.length}`, '', '---', '', rendered.join('\n\n---\n\n'), '' ].join('\n'); // 6. Filename: derived from title + short id, editable via prompt() const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); const suggested = `chatgpt_${slug || 'conversation'}_${convId.slice(0, 8)}.md`; const filename = prompt('Save conversation as:', suggested); if (!filename) return; // user cancelled // 7. Download const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename.endsWith('.md') ? filename : filename + '.md'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } catch (e) { alert('Export failed: ' + e.message); console.error(e); } })(); -
Export AllTrails GPX
Saves the trail map you're viewing on alltrails.com as a .gpx file, from the route data your browser already loaded to draw the map.
Export AllTrails GPX Drag this onto your bookmarks bar.
Source — alltrails-gpx-export.js
/** * AllTrails GPX export bookmarklet * * Run it on a trail's map page on alltrails.com, after the map has finished * loading. To draw the route, the page already asks its own API for the * line and elevation data and loads it into your browser — this just reads * that same data (via the browser's standard Performance/Resource Timing * API, which lists requests the page itself already made, and a client * header read back out of the page's own public JavaScript) and saves it * as a plain .gpx track file you can use in any map or GPS app. * * @title Export AllTrails GPX * @description Saves the trail map you're viewing on alltrails.com as a .gpx file, * from the route data your browser already loaded to draw the map. * @order 30 */ (async () => { try { if (!location.hostname.endsWith('alltrails.com')) { alert('Open a trail map page on alltrails.com first.'); return; } // The map page fetches its own route/elevation data to draw the map; // find that request among the ones the browser already made. const mapUrls = [...new Set( performance.getEntriesByType('resource') .map((e) => e.name) .filter((u) => /\/api\/alltrails\/maps\/\d+/.test(u)) )]; if (!mapUrls.length) { alert('Could not find the map data yet — make sure the map has finished loading, then try again.'); return; } const mapUrl = mapUrls[mapUrls.length - 1]; // That request carries a client header alongside the session cookie. // It's a fixed string shipped in the page's own public JS (the same one // the site also hands to third-party map-tile providers as a "key=" // query parameter), so it's read back out of that JS rather than // copied here, in case the site ever changes it. const findClientKey = async () => { const scriptUrls = [...new Set( performance.getEntriesByType('resource') .map((e) => e.name) .filter((u) => u.endsWith('.js') && u.includes('cloudfront.net')) )]; for (const url of scriptUrls) { try { const text = await (await fetch(url)).text(); const headerIdx = text.search(/"X-AT-KEY"\s*:/i); if (headerIdx === -1) continue; const before = text.slice(Math.max(0, headerIdx - 500), headerIdx); const tokens = [...before.matchAll(/=\s*"([a-z0-9]{24,40})"/gi)]; if (tokens.length) return tokens[tokens.length - 1][1]; } catch (e) { /* try the next chunk */ } } return null; }; const clientKey = await findClientKey(); if (!clientKey) { alert('Could not find the site\'s client key in its scripts — AllTrails may have changed its code.'); return; } const res = await fetch(mapUrl, { credentials: 'include', headers: { 'X-AT-KEY': clientKey } }); if (!res.ok) { alert(`Could not load the map data (HTTP ${res.status}).`); return; } const data = await res.json(); const map = (data.maps || [])[0]; if (!map) { alert('No route data in the response — AllTrails may have changed its API.'); return; } // Both the route line and its elevation profile are delta-encoded the // same way a Google-style polyline is: a run of base-64-ish characters // decoding to a stream of zigzag-varint signed integers. const decodeDeltas = (str) => { const out = []; let index = 0; while (index < str.length) { let shift = 0, result = 0, byte; do { byte = str.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); out.push(result & 1 ? ~(result >> 1) : (result >> 1)); } return out; }; const decodePoints = (pointsData) => { const deltas = decodeDeltas(pointsData); const coords = []; let lat = 0, lng = 0; for (let i = 0; i < deltas.length; i += 2) { lat += deltas[i]; lng += deltas[i + 1]; coords.push([lat / 1e5, lng / 1e5]); } return coords; }; // Pairs of (point-index step, elevation delta); only the elevation // stream is needed, the index just confirms it lines up with the points. const decodeElevations = (indexedElevationData) => { if (!indexedElevationData) return null; const deltas = decodeDeltas(indexedElevationData); const elevations = []; let ele = 0; for (let i = 0; i < deltas.length; i += 2) { ele += deltas[i + 1]; elevations.push(ele / 1e5); } return elevations; }; const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const segments = []; (map.routes || []).forEach((route) => { (route.lineSegments || []) .slice() .sort((a, b) => a.sequenceNum - b.sequenceNum) .forEach((seg) => { const poly = seg.polyline || {}; if (!poly.pointsData) return; const coords = decodePoints(poly.pointsData); const elevations = decodeElevations(poly.indexedElevationData); const points = coords.map(([lat, lng], i) => { const ele = elevations && elevations[i] != null ? `<ele>${elevations[i].toFixed(1)}</ele>` : ''; return ` <trkpt lat="${lat.toFixed(6)}" lon="${lng.toFixed(6)}">${ele}</trkpt>`; }).join('\n'); segments.push(` <trkseg>\n${points}\n </trkseg>`); }); }); if (!segments.length) { alert('No route line found for this trail.'); return; } const trailName = map.name || 'AllTrails trail'; const gpx = '<?xml version="1.0" encoding="UTF-8"?>\n' + '<gpx version="1.1" creator="AllTrails GPX export bookmarklet" xmlns="http://www.topografix.com/GPX/1/1">\n' + ` <metadata><name>${esc(trailName)}</name><link href="${esc(location.href)}"></link></metadata>\n` + ` <trk><name>${esc(trailName)}</name>\n${segments.join('\n')}\n </trk>\n` + '</gpx>\n'; const slug = trailName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); const suggested = `${slug || 'trail'}.gpx`; const filename = prompt('Save trail as:', suggested); if (!filename) return; // user cancelled const blob = new Blob([gpx], { type: 'application/gpx+xml;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename.endsWith('.gpx') ? filename : filename + '.gpx'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } catch (e) { alert('Export failed: ' + e.message); console.error(e); } })(); -
Export Teams Chat
Downloads the Microsoft Teams chat you are viewing as a Markdown file, with pasted images saved alongside it in a .zip.
Export Teams Chat Drag this onto your bookmarks bar.
Source — teams-export.js
/** * Microsoft Teams chat → Markdown (+ images) export bookmarklet * * Run it in the Teams web app (teams.cloud.microsoft / teams.microsoft.com) * while a chat is open. It uses Teams' own chat service API with the tokens * the logged-in page already holds, so it captures the whole history * without scrolling or DOM scraping. * * Inline images (pasted screenshots, photos) are downloaded too. The export * is then a .zip holding the .md file plus an images/ folder it links to; * a chat without images is saved as a plain .md file. Shared files * (SharePoint / OneDrive) are linked, not downloaded. * * Output filename defaults to * teams_<slugified-topic>_<short-id>.zip (or .md) * and a prompt() lets you edit it before saving. * * @title Export Teams Chat * @description Downloads the Microsoft Teams chat you are viewing as a Markdown file, * with pasted images saved alongside it in a .zip. * @order 40 */ (async () => { try { // 1. Conversation id: Teams rewrites the URL to its bare origin, so read // it from the open chat's compose box / images, else from a deep link. const THREAD = /19:[\w.\-]+@[\w.\-]+/; const attr = (sel, name) => { const el = document.querySelector(sel); return (el && el.getAttribute(name)) || ''; }; const convId = (attr('[data-track-thread-id]', 'data-track-thread-id').match(THREAD) || attr('[data-gallery-id^="gallery-conversation-"]', 'data-gallery-id').match(THREAD) || decodeURIComponent(location.href).match(THREAD) || [])[0]; if (!convId) { alert('Open a Teams chat first.'); return; } // 2. Skype token: exchange one of the page's cached api.spaces.skype.com // access tokens (current tenant first) and keep the first that can // read this conversation. let tid; try { tid = JSON.parse(localStorage.getItem('tmp.auth.v1.GLOBAL.User.User')).item.profile.tid; } catch (e) {} const now = Date.now() / 1000; const bearers = Object.keys(localStorage) .filter(k => k.includes('|accesstoken|')) .map(k => { try { return JSON.parse(localStorage.getItem(k)); } catch (e) { return null; } }) .filter(t => t && t.secret && /api\.spaces\.skype\.com/.test(t.target || '') && Number(t.expiresOn) > now) .sort((a, b) => (b.realm === tid) - (a.realm === tid)); if (!bearers.length) { alert('No usable Teams token found — reload Teams and try again.'); return; } const view = 'view=msnp24Equivalent|supportsMessageProperties'; let skypeToken, chatService, firstPage; for (const b of bearers) { const authz = await fetch('https://teams.microsoft.com/api/authsvc/v1.0/authz', { method: 'POST', headers: { Authorization: 'Bearer ' + b.secret } }); if (!authz.ok) continue; const j = await authz.json(); const token = j.tokens && j.tokens.skypeToken; const service = j.regionGtms && j.regionGtms.chatService; if (!token || !service) continue; const res = await fetch(`${service}/v1/users/ME/conversations/${encodeURIComponent(convId)}/messages` + `?${view}&pageSize=200&startTime=1`, { headers: { Authentication: 'skypetoken=' + token } }); if (!res.ok) continue; skypeToken = token; chatService = service; firstPage = await res.json(); break; } if (!firstPage) { alert('Could not read this chat. Teams may have changed its API.'); return; } const auth = { headers: { Authentication: 'skypetoken=' + skypeToken } }; // 3. All messages: the API pages backwards from the newest one const raw = []; for (let page = firstPage, n = 0; page && n < 500; n++) { raw.push(...(page.messages || [])); const next = page._metadata && page._metadata.backwardLink; page = next ? await (await fetch(next, auth)).json() : null; } const msgs = raw.sort((a, b) => new Date(a.originalarrivaltime) - new Date(b.originalarrivaltime)); // Thread properties (topic, creation time, members) — optional let thread = {}; try { const r = await fetch(`${chatService}/v1/threads/${encodeURIComponent(convId)}?view=msnp24Equivalent`, auth); if (r.ok) thread = await r.json(); } catch (e) {} // 4. Helpers const mriOf = (m) => (m.from || '').split('/').pop(); const names = {}; msgs.forEach(m => { if (m.imdisplayname) names[mriOf(m)] = m.imdisplayname; }); const who = (mri) => names[mri] || 'someone'; const when = (t) => new Date(t).toLocaleString('sv-SE').slice(0, 16); // YYYY-MM-DD HH:MM, local time const list = (v) => { if (typeof v === 'string') { try { v = JSON.parse(v); } catch (e) { return []; } } return Array.isArray(v) ? v : []; }; // Teams enforces Trusted Types, which blocks DOMParser/innerHTML on plain // strings. An XHR 'document' response parses HTML just as inertly // (no scripts, no image loads) and is not a Trusted Types sink. const html = (s) => new Promise((resolve, reject) => { const url = URL.createObjectURL(new Blob([s || ''], { type: 'text/html' })); const x = new XMLHttpRequest(); x.open('GET', url); x.responseType = 'document'; x.onload = () => { URL.revokeObjectURL(url); resolve(x.response.body); }; x.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Could not parse message HTML')); }; x.send(); }); const bodies = await Promise.all(msgs.map(m => /^(RichText|ThreadActivity)/.test(m.messagetype) ? html(m.content) : null)); // Inline images are collected here and swapped in after downloading const images = []; const imageRef = (src) => { let i = images.findIndex(im => im.src === src); if (i < 0) i = images.push({ src }) - 1; return `\u0000IMG${i}\u0000`; }; // 5. Teams message HTML → Markdown const wrap = (mark, s) => { const m = s.match(/^(\s*)([\s\S]*?)(\s*)$/); return m[2] ? m[1] + mark + m[2] + mark + m[3] : s; }; const toMd = (node, pre) => { if (node.nodeType === 3) { if (pre) return node.data; return /^\s*\n\s*$/.test(node.data) ? '' : node.data.replace(/\s+/g, ' '); } if (node.nodeType !== 1) return ''; const kids = () => [...node.childNodes].map(n => toMd(n, pre)).join(''); const tag = node.tagName.toLowerCase(); const type = node.getAttribute('itemtype') || ''; switch (tag) { case 'br': return ' \n'; case 'p': case 'div': case 'section': return '\n\n' + kids().trim() + '\n\n'; case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return '\n\n' + '#'.repeat(+tag[1]) + ' ' + kids().trim() + '\n\n'; case 'strong': case 'b': return wrap('**', kids()); case 'em': case 'i': return wrap('*', kids()); case 's': case 'strike': case 'del': return wrap('~~', kids()); case 'code': return pre ? kids() : wrap('`', kids()); case 'pre': return '\n\n```\n' + toMd(node, true).replace(/\n$/, '') + '\n```\n\n'; case 'a': { const href = node.getAttribute('href'); const text = kids().trim(); if (!href) return text; // Teams shortens long URLs in the text to "https://…/prefix…" return !text || href.startsWith(text.replace(/…$/, '')) ? `<${href}>` : `[${text}](${href})`; } case 'img': { const alt = node.getAttribute('alt') || ''; const src = node.getAttribute('src') || ''; if (/Emoji/.test(type)) return alt; if (/AMSImage/.test(type)) return `})`; return /^https?:/.test(src) ? `` : alt; } case 'blockquote': { if (/Reply/.test(type)) { const name = (node.querySelector('[itemprop="mri"]') || {}).textContent || ''; const preview = (node.querySelector('[itemprop="preview"]') || {}).textContent || ''; return `\n\n> **${name.trim()}:** ${preview.trim()}\n\n`; } return '\n\n' + kids().trim().split('\n').map(l => '> ' + l).join('\n') + '\n\n'; } case 'ul': case 'ol': return '\n\n' + [...node.children].filter(li => li.tagName === 'LI').map((li, i) => (tag === 'ol' ? `${i + 1}. ` : '- ') + toMd(li, pre).trim().replace(/\n/g, '\n ') ).join('\n') + '\n\n'; case 'table': { const rows = [...node.querySelectorAll('tr')].map(tr => '| ' + [...tr.children].map(c => toMd(c, pre).trim().replace(/\s*\n+\s*/g, ' ').replace(/\|/g, '\\|')).join(' | ') + ' |'); if (rows.length) rows.splice(1, 0, rows[0].replace(/[^|]+/g, ' --- ')); return '\n\n' + rows.join('\n') + '\n\n'; } case 'script': case 'style': return ''; default: return kids(); } }; const bodyToMd = (body) => toMd(body).replace(/\n{3,}/g, '\n\n').replace(/(\n\n| \n) +/g, '$1').trim(); // 6. Render messages const REACTIONS = { like: '👍', heart: '❤️', laugh: '😆', surprised: '😮', sad: '😢', angry: '😠' }; const reaction = (key) => { const cp = key.match(/^([0-9a-f]{4,6})_/i); return REACTIONS[key] || (cp ? String.fromCodePoint(parseInt(cp[1], 16)) : `:${key}:`); }; const renderEvent = (m, x) => { const get = (sel) => (x.querySelector(sel) || {}).textContent || ''; const targets = [...x.querySelectorAll('target')].map(t => who(t.textContent)).join(', '); switch (m.messagetype) { case 'ThreadActivity/AddMember': return `*${who(get('initiator'))} added ${targets}*`; case 'ThreadActivity/DeleteMember': return `*${who(get('initiator'))} removed ${targets}*`; case 'ThreadActivity/TopicUpdate': return `*${who(get('initiator'))} renamed the chat to “${get('value')}”*`; default: return ''; } }; const renderMsg = (m, i) => { if (/^ThreadActivity\//.test(m.messagetype)) return renderEvent(m, bodies[i]); if (!/^(Text|RichText)/.test(m.messagetype)) return ''; const p = m.properties || {}; const name = m.imdisplayname || m.fromDisplayNameInToken || mriOf(m); const head = `## ${name} · ${when(m.composetime || m.originalarrivaltime)}${p.edittime ? ' *(edited)*' : ''}`; if (p.deletetime) return `${head}\n\n*(message deleted)*`; const body = bodies[i] ? bodyToMd(bodies[i]) : (m.content || '').trim(); const files = list(p.files).map(f => `📎 [${f.fileName || f.title || 'file'}](${f.objectUrl || f.fileInfo && f.fileInfo.fileUrl || ''})`); const reacts = list(p.emotions).filter(e => (e.users || []).length) .map(e => `${reaction(e.key)} ${e.users.length}`); const parts = [body, files.join(' \n'), reacts.length ? `*Reactions: ${reacts.join(', ')}*` : ''] .filter(s => s && s.trim()); return parts.length ? `${head}\n\n${parts.join('\n\n')}` : ''; }; const rendered = msgs.map(renderMsg).filter(Boolean); // 7. Download inline images (the skype token authorises the media service). // The message links the 800px "imgo" preview; "imgpsh_fullsize" is the original. const EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp' }; const media = { headers: { Authorization: 'skype_token ' + skypeToken } }; for (const [i, im] of images.entries()) { try { const full = im.src.replace(/\/views\/imgo$/, '/views/imgpsh_fullsize'); let r = await fetch(full, media); if (!r.ok && full !== im.src) r = await fetch(im.src, media); if (!r.ok) throw new Error(r.status); const type = (r.headers.get('content-type') || '').split(';')[0]; im.data = new Uint8Array(await r.arrayBuffer()); im.path = `images/${String(i + 1).padStart(3, '0')}.${EXT[type] || 'img'}`; } catch (e) { console.warn('Image download failed, linking it instead:', im.src, e); im.path = im.src; } } const saved = images.filter(im => im.data); // 8. Assemble the Markdown const tp = thread.properties || {}; const others = [...new Set(msgs.map(m => m.imdisplayname).filter(Boolean))]; const title = tp.topic || others.join(', ') || 'Teams chat'; const md = [ `# ${title}`, '', `- **Conversation ID:** ${convId}`, `- **Created:** ${tp.createdat ? new Date(+tp.createdat).toISOString() : 'unknown'}`, `- **Exported:** ${new Date().toISOString()}`, `- **Members:** ${(thread.members || []).length || 'unknown'}`, `- **Messages:** ${rendered.length}`, `- **Images:** ${saved.length}`, '', '---', '', rendered.join('\n\n'), '' ].join('\n').replace(/\u0000IMG(\d+)\u0000/g, (_, i) => images[i].path); // 9. Filename: derived from title + short id, editable via prompt() const ext = saved.length ? '.zip' : '.md'; const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); const shortId = convId.slice(3).replace(/[^a-z0-9]/gi, '').slice(0, 8); const filename = prompt('Save chat as:', `teams_${slug || 'chat'}_${shortId}${ext}`); if (!filename) return; // user cancelled const base = filename.replace(/\.(zip|md)$/i, ''); // 10. Build the file: the .md alone, or a stored (uncompressed) zip with images let blob; if (!saved.length) { blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); } else { const enc = new TextEncoder(); const files = [{ name: base + '.md', data: enc.encode(md) }, ...saved.map(im => ({ name: im.path, data: im.data }))]; const CRC = Array.from({ length: 256 }, (_, n) => { for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1; return n >>> 0; }); const crc32 = (u8) => { let c = ~0; for (const b of u8) c = CRC[(c ^ b) & 255] ^ (c >>> 8); return ~c >>> 0; }; const d = new Date(); const dosTime = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1); const dosDate = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(); const record = (fields) => { const v = new DataView(new ArrayBuffer(fields.reduce((s, [n]) => s + n, 0))); let o = 0; fields.forEach(([n, x]) => { n === 4 ? v.setUint32(o, x, true) : v.setUint16(o, x, true); o += n; }); return new Uint8Array(v.buffer); }; const out = [], central = []; let offset = 0; for (const f of files) { const name = enc.encode(f.name), crc = crc32(f.data), size = f.data.length; // Shared header fields; flag 0x0800 marks UTF-8 filenames, method 0 = stored const common = [[2, 20], [2, 0x0800], [2, 0], [2, dosTime], [2, dosDate], [4, crc], [4, size], [4, size], [2, name.length], [2, 0]]; const local = record([[4, 0x04034b50], ...common]); out.push(local, name, f.data); central.push(record([[4, 0x02014b50], [2, 20], ...common, [2, 0], [2, 0], [2, 0], [4, 0], [4, offset]]), name); offset += local.length + name.length + size; } const cdSize = central.reduce((s, p) => s + p.length, 0); const end = record([[4, 0x06054b50], [2, 0], [2, 0], [2, files.length], [2, files.length], [4, cdSize], [4, offset], [2, 0]]); blob = new Blob([...out, ...central, end], { type: 'application/zip' }); } // 11. Download const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = base + ext; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } catch (e) { alert('Export failed: ' + e.message); console.error(e); } })();
Source and licence
Every bookmarklet above is generated from the cleartext file linked under its source listing. The short version you drag is produced at build time — it is never stored as a second copy, so what you read is exactly what you run.
Contributions and bug reports are welcome on the repository.