culm.at

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

  1. Make sure your bookmarks bar is visible (Ctrl+Shift+B, or Cmd+Shift+B on a Mac).
  2. Drag the button below onto that bar.
  3. 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

    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);
      }
    })();

    View on GitHub

  • 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

    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);
      }
    })();

    View on GitHub

  • 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

    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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    
        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);
      }
    })();

    View on GitHub

  • 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

    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 `![${alt || 'image'}](${imageRef(src)})`;
              return /^https?:/.test(src) ? `![${alt}](${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);
      }
    })();

    View on GitHub

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.