Export/copy current Yuanbao conversation and export all conversations as ZIP (MD/JSON/DOCX)
// ==UserScript== // @name Chat Export Toolkit // @namespace https://github.com/gandli/chat-export-toolkit // @version 0.6.0 // @description Export/copy current Yuanbao conversation and export all conversations as ZIP (MD/JSON/DOCX) // @author gandli // @match *://yuanbao.tencent.com/* // @match *://*.yuanbao.tencent.com/* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js // @grant none // @run-at document-start // @license MIT // ==/UserScript== (function () { 'use strict'; // API 端点缓存(动态发现 + 回退) let API_ENDPOINTS = { detail: null, list: null, discovered: false, }; // 正则表达式用于拦截响应(支持任意版本 v\d+ 和新结构) const YUANBAO_DETAIL_RE = /\/api\/(?:user\/agent\/)?conversation\/v?\d*\/?detail/; const YUANBAO_LIST_RE = /\/api\/(?:user\/agent\/)?conversation\/v?\d*\/?(?:list|page|list_page)/; const state = { current: null, captured: new Map(), listHints: new Map(), ui: { busy: false, panelOpen: false, progress: { show: false, text: '', percent: 0 }, }, }; const Utils = { nowStamp() { const d = new Date(); const offset = d.getTimezoneOffset() * 60 * 1000; return new Date(d.getTime() - offset) .toISOString() .slice(0, 19) .replace('T', '_') .replace(/:/g, '-'); }, formatTimestamp(ts) { if (!ts) return ''; try { const n = typeof ts === 'string' ? Number.parseInt(ts, 10) : ts; const d = new Date(typeof n === 'number' && n < 1e12 ? n * 1000 : n); return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(); } catch { return ''; } }, sanitizeFilename(name) { return String(name || 'export').replace(/[\/\\?%*:|"<>]/g, '-').trim(); }, download(content, mime, filename) { const blob = content instanceof Blob ? content : new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 500); }, async copyText(text) { const val = String(text || ''); if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(val); return; } const ta = document.createElement('textarea'); ta.value = val; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }, xmlEscape(text) { return String(text) .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); }, adjustHeaderLevels(text, increaseBy = 1) { if (!text) return ''; return String(text).replace(/^(#+)(\s*)(.*?)\s*$/gm, (_m, hashes, _space, content) => { return '#'.repeat(hashes.length + increaseBy) + ' ' + String(content).trim(); }); }, extractConversationId(url) { try { const u = new URL(url, window.location.href); return ( u.searchParams.get('conversationId') || u.searchParams.get('conversation_id') || u.searchParams.get('id') || '' ); } catch { const m = String(url).match(/[?&](?:conversationId|conversation_id|id)=([^&]+)/); return m ? decodeURIComponent(m[1]) : ''; } }, }; function mdToDocxParagraphs(markdown) { const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n'); const out = []; let inCode = false; const p = (text, style = '') => { const s = style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : ''; return `<w:p>${s}<w:r><w:t xml:space="preserve">${Utils.xmlEscape(text)}</w:t></w:r></w:p>`; }; for (const raw of lines) { const line = raw ?? ''; if (/^```/.test(line.trim())) { inCode = !inCode; continue; } if (inCode) { out.push(p(line, 'CodeBlock')); continue; } const h = line.match(/^(#{1,6})\s+(.*)$/); if (h) { const level = Math.min(3, h[1].length); out.push(p(h[2], `Heading${level}`)); continue; } const bullet = line.match(/^\s*[-*]\s+(.*)$/); if (bullet) { out.push(p(`• ${bullet[1]}`, 'Normal')); continue; } const quote = line.match(/^>\s?(.*)$/); if (quote) { out.push(p(`❝ ${quote[1]}`, 'Quote')); continue; } if (!line.trim()) { out.push('<w:p/>'); continue; } out.push(p(line, 'Normal')); } return out; } async function buildDocxBlob(markdownText) { if (typeof JSZip === 'undefined') throw new Error('JSZip missing'); const paragraphs = mdToDocxParagraphs(markdownText); const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` + `<w:body>` + paragraphs.join('') + `<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>` + `</w:body></w:document>`; const stylesXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` + `<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>` + `<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="40"/></w:rPr></w:style>` + `<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>` + `<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:qFormat/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style>` + `<w:style w:type="paragraph" w:styleId="CodeBlock"><w:name w:val="Code Block"/><w:basedOn w:val="Normal"/><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="20"/></w:rPr></w:style>` + `<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="420"/></w:pPr><w:rPr><w:i/></w:rPr></w:style>` + `</w:styles>`; const contentTypesXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` + `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` + `<Default Extension="xml" ContentType="application/xml"/>` + `<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` + `<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>` + `</Types>`; const relsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` + `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` + `</Relationships>`; const docRelsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` + `<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` + `</Relationships>`; const zip = new JSZip(); zip.file('[Content_Types].xml', contentTypesXml); zip.folder('_rels').file('.rels', relsXml); const word = zip.folder('word'); word.file('document.xml', documentXml); word.file('styles.xml', stylesXml); word.folder('_rels').file('document.xml.rels', docRelsXml); return zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); } function yuanbaoToMarkdown(data) { const title = data?.sessionTitle || data?.title || 'Yuanbao Chat'; const out = []; out.push(`# ${title}`); out.push(''); out.push(`> Exported at: ${new Date().toLocaleString()}`); out.push(''); const convs = Array.isArray(data?.convs) ? [...data.convs] : []; convs.sort((a, b) => (a?.index || 0) - (b?.index || 0)); for (const turn of convs) { const speaker = String(turn?.speaker || '').toLowerCase(); const role = speaker === 'ai' ? 'Assistant' : speaker === 'user' || speaker === 'human' ? 'User' : speaker || 'Unknown'; const idx = turn?.index != null ? ` (Turn ${turn.index})` : ''; const ts = Utils.formatTimestamp(turn?.createTime); out.push(`## ${role}${idx}`); if (ts) out.push(`*${ts}*`); out.push(''); const blocks = []; const speeches = Array.isArray(turn?.speechesV2) ? turn.speechesV2 : []; for (const speech of speeches) { const content = Array.isArray(speech?.content) ? speech.content : []; for (const block of content) { if (block?.type === 'text') blocks.push(Utils.adjustHeaderLevels(block?.msg || '', 1)); else if (block?.type === 'think') { const t = block?.title ? `> [Think] ${block.title}` : `> [Think]`; const body = String(block?.content || '').replace(/\n/g, '\n> '); blocks.push(`${t}\n> ${body}`); } else if (block?.msg) blocks.push(String(block.msg)); else blocks.push('`[Unsupported block]`'); } } const body = blocks.join('\n\n').trim(); out.push(body || '_No content_'); out.push(''); out.push('---'); out.push(''); } return out.join('\n').trim() + '\n'; } function getFilename(scope, format, title) { const safeTitle = Utils.sanitizeFilename(title || 'Yuanbao'); const prefix = scope === 'all' ? 'ALL_' : 'CURRENT_'; return `${prefix}Yuanbao_${safeTitle}_${Utils.nowStamp()}.${format}`; } async function fetchJson(url, options = {}) { const res = await fetch(url, { credentials: 'include', ...options, headers: { Accept: 'application/json, text/plain, */*', ...(options.headers || {}) }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } function pickArray(obj, candidates) { for (const key of candidates) { const val = obj?.[key]; if (Array.isArray(val)) return val; } return []; } function getConvId(item) { return ( item?.conversationId || item?.conversation_id || item?.convId || item?.conversationUuid || item?.sessionId || item?.chatId || item?.id || '' ); } function getConvTitle(item) { return item?.title || item?.sessionTitle || item?.name || item?.conversationTitle || item?.summary || 'Yuanbao Chat'; } function setBusy(v) { state.ui.busy = !!v; const panel = document.getElementById('cet-panel'); if (panel) panel.classList.toggle('is-busy', state.ui.busy); updateUiState(); } function setProgress(show, text = '', percent = 0) { state.ui.progress = { show, text, percent: Math.max(0, Math.min(100, percent || 0)) }; const box = document.getElementById('cet-progress'); const label = document.getElementById('cet-progress-text'); const bar = document.getElementById('cet-progress-bar'); if (!box || !label || !bar) return; box.style.display = show ? 'block' : 'none'; label.textContent = text || ''; bar.style.width = `${state.ui.progress.percent}%`; } function showToast(text) { const el = document.getElementById('cet-toast'); if (!el) return; el.textContent = text; el.classList.add('show'); clearTimeout(showToast._timer); showToast._timer = setTimeout(() => el.classList.remove('show'), 1500); } function updateUiState() { const count = state.captured.size; const status = document.getElementById('cet-status'); if (status) { status.textContent = state.current ? `已就绪 · 已缓存 ${count} 个会话` : `等待对话数据 · 已缓存 ${count} 个会话`; } const badge = document.getElementById('cet-fab-badge'); if (badge) badge.textContent = String(count); const hasCurrent = !!state.current; const disable = (id, cond) => { const el = document.getElementById(id); if (el) el.disabled = cond || state.ui.busy; }; disable('cet-current-save-md', !hasCurrent); disable('cet-current-save-json', !hasCurrent); disable('cet-current-save-docx', !hasCurrent); disable('cet-current-copy-md', !hasCurrent); disable('cet-current-copy-json', !hasCurrent); disable('cet-all-md', false); disable('cet-all-json', false); disable('cet-all-docx', false); } function collectConversationMetasFromJson(node, out, seen) { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (const x of node) collectConversationMetasFromJson(x, out, seen); return; } const maybeId = String(getConvId(node) || '').trim(); if (maybeId && !seen.has(maybeId)) { seen.add(maybeId); out.push({ id: maybeId, title: getConvTitle(node) }); } for (const v of Object.values(node)) { if (v && typeof v === 'object') collectConversationMetasFromJson(v, out, seen); } } function collectConversationMetasFromDom(out, seen) { const nodes = document.querySelectorAll('a[href*="/chat/"]'); for (const a of nodes) { const href = a.getAttribute('href') || ''; const m = href.match(/\/chat\/([^/?#]+)/); if (!m) continue; const id = decodeURIComponent(m[1]); if (!id || seen.has(id)) continue; seen.add(id); const text = (a.textContent || '').trim(); out.push({ id, title: text || 'Yuanbao Chat' }); } } // 动态发现 API 端点 async function discoverApiEndpoints() { if (API_ENDPOINTS.discovered) return API_ENDPOINTS; // 首先尝试从已拦截的请求中选择端点 const endpoints = { detail: selectBestEndpoint('detail'), list: selectBestEndpoint('list'), }; try { // 如果从拦截请求中没有找到端点,则尝试从页面 JS 资源中提取 if (!endpoints.detail || !endpoints.list) { const scripts = Array.from(document.querySelectorAll('script[src]')); const jsUrls = scripts.map(s => s.src).filter(src => src.includes('.js')); // 检查内联脚本 const inlineScripts = Array.from(document.querySelectorAll('script:not([src])')); const inlineContents = inlineScripts.map(s => s.textContent).filter(Boolean); // 从 JS 文件内容中提取 API 模式 - 使用更广泛的模式 const apiPatterns = [ /["'`]\/api\/[^"']*conversation[^"']*detail["'`]/gi, /["'`]\/api\/[^"']*conversation[^"']*\/(?:list|page|list_page)["'`]/gi, /["'`]\/api\/[^"']*\/detail["'`]/gi, /["'`]\/api\/[^"']*\/(?:list|page|list_page)["'`]/gi, ]; // 尝试从内联脚本中提取 for (const content of inlineContents) { for (const pattern of apiPatterns) { const matches = content.match(pattern); if (matches) { for (const match of matches) { const path = match.replace(/["'`]/g, ''); if (path.includes('/detail') && !endpoints.detail) { endpoints.detail = path; console.log('[Chat Export] Found detail API in inline script:', path); } else if (path.includes('/list') || path.includes('/page') || path.includes('/list_page')) { if (!endpoints.list || path.includes('/list')) { endpoints.list = path; console.log('[Chat Export] Found list API in inline script:', path); } } } } } } // 如果内联脚本没有找到,尝试从外部 JS 文件提取(采样前 5 个) if (!endpoints.detail || !endpoints.list) { const sampleScripts = jsUrls.slice(0, 5); for (const scriptUrl of sampleScripts) { try { const response = await fetch(scriptUrl, { signal: AbortSignal.timeout(3000) }); const text = await response.text(); for (const pattern of apiPatterns) { const matches = text.match(pattern); if (matches) { for (const match of matches) { const path = match.replace(/["'`]/g, ''); if (path.includes('/detail') && !endpoints.detail) { endpoints.detail = path; console.log('[Chat Export] Found detail API in external script:', path); } else if ((path.includes('/list') || path.includes('/page')) && !endpoints.list) { endpoints.list = path; console.log('[Chat Export] Found list API in external script:', path); } } } } if (endpoints.detail && endpoints.list) break; } catch (err) { console.log('[Chat Export] Failed to load JS file:', scriptUrl, err?.message); } } } } // 检查拦截到的API请求 if (!endpoints.detail && state.captured.size > 0) { // 如果已经有捕获的对话,尝试从中推断API端点 const capturedEntry = state.captured.entries().next().value; if (capturedEntry) { // 这里可以尝试反向推断API端点,但需要更多信息 } } if (!endpoints.list && state.listHints.size > 0) { // 如果已经有列表提示,尝试从中推断API端点 } } catch (err) { console.log('[Chat Export] API discovery error:', err?.message); } // 如果仍然没有找到端点,使用回退探测 if (!endpoints.detail) { console.log('[Chat Export] Using fallback probe for detail API'); endpoints.detail = await probeDetailApi(); } if (!endpoints.list) { console.log('[Chat Export] Using fallback probe for list API'); endpoints.list = await probeListApi(); } console.log('[Chat Export] Discovered API endpoints:', endpoints); API_ENDPOINTS = { ...endpoints, discovered: true }; return API_ENDPOINTS; } // 探测 detail API 端点 async function probeDetailApi() { // 首先尝试使用已发现的端点 const knownEndpoint = selectBestEndpoint('detail'); if (knownEndpoint) { // 提取路径部分(去掉域名) const pathOnly = new URL(knownEndpoint, window.location.origin).pathname; console.log('[Chat Export] Using known detail endpoint:', pathOnly); return pathOnly; } // 如果没有已知端点,则尝试常见模式 const candidates = [ '/api/user/agent/conversation/v2/detail', '/api/user/agent/conversation/v1/detail', '/api/conversation/v2/detail', '/api/conversation/v1/detail', // 新增可能的端点模式 '/api/user/agent/conversation/detail', '/api/conversation/detail', '/api/v1/user/agent/conversation/detail', '/api/v2/user/agent/conversation/detail', '/api/conversation/detail/v1', '/api/conversation/detail/v2', ]; for (const endpoint of candidates) { try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId: 'probe' }), signal: AbortSignal.timeout(5000), }); // 404 表示端点存在但参数错误,非 404 表示可能是正确端点 // 405 表示端点存在但方法不允许,也可能是正确端点 if (response.status !== 404 && response.status !== 405) { return endpoint; } } catch { // 继续尝试下一个 } } return candidates[0]; // 默认回退 } // 探测 list API 端点 async function probeListApi() { // 首先尝试使用已发现的端点 const knownEndpoint = selectBestEndpoint('list'); if (knownEndpoint) { // 提取路径部分(去掉域名) const pathOnly = new URL(knownEndpoint, window.location.origin).pathname; console.log('[Chat Export] Using known list endpoint:', pathOnly); return pathOnly; } const candidates = [ '/api/user/agent/conversation/v2/list', '/api/user/agent/conversation/v2/page', '/api/user/agent/conversation/v1/list', '/api/user/agent/conversation/v1/page', '/api/conversation/v2/list', '/api/conversation/v1/list', // 新增可能的端点模式 '/api/user/agent/conversation/list', '/api/user/agent/conversation/page', '/api/conversation/list', '/api/conversation/page', '/api/v1/user/agent/conversation/list', '/api/v2/user/agent/conversation/list', '/api/v1/user/agent/conversation/page', '/api/v2/user/agent/conversation/page', '/api/conversation/list/v1', '/api/conversation/list/v2', '/api/conversation/page/v1', '/api/conversation/page/v2', ]; for (const endpoint of candidates) { try { const response = await fetch(`${endpoint}?page=1&pageSize=1`, { signal: AbortSignal.timeout(5000), }); // 404 表示端点存在但参数错误,405 表示方法不允许但端点存在 if (response.status !== 404 && response.status !== 405) { return endpoint; } } catch { // 继续尝试下一个 } } return candidates[0]; // 默认回退 } async function fetchAllConversationMetas() { const all = []; const seen = new Set(); // 首先尝试动态发现 API 端点 const apiEndpoints = await discoverApiEndpoints(); const listEndpoint = apiEndpoints.list; // 构建候选端点列表:动态发现的端点优先 const baseCandidates = []; if (listEndpoint) { // 从发现的路径中提取基础路径(去除查询参数) const basePath = listEndpoint.split('?')[0]; baseCandidates.push(basePath); } // 添加回退端点 baseCandidates.push( '/api/user/agent/conversation/v2/list', '/api/user/agent/conversation/v2/page', '/api/user/agent/conversation/v2/list_page', '/api/user/agent/conversation/v1/list', '/api/user/agent/conversation/v1/page', '/api/user/agent/conversation/v1/list_page', '/api/conversation/v2/list', '/api/conversation/v2/page', '/api/conversation/v1/list', '/api/conversation/v1/page', // 新增可能的端点 '/api/user/agent/conversation/list', '/api/user/agent/conversation/page', '/api/conversation/list', '/api/conversation/page', '/api/v1/user/agent/conversation/list', '/api/v2/user/agent/conversation/list', '/api/v1/user/agent/conversation/page', '/api/v2/user/agent/conversation/page', '/api/conversation/list/v1', '/api/conversation/list/v2', '/api/conversation/page/v1', '/api/conversation/page/v2', ); // 去重 const uniqueCandidates = [...new Set(baseCandidates)]; for (const base of uniqueCandidates) { let hit = false; // 增加分页上限到 200,但添加智能检测 for (let page = 1; page <= 200; page += 1) { const postCandidates = [ { page, pageSize: 50 }, { pageNum: page, pageSize: 50 }, { offset: (page - 1) * 50, limit: 50 }, { cursor: String(page), size: 50 }, // 新增可能的参数格式 { page, size: 50 }, { pageIndex: page, pageSize: 50 }, { skip: (page - 1) * 50, limit: 50 }, { start: (page - 1) * 50, count: 50 }, ]; let before = all.length; // 优先尝试POST方法(腾讯元宝API主要是POST) for (const body of postCandidates) { try { const json = await fetchJson(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // 尝试更多可能的数据结构 const conversations = pickArray(json, ['conversations', 'data', 'result', 'response', 'payload']) || pickArray(json?.data, ['conversations', 'result', 'response', 'payload']) || pickArray(json?.result, ['conversations', 'data', 'response', 'payload']) || pickArray(json?.response, ['conversations', 'data', 'result', 'payload']) || pickArray(json?.payload, ['conversations', 'data', 'result', 'response']); if (conversations.length > 0) { collectConversationMetasFromJson(conversations, all, seen); } else { collectConversationMetasFromJson(json, all, seen); } hit = true; if (all.length > before) break; } catch (err) { // 如果POST失败,记录错误但继续尝试其他参数 continue; } } // 如果POST成功获取到数据,跳出循环 if (all.length > before) break; // 如果POST方法失败,再尝试GET方法作为备选 const getCandidates = [ `${base}?page=${page}&pageSize=50`, `${base}?pageNum=${page}&pageSize=50`, `${base}?offset=${(page - 1) * 50}&limit=50`, `${base}?cursor=${encodeURIComponent(String(page))}&size=50`, // 新增可能的参数格式 `${base}?page=${page}&size=50`, `${base}?pageIndex=${page}&pageSize=50`, `${base}?skip=${(page - 1) * 50}&limit=50`, `${base}?start=${(page - 1) * 50}&count=50`, ]; for (const u of getCandidates) { try { const json = await fetchJson(u); collectConversationMetasFromJson(json, all, seen); hit = true; // 如果此页没有新数据,可能已经到达最后一页 if (all.length === before) { // 尝试其他参数格式 continue; } break; } catch (err) { // 记录第一个页面的错误用于调试 if (page === 1 && err?.message?.includes('404')) { console.log('[Chat Export] API endpoint failed (404):', u, err.message); } } } // 智能检测:如果连续 2 页没有新数据,停止 if (all.length === before && page > 2) break; // 第一页没有数据且没有命中,停止 if (all.length === before && page === 1 && !hit) break; } // 如果已经获取到数据,停止尝试其他端点 if (all.length > 0) break; } // list intercept cache state.listHints.forEach((title, id) => { if (seen.has(id)) return; seen.add(id); all.push({ id, title }); }); // current captured cache state.captured.forEach((rec) => { if (seen.has(rec.id)) return; seen.add(rec.id); all.push({ id: rec.id, title: rec.title }); }); // DOM fallback for /chat page links collectConversationMetasFromDom(all, seen); return all; } async function fetchConversationDetailById(id) { // 首先尝试获取动态发现的 API 端点 const apiEndpoints = await discoverApiEndpoints(); const detailEndpoint = apiEndpoints.detail; const bodyCandidates = [ { conversationId: id }, { conversation_id: id }, { sessionId: id }, { chatId: id }, { id }, // 新增可能的参数格式 { conversation_uuid: id }, { conversationUuid: id }, { session_id: id }, { chat_id: id }, ]; // 构建候选端点列表:动态发现的端点优先 const detailPaths = []; if (detailEndpoint) { const basePath = detailEndpoint.split('?')[0]; detailPaths.push(basePath); } detailPaths.push( '/api/user/agent/conversation/v2/detail', '/api/user/agent/conversation/v1/detail', '/api/conversation/v2/detail', '/api/conversation/v1/detail', // 新增可能的端点 '/api/user/agent/conversation/detail', '/api/conversation/detail', '/api/v1/user/agent/conversation/detail', '/api/v2/user/agent/conversation/detail', '/api/conversation/detail/v1', '/api/conversation/detail/v2', ); // 去重 const uniquePaths = [...new Set(detailPaths)]; // 优先尝试POST方法(腾讯元宝API主要是POST) for (const path of uniquePaths) { for (const body of bodyCandidates) { try { const json = await fetchJson(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // 检查更多可能的响应结构 if (Array.isArray(json?.convs)) return json; if (Array.isArray(json?.data?.convs)) return json.data; if (Array.isArray(json?.result?.convs)) return json.result; if (Array.isArray(json?.response?.convs)) return json.response; if (Array.isArray(json?.payload?.convs)) return json.payload; if (Array.isArray(json?.data?.data?.convs)) return json.data.data; if (Array.isArray(json?.result?.result?.convs)) return json.result.result; } catch { // 继续尝试下一个参数组合 continue; } } } // 如果POST方法失败,尝试GET方法作为回退 const getBasePaths = []; if (detailEndpoint) { getBasePaths.push(detailEndpoint.split('?')[0]); } getBasePaths.push( '/api/user/agent/conversation/v2/detail', '/api/user/agent/conversation/v1/detail', '/api/conversation/v2/detail', '/api/conversation/v1/detail', // 新增可能的端点 '/api/user/agent/conversation/detail', '/api/conversation/detail', '/api/v1/user/agent/conversation/detail', '/api/v2/user/agent/conversation/detail', '/api/conversation/detail/v1', '/api/conversation/detail/v2', ); const uniqueGetPaths = [...new Set(getBasePaths)]; const paramNames = ['conversationId', 'conversation_id', 'sessionId', 'chatId', 'id', 'conversation_uuid', 'conversationUuid', 'session_id', 'chat_id']; for (const basePath of uniqueGetPaths) { for (const paramName of paramNames) { const u = `${basePath}?${paramName}=${encodeURIComponent(id)}`; try { const json = await fetchJson(u); // 检查更多可能的响应结构 if (Array.isArray(json?.convs)) return json; if (Array.isArray(json?.data?.convs)) return json.data; if (Array.isArray(json?.result?.convs)) return json.result; if (Array.isArray(json?.response?.convs)) return json.response; if (Array.isArray(json?.payload?.convs)) return json.payload; if (Array.isArray(json?.data?.data?.convs)) return json.data.data; if (Array.isArray(json?.result?.result?.convs)) return json.result.result; } catch { // next } } } throw new Error(`无法获取会话详情:${id}`); } async function ensureAllConversationsLoaded(onProgress) { const metas = await fetchAllConversationMetas(); if (!metas.length) throw new Error('未发现任何会话(请先在左侧会话列表滚动加载更多)'); let loaded = 0; const failed = []; for (let i = 0; i < metas.length; i += 1) { const meta = metas[i]; const percent = Math.round(((i + 1) / metas.length) * 100); onProgress?.(`拉取会话 ${i + 1}/${metas.length}`, percent); if (state.captured.has(meta.id)) { loaded += 1; continue; } try { const detail = await fetchConversationDetailById(meta.id); const title = detail?.sessionTitle || detail?.title || meta.title || 'Yuanbao Chat'; const md = yuanbaoToMarkdown(detail); state.captured.set(meta.id, { id: meta.id, title, md, jsonText: JSON.stringify(detail), capturedAt: new Date().toISOString(), }); loaded += 1; } catch { failed.push(meta.id); } updateUiState(); } if (!loaded) throw new Error('会话读取失败(可能需要重新登录)'); return { total: metas.length, loaded, failed }; } function buildAllConversationsDocMarkdown(list) { return list .map((c, idx) => { const cleaned = String(c.md || '') .replace(/^#\s+.*?\n+/m, '') .trim(); return `# ${idx + 1}. ${c.title}\n\n${cleaned}`; }) .join('\n\n---\n\n'); } async function exportAll(format) { try { setBusy(true); // 如果缓存为空,先拉取 if (state.captured.size === 0) { setProgress(true, '正在读取全部会话...', 2); await ensureAllConversationsLoaded((text, percent) => setProgress(true, text, percent)); } const allList = Array.from(state.captured.values()); const stats = { total: allList.length, loaded: allList.length, failed: [] }; // ZIP 打包导出 await exportAsZip(allList, format, stats); setProgress(true, '导出完成', 100); showToast(`全部对话 ${format.toUpperCase()} ZIP 已导出(${allList.length} 个会话)`); setTimeout(() => setProgress(false), 600); } catch (err) { setProgress(false); alert(`批量导出失败:${err?.message || err}`); } finally { setBusy(false); } } async function exportAsZip(allList, format, stats) { if (typeof JSZip === 'undefined') { throw new Error('JSZip 库未加载'); } setProgress(true, format === 'docx' ? '正在生成 DOCX 并打包...' : '正在打包 ZIP...', 85); const zip = new JSZip(); const folder = zip.folder('yuanbao-conversations'); for (let i = 0; i < allList.length; i++) { const c = allList[i]; const safeTitle = Utils.sanitizeFilename(c.title || `conversation-${i + 1}`); const filename = `${String(i + 1).padStart(3, '0')}_${safeTitle}`; // 更新进度 const progressPercent = 85 + Math.round(((i + 1) / allList.length) * 10); setProgress(true, `正在处理 ${i + 1}/${allList.length}...`, progressPercent); if (format === 'md') { folder.file(`${filename}.md`, c.md); } else if (format === 'json') { folder.file(`${filename}.json`, c.jsonText); } else if (format === 'docx') { // 生成 DOCX blob const docText = `# ${c.title}\n\n> 导出时间: ${new Date().toLocaleString()}\n\n${String(c.md || '').replace(/^#\s+.*?\n+/m, '').trim()}`; const docxBlob = await buildDocxBlob(docText); const docxArrayBuffer = await docxBlob.arrayBuffer(); folder.file(`${filename}.docx`, docxArrayBuffer); } } // 添加索引文件 if (format === 'docx') { const indexContent = buildIndexDocx(allList, stats); const indexBlob = await buildDocxBlob(indexContent); const indexArrayBuffer = await indexBlob.arrayBuffer(); folder.file('_index.docx', indexArrayBuffer); } else if (format === 'json') { const indexContent = JSON.stringify({ exportedAt: new Date().toISOString(), total: allList.length, loaded: stats?.loaded, failed: stats?.failed?.length || 0, conversations: allList.map((c, i) => ({ index: i + 1, id: c.id, title: c.title, filename: `${String(i + 1).padStart(3, '0')}_${Utils.sanitizeFilename(c.title || `conversation-${i + 1}`)}.json` })) }, null, 2); folder.file('_index.json', indexContent); } else { const indexContent = buildIndexMarkdown(allList, stats); folder.file('_index.md', indexContent); } setProgress(true, '正在生成 ZIP...', 97); const zipBlob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } }); Utils.download(zipBlob, 'application/zip', `ALL_Yuanbao_Conversations_${format.toUpperCase()}_${Utils.nowStamp()}.zip`); } function buildIndexMarkdown(allList, stats) { const out = []; out.push('# 元宝会话导出索引'); out.push(''); out.push(`> 导出时间: ${new Date().toLocaleString()}`); out.push(`> 会话总数: ${allList.length}`); if (stats && stats.failed && stats.failed.length > 0) { out.push(`> 成功: ${stats.loaded}, 失败: ${stats.failed.length}`); } out.push(''); out.push('## 会话列表'); out.push(''); for (let i = 0; i < allList.length; i++) { const c = allList[i]; const safeTitle = Utils.sanitizeFilename(c.title || `conversation-${i + 1}`); out.push(`${i + 1}. [${c.title || '未命名会话'}](./${String(i + 1).padStart(3, '0')}_${safeTitle}.md)`); } out.push(''); out.push('---'); out.push('*由 Chat Export Toolkit 导出*'); return out.join('\n'); } function buildIndexDocx(allList, stats) { const out = []; out.push('# 元宝会话导出索引'); out.push(''); out.push(`导出时间: ${new Date().toLocaleString()}`); out.push(`会话总数: ${allList.length}`); if (stats && stats.failed && stats.failed.length > 0) { out.push(`成功: ${stats.loaded}, 失败: ${stats.failed.length}`); } out.push(''); out.push('## 会话列表'); out.push(''); for (let i = 0; i < allList.length; i++) { const c = allList[i]; out.push(`${i + 1}. ${c.title || '未命名会话'}`); } out.push(''); out.push('---'); out.push('由 Chat Export Toolkit 导出'); return out.join('\n'); } function ensureUi() { if (document.getElementById('cet-fab')) return; const style = document.createElement('style'); style.textContent = ` :root { --cet-green-primary: #01B259; --cet-green-hover: #4BC979; --cet-green-bg: #F5FBF5; --cet-black: #191919; --cet-gray-border: #F3F3F3; --cet-gray-text: #BABABA; --cet-white: #FFFFFF; --cet-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); --cet-shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.12); --cet-radius: 12px; } /* 浮动按钮 - 更加精致的品牌感 */ #cet-fab { position: fixed; right: 24px; bottom: 24px; z-index: 999999; height: 44px; min-width: 90px; border-radius: 22px; border: 1px solid var(--cet-gray-border); background: var(--cet-white); color: var(--cet-black); font-size: 14px; font-weight: 600; cursor: pointer; padding: 0 18px; box-shadow: var(--cet-shadow); display: flex; align-items: center; justify-content: center; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; } #cet-fab:hover { box-shadow: var(--cet-shadow-hover); transform: translateY(-2px); border-color: var(--cet-green-hover); } #cet-fab-badge { display: inline-block; margin-left: 8px; min-width: 18px; height: 18px; line-height: 18px; border-radius: 9px; font-size: 11px; text-align: center; background: var(--cet-green-primary); color: #fff; padding: 0 5px; font-weight: 700; box-shadow: 0 2px 4px rgba(1, 178, 89, 0.2); } /* 主面板 - 高级卡片感 */ #cet-panel { position: fixed; right: 24px; bottom: 84px; z-index: 1000000; width: 300px; border-radius: 16px; color: var(--cet-black); background: var(--cet-white); border: 1px solid var(--cet-gray-border); box-shadow: var(--cet-shadow-hover); padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; display: none; transform-origin: right bottom; animation: cet-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } @keyframes cet-pop-in { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } } #cet-panel.open { display: block; } #cet-title { font-weight: 700; font-size: 16px; margin-bottom: 6px; color: var(--cet-black); letter-spacing: -0.2px; } #cet-status { font-size: 12px; color: var(--cet-gray-text); margin-bottom: 16px; font-weight: 400; } /* 进度条 - 动态交互感 */ #cet-progress { display: none; margin-bottom: 16px; background: var(--cet-green-bg); padding: 12px; border-radius: 10px; } #cet-progress-text { font-size: 12px; color: var(--cet-green-primary); margin-bottom: 8px; font-weight: 600; } #cet-progress-track { width: 100%; height: 6px; border-radius: 3px; background: #E8F5EE; overflow: hidden; } #cet-progress-bar { width: 0%; height: 100%; background: var(--cet-green-primary); transition: width 0.3s ease; } /* 分组 */ .cet-group { margin-bottom: 20px; } .cet-group:last-child { margin-bottom: 0; } .cet-label { font-size: 12px; color: var(--cet-gray-text); margin-bottom: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; } /* 分段控制器 - 软胶囊风格 */ .cet-segmented { display: flex; background: var(--cet-gray-border); border-radius: 10px; padding: 4px; margin-bottom: 12px; } .cet-segmented-btn { flex: 1; border: none; border-radius: 7px; padding: 8px; background: transparent; color: #777; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.25s ease; font-family: inherit; } .cet-segmented-btn:hover:not(.active):not(:disabled) { color: var(--cet-black); } .cet-segmented-btn.active { background: var(--cet-white); color: var(--cet-green-primary); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .cet-segmented-btn:disabled { opacity: 0.4; cursor: not-allowed; } /* 操作按钮 - 品牌调色整合 */ .cet-actions { display: flex; gap: 10px; } .cet-action-btn { flex: 1; border: 1px solid var(--cet-gray-border); border-radius: 10px; padding: 10px 12px; background: var(--cet-white); color: var(--cet-black); cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s ease; font-family: inherit; display: flex; align-items: center; justify-content: center; } .cet-action-btn:hover:not(:disabled) { background: var(--cet-green-bg); border-color: var(--cet-green-hover); color: var(--cet-green-primary); transform: translateY(-1px); } .cet-action-btn:active:not(:disabled) { transform: translateY(0); } .cet-action-btn:disabled { opacity: 0.4; cursor: not-allowed; } .cet-action-btn.primary { background: var(--cet-green-primary); border-color: var(--cet-green-primary); color: var(--cet-white); box-shadow: 0 4px 12px rgba(1, 178, 89, 0.2); } .cet-action-btn.primary:hover:not(:disabled) { background: var(--cet-green-hover); border-color: var(--cet-green-hover); color: var(--cet-white); box-shadow: 0 6px 16px rgba(1, 178, 89, 0.3); } #cet-panel.is-busy .cet-action-btn { opacity: 0.5; pointer-events: none; } /* Toast 提示 - 磨砂玻璃质感 */ #cet-toast { position: fixed; left: 50%; bottom: 100px; z-index: 1000001; color: var(--cet-white); background: rgba(25, 25, 25, 0.85); border-radius: 20px; padding: 8px 16px; font-size: 13px; font-weight: 500; opacity: 0; transform: translate(-50%, 12px); transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1); pointer-events: none; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); max-width: 80%; text-align: center; } #cet-toast.show { opacity: 1; transform: translate(-50%, 0); } `.trim(); document.documentElement.appendChild(style); const fab = document.createElement('button'); fab.id = 'cet-fab'; fab.innerHTML = `导出 <span id="cet-fab-badge">0</span>`; document.body.appendChild(fab); const panel = document.createElement('div'); panel.id = 'cet-panel'; panel.innerHTML = ` <div id="cet-title">Chat Export Toolkit</div> <div id="cet-status">等待对话数据 · 已缓存 0 个会话</div> <div id="cet-progress"> <div id="cet-progress-text">准备中...</div> <div id="cet-progress-track"><div id="cet-progress-bar"></div></div> </div> <!-- 当前会话 --> <div class="cet-group"> <div class="cet-label">当前会话</div> <div class="cet-segmented" id="cet-current-format"> <button class="cet-segmented-btn active" data-format="md">Markdown</button> <button class="cet-segmented-btn" data-format="json">JSON</button> <button class="cet-segmented-btn" data-format="docx">DOCX</button> </div> <div class="cet-actions"> <button class="cet-action-btn" id="cet-current-copy">复制</button> <button class="cet-action-btn primary" id="cet-current-save">保存</button> </div> </div> <!-- 全部会话 --> <div class="cet-group"> <div class="cet-label">全部会话 · 导出为 ZIP</div> <div class="cet-segmented" id="cet-all-format"> <button class="cet-segmented-btn active" data-format="md">Markdown</button> <button class="cet-segmented-btn" data-format="json">JSON</button> <button class="cet-segmented-btn" data-format="docx">DOCX</button> </div> <div class="cet-actions"> <button class="cet-action-btn export" id="cet-all-export" style="width:100%;">导出 ZIP</button> </div> </div> `.trim(); document.body.appendChild(panel); const toast = document.createElement('div'); toast.id = 'cet-toast'; document.body.appendChild(toast); fab.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); state.ui.panelOpen = !state.ui.panelOpen; panel.classList.toggle('open', state.ui.panelOpen); }); document.addEventListener('click', (e) => { if (!state.ui.panelOpen) return; const t = e.target; if (panel.contains(t) || fab.contains(t)) return; state.ui.panelOpen = false; panel.classList.remove('open'); }); // 状态管理 const uiState = { currentFormat: 'md', allFormat: 'md' }; // 分段控制器切换 const setupSegmented = (containerId, stateKey) => { const container = document.getElementById(containerId); if (!container) return; const buttons = container.querySelectorAll('.cet-segmented-btn'); buttons.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); buttons.forEach(b => b.classList.remove('active')); btn.classList.add('active'); uiState[stateKey] = btn.dataset.format; }); }); }; setupSegmented('cet-current-format', 'currentFormat'); setupSegmented('cet-all-format', 'allFormat'); const on = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('click', (e) => (e.preventDefault(), e.stopPropagation(), fn())); }; // 当前会话 - 复制 on('cet-current-copy', async () => { if (!state.current) return; const format = uiState.currentFormat; try { if (format === 'md') { await Utils.copyText(state.current.md); showToast('当前会话 MD 已复制'); } else if (format === 'json') { await Utils.copyText(state.current.jsonText); showToast('当前会话 JSON 已复制'); } else if (format === 'docx') { showToast('DOCX 不支持复制'); } } catch (err) { alert(`复制失败: ${err?.message || err}`); } }); // 当前会话 - 保存 on('cet-current-save', async () => { if (!state.current) return; const format = uiState.currentFormat; if (format === 'md') { Utils.download(state.current.md, 'text/markdown', getFilename('current', 'md', state.current.title)); showToast('当前会话 MD 已保存'); } else if (format === 'json') { Utils.download(state.current.jsonText, 'application/json', getFilename('current', 'json', state.current.title)); showToast('当前会话 JSON 已保存'); } else if (format === 'docx') { try { setBusy(true); setProgress(true, '正在生成 DOCX...', 95); const docText = `# ${state.current.title}\n\n> 导出时间: ${new Date().toLocaleString()}\n\n${String(state.current.md || '').replace(/^#\s+.*?\n+/m, '').trim()}`; const blob = await buildDocxBlob(docText); Utils.download(blob, blob.type, getFilename('current', 'docx', state.current.title)); setProgress(true, '保存完成', 100); showToast('当前会话 DOCX 已保存'); setTimeout(() => setProgress(false), 500); } catch (err) { setProgress(false); alert(`DOCX 保存失败: ${err?.message || err}`); } finally { setBusy(false); } } }); // 全部会话 - ZIP 导出 on('cet-all-export', () => exportAll(uiState.allFormat)); updateUiState(); } function onReady(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn(); } function handleConversationListResponse(text) { let json; try { json = JSON.parse(text); } catch { return; } const tmp = []; const seen = new Set(); // 尝试更多可能的数据结构 const conversations = pickArray(json, ['conversations', 'data', 'result', 'response', 'payload']) || pickArray(json?.data, ['conversations', 'result', 'response', 'payload']) || pickArray(json?.result, ['conversations', 'data', 'response', 'payload']) || pickArray(json?.response, ['conversations', 'data', 'result', 'payload']) || pickArray(json?.payload, ['conversations', 'data', 'result', 'response']); if (conversations.length > 0) { collectConversationMetasFromJson(conversations, tmp, seen); } else { collectConversationMetasFromJson(json, tmp, seen); } for (const row of tmp) { state.listHints.set(row.id, row.title); } } function handleYuanbaoResponse(text, url) { let json; try { json = JSON.parse(text); } catch { return; } // 检查更多可能的响应结构 let convsData = null; if (Array.isArray(json.convs)) { convsData = json; } else if (Array.isArray(json?.data?.convs)) { convsData = json.data; } else if (Array.isArray(json?.result?.convs)) { convsData = json.result; } else if (Array.isArray(json?.response?.convs)) { convsData = json.response; } else if (Array.isArray(json?.payload?.convs)) { convsData = json.payload; } else if (Array.isArray(json?.data?.data?.convs)) { convsData = json.data.data; } else if (Array.isArray(json?.result?.result?.convs)) { convsData = json.result.result; } if (!convsData) return; const idFromUrl = Utils.extractConversationId(url); const title = convsData.sessionTitle || convsData.title || json.sessionTitle || json.title || 'Yuanbao Chat'; const id = idFromUrl || `${Utils.sanitizeFilename(title)}_${Utils.nowStamp()}`; state.listHints.set(id, title); state.current = { id, title, md: yuanbaoToMarkdown(convsData), jsonText: text, capturedAt: new Date().toISOString(), }; state.captured.set(id, state.current); updateUiState(); } // 存储已发现的API端点 let discoveredEndpoints = { detail: new Set(), list: new Set() }; function installInterceptors() { const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (...args) { const method = args[0]; const url = args[1]; this.addEventListener( 'load', () => { try { const responseUrl = this.responseURL || url; // 使用原始URL或响应URL // 检查是否是腾讯元宝相关的API请求 if (responseUrl.includes('/conversation/')) { // 记录所有conversation相关的端点,不管是否成功 if (responseUrl.includes('/detail')) { discoveredEndpoints.detail.add(responseUrl); console.log('[Chat Export] Discovered detail API via XHR:', responseUrl, 'Status:', this.status); } else if (responseUrl.includes('/list') || responseUrl.includes('/page')) { discoveredEndpoints.list.add(responseUrl); console.log('[Chat Export] Discovered list API via XHR:', responseUrl, 'Status:', this.status); } } // 检查是否是我们已知的模式,只处理成功的请求 if ((YUANBAO_DETAIL_RE.test(responseUrl) || discoveredEndpoints.detail.has(responseUrl)) && this.status === 200) { console.log('[Chat Export] Intercepted detail API:', responseUrl); handleYuanbaoResponse(this.responseText, responseUrl); return; } if ((YUANBAO_LIST_RE.test(responseUrl) || discoveredEndpoints.list.has(responseUrl)) && this.status === 200) { console.log('[Chat Export] Intercepted list API:', responseUrl); handleConversationListResponse(this.responseText); } } catch (err) { console.error('[Chat Export] XHR intercept error:', err); } }, { once: true } ); return originalOpen.apply(this, args); }; const originalFetch = window.fetch; window.fetch = async (...args) => { const url = args[0] instanceof Request ? args[0].url : String(args[0] || ''); const res = await originalFetch(...args); try { // 检查是否是腾讯元宝相关的API请求 if (url.includes('/conversation/')) { // 记录所有conversation相关的端点,不管是否成功 if (url.includes('/detail')) { discoveredEndpoints.detail.add(url); console.log('[Chat Export] Discovered detail API via fetch:', url, 'Status:', res.status); } else if (url.includes('/list') || url.includes('/page')) { discoveredEndpoints.list.add(url); console.log('[Chat Export] Discovered list API via fetch:', url, 'Status:', res.status); } } // 检查是否是我们已知的模式或已发现的端点,只处理成功的请求 if (((YUANBAO_DETAIL_RE.test(url) || discoveredEndpoints.detail.has(url)) && res.status === 200)) { console.log('[Chat Export] Intercepted fetch detail API:', url); res .clone() .text() .then((t) => handleYuanbaoResponse(t, url)) .catch(() => {}); } else if (((YUANBAO_LIST_RE.test(url) || discoveredEndpoints.list.has(url)) && res.status === 200)) { console.log('[Chat Export] Intercepted fetch list API:', url); res .clone() .text() .then((t) => handleConversationListResponse(t)) .catch(() => {}); } } catch (err) { console.error('[Chat Export] Fetch intercept error:', err); } return res; }; } // 从已发现的端点中选择最可能的API端点 function selectBestEndpoint(type) { const candidates = Array.from(discoveredEndpoints[type]); if (candidates.length === 0) return null; // 优先选择最近被发现的成功的端点 // 由于Set是有序的,最后添加的会在末尾 return candidates[candidates.length - 1]; } let autoFetchStarted = false; async function startAutoFetch() { if (autoFetchStarted) return; autoFetchStarted = true; // 延迟 3 秒启动,等待页面初始化 await new Promise(resolve => setTimeout(resolve, 3000)); // 静默拉取所有对话并缓存 try { const metas = await fetchAllConversationMetas(); if (!metas.length) return; let loaded = 0; for (let i = 0; i < metas.length; i += 1) { const meta = metas[i]; if (state.captured.has(meta.id)) { loaded += 1; continue; } try { const detail = await fetchConversationDetailById(meta.id); const title = detail?.sessionTitle || detail?.title || meta.title || 'Yuanbao Chat'; const md = yuanbaoToMarkdown(detail); state.captured.set(meta.id, { id: meta.id, title, md, jsonText: JSON.stringify(detail), capturedAt: new Date().toISOString(), }); loaded += 1; } catch { // 静默失败 } updateUiState(); // 每 5 个会话暂停 100ms,避免请求过快 if ((i + 1) % 5 === 0) { await new Promise(resolve => setTimeout(resolve, 100)); } } } catch { // 静默失败 } } function init() { installInterceptors(); onReady(() => { ensureUi(); // 页面加载后自动开始后台拉取 startAutoFetch(); }); } init(); })();