// ==UserScript== // @name Lyra Exporter Fetch // @name:en Lyra Exporter Fetch // @namespace userscript://lyra-conversation-exporter // @version 7.0 // @description Lyra's Exporter - AI对话导出器的配套脚本,专业的AI对话导出器 - 支持Claude、ChatGPT、Gemini、NotebookLM等多平台,轻松管理数百个对话窗口,导出完整时间线、附加图片、思考过程、附件、工具调用和Artifacts。 // @description:en The essential companion script for Lyra's Exporter, designed for unified management and export of your conversation histories across multiple platforms, including Claude, ChatGPT, Gemini, NotebookLM, and more. // @homepage https://github.com/Yalums/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 // @require https://cdn.jsdelivr.net/npm/fflate@0.7.4/umd/index.js // @license GNU General Public License v3.0 // @downloadURL none // ==/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:', 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:', 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 = ''; const collapseIcon = ''; const expandIcon = ''; const exportIcon = ''; const zipIcon = ''; // ===== 工具函数 ===== 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 = `
${text}`; }, 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}`; } 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.3s 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.68 !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: all !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.3s 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.3s !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.3s 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.3s' }); } 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(); })();