// ==UserScript== // @name Arena++ // @name:en Arena++ // @name:ja Arena++ // @name:ru Arena++ // @namespace http://tampermonkey.net/ // @version 1.2.1 // @description 提取 Arena.ai 对话(支持公式/MD/选区复制) // @description:en Extract the Arena.ai dialog (supports formula/MD/selection copying) // @description:ja Arena.ai ダイアログを抽出します (式/MD/選択のコピーをサポート) // @description:ru Распакуйте диалоговое окно Arena.ai (поддерживает копирование формул/MD/выделений). // @author Panda is cat // @match https://arena.ai/* // @grant GM_setClipboard // @grant GM_addStyle // @run-at document-end // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/565333/Arena%2B%2B.user.js // @updateURL https://update.greasyfork.icu/scripts/565333/Arena%2B%2B.meta.js // ==/UserScript== (function() { 'use strict'; // ================= I18N (多语言配置) ================= const userLang = navigator.language.startsWith('zh') ? 'zh' : 'en'; const I18N = { zh: { mode_on: "🖱️ 选择模式: 开", mode_off: "🖱️ 选择模式: 关", copy_btn: "📋 提取并复制", copy_done: "✅ 已复制!", alert_empty: "❌ 未找到消息或未选中任何内容!", role_user: "User", role_ai_default: "AI" }, en: { mode_on: "🖱️ Select Mode: ON", mode_off: "🖱️ Select Mode: OFF", copy_btn: "📋 Extract & Copy", copy_done: "✅ Copied!", alert_empty: "❌ No messages found or selected!", role_user: "User", role_ai_default: "AI" } }; const TEXT = I18N[userLang] || I18N.en; // ================= 样式配置 ================= const CONFIG = { highlightColor: 'rgba(121, 219, 143, 0.3)', borderColor: '#10a37f', buttonColor: '#3b82f6' }; GM_addStyle(` #ace-floating-panel { position: fixed; bottom: 20px; right: 20px; z-index: 9999; background: #fff; padding: 10px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-family: sans-serif; display: flex; gap: 8px; border: 1px solid #e5e7eb; } .ace-btn { padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; color: white; } #ace-toggle-select { background-color: #6b7280; } #ace-toggle-select.active { background-color: ${CONFIG.borderColor}; } #ace-copy-btn { background-color: ${CONFIG.buttonColor}; } #ace-copy-btn:hover { opacity: 0.9; } .ace-selectable { cursor: pointer !important; position: relative; transition: all 0.2s; } .ace-selectable:hover { box-shadow: 0 0 0 2px rgba(0,0,0,0.1); } .ace-selected { background-color: ${CONFIG.highlightColor} !important; box-shadow: 0 0 0 2px ${CONFIG.borderColor} !important; border-radius: 6px; } .ace-selected * { pointer-events: none; } `); // ================= 核心类 ================= class ChatExporter { constructor() { this.selectionMode = false; this.messages = []; this.selectedSet = new Set(); this.initUI(); } initUI() { const panel = document.createElement('div'); panel.id = 'ace-floating-panel'; panel.innerHTML = ` `; document.body.appendChild(panel); this.btnToggle = panel.querySelector('#ace-toggle-select'); this.btnCopy = panel.querySelector('#ace-copy-btn'); this.btnToggle.addEventListener('click', () => this.toggleSelectionMode()); this.btnCopy.addEventListener('click', () => this.extractAndCopy()); } async toggleSelectionMode() { this.selectionMode = !this.selectionMode; this.btnToggle.textContent = this.selectionMode ? TEXT.mode_on : TEXT.mode_off; this.btnToggle.classList.toggle('active', this.selectionMode); await this.scanMessages(); if (this.selectionMode) { this.messages.forEach((msg) => { msg.element.classList.add('ace-selectable'); msg.element.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.toggleMessageSelection(msg.element); }; }); if(this.messages.length > 0) return; } else { this.messages.forEach(msg => { msg.element.classList.remove('ace-selectable', 'ace-selected'); msg.element.onclick = null; }); this.selectedSet.clear(); } } toggleMessageSelection(element) { if (element.classList.contains('ace-selected')) { element.classList.remove('ace-selected'); this.selectedSet.delete(element); } else { element.classList.add('ace-selected'); this.selectedSet.add(element); } } /** * 核心重构:消息扫描逻辑 * 不再依赖双栏坐标,而是根据 DOM 特征(bg-surface-raised)和局部查找 */ async scanMessages() { // 1. 获取所有可能是消息内容的块 // .prose 是 markdown 内容的标准容器,.message-content 是某些版本的容器 const rawElements = Array.from(document.querySelectorAll('.prose, .message-content, [data-testid="chatbot"] .markdown')); if (rawElements.length === 0) return; this.messages = rawElements.map(el => { const rect = el.getBoundingClientRect(); let role = "Unknown"; let displayName = TEXT.role_ai_default; // --- 步骤 1: 判定是否为用户 (基于你提供的 bg-surface-raised 类) --- // 用户对话框特征:
...
// 向上查找最近的 bg-surface-raised const userContainer = el.closest('.bg-surface-raised'); if (userContainer) { role = "User"; displayName = TEXT.role_user; } else { // --- 步骤 2: 如果不是用户,则为 AI,寻找模型名称 --- role = "AI"; // 从当前元素向上寻找容器,然后在容器内寻找 span.truncate // 向上遍历 5 层足够覆盖大多数层级 let parent = el.parentElement; let foundName = null; for(let i=0; i<6; i++) { if (!parent) break; // 在父级内查找包含 truncate 的 span const nameSpan = parent.querySelector('span.truncate'); // 确保找到的 nameSpan 不是消息内容本身的一部分,而是 header if (nameSpan && nameSpan.innerText && !el.contains(nameSpan)) { const txt = nameSpan.innerText.trim(); // 排除空文本或明显不是模型名的文本 if (txt.length > 0 && txt.toLowerCase() !== 'user') { foundName = txt; break; // 找到了就停止 } } parent = parent.parentElement; } if (foundName) { displayName = foundName; } } return { element: el, top: rect.top, // 依然保留 Top 用于按屏幕顺序排序 role: role, name: displayName }; }); // 3. 排序:确保消息按视觉顺序排列 (解决 DOM 乱序问题) this.messages.sort((a, b) => a.top - b.top); } elementToMarkdown(element) { if (!element) return ""; const clone = element.cloneNode(true); // 修复 KaTeX clone.querySelectorAll('annotation[encoding="application/x-tex"]').forEach(node => { const tex = node.textContent.trim(); const container = node.closest('.katex'); if (container) { const isBlock = container.classList.contains('display') || window.getComputedStyle(container).display === 'block'; container.parentNode.replaceChild(document.createTextNode(isBlock ? `\n$$\n${tex}\n$$\n` : `$${tex}$`), container); } }); // 修复代码块 clone.querySelectorAll('pre').forEach(pre => { const code = pre.querySelector('code'); let lang = ""; if (code) { code.classList.forEach(cls => { if (cls.startsWith('language-')) lang = cls.replace('language-', ''); }); } pre.replaceWith(`\n\`\`\`${lang}\n${pre.innerText}\n\`\`\`\n`); }); // 基础格式 clone.querySelectorAll('b, strong').forEach(e => e.textContent = `**${e.textContent}**`); clone.querySelectorAll('i, em').forEach(e => e.textContent = `*${e.textContent}*`); clone.querySelectorAll('a').forEach(e => e.textContent = `[${e.textContent}](${e.href})`); ['h1','h2','h3','h4'].forEach((t, i) => { clone.querySelectorAll(t).forEach(e => e.textContent = `\n${'#'.repeat(i+1)} ${e.textContent}\n`); }); clone.querySelectorAll('li').forEach(li => li.prepend('- ')); return clone.innerText.replace(/\n{3,}/g, '\n\n').trim(); } async extractAndCopy() { await this.scanMessages(); let targetMessages = this.messages; if (this.selectionMode && this.selectedSet.size > 0) { targetMessages = this.messages.filter(msg => this.selectedSet.has(msg.element)); } if (targetMessages.length === 0) { alert(TEXT.alert_empty); return; } let output = ""; let lastRole = ""; // 用于去重 header targetMessages.forEach(msg => { const text = this.elementToMarkdown(msg.element); if (!text) return; // 只有当名字变化时,才打印 Header (可选,这里为了清晰每次都打印,或你可以取消注释下面的逻辑) // if (msg.name !== lastRole) { output += `\n---\n### **${msg.name}**:\n\n`; lastRole = msg.name; // } else { // output += `\n\n`; // } output += text + "\n"; }); try { if (typeof GM_setClipboard !== 'undefined') { GM_setClipboard(output); } else { await navigator.clipboard.writeText(output); } const btnOriginalText = this.btnCopy.textContent; this.btnCopy.textContent = TEXT.copy_done; this.btnCopy.style.backgroundColor = CONFIG.borderColor; setTimeout(() => { this.btnCopy.textContent = btnOriginalText; this.btnCopy.style.backgroundColor = CONFIG.buttonColor; }, 2000); } catch (err) { console.error(err); alert("Copy failed."); } } } // 延迟启动 setTimeout(() => { new ChatExporter(); }, 2500); })();