Greasy Fork is available in English.
Claude、ChatGPT、Gemini、NotebookLM、Google AI Studio对话管理工具的配套脚本:一键获取对话UUID和完整JSON数据,支持树形分支模式导出。轻松管理数百个对话窗口,导出完整时间线、思考过程、工具调用和Artifacts。配合Lyra's Exporter使用,让每次AI对话都成为您的数字资产!
当前为
// ==UserScript== // @name Lyra's Fetch // @namespace userscript://lyra-universal-ai-exporter // @version 2.1 // @description Claude、ChatGPT、Gemini、NotebookLM、Google AI Studio对话管理工具的配套脚本:一键获取对话UUID和完整JSON数据,支持树形分支模式导出。轻松管理数百个对话窗口,导出完整时间线、思考过程、工具调用和Artifacts。配合Lyra's Exporter使用,让每次AI对话都成为您的数字资产! // @description:en Claude, ChatGPT, Gemini, NotebookLM, Google AI Studio conversation management companion script: One-click UUID extraction and complete JSON export with tree-branch mode support. Designed for power users to easily manage hundreds of conversation windows, export complete timelines, thinking processes, tool calls and Artifacts. Works with Lyra's Exporter management app to turn every AI chat into your digital asset! // @homepage https://yalums.github.io/lyra-exporter/ // @supportURL https://github.com/Yalums/lyra-exporter/issues // @author Yalums // @match https://claude.easychat.top/* // @match https://pro.easychat.top/* // @match https://claude.ai/* // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://share.tu-zi.com/* // @match https://gemini.google.com/app/* // @match https://notebooklm.google.com/* // @match https://aistudio.google.com/* // @include *://gemini.google.com/* // @include *://notebooklm.google.com/* // @include *://aistudio.google.com/* // @run-at document-start // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js // @license GNU General Public License v3.0 // ==/UserScript== (function() { 'use strict'; if (window.lyraFetchInitialized) return; window.lyraFetchInitialized = true; // ===== 配置 ===== const Config = { CONTROL_ID: 'lyra-controls', TOGGLE_ID: 'lyra-toggle-button', LANG_SWITCH_ID: 'lyra-lang-switch', TREE_SWITCH_ID: 'lyra-tree-mode-switch', IMAGE_SWITCH_ID: 'lyra-image-switch', WORKSPACE_TYPE_ID: 'lyra-workspace-type', MANUAL_ID_BTN: 'lyra-manual-id-btn', // 使用 old.js 的 URL 和 Origin 以支持 postMessage 预览功能 EXPORTER_URL: 'https://yalums.github.io/lyra-exporter/', EXPORTER_ORIGIN: 'https://yalums.github.io' }; // ===== 状态管理 ===== const State = { currentPlatform: (() => { const host = window.location.hostname; if (host.includes('claude') || host.includes('easychat.top')) return 'claude'; if (host.includes('chatgpt') || host.includes('openai') || host.includes('tu-zi.com')) return 'chatgpt'; if (host.includes('gemini')) return 'gemini'; if (host.includes('notebooklm')) return 'notebooklm'; if (host.includes('aistudio')) return 'aistudio'; return null; })(), isPanelCollapsed: localStorage.getItem('lyraExporterCollapsed') === 'true', includeImages: localStorage.getItem('lyraIncludeImages') === 'true', capturedUserId: localStorage.getItem('lyraClaudeUserId') || '', // ChatGPT 相关状态 chatgptAccessToken: null, chatgptWorkspaceId: localStorage.getItem('lyraChatGPTWorkspaceId') || '', chatgptWorkspaceType: localStorage.getItem('lyraChatGPTWorkspaceType') || 'user', // 'user' or 'team' chatgptCapturedWorkspaces: new Set(), panelInjected: false }; let collectedData = new Map(); // ai studio的滚动常量 const SCROLL_DELAY_MS = 250; const SCROLL_TOP_WAIT_MS = 1000; // ===== i18n 国际化 (REVERTED to simple v8.1 strings) ===== const i18n = { languages: { zh: { loading: '加载中...', exporting: '导出中...', compressing: '压缩中...', preparing: '准备中...', exportSuccess: '导出成功!', noContent: '没有可导出的对话内容。', exportCurrentJSON: '导出当前', exportAllConversations: '导出全部', branchMode: '多分支', includeImages: '含图像', enterFilename: '请输入文件名(不含扩展名):', untitledChat: '未命名对话', uuidNotFound: '未找到对话UUID!', fetchFailed: '获取对话数据失败', exportFailed: '导出失败: ', gettingConversation: '获取对话', withImages: ' (处理图片中...)', successExported: '成功导出', conversations: '个对话!', manualUserId: '手动设置ID', enterUserId: '请输入您的组织ID (settings/account):', userIdSaved: '用户ID已保存!', workspaceType: '团队空间', userWorkspace: '个人区', teamWorkspace: '工作区', manualWorkspaceId: '手动设置工作区ID', enterWorkspaceId: '请输入工作区ID (工作空间设置/工作空间 ID):', workspaceIdSaved: '工作区ID已保存!', tokenNotFound: '未找到访问令牌!', viewOnline: '预览对话', loadFailed: '加载失败: ', cannotOpenExporter: '无法打开 Lyra Exporter,请检查弹窗拦截', }, en: { loading: 'Loading...', exporting: 'Exporting...', compressing: 'Compressing...', preparing: 'Preparing...', exportSuccess: 'Export successful!', noContent: 'No conversation content to export.', exportCurrentJSON: 'Export', exportAllConversations: 'Save All', branchMode: 'Branch', includeImages: 'Images', enterFilename: 'Enter filename (without extension):', untitledChat: 'Untitled Chat', uuidNotFound: 'UUID not found!', fetchFailed: 'Failed to fetch conversation data', exportFailed: 'Export failed: ', gettingConversation: 'Getting conversation', withImages: ' (processing images...)', successExported: 'Successfully exported', conversations: 'conversations!', manualUserId: 'Customize UUID', enterUserId: 'Organization ID (settings/account)', userIdSaved: 'User ID saved!', workspaceType: 'Workspace', userWorkspace: 'Personal', teamWorkspace: 'Team', manualWorkspaceId: 'Set Workspace ID', enterWorkspaceId: 'Enter Workspace ID(Workspace settings/Workspace ID):', workspaceIdSaved: 'Workspace ID saved!', tokenNotFound: 'Access token not found!', viewOnline: 'Preview', loadFailed: 'Load failed: ', cannotOpenExporter: 'Cannot open Lyra Exporter, please check popup blocker', } }, currentLang: localStorage.getItem('lyraExporterLanguage') || (navigator.language.startsWith('zh') ? 'zh' : 'en'), // Reverted to simple t() function t: (key) => i18n.languages[i18n.currentLang]?.[key] || key, setLanguage: (lang) => { i18n.currentLang = lang; localStorage.setItem('lyraExporterLanguage', lang); }, // Kept from old.js for UI getLanguageShort() { return this.currentLang === 'zh' ? '简体中文' : 'English'; } }; const previewIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>'; const collapseIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"></polyline></svg>'; const expandIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>'; const exportIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>'; const zipIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 11V9a7 7 0 0 0-7-7a7 7 0 0 0-7 7v2"></path><rect x="3" y="11" width="18" height="10" rx="2" ry="2"></rect></svg>'; // ===== 工具函数 ===== const Utils = { sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)), sanitizeFilename: (name) => name.replace(/[^a-z0-9\u4e00-\u9fa5]/gi, '_').substring(0, 100), blobToBase64: (blob) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result.split(',')[1]); reader.onerror = reject; reader.readAsDataURL(blob); }), downloadJSON: (jsonString, filename) => { const blob = new Blob([jsonString], { type: 'application/json' }); Utils.downloadFile(blob, filename); }, downloadFile: (blob, filename) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }, setButtonLoading: (btn, text) => { btn.disabled = true; btn.innerHTML = `<div class="lyra-loading"></div> <span>${text}</span>`; }, restoreButton: (btn, originalContent) => { btn.disabled = false; btn.innerHTML = originalContent; }, createButton: (innerHTML, onClick, useInlineStyles = false) => { const btn = document.createElement('button'); btn.className = 'lyra-button'; btn.innerHTML = innerHTML; btn.addEventListener('click', () => onClick(btn)); // 为 notebooklm 和 gemini 使用内联样式(优先级最高) if (useInlineStyles) { Object.assign(btn.style, { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '8px', width: '100%', maxWidth: '100%', padding: '8px 12px', margin: '8px 0', border: 'none', borderRadius: '6px', fontSize: '11px', fontWeight: '500', cursor: 'pointer', letterSpacing: '0.3px', height: '32px', boxSizing: 'border-box', whiteSpace: 'nowrap' }); } return btn; }, createToggle: (label, id, checked = false) => { const container = document.createElement('div'); container.className = 'lyra-toggle'; const labelSpan = document.createElement('span'); labelSpan.className = 'lyra-toggle-label'; labelSpan.textContent = label; const switchLabel = document.createElement('label'); switchLabel.className = 'lyra-switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.id = id; input.checked = checked; const slider = document.createElement('span'); slider.className = 'lyra-slider'; switchLabel.appendChild(input); switchLabel.appendChild(slider); container.appendChild(labelSpan); container.appendChild(switchLabel); return container; }, createProgressElem: (parent) => { const elem = document.createElement('div'); elem.className = 'lyra-progress'; parent.appendChild(elem); return elem; } }; // ===== Lyra Communicator (移植自 old.js 的 openLyraExporterWithData) ===== const LyraCommunicator = { open: async (jsonData, filename) => { try { const exporterWindow = window.open(Config.EXPORTER_URL, '_blank'); if (!exporterWindow) { alert(i18n.t('cannotOpenExporter')); return false; } const checkInterval = setInterval(() => { try { exporterWindow.postMessage({ type: 'LYRA_HANDSHAKE', source: 'lyra-fetch-script' }, Config.EXPORTER_ORIGIN); } catch (e) { // Error sending handshake } }, 1000); const handleMessage = (event) => { if (event.origin !== Config.EXPORTER_ORIGIN) { return; } if (event.data && event.data.type === 'LYRA_READY') { clearInterval(checkInterval); const dataToSend = { type: 'LYRA_LOAD_DATA', source: 'lyra-fetch-script', data: { content: jsonData, filename: filename || `${State.currentPlatform}_export_${new Date().toISOString().slice(0,10)}.json` } }; exporterWindow.postMessage(dataToSend, Config.EXPORTER_ORIGIN); window.removeEventListener('message', handleMessage); } }; window.addEventListener('message', handleMessage); setTimeout(() => { clearInterval(checkInterval); window.removeEventListener('message', handleMessage); }, 45000); // 45s 超时 return true; } catch (error) { alert(`${i18n.t('cannotOpenExporter')}: ${error.message}`); return false; } } }; // ===== 平台处理器:Claude ===== const ClaudeHandler = { init: () => { // 拦截请求以捕获用户ID const script = document.createElement('script'); script.textContent = ` (function() { function captureUserId(url) { const match = url.match(/\\/api\\/organizations\\/([a-f0-9-]+)\\//); if (match && match[1]) { localStorage.setItem('lyraClaudeUserId', match[1]); window.dispatchEvent(new CustomEvent('lyraUserIdCaptured', { detail: { userId: match[1] } })); } } const originalXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function() { if (arguments[1]) captureUserId(arguments[1]); return originalXHROpen.apply(this, arguments); }; const originalFetch = window.fetch; window.fetch = function(resource) { const url = typeof resource === 'string' ? resource : (resource.url || ''); if (url) captureUserId(url); return originalFetch.apply(this, arguments); }; })(); `; (document.head || document.documentElement).appendChild(script); script.remove(); window.addEventListener('lyraUserIdCaptured', (e) => { if (e.detail.userId) State.capturedUserId = e.detail.userId; }); // ✅ 移除了定时更新 status UI 的代码,因为已经不显示 UUID 了 }, addUI: (controlsArea) => { // ✅ 只添加两个toggle,手动输入按钮移到 createPanel 中 // ✅ 保留:分支模式开关 const treeMode = window.location.search.includes('tree=true'); controlsArea.appendChild(Utils.createToggle(i18n.t('branchMode'), Config.TREE_SWITCH_ID, treeMode)); // ✅ 保留:图片开关 controlsArea.appendChild(Utils.createToggle(i18n.t('includeImages'), Config.IMAGE_SWITCH_ID, State.includeImages)); document.addEventListener('change', (e) => { if (e.target.id === Config.IMAGE_SWITCH_ID) { State.includeImages = e.target.checked; localStorage.setItem('lyraIncludeImages', State.includeImages); } }); }, addButtons: (controlsArea) => { controlsArea.appendChild(Utils.createButton( `${previewIcon} ${i18n.t('viewOnline')}`, async (btn) => { const uuid = ClaudeHandler.getCurrentUUID(); if (!uuid) { alert(i18n.t('uuidNotFound')); return; } if (!await ClaudeHandler.ensureUserId()) return; const original = btn.innerHTML; Utils.setButtonLoading(btn, i18n.t('loading')); try { const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; const data = await ClaudeHandler.getConversation(uuid, includeImages); if (!data) throw new Error(i18n.t('fetchFailed')); const jsonString = JSON.stringify(data, null, 2); const filename = `claude_${data.name || 'conversation'}_${uuid.substring(0, 8)}.json`; await LyraCommunicator.open(jsonString, filename); } catch (error) { alert(`${i18n.t('loadFailed')} ${error.message}`); } finally { Utils.restoreButton(btn, original); } } )); controlsArea.appendChild(Utils.createButton( `${exportIcon} ${i18n.t('exportCurrentJSON')}`, async (btn) => { const uuid = ClaudeHandler.getCurrentUUID(); if (!uuid) { alert(i18n.t('uuidNotFound')); return; } if (!await ClaudeHandler.ensureUserId()) return; const filename = prompt(i18n.t('enterFilename'), Utils.sanitizeFilename(`claude_${uuid.substring(0, 8)}`)); if (!filename?.trim()) return; const original = btn.innerHTML; Utils.setButtonLoading(btn, i18n.t('exporting')); try { const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; const data = await ClaudeHandler.getConversation(uuid, includeImages); if (!data) throw new Error(i18n.t('fetchFailed')); Utils.downloadJSON(JSON.stringify(data, null, 2), `${filename.trim()}.json`); } catch (error) { alert(`${i18n.t('exportFailed')} ${error.message}`); } finally { Utils.restoreButton(btn, original); } } )); controlsArea.appendChild(Utils.createButton( `${zipIcon} ${i18n.t('exportAllConversations')}`, (btn) => ClaudeHandler.exportAll(btn, controlsArea) )); }, getCurrentUUID: () => window.location.pathname.match(/\/chat\/([a-zA-Z0-9-]+)/)?.[1], ensureUserId: async () => { if (State.capturedUserId) return State.capturedUserId; const saved = localStorage.getItem('lyraClaudeUserId'); if (saved) { State.capturedUserId = saved; return saved; } alert('未能检测到用户ID / User ID not detected'); return null; }, getBaseUrl: () => { if (window.location.hostname.includes('claude.ai')) { return 'https://claude.ai'; } else if (window.location.hostname.includes('easychat.top')) { return `https://${window.location.hostname}`; } return window.location.origin; }, getAllConversations: async () => { const userId = await ClaudeHandler.ensureUserId(); if (!userId) return null; try { const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/chat_conversations`); if (!response.ok) throw new Error('Fetch failed'); return await response.json(); } catch (error) { console.error('Get all conversations error:', error); return null; } }, getConversation: async (uuid, includeImages = false) => { const userId = await ClaudeHandler.ensureUserId(); if (!userId) return null; try { const treeMode = document.getElementById(Config.TREE_SWITCH_ID)?.checked || false; const endpoint = treeMode ? `/api/organizations/${userId}/chat_conversations/${uuid}?tree=True&rendering_mode=messages&render_all_tools=true` : `/api/organizations/${userId}/chat_conversations/${uuid}`; const apiUrl = `${ClaudeHandler.getBaseUrl()}${endpoint}`; const response = await fetch(apiUrl); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); const data = await response.json(); if (includeImages && data.chat_messages) { for (const msg of data.chat_messages) { const fileArrays = ['files', 'files_v2', 'attachments']; for (const key of fileArrays) { if (Array.isArray(msg[key])) { for (const file of msg[key]) { const isImage = file.file_kind === 'image' || file.file_type?.startsWith('image/'); const imageUrl = file.preview_url || file.thumbnail_url || file.file_url; if (isImage && imageUrl && !file.embedded_image) { try { const fullUrl = imageUrl.startsWith('http') ? imageUrl : ClaudeHandler.getBaseUrl() + imageUrl; const imgResp = await fetch(fullUrl); if (imgResp.ok) { const blob = await imgResp.blob(); const base64 = await Utils.blobToBase64(blob); file.embedded_image = { type: 'image', format: blob.type, size: blob.size, data: base64, original_url: imageUrl }; } } catch (err) { console.error('Process image error:', err); } } } } } } } return data; } catch (error) { console.error('Get conversation error:', error); return null; } }, exportAll: async (btn, controlsArea) => { // 使用 fflate 替代 JSZip 以解决沙盒环境中的 Web Workers 限制 if (typeof fflate === 'undefined' || typeof fflate.zipSync !== 'function' || typeof fflate.strToU8 !== 'function') { alert('Error: fflate library not loaded.'); return; } if (!await ClaudeHandler.ensureUserId()) return; const progress = Utils.createProgressElem(controlsArea); progress.textContent = i18n.t('preparing'); const original = btn.innerHTML; Utils.setButtonLoading(btn, i18n.t('exporting')); try { const allConvs = await ClaudeHandler.getAllConversations(); if (!allConvs || !Array.isArray(allConvs)) throw new Error(i18n.t('fetchFailed')); const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; let exported = 0; console.log(`Starting export of ${allConvs.length} conversations`); // 收集所有对话数据并准备 ZIP 条目 const zipEntries = {}; for (let i = 0; i < allConvs.length; i++) { const conv = allConvs[i]; progress.textContent = `${i18n.t('gettingConversation')} ${i + 1}/${allConvs.length}${includeImages ? i18n.t('withImages') : ''}`; if (i > 0 && i % 5 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); } else if (i > 0) { await Utils.sleep(300); } try { const data = await ClaudeHandler.getConversation(conv.uuid, includeImages); if (data) { const title = Utils.sanitizeFilename(data.name || conv.uuid); const filename = `claude_${conv.uuid.substring(0, 8)}_${title}.json`; // 使用 fflate.strToU8 将 JSON 字符串转换为 Uint8Array zipEntries[filename] = fflate.strToU8(JSON.stringify(data, null, 2)); exported++; } } catch (error) { console.error(`Failed to process ${conv.uuid}:`, error); } } console.log(`Export complete: ${exported} files. Compressing...`); progress.textContent = `${i18n.t('compressing')}…`; // 使用 fflate.zipSync 同步生成 ZIP(避免 Web Workers 问题) // 压缩级别 1 = 快速压缩,适合大量数据 const zipUint8 = fflate.zipSync(zipEntries, { level: 1 }); const zipBlob = new Blob([zipUint8], { type: 'application/zip' }); const zipFilename = `claude_export_all_${new Date().toISOString().slice(0, 10)}.zip`; Utils.downloadFile(zipBlob, zipFilename); alert(`${i18n.t('successExported')} ${exported} ${i18n.t('conversations')}`); } catch (error) { console.error('Export all error:', error); alert(`${i18n.t('exportFailed')} ${error.message}`); } finally { Utils.restoreButton(btn, original); if (progress.parentNode) progress.parentNode.removeChild(progress); } } }; // ===== ChatGPT 处理器 ===== const ChatGPTHandler = { init: () => { // 拦截网络请求以捕获 access token 和 workspace ID const rawFetch = window.fetch; window.fetch = async function(resource, options) { // 捕获 Authorization header const headers = options?.headers; if (headers) { let authHeader = null; if (typeof headers === 'string') { authHeader = headers; } else if (headers instanceof Headers) { authHeader = headers.get('Authorization'); } else { authHeader = headers.Authorization || headers.authorization; } if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); if (token && token.toLowerCase() !== 'dummy') { State.chatgptAccessToken = token; } } // 捕获 workspace ID let workspaceId = null; if (headers instanceof Headers) { workspaceId = headers.get('ChatGPT-Account-Id'); } else if (typeof headers === 'object') { workspaceId = headers['ChatGPT-Account-Id']; } if (workspaceId && !State.chatgptCapturedWorkspaces.has(workspaceId)) { State.chatgptCapturedWorkspaces.add(workspaceId); } } return rawFetch.apply(this, arguments); }; }, ensureAccessToken: async () => { if (State.chatgptAccessToken) return State.chatgptAccessToken; try { const response = await fetch('/api/auth/session?unstable_client=true'); const session = await response.json(); if (session.accessToken) { State.chatgptAccessToken = session.accessToken; return session.accessToken; } } catch (error) { console.error('Failed to get access token:', error); } return null; }, getOaiDeviceId: () => { const cookieString = document.cookie; const match = cookieString.match(/oai-did=([^;]+)/); return match ? match[1] : null; }, ensureWorkspaceId: () => { if (State.chatgptWorkspaceId) return State.chatgptWorkspaceId; // 尝试从捕获的workspace中获取 const captured = Array.from(State.chatgptCapturedWorkspaces); if (captured.length > 0) { State.chatgptWorkspaceId = captured[0]; localStorage.setItem('lyraChatGPTWorkspaceId', captured[0]); return captured[0]; } return null; }, getCurrentConversationId: () => { const match = window.location.pathname.match(/\/c\/([a-zA-Z0-9-]+)/); return match ? match[1] : null; }, getAllConversations: async (workspaceId) => { const token = await ChatGPTHandler.ensureAccessToken(); if (!token) throw new Error(i18n.t('tokenNotFound')); const deviceId = ChatGPTHandler.getOaiDeviceId(); if (!deviceId) throw new Error('Cannot get device ID'); const headers = { 'Authorization': `Bearer ${token}`, 'oai-device-id': deviceId }; // 只在团队空间且有workspace ID时才添加 if (State.chatgptWorkspaceType === 'team' && workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; } const allConversations = []; let offset = 0; let hasMore = true; while (hasMore) { const response = await fetch(`/backend-api/conversations?offset=${offset}&limit=28&order=updated`, { headers }); if (!response.ok) throw new Error('Failed to fetch conversation list'); const data = await response.json(); if (data.items && data.items.length > 0) { allConversations.push(...data.items); hasMore = data.items.length === 28; offset += data.items.length; } else { hasMore = false; } } return allConversations; }, getConversation: async (conversationId, workspaceId) => { const token = await ChatGPTHandler.ensureAccessToken(); if (!token) { console.error('[ChatGPT] Token not found'); throw new Error(i18n.t('tokenNotFound')); } const deviceId = ChatGPTHandler.getOaiDeviceId(); if (!deviceId) { console.error('[ChatGPT] Device ID not found in cookies'); throw new Error('Cannot get device ID'); } const headers = { 'Authorization': `Bearer ${token}`, 'oai-device-id': deviceId }; // 关键修复:只在团队空间模式时才添加workspace ID header // 用户空间不需要这个header if (State.chatgptWorkspaceType === 'team' && workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; } console.log('[ChatGPT] Fetching conversation:', { conversationId, workspaceId, workspaceType: State.chatgptWorkspaceType, willAddWorkspaceHeader: State.chatgptWorkspaceType === 'team' && !!workspaceId, hasToken: !!token, hasDeviceId: !!deviceId, tokenPrefix: token.substring(0, 10) + '...', headers: { ...headers, 'Authorization': 'Bearer ***' } }); const response = await fetch(`/backend-api/conversation/${conversationId}`, { headers }); console.log('[ChatGPT] Response status:', response.status); if (!response.ok) { const errorText = await response.text(); console.error('[ChatGPT] Fetch failed:', { status: response.status, statusText: response.statusText, error: errorText, conversationId, workspaceType: State.chatgptWorkspaceType }); // 如果是 404 错误,提示用户切换工作区类型 let errorMessage = `Failed to fetch conversation (${response.status}): ${errorText || response.statusText}`; if (response.status === 404) { const currentMode = State.chatgptWorkspaceType === 'team' ? i18n.t('teamWorkspace') : i18n.t('userWorkspace'); const suggestMode = State.chatgptWorkspaceType === 'team' ? i18n.t('userWorkspace') : i18n.t('teamWorkspace'); errorMessage += `\n\n当前模式: ${currentMode}\n建议尝试切换到: ${suggestMode}并手动填写工作区ID`; } throw new Error(errorMessage); } return await response.json(); }, previewConversation: async () => { const conversationId = ChatGPTHandler.getCurrentConversationId(); if (!conversationId) { alert(i18n.t('uuidNotFound')); return; } try { // 只在团队空间模式时获取workspace ID,用户空间传空字符串 const workspaceId = State.chatgptWorkspaceType === 'team' ? ChatGPTHandler.ensureWorkspaceId() : ''; const data = await ChatGPTHandler.getConversation(conversationId, workspaceId); const win = window.open(Config.EXPORTER_URL, '_blank'); if (!win) { alert(i18n.t('cannotOpenExporter')); return; } const checkReady = setInterval(() => { try { win.postMessage({ type: 'lyra-preview', data: data, platform: 'chatgpt' }, Config.EXPORTER_ORIGIN); clearInterval(checkReady); } catch (e) {} }, 100); setTimeout(() => clearInterval(checkReady), 5000); } catch (error) { console.error('Preview error:', error); // 提示用户切换工作区类型 alert(`${i18n.t('loadFailed')} ${error.message}`); } }, exportCurrent: async (btn) => { const conversationId = ChatGPTHandler.getCurrentConversationId(); if (!conversationId) { alert(i18n.t('uuidNotFound')); return; } const original = btn.innerHTML; Utils.setButtonLoading(btn, i18n.t('exporting')); try { const workspaceId = State.chatgptWorkspaceType === 'team' ? ChatGPTHandler.ensureWorkspaceId() : ''; const data = await ChatGPTHandler.getConversation(conversationId, workspaceId); const filename = prompt(i18n.t('enterFilename'), data.title || i18n.t('untitledChat')); if (!filename) { Utils.restoreButton(btn, original); return; } Utils.downloadJSON(JSON.stringify(data, null, 2), `${Utils.sanitizeFilename(filename)}.json`); } catch (error) { console.error('Export error:', error); alert(`${i18n.t('exportFailed')} ${error.message}`); } finally { Utils.restoreButton(btn, original); } }, exportAll: async (btn, controlsArea) => { if (typeof fflate === 'undefined' || typeof fflate.zipSync !== 'function' || typeof fflate.strToU8 !== 'function') { alert('Error: fflate library not loaded.'); return; } const progress = Utils.createProgressElem(controlsArea); progress.textContent = i18n.t('preparing'); const original = btn.innerHTML; Utils.setButtonLoading(btn, i18n.t('exporting')); try { const workspaceId = State.chatgptWorkspaceType === 'team' ? ChatGPTHandler.ensureWorkspaceId() : ''; const allConvs = await ChatGPTHandler.getAllConversations(workspaceId); if (!allConvs || !Array.isArray(allConvs)) throw new Error(i18n.t('fetchFailed')); let exported = 0; const zipEntries = {}; for (let i = 0; i < allConvs.length; i++) { const conv = allConvs[i]; progress.textContent = `${i18n.t('gettingConversation')} ${i + 1}/${allConvs.length}`; if (i > 0 && i % 5 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); } else if (i > 0) { await Utils.sleep(300); } try { const data = await ChatGPTHandler.getConversation(conv.id, workspaceId); if (data) { const title = Utils.sanitizeFilename(data.title || conv.id); const filename = `chatgpt_${conv.id.substring(0, 8)}_${title}.json`; zipEntries[filename] = fflate.strToU8(JSON.stringify(data, null, 2)); exported++; } } catch (error) { console.error(`Failed to process ${conv.id}:`, error); } } progress.textContent = `${i18n.t('compressing')}…`; const zipUint8 = fflate.zipSync(zipEntries, { level: 1 }); const zipBlob = new Blob([zipUint8], { type: 'application/zip' }); const zipFilename = `chatgpt_export_all_${new Date().toISOString().slice(0, 10)}.zip`; Utils.downloadFile(zipBlob, zipFilename); alert(`${i18n.t('successExported')} ${exported} ${i18n.t('conversations')}`); } catch (error) { console.error('Export all error:', error); alert(`${i18n.t('exportFailed')} ${error.message}`); } finally { Utils.restoreButton(btn, original); if (progress.parentNode) progress.parentNode.removeChild(progress); } }, addUI: (controls) => { // 工作区类型切换:默认用户空间(unchecked),切换到团队空间(checked) const initialLabel = State.chatgptWorkspaceType === 'team' ? i18n.t('teamWorkspace') : i18n.t('userWorkspace'); const workspaceToggle = Utils.createToggle( initialLabel, Config.WORKSPACE_TYPE_ID, State.chatgptWorkspaceType === 'team' ); // 添加change事件监听 const toggleInput = workspaceToggle.querySelector('input'); const toggleLabel = workspaceToggle.querySelector('.lyra-toggle-label'); toggleInput.addEventListener('change', (e) => { State.chatgptWorkspaceType = e.target.checked ? 'team' : 'user'; localStorage.setItem('lyraChatGPTWorkspaceType', State.chatgptWorkspaceType); toggleLabel.textContent = e.target.checked ? i18n.t('teamWorkspace') : i18n.t('userWorkspace'); console.log('[ChatGPT] Workspace type changed to:', State.chatgptWorkspaceType); }); controls.appendChild(workspaceToggle); }, addButtons: (controls) => { // 预览按钮 controls.appendChild(Utils.createButton( `${previewIcon} ${i18n.t('viewOnline')}`, () => ChatGPTHandler.previewConversation() )); // 导出当前按钮 controls.appendChild(Utils.createButton( `${exportIcon} ${i18n.t('exportCurrentJSON')}`, (btn) => ChatGPTHandler.exportCurrent(btn) )); // 导出全部按钮 controls.appendChild(Utils.createButton( `${zipIcon} ${i18n.t('exportAllConversations')}`, (btn) => ChatGPTHandler.exportAll(btn, controls) )); // 手动设置Workspace ID const workspaceIdLabel = document.createElement('div'); workspaceIdLabel.className = 'lyra-input-trigger'; workspaceIdLabel.textContent = `${i18n.t('manualWorkspaceId')}`; workspaceIdLabel.addEventListener('click', () => { const newId = prompt(i18n.t('enterWorkspaceId'), State.chatgptWorkspaceId); if (newId?.trim()) { State.chatgptWorkspaceId = newId.trim(); localStorage.setItem('lyraChatGPTWorkspaceId', State.chatgptWorkspaceId); alert(i18n.t('workspaceIdSaved')); } }); controls.appendChild(workspaceIdLabel); } }; // ===== 平台处理器:Gemini/NotebookLM/AIStudio ===== // ===== 图片抓取辅助函数 (移植自 old.js) ===== function fetchViaGM(url) { return new Promise((resolve, reject) => { // 检查 GM_xmlhttpRequest 是否可用 if (typeof GM_xmlhttpRequest === 'undefined') { console.error('GM_xmlhttpRequest is not defined. Make sure @grant GM_xmlhttpRequest is in the script header.'); // 备用方案:尝试使用 fetch (可能会因CORS失败) fetch(url).then(response => { if (response.ok) return response.blob(); throw new Error(`Fetch failed with status: ${response.status}`); }).then(resolve).catch(reject); return; } // 优先使用 GM_xmlhttpRequest GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", onload: function(response) { if (response.status >= 200 && response.status < 300) { resolve(response.response); } else { reject(new Error(`GM_xmlhttpRequest failed with status: ${response.status}`)); } }, onerror: function(error) { reject(new Error(`GM_xmlhttpRequest network error: ${error.statusText || 'Unknown error'}`)); } }); }); } // REVERTED: Removed data:image logic async function processImageElement(imgElement) { if (!imgElement) return null; let imageUrlToFetch = null; // 尝试从 Gemini 的 Lens 链接中获取原始 URL const previewContainer = imgElement.closest('user-query-file-preview'); if (previewContainer) { const lensLinkElement = previewContainer.querySelector('a[href*="lens.google.com"]'); if (lensLinkElement && lensLinkElement.href) { try { const urlObject = new URL(lensLinkElement.href); const realImageUrl = urlObject.searchParams.get('url'); if (realImageUrl) { imageUrlToFetch = realImageUrl; } } catch (e) { console.error('Error parsing Lens URL:', e); } } } // 备用方案:直接使用 src (REVERTED to skip data: URLs) if (!imageUrlToFetch) { const fallbackSrc = imgElement.src; if (fallbackSrc && !fallbackSrc.startsWith('data:')) { imageUrlToFetch = fallbackSrc; } } if (!imageUrlToFetch) { // Do not log here, it's normal for data: URLs to be skipped return null; } try { // (关键) 使用 fetchViaGM 抓取图片 const blob = await fetchViaGM(imageUrlToFetch); const base64 = await Utils.blobToBase64(blob); // Utils.blobToBase64 存在于 new.js return { type: 'image', format: blob.type, size: blob.size, data: base64, original_src: imageUrlToFetch }; } catch (error) { console.error('Failed to process image:', imageUrlToFetch, error); return null; } } function htmlToMarkdown(element) { if (!element) return ''; let result = ''; function processNode(node) { if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } if (node.nodeType !== Node.ELEMENT_NODE) { return ''; } const tagName = node.tagName.toLowerCase(); const children = Array.from(node.childNodes).map(processNode).join(''); switch(tagName) { case 'h1': return `\n# ${children}\n`; case 'h2': return `\n## ${children}\n`; case 'h3': return `\n### ${children}\n`; case 'h4': return `\n#### ${children}\n`; case 'h5': return `\n##### ${children}\n`; case 'h6': return `\n###### ${children}\n`; case 'strong': case 'b': return `**${children}**`; case 'em': case 'i': return `*${children}*`; case 'code': // 检查子节点是否包含换行符,或者父节点是否是 'pre' const parentIsPre = node.parentElement?.tagName.toLowerCase() === 'pre'; if (children.includes('\n') || parentIsPre) { // 如果父是pre,我们假设pre-code结构,这个逻辑会被pre处理 if (parentIsPre) return children; // 单独的code块 return `\n\`\`\`\n${children}\n\`\`\`\n`; } return `\`${children}\``; case 'pre': const codeChild = node.querySelector('code'); if (codeChild) { const lang = codeChild.className.match(/language-(\w+)/)?.[1] || ''; const codeContent = codeChild.textContent; // 使用 textContent 获取原始代码 return `\n\`\`\`${lang}\n${codeContent}\n\`\`\`\n`; } // 备用:如果pre里没有code return `\n\`\`\`\n${children}\n\`\`\`\n`; case 'hr': return '\n---\n'; case 'br': return '\n'; case 'p': return `\n${children}\n`; // div 默认不添加额外换行,除非它是内容的主要容器 case 'div': return `${children}`; // 相比 old.js 做了调整,避免过多换行 case 'a': const href = node.getAttribute('href'); if (href) { return `[${children}](${href})`; } return children; case 'ul': return `\n${Array.from(node.children).map(li => `- ${processNode(li)}`).join('\n')}\n`; case 'ol': return `\n${Array.from(node.children).map((li, i) => `${i + 1}. ${processNode(li)}`).join('\n')}\n`; case 'li': // 'ul' 和 'ol' 的逻辑已经处理了 'li',这里直接返回子内容 return children; case 'blockquote': return `\n> ${children.split('\n').join('\n> ')}\n`; case 'table': return `\n${children}\n`; case 'thead': return `${children}`; case 'tbody': return `${children}`; case 'tr': return `${children}|\n`; case 'th': return `| **${children}** `; case 'td': return `| ${children} `; default: return children; } } result = processNode(element); // 清理:移除开头的空白,并将多个换行符合并为最多两个 result = result.replace(/^\s+/, ''); result = result.replace(/\n{3,}/g, '\n\n'); result = result.trim(); return result; } function getAIStudioScroller() { const selectors = [ 'ms-chat-session ms-autoscroll-container', // new.js 似乎没这个 'mat-sidenav-content', // old.js 有 '.chat-view-container' // old.js 有 ]; for (const selector of selectors) { const el = document.querySelector(selector); // 确保元素存在并且确实可滚动 if (el && (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)) { return el; } } // 最终备用 return document.documentElement; } // (修改) 恢复 AI Studio 增量数据提取 // (注意:此函数依赖于上面添加的全局 'collectedData' 和 'htmlToMarkdown') async function extractDataIncremental_AiStudio(includeImages = true) { const turns = document.querySelectorAll('ms-chat-turn'); // (关键) 必须使用 for...of 来支持 await for (const turn of turns) { if (collectedData.has(turn)) { continue; } const isUserTurn = turn.querySelector('.chat-turn-container.user'); const isModelTurn = turn.querySelector('.chat-turn-container.model'); // (关键) 改变数据结构以包含图片 let turnData = { type: 'unknown', text: '', images: [] }; if (isUserTurn) { const userPromptNode = isUserTurn.querySelector('.user-prompt-container .turn-content'); if (userPromptNode) { let userText = userPromptNode.innerText.trim(); if (userText.match(/^User\s*[\n:]?/i)) { // remove extra "User" userText = userText.replace(/^User\s*[\n:]?/i, '').trim(); } if (userText) { turnData.type = 'user'; turnData.text = userText; } } // (新增) 抓取用户图片 - 根据 includeImages 参数决定是否处理 if (includeImages) { const imgNodes = isUserTurn.querySelectorAll('.user-prompt-container img'); const imgPromises = Array.from(imgNodes).map(processImageElement); turnData.images = (await Promise.all(imgPromises)).filter(Boolean); } } else if (isModelTurn) { const responseChunks = isModelTurn.querySelectorAll('ms-prompt-chunk'); let responseTexts = []; const imgPromises = []; // (新增) 收集模型图片 responseChunks.forEach(chunk => { // 过滤掉 'thought' 块 if (!chunk.querySelector('ms-thought-chunk')) { // 使用 old.js 的选择器 const cmarkNode = chunk.querySelector('ms-cmark-node'); if (cmarkNode) { // (关键修复) 调用 htmlToMarkdown const markdownText = htmlToMarkdown(cmarkNode); if (markdownText) { responseTexts.push(markdownText); } // (新增) 在 cmark 节点内查找图片 - 根据 includeImages 参数决定是否处理 if (includeImages) { const imgNodes = cmarkNode.querySelectorAll('img'); imgNodes.forEach(img => imgPromises.push(processImageElement(img))); } } } }); const responseText = responseTexts.join('\n\n').trim(); if (responseText) { turnData.type = 'model'; turnData.text = responseText; } // (新增) 处理模型图片 if (includeImages) { turnData.images = (await Promise.all(imgPromises)).filter(Boolean); } } // (关键) 只有在有内容时才添加 if (turnData.type !== 'unknown' && (turnData.text || turnData.images.length > 0)) { collectedData.set(turn, turnData); } } } const ScraperHandler = { handlers: { gemini: { // REVERTED: Simple prompt logic from v8.1 getTitle: () => prompt('请输入对话标题 / Enter title:', '对话') || i18n.t('untitledChat'), extractData: async (includeImages = true) => { const conversationData = []; const turns = document.querySelectorAll("div.conversation-turn, div.single-turn, div.conversation-container"); // 合并选择器 // (关键) 改造为异步函数以处理图片 const processContainer = async (container) => { const userQueryElement = container.querySelector("user-query .query-text") || container.querySelector(".query-text-line"); const modelResponseContainer = container.querySelector("model-response") || container; const modelResponseElement = modelResponseContainer.querySelector("message-content .markdown-main-panel"); const humanText = userQueryElement ? userQueryElement.innerText.trim() : ""; let assistantText = ""; if (modelResponseElement) { assistantText = htmlToMarkdown(modelResponseElement); } else { // 备用方案(针对旧版或简单结构) const fallbackEl = modelResponseContainer.querySelector("model-response, .response-container"); if (fallbackEl) assistantText = fallbackEl.innerText.trim(); // 简单文本 } // (新增) 抓取图片 - 根据 includeImages 参数决定是否处理 let userImages = []; let modelImages = []; if (includeImages) { const userImageElements = container.querySelectorAll("user-query img"); const modelImageElements = modelResponseContainer.querySelectorAll("model-response img"); // (新增) 并行处理图片 const userImagesPromises = Array.from(userImageElements).map(processImageElement); const modelImagesPromises = Array.from(modelImageElements).map(processImageElement); userImages = (await Promise.all(userImagesPromises)).filter(Boolean); modelImages = (await Promise.all(modelImagesPromises)).filter(Boolean); } if (humanText || assistantText || userImages.length > 0 || modelImages.length > 0) { conversationData.push({ human: { text: humanText, images: userImages }, assistant: { text: assistantText, images: modelImages } }); } }; // (关键) 使用 for...of 循环来支持 await for (const turn of turns) { await processContainer(turn); } return conversationData; } }, notebooklm: { // REVERTED: Simple date logic from v8.1 getTitle: () => 'NotebookLM_' + new Date().toISOString().slice(0, 10), extractData: async (includeImages = true) => { const data = []; const turns = document.querySelectorAll("div.chat-message-pair"); // (关键) 使用 for...of 循环来支持 await for (const turn of turns) { let question = turn.querySelector("chat-message .from-user-container .message-text-content")?.innerText.trim() || ""; if (question.startsWith('[Preamble] ')) question = question.substring('[Preamble] '.length).trim(); let answer = ""; const answerEl = turn.querySelector("chat-message .to-user-container .message-text-content"); if (answerEl) { const parts = []; answerEl.querySelectorAll('labs-tailwind-structural-element-view-v2').forEach(el => { let line = el.querySelector('.bullet')?.innerText.trim() + ' ' || ''; const para = el.querySelector('.paragraph'); if (para) { let text = ''; para.childNodes.forEach(node => { if (node.nodeType === Node.TEXT_NODE) text += node.textContent; else if (node.nodeType === Node.ELEMENT_NODE && !node.querySelector?.('.citation-marker')) { text += node.classList?.contains('bold') ? `**${node.innerText}**` : (node.innerText || node.textContent || ''); } }); line += text; } if (line.trim()) parts.push(line.trim()); }); answer = parts.join('\n\n'); } // (新增) 抓取图片 - 根据 includeImages 参数决定是否处理 let userImages = []; let modelImages = []; if (includeImages) { const userImageElements = turn.querySelectorAll("chat-message .from-user-container img"); const modelImageElements = turn.querySelectorAll("chat-message .to-user-container img"); const userImagesPromises = Array.from(userImageElements).map(processImageElement); const modelImagesPromises = Array.from(modelImageElements).map(processImageElement); userImages = (await Promise.all(userImagesPromises)).filter(Boolean); modelImages = (await Promise.all(modelImagesPromises)).filter(Boolean); } if (question || answer || userImages.length > 0 || modelImages.length > 0) { data.push({ human: { text: question, images: userImages }, assistant: { text: answer, images: modelImages } }); } } return data; } }, aistudio: { // REVERTED: Simple prompt logic from v8.1 getTitle: () => prompt('请输入对话标题 / Enter title:', 'AI_Studio_Chat') || 'AI_Studio_Chat', extractData: async (includeImages = true) => { // 清空上次抓取的数据 collectedData.clear(); const scroller = getAIStudioScroller(); scroller.scrollTop = 0; await Utils.sleep(SCROLL_TOP_WAIT_MS); let lastScrollTop = -1; while (true) { await extractDataIncremental_AiStudio(includeImages); // (关键) 传递 includeImages 参数 // 检查是否到达底部 if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 10) { break; } lastScrollTop = scroller.scrollTop; scroller.scrollTop += scroller.clientHeight * 0.85; // 向下滚动 await Utils.sleep(SCROLL_DELAY_MS); // 等待内容加载 // 如果滚动位置没有变化,说明已到底部 if (scroller.scrollTop === lastScrollTop) { break; } } // 扫描完成,最后抓取一次 await extractDataIncremental_AiStudio(includeImages); // (关键) 传递 includeImages 参数 await Utils.sleep(500); // 整理数据 (来自 old.js) const finalTurnsInDom = document.querySelectorAll('ms-chat-turn'); let sortedData = []; finalTurnsInDom.forEach(turnNode => { if (collectedData.has(turnNode)) { sortedData.push(collectedData.get(turnNode)); } }); const pairedData = []; // (关键) lastHuman 现在是一个对象 let lastHuman = null; sortedData.forEach(item => { if (item.type === 'user') { // 如果连续出现 user,合并 if (!lastHuman) lastHuman = { text: '', images: [] }; lastHuman.text = (lastHuman.text ? lastHuman.text + '\n' : '') + item.text; lastHuman.images.push(...item.images); } else if (item.type === 'model' && lastHuman) { // 发现 model,与之前的 user 配对 pairedData.push({ human: lastHuman, assistant: { text: item.text, images: item.images } }); lastHuman = null; // 重置 } else if (item.type === 'model' && !lastHuman) { // 发现一个没有对应 user 的 model pairedData.push({ human: { text: "[No preceding user prompt found]", images: [] }, assistant: { text: item.text, images: item.images } }); } }); // 如果最后有 user 提问但没有 model 回答 if (lastHuman) { pairedData.push({ human: lastHuman, assistant: { text: "[Model response is pending]", images: [] } }); } return pairedData; } } }, addButtons: (controlsArea, platform) => { const handler = ScraperHandler.handlers[platform]; if (!handler) return; // 为 gemini 和 aistudio 添加 includeImages toggle if (platform === 'gemini' || platform === 'aistudio') { const imageToggle = Utils.createToggle(i18n.t('includeImages'), Config.IMAGE_SWITCH_ID, State.includeImages); controlsArea.appendChild(imageToggle); // 为 toggle 添加主题色 const themeColors = { gemini: '#1a73e8', aistudio: '#777779' }; const toggleSwitch = imageToggle.querySelector('.lyra-switch input'); if (toggleSwitch) { toggleSwitch.addEventListener('change', (e) => { State.includeImages = e.target.checked; localStorage.setItem('lyraIncludeImages', State.includeImages); }); // 应用主题色到 slider const slider = imageToggle.querySelector('.lyra-slider'); if (slider) { const color = themeColors[platform]; slider.style.setProperty('--theme-color', color); } } } // 判断是否使用内联样式(notebooklm 和 gemini) const useInlineStyles = (platform === 'notebooklm' || platform === 'gemini'); // 获取按钮主题色 const buttonColor = { gemini: '#1a73e8', notebooklm: '#000000', aistudio: '#777779' }[platform] || '#4285f4'; // (新增) 预览按钮 const previewBtn = Utils.createButton( `${previewIcon} ${i18n.t('viewOnline')}`, async (btn) => { const title = handler.getTitle(); if (!title) return; // 用户取消了 prompt const original = btn.innerHTML; Utils.setButtonLoading(btn, i18n.t('loading')); let progressElem = null; if (platform === 'aistudio') { progressElem = Utils.createProgressElem(controlsArea); progressElem.textContent = i18n.t('loading'); // Use 'loading' for consistency } try { // 获取 includeImages 状态 const includeImages = (platform === 'gemini' || platform === 'aistudio') ? (document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false) : true; const conversationData = await handler.extractData(includeImages); if (!conversationData || conversationData.length === 0) { alert(i18n.t('noContent')); Utils.restoreButton(btn, original); if (progressElem) progressElem.remove(); return; } const finalJson = { title: title, platform: platform, exportedAt: new Date().toISOString(), conversation: conversationData }; const filename = `${platform}_${Utils.sanitizeFilename(title)}_${new Date().toISOString().slice(0, 10)}.json`; await LyraCommunicator.open(JSON.stringify(finalJson, null, 2), filename); } catch (error) { alert(`${i18n.t('loadFailed')} ${error.message}`); } finally { Utils.restoreButton(btn, original); if (progressElem) progressElem.remove(); } }, useInlineStyles ); // 应用按钮颜色 if (useInlineStyles) { Object.assign(previewBtn.style, { backgroundColor: buttonColor, color: 'white' }); } controlsArea.appendChild(previewBtn); const exportBtn = Utils.createButton( `${exportIcon} ${i18n.t('exportCurrentJSON')}`, async (btn) => { const title = handler.getTitle(); if (!title) return; // 用户取消了 prompt const original = btn.innerHTML; Utils.setButtonLoading(btn, i18n.t('exporting')); let progressElem = null; if (platform === 'aistudio') { progressElem = Utils.createProgressElem(controlsArea); progressElem.textContent = i18n.t('exporting'); // Use 'exporting' for consistency } try { // 获取 includeImages 状态 const includeImages = (platform === 'gemini' || platform === 'aistudio') ? (document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false) : true; const conversationData = await handler.extractData(includeImages); if (!conversationData || conversationData.length === 0) { alert(i18n.t('noContent')); Utils.restoreButton(btn, original); if (progressElem) progressElem.remove(); return; } const finalJson = { title: title, platform: platform, exportedAt: new Date().toISOString(), conversation: conversationData }; const filename = `${platform}_${Utils.sanitizeFilename(title)}_${new Date().toISOString().slice(0, 10)}.json`; Utils.downloadJSON(JSON.stringify(finalJson, null, 2), filename); } catch (error) { alert(`${i18n.t('exportFailed')} ${error.message}`); } finally { Utils.restoreButton(btn, original); if (progressElem) progressElem.remove(); } }, useInlineStyles ); // 应用按钮颜色 if (useInlineStyles) { Object.assign(exportBtn.style, { backgroundColor: buttonColor, color: 'white' }); } controlsArea.appendChild(exportBtn); } }; const UI = { injectStyle: () => { const buttonColor = { claude: '#141413', chatgpt: '#10A37F', gemini: '#1a73e8', notebooklm: '#000000', aistudio: '#777779' }[State.currentPlatform] || '#4285f4'; const style = ` #${Config.CONTROL_ID} { position: fixed !important; top: 50% !important; right: 0 !important; transform: translateY(-50%) !important; background: white !important; border: 1px solid #dadce0 !important; border-radius: 8px !important; padding: 16px 16px 8px 16px !important; width: 136px !important; z-index: 999999 !important; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif !important; transition: all 0.7s cubic-bezier(0.4, 0, 0.2, 1) !important; box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; } #${Config.CONTROL_ID}.collapsed { transform: translateY(-50%) translateX(calc(100% - 35px)) !important; opacity: 0.6 !important; background: white !important; border-color: #dadce0 !important; border-radius: 8px 0 0 8px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; pointer-events: none !important; } #${Config.CONTROL_ID}.collapsed .lyra-main-controls { opacity: 0 !important; pointer-events: none !important; } #${Config.CONTROL_ID}:hover { opacity: 1 !important; } #${Config.TOGGLE_ID} { position: absolute !important; left: 0 !important; top: 50% !important; transform: translateY(-50%) translateX(-50%) !important; cursor: pointer !important; width: 32px !important; height: 32px !important; display: flex !important; align-items: center !important; justify-content: center !important; background: #ffffff !important; color: ${buttonColor} !important; border-radius: 50% !important; box-shadow: 0 1px 3px rgba(0,0,0,0.2) !important; border: 1px solid #dadce0 !important; transition: all 0.7s cubic-bezier(0.4, 0, 0.2, 1) !important; z-index: 1000 !important; pointer-events: all !important; } #${Config.CONTROL_ID}.collapsed #${Config.TOGGLE_ID} { z-index: 2 !important; left: 17.5px !important; transform: translateY(-50%) translateX(-50%) !important; width: 24px !important; height: 24px !important; background: ${buttonColor} !important; color: white !important; } #${Config.CONTROL_ID}.collapsed #${Config.TOGGLE_ID}:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.25), 0 0 0 3px rgba(255,255,255,0.9) !important; transform: translateY(-50%) translateX(-50%) scale(1.15) !important; opacity: 0.9 !important; } .lyra-main-controls { margin-left: 0px !important; padding: 0 3px !important; transition: opacity 0.7s !important; } .lyra-title { font-size: 16px !important; font-weight: 700 !important; color: #202124 !important; text-align: center; margin-bottom: 12px !important; padding-bottom: 0px !important; letter-spacing: 0.3px !important; } .lyra-input-trigger { display: flex !important; align-items: center !important; justify-content: center !important; gap: 3px !important; font-size: 10px !important; margin: 10px auto 0 auto !important; padding: 2px 6px !important; border-radius: 3px !important; background: transparent !important; cursor: pointer !important; transition: all 0.15s !important; white-space: nowrap !important; color: #5f6368 !important; border: none !important; font-weight: 500 !important; width: fit-content !important; } .lyra-input-trigger:hover { background: #f1f3f4 !important; color: #202124 !important; } .lyra-button { display: flex !important; align-items: center !important; justify-content: flex-start !important; gap: 8px !important; width: 100% !important; padding: 8px 12px !important; margin: 8px 0 !important; border: none !important; border-radius: 6px !important; background: ${buttonColor} !important; color: white !important; font-size: 11px !important; font-weight: 500 !important; cursor: pointer !important; letter-spacing: 0.3px !important; height: 32px !important; // ← 新增:固定按钮高度 box-sizing: border-box !important; // ← 新增:确保padding计入总高度 } .lyra-button svg { width: 16px !important; height: 16px !important; flex-shrink: 0 !important; } .lyra-button:disabled { opacity: 0.6 !important; cursor: not-allowed !important; } .lyra-status { font-size: 10px !important; padding: 6px 8px !important; border-radius: 4px !important; margin: 4px 0 !important; text-align: center !important; } .lyra-status.success { background: #e8f5e9 !important; color: #2e7d32 !important; border: 1px solid #c8e6c9 !important; } .lyra-status.error { background: #ffebee !important; color: #c62828 !important; border: 1px solid #ffcdd2 !important; } .lyra-toggle { display: flex !important; align-items: center !important; justify-content: space-between !important; font-size: 11px !important; font-weight: 500 !important; color: #5f6368 !important; margin: 3px 0 !important; gap: 8px !important; padding: 4px 8px !important; } .lyra-toggle:last-of-type { margin-bottom: 14px !important; } .lyra-switch { position: relative !important; display: inline-block !important; width: 32px !important; height: 16px !important; flex-shrink: 0 !important; } .lyra-switch input { opacity: 0 !important; width: 0 !important; height: 0 !important; } .lyra-slider { position: absolute !important; cursor: pointer !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; background-color: #ccc !important; transition: .3s !important; border-radius: 34px !important; --theme-color: ${buttonColor}; } .lyra-slider:before { position: absolute !important; content: "" !important; height: 12px !important; width: 12px !important; left: 2px !important; bottom: 2px !important; background-color: white !important; transition: .3s !important; border-radius: 50% !important; } input:checked + .lyra-slider { background-color: var(--theme-color, ${buttonColor}) !important; } input:checked + .lyra-slider:before { transform: translateX(16px) !important; } .lyra-loading { display: inline-block !important; width: 14px !important; height: 14px !important; border: 2px solid rgba(255, 255, 255, 0.3) !important; border-radius: 50% !important; border-top-color: #fff !important; animation: lyra-spin 0.8s linear infinite !important; } @keyframes lyra-spin { to { transform: rotate(360deg); } } .lyra-progress { font-size: 10px !important; color: #5f6368 !important; margin-top: 4px !important; text-align: center !important; padding: 4px !important; background: #f8f9fa !important; border-radius: 4px !important; } .lyra-lang-toggle { display: flex !important; align-items: center !important; justify-content: center !important; gap: 3px !important; font-size: 10px !important; margin: 4px auto 0 auto !important; padding: 2px 6px !important; border-radius: 3px !important; background: transparent !important; cursor: pointer !important; transition: all 0.15s !important; white-space: nowrap !important; color: #5f6368 !important; border: none !important; font-weight: 500 !important; width: fit-content !important; } .lyra-lang-toggle:hover { background: #f1f3f4 !important; color: #202124 !important; } `; if (typeof GM_addStyle !== 'undefined') { GM_addStyle(style); } else { const styleEl = document.createElement('style'); styleEl.textContent = style; (document.head || document.documentElement).appendChild(styleEl); } }, toggleCollapsed: () => { State.isPanelCollapsed = !State.isPanelCollapsed; localStorage.setItem('lyraExporterCollapsed', State.isPanelCollapsed); const panel = document.getElementById(Config.CONTROL_ID); const toggle = document.getElementById(Config.TOGGLE_ID); if (!panel || !toggle) return; if (State.isPanelCollapsed) { panel.classList.add('collapsed'); toggle.innerHTML = collapseIcon; } else { panel.classList.remove('collapsed'); toggle.innerHTML = expandIcon; } }, recreatePanel: () => { document.getElementById(Config.CONTROL_ID)?.remove(); State.panelInjected = false; UI.createPanel(); }, createPanel: () => { if (document.getElementById(Config.CONTROL_ID) || State.panelInjected) return false; const container = document.createElement('div'); container.id = Config.CONTROL_ID; if (State.isPanelCollapsed) container.classList.add('collapsed'); // 为 notebooklm 和 gemini 添加内联样式固定面板宽高 if (State.currentPlatform === 'notebooklm' || State.currentPlatform === 'gemini') { Object.assign(container.style, { position: 'fixed', top: '50%', right: '0', transform: 'translataeY(-50%)', background: 'white', border: '1px solid #dadce0', borderRadius: '8px', padding: '16px 16px 8px 16px', width: '136px', zIndex: '999999', fontFamily: "'Segoe UI', system-ui, -apple-system, sans-serif", transition: 'all 0.7s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', boxSizing: 'border-box' }); } const toggle = document.createElement('div'); toggle.id = Config.TOGGLE_ID; toggle.innerHTML = State.isPanelCollapsed ? collapseIcon : expandIcon; toggle.addEventListener('click', UI.toggleCollapsed); container.appendChild(toggle); const controls = document.createElement('div'); controls.className = 'lyra-main-controls'; // 为 notebooklm 和 gemini 的 controls 添加内联样式 if (State.currentPlatform === 'notebooklm' || State.currentPlatform === 'gemini') { Object.assign(controls.style, { marginLeft: '0px', padding: '0 3px', transition: 'opacity 0.7s' }); } const title = document.createElement('div'); title.className = 'lyra-title'; const titles = { claude: 'Claude', chatgpt: 'ChatGPT', gemini: 'Gemini', notebooklm: 'Note LM', aistudio: 'AI Studio' }; title.textContent = titles[State.currentPlatform] || 'Exporter'; controls.appendChild(title); // 添加平台特定UI和按钮 if (State.currentPlatform === 'claude') { ClaudeHandler.addUI(controls); ClaudeHandler.addButtons(controls); // 手动输入ID标签(放在语言切换之前) const inputLabel = document.createElement('div'); inputLabel.className = 'lyra-input-trigger'; inputLabel.textContent = `${i18n.t('manualUserId')}`; inputLabel.addEventListener('click', () => { const newId = prompt(i18n.t('enterUserId'), State.capturedUserId); if (newId?.trim()) { State.capturedUserId = newId.trim(); localStorage.setItem('lyraClaudeUserId', State.capturedUserId); alert(i18n.t('userIdSaved')); UI.recreatePanel(); } }); controls.appendChild(inputLabel); } else if (State.currentPlatform === 'chatgpt') { ChatGPTHandler.addUI(controls); ChatGPTHandler.addButtons(controls); } else { ScraperHandler.addButtons(controls, State.currentPlatform); } // 语言切换 const langToggle = document.createElement('div'); langToggle.className = 'lyra-lang-toggle'; langToggle.textContent = `🌐 ${i18n.getLanguageShort()}`; langToggle.addEventListener('click', () => { i18n.setLanguage(i18n.currentLang === 'zh' ? 'en' : 'zh'); UI.recreatePanel(); }); controls.appendChild(langToggle); container.appendChild(controls); document.body.appendChild(container); State.panelInjected = true; const panel = document.getElementById(Config.CONTROL_ID); if (State.isPanelCollapsed) { panel.classList.add('collapsed'); toggle.innerHTML = collapseIcon; } else { panel.classList.remove('collapsed'); toggle.innerHTML = expandIcon; } return true; } }; const init = () => { if (!State.currentPlatform) return; if (State.currentPlatform === 'claude') ClaudeHandler.init(); if (State.currentPlatform === 'chatgpt') ChatGPTHandler.init(); UI.injectStyle(); const initPanel = () => { if (State.currentPlatform === 'claude') { if (/\/chat\/[a-zA-Z0-9-]+/.test(window.location.href)) UI.createPanel(); let lastUrl = window.location.href; new MutationObserver(() => { if (window.location.href !== lastUrl) { lastUrl = window.location.href; setTimeout(() => { if (/\/chat\/[a-zA-Z0-9-]+/.test(lastUrl) && !document.getElementById(Config.CONTROL_ID)) { UI.createPanel(); } }, 1000); } }).observe(document.body, { childList: true, subtree: true }); } else if (State.currentPlatform === 'chatgpt') { if (/\/c\/[a-zA-Z0-9-]+/.test(window.location.href)) UI.createPanel(); let lastUrl = window.location.href; new MutationObserver(() => { if (window.location.href !== lastUrl) { lastUrl = window.location.href; setTimeout(() => { if (/\/c\/[a-zA-Z0-9-]+/.test(lastUrl) && !document.getElementById(Config.CONTROL_ID)) { UI.createPanel(); } }, 1000); } }).observe(document.body, { childList: true, subtree: true }); } else { UI.createPanel(); } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(initPanel, 2000)); } else { setTimeout(initPanel, 2000); } }; init(); })();