提取 Arena.ai 对话(支持公式/MD/选区复制)
// ==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
// ==/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 = `
<button id="ace-toggle-select" class="ace-btn">${TEXT.mode_off}</button>
<button id="ace-copy-btn" class="ace-btn">${TEXT.copy_btn}</button>
`;
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 类) ---
// 用户对话框特征:<div class="bg-surface-raised ..."><div class="prose">...</div></div>
// 向上查找最近的 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);
})();