您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Export Microsoft Copilot conversations to Markdown or JSON (optionally includes AI Thoughts when present)
// ==UserScript== // @name Copilot Conversation Exporter // @namespace https://github.com/NoahTheGinger/Userscripts/ // @version 2.0.0 // @description Export Microsoft Copilot conversations to Markdown or JSON (optionally includes AI Thoughts when present) // @author NoahTheGinger // @match https://copilot.microsoft.com/* // @grant none // @run-at document-start // @license MIT // ==/UserScript== (function () { 'use strict'; // ========== // Config // ========== const ORIGIN = new URL(location.href).origin; // https://copilot.microsoft.com const API_BASE = `${ORIGIN}/c/api`; const API_VERSION = '2'; // history endpoint supports ?api-version=2 // ========== // Auth capturing (best-effort) // ========== let capturedAuthToken = null; function tryCaptureAuthFromHeaders(headersLike) { try { if (!headersLike) return; const headers = headersLike instanceof Headers ? headersLike : new Headers(headersLike); const auth = headers.get('Authorization') || headers.get('authorization'); if (auth && auth.startsWith('Bearer ')) { capturedAuthToken = auth.replace(/^Bearer\s+/, ''); } } catch {} } // Patch fetch at document-start to sniff Authorization headers sent by the app (function patchFetch() { const origFetch = window.fetch; window.fetch = function patchedFetch(input, init) { try { if (init && init.headers) tryCaptureAuthFromHeaders(init.headers); if (input && typeof input === 'object' && input.headers) tryCaptureAuthFromHeaders(input.headers); } catch {} return origFetch.apply(this, arguments); }; })(); // Patch XMLHttpRequest to sniff Authorization headers (function patchXHR() { const origOpen = XMLHttpRequest.prototype.open; const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function (method, url) { this._url = url; this._method = method; return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function (name, value) { try { if (typeof name === 'string' && name.toLowerCase() === 'authorization' && value && value.startsWith('Bearer ')) { capturedAuthToken = value.replace(/^Bearer\s+/, ''); } } catch {} return origSetRequestHeader.apply(this, arguments); }; })(); // ========== // Utilities // ========== function sanitizeFilename(title) { return title.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 200); } function downloadFile(filename, mimeType, content) { const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 0); } function standardizeLineBreaks(text) { return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } function getCurrentTimestamp() { return new Date().toISOString().slice(0, 19).replace(/:/g, '-'); } function getChatIdFromUrl() { // Expected: https://copilot.microsoft.com/chats/{chatId} const m = location.pathname.match(/^\/chats\/([A-Za-z0-9_-]+)/i); return m ? m[1] : null; } function getUILanguage() { return document.documentElement.getAttribute('lang') || navigator.language || 'en-US'; } async function ensureSession() { // Lightly poke the start endpoint (this sometimes ensures cookies/session are initialized) try { await fetch(`${API_BASE}/start`, { method: 'POST', credentials: 'include' }); } catch {} } async function fetchJSON(url, options = {}) { const opts = { method: 'GET', credentials: 'include', headers: {}, ...options }; opts.headers = new Headers(opts.headers); // Prefer captured Authorization token if available if (capturedAuthToken && !opts.headers.has('Authorization')) { opts.headers.set('Authorization', `Bearer ${capturedAuthToken}`); } // Send UI language if available (consistent with site) const lang = getUILanguage(); if (!opts.headers.has('x-search-uilang') && lang) { opts.headers.set('x-search-uilang', lang.toLowerCase()); } const res = await fetch(url, opts); if (!res.ok) { let detail = res.statusText || ''; try { const data = await res.json(); detail = data?.error || data?.message || JSON.stringify(data) || detail; } catch {} throw new Error(`Request failed (${res.status}): ${detail}`); } return res.json(); } async function fetchConversationHistory(chatId) { if (!chatId) throw new Error('No chat ID found in URL'); // Warm session first (harmless if already warm) await ensureSession(); const url = `${API_BASE}/conversations/${encodeURIComponent(chatId)}/history?api-version=${API_VERSION}`; try { return await fetchJSON(url); } catch (err) { // Retry after warming session again (in case of race/expiry) await ensureSession(); return await fetchJSON(url); } } async function fetchConversationsList() { const url = `${API_BASE}/conversations${API_VERSION ? `?api-version=${API_VERSION}` : ''}`; try { return await fetchJSON(url); } catch { return null; } } async function lookupConversationTitle(chatId) { if (!chatId) return 'Copilot Conversation'; const list = await fetchConversationsList(); // Expected shape: maybe { results: [ { id, title, ... }, ... ] } or just an array try { const items = Array.isArray(list?.results) ? list.results : (Array.isArray(list) ? list : []); const found = items.find(x => x?.id === chatId); return found?.title || 'Copilot Conversation'; } catch { return 'Copilot Conversation'; } } // ========== // Transform Copilot history -> Markdown // ========== function transformAuthor(author) { // In history JSON: author: { type: "ai" | "human" } if (!author || !author.type) return 'Unknown'; return author.type === 'ai' ? 'Assistant' : author.type === 'human' ? 'User' : author.type; } function sortByCreatedAtAsc(results) { try { return [...results].sort((a, b) => { const at = new Date(a.createdAt).getTime() || 0; const bt = new Date(b.createdAt).getTime() || 0; return at - bt; }); } catch { return results; } } function partsToMarkdown(parts) { if (!Array.isArray(parts)) return { body: '', thoughts: '' }; const textBlocks = []; const imageBlocks = []; const citations = []; const thoughtsBlocks = []; for (const part of parts) { switch (part?.type) { case 'text': if (typeof part.text === 'string' && part.text.trim()) { textBlocks.push(part.text); } break; case 'image': { // Embed image, optionally include prompt as a caption line if (part.url) { const alt = 'image'; imageBlocks.push(``); if (part.prompt && String(part.prompt).trim()) { imageBlocks.push(`*Prompt: ${part.prompt}*`); } } break; } case 'citation': // Collect for optional "Sources" listing; avoid exact duplicates if (part.url) { const key = `${part.title || part.url}::${part.url}`; if (!citations.some(c => c._k === key)) { citations.push({ _k: key, title: part.title || part.url, url: part.url }); } } break; case 'chainOfThought': // Reasoning content (include only if present) if (typeof part.text === 'string' && part.text.trim()) { thoughtsBlocks.push(part.text); } if (part.screenshotUrl) { thoughtsBlocks.push(``); } break; default: // Ignore unsupported part types silently break; } } const sections = []; if (textBlocks.length) { sections.push(textBlocks.join('\n\n')); } if (imageBlocks.length) { sections.push(imageBlocks.join('\n\n')); } // If you prefer to show citations, uncomment the block below to include a sources list: // if (citations.length) { // const list = citations.map(c => `- [${c.title}](${c.url})`).join('\n'); // sections.push(`#### Sources\n${list}`); // } const body = sections.filter(Boolean).join('\n\n'); const thoughts = thoughtsBlocks.length ? thoughtsBlocks.join('\n\n') : ''; return { body, thoughts }; } // ---------------------------------------------------------- // Updated markdown conversion – thoughts come first // ---------------------------------------------------------- function conversationToMarkdown({ title, results }) { // Ensure the messages are in chronological order const sorted = sortByCreatedAtAsc(results || []); const lines = []; // Title lines.push(`# ${title || 'Copilot Conversation'}`); lines.push(''); // blank line after title // Walk through each message for (const msg of sorted) { const authorLabel = transformAuthor(msg.author); const { body, thoughts } = partsToMarkdown(msg.content); const hasBody = body && body.trim().length > 0; const hasThought = thoughts && thoughts.trim().length > 0; // Skip totally empty turns if (!hasBody && !hasThought) continue; // ----------------------------------------------------------------- // Assistant – we want Thoughts *before* the answer // ----------------------------------------------------------------- if (authorLabel === 'Assistant') { // 1. Thoughts (if any) if (hasThought) { lines.push('#### Thoughts'); lines.push(thoughts); lines.push(''); // blank line after thoughts } // 2. Assistant answer (if any) if (hasBody) { lines.push(`#### ${authorLabel}:`); lines.push(body); lines.push(''); // blank line after answer } continue; // everything for this turn is done } // ----------------------------------------------------------------- // Any other speaker (User, Tool, …) – keep the old order // ----------------------------------------------------------------- lines.push(`#### ${authorLabel}:`); if (hasBody) lines.push(body); // (No “Thoughts” section for non‑assistant messages) lines.push(''); // blank line after the turn } // Trim trailing new‑lines but keep a final newline for nice file endings return lines.join('\n').trim() + '\n'; } // ========== // Exporters // ========== async function exportToMarkdown() { try { const chatId = getChatIdFromUrl(); if (!chatId) { alert('Open a conversation page first (URL should be /chats/{id}).'); return; } console.log('[Copilot Exporter] Fetching history...'); const history = await fetchConversationHistory(chatId); console.log('[Copilot Exporter] Looking up title...'); const title = await lookupConversationTitle(chatId); console.log('[Copilot Exporter] Converting to Markdown...'); const md = conversationToMarkdown({ title: title || 'Copilot Conversation', results: Array.isArray(history?.results) ? history.results : [] }); const safeTitle = sanitizeFilename(title || 'Copilot Conversation'); const timestamp = getCurrentTimestamp(); const filename = `${safeTitle}_${timestamp}.md`; downloadFile(filename, 'text/markdown', standardizeLineBreaks(md)); console.log('[Copilot Exporter] Markdown export complete.'); } catch (err) { console.error('[Copilot Exporter] Markdown export failed:', err); alert(`Markdown export failed: ${err.message}`); } } async function exportToJSON() { try { const chatId = getChatIdFromUrl(); if (!chatId) { alert('Open a conversation page first (URL should be /chats/{id}).'); return; } console.log('[Copilot Exporter] Fetching history (JSON)...'); const history = await fetchConversationHistory(chatId); console.log('[Copilot Exporter] Looking up title...'); const title = await lookupConversationTitle(chatId); const jsonContent = JSON.stringify(history, null, 2); const safeTitle = sanitizeFilename(title || 'Copilot Conversation'); const timestamp = getCurrentTimestamp(); const filename = `${safeTitle}_${timestamp}.json`; downloadFile(filename, 'application/json', jsonContent); console.log('[Copilot Exporter] JSON export complete.'); } catch (err) { console.error('[Copilot Exporter] JSON export failed:', err); alert(`JSON export failed: ${err.message}`); } } // ========== // UI (Export button + modal) // ========== function showExportDialog() { const modal = document.createElement('div'); modal.style.cssText = ` position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 2147483647; display: flex; align-items: center; justify-content: center; `; const dialog = document.createElement('div'); dialog.style.cssText = ` background: white; border-radius: 8px; padding: 24px; min-width: 300px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; `; dialog.innerHTML = ` <h3 style="margin:0 0 16px 0; color:#333; font-size:18px;">Choose Export Format</h3> <p style="margin:0 0 20px 0; color:#666; font-size:14px;">Select the format you'd like to export this conversation in:</p> <div style="display:flex; gap:12px; justify-content:flex-end;"> <button id="export-markdown-btn" style=" background:#10a37f; color:white; border:none; border-radius:6px; padding:8px 16px; font-size:14px; cursor:pointer; ">Markdown (.md)</button> <button id="export-json-btn" style=" background:#2563eb; color:white; border:none; border-radius:6px; padding:8px 16px; font-size:14px; cursor:pointer; ">JSON (.json)</button> <button id="export-cancel-btn" style=" background:#6b7280; color:white; border:none; border-radius:6px; padding:8px 16px; font-size:14px; cursor:pointer; ">Cancel</button> </div> `; modal.appendChild(dialog); document.body.appendChild(modal); const markdownBtn = dialog.querySelector('#export-markdown-btn'); const jsonBtn = dialog.querySelector('#export-json-btn'); const cancelBtn = dialog.querySelector('#export-cancel-btn'); markdownBtn.addEventListener('mouseenter', () => markdownBtn.style.background = '#0d9568'); markdownBtn.addEventListener('mouseleave', () => markdownBtn.style.background = '#10a37f'); jsonBtn.addEventListener('mouseenter', () => jsonBtn.style.background = '#1d4ed8'); jsonBtn.addEventListener('mouseleave', () => jsonBtn.style.background = '#2563eb'); cancelBtn.addEventListener('mouseenter', () => cancelBtn.style.background = '#4b5563'); cancelBtn.addEventListener('mouseleave', () => cancelBtn.style.background = '#6b7280'); markdownBtn.addEventListener('click', () => { document.body.removeChild(modal); exportToMarkdown(); }); jsonBtn.addEventListener('click', () => { document.body.removeChild(modal); exportToJSON(); }); cancelBtn.addEventListener('click', () => document.body.removeChild(modal)); const onBgClick = (e) => { if (e.target === modal) { document.body.removeChild(modal); window.removeEventListener('click', onBgClick); } }; window.addEventListener('click', onBgClick); const handleEscape = (e) => { if (e.key === 'Escape') { document.body.removeChild(modal); document.removeEventListener('keydown', handleEscape); } }; document.addEventListener('keydown', handleEscape); } function createExportButton() { const btn = document.createElement('button'); btn.id = 'copilot-export-btn'; btn.textContent = 'Export'; btn.title = 'Export conversation to Markdown or JSON'; btn.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 2147483646; background: #10a37f; color: white; border: none; border-radius: 6px; padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1); `; btn.addEventListener('mouseenter', () => btn.style.background = '#0d9568'); btn.addEventListener('mouseleave', () => btn.style.background = '#10a37f'); btn.addEventListener('click', showExportDialog); return btn; } function addButton() { try { const existing = document.getElementById('copilot-export-btn'); if (existing) existing.remove(); const btn = createExportButton(); document.body.appendChild(btn); } catch (e) { console.warn('[Copilot Exporter] Unable to add button yet. Will retry.'); } } function ready(fn) { if (document.readyState !== 'loading') { fn(); } else { document.addEventListener('DOMContentLoaded', fn); } } // ========== // Init // ========== ready(addButton); // Re-inject button on SPA navigations let lastHref = location.href; setInterval(() => { if (location.href !== lastHref) { lastHref = location.href; setTimeout(addButton, 800); } }, 800); })();