// ==UserScript== // @name Translate X Post with Volces API (Markdown Support) // @namespace http://tampermonkey.net/ // @version 2.7 // @description Dynamically translate X posts with Markdown support // @author You // @match https://x.com/* // @grant GM.xmlHttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/marked@5.1.2/marked.min.js // @require https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js // @downloadURL https://raw.githubusercontent.com/joeseesun/qiaomu-userscripts/main/%E6%8E%A8%E7%89%B9%E5%B8%96%E5%AD%90%E7%BF%BB%E8%AF%91/X%E7%BF%BB%E8%AF%91.js // @updateURL https://raw.githubusercontent.com/joeseesun/qiaomu-userscripts/main/%E6%8E%A8%E7%89%B9%E5%B8%96%E5%AD%90%E7%BF%BB%E8%AF%91/X%E7%BF%BB%E8%AF%91.js // @homepageURL https://github.com/joeseesun/qiaomu-userscripts // @supportURL https://github.com/joeseesun/qiaomu-userscripts/issues // ==/UserScript== // 用户配置选项 const CONFIG = { // 卡片展示配置 CARD: { AUTO_EXPAND: true, // 是否自动展开翻译卡片 EXPAND_DELAY: 1000, // 自动展开延迟时间(毫秒) INITIAL_STATE: 'expanded', // 初始状态:'expanded' 或 'collapsed' ANIMATION_DURATION: 300, // 动画持续时间(毫秒) MAX_HEIGHT: '1000px' // 展开时的最大高度 } }; console.log('Script loaded and running on:', window.location.href); // 样式配置(便于修改) const STYLES = { TRANSLATION_CONTAINER: { margin: '10px 0', padding: '10px', backgroundColor: '#f0f0f0', border: '2px solid #ccc', borderRadius: '8px', fontFamily: 'Arial, sans-serif', fontSize: '16px', lineHeight: '1.5', color: '#000000', opacity: '0', maxHeight: '0', overflow: 'hidden', transition: 'opacity 0.3s ease-in-out, max-height 0.3s ease-in-out' } }; // 添加 Markdown 样式 GM_addStyle(` .translation-container { opacity: 0; max-height: 0; overflow: hidden; transition: opacity 0.3s ease-in-out, max-height 0.3s ease-in-out; position: relative; } .translation-container.show { opacity: 1; max-height: 1000px; } .translation-container .collapse-btn { position: absolute; top: 5px; right: 5px; padding: 2px 8px; background-color: #e0e0e0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; color: #666; transition: background-color 0.2s; } .translation-container .collapse-btn:hover { background-color: #d0d0d0; } .translation-container h1, .translation-container h2, .translation-container h3, .translation-container h4, .translation-container h5, .translation-container h6 { margin-top: 10px; margin-bottom: 5px; font-weight: bold; } .translation-container h1 { font-size: 1.4em; } .translation-container h2 { font-size: 1.3em; } .translation-container h3 { font-size: 1.2em; } .translation-container h4 { font-size: 1.1em; } .translation-container h5, .translation-container h6 { font-size: 1em; } .translation-container p { margin: 8px 0; } .translation-container ul, .translation-container ol { padding-left: 20px; margin: 8px 0; } .translation-container li { margin: 3px 0; } .translation-container code { background-color: #e8e8e8; padding: 2px 4px; border-radius: 3px; font-family: monospace; } .translation-container pre { background-color: #e8e8e8; padding: 8px; border-radius: 5px; overflow: auto; } .translation-container pre code { background-color: transparent; padding: 0; } .translation-container blockquote { border-left: 3px solid #ccc; margin: 8px 0; padding-left: 10px; color: #555; } .translation-container a { color: #0366d6; text-decoration: none; } .translation-container a:hover { text-decoration: underline; } .translation-container table { border-collapse: collapse; width: 100%; margin: 10px 0; } .translation-container th, .translation-container td { border: 1px solid #ddd; padding: 6px; } .translation-container th { background-color: #e0e0e0; } .translation-container img { max-width: 100%; height: auto; } `); // Prompt 配置(便于优化调整) const SYSTEM_PROMPT = `你是个超级人工智能助手`; const USER_PROMPT = ` 处理说明: 1. 如果文本不是中文则重写为简体中文,并只返回翻译结果,不包含任何解释或额外信息。 2. Toast原帖子,用百度贴吧臭嘴老哥风格,限制50字。(纯娱乐,为刷帖多个反面斗嘴角色) 3. 如文本是英文,除翻译外,提炼3个值得学习的单词或词汇,给出中文翻译、音标和解释,限制50字。 要求:分三步处理下面的文本,支持Markdown: 输出格式: ## 🤖 翻译 [翻译内容] ## 🗣️ 回复 [回复内容] ## 📖 词汇 [词汇内容] 处理文本:`; // API 配置 const API_ENDPOINT = 'https://ark.cn-beijing.volces.com/api/v3/chat/completions'; const DEFAULT_MODEL = 'ep-20250222222029-sx6sd'; const STORAGE_KEYS = { API_KEY: 'volces_api_key', MODEL: 'volces_model' }; let missingApiKeyShown = false; if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('配置火山方舟 API Key', setApiConfig); GM_registerMenuCommand('清空火山方舟 API Key', clearApiConfig); } function getApiKey() { return String(GM_getValue(STORAGE_KEYS.API_KEY, '') || '').trim(); } function getModel() { return String(GM_getValue(STORAGE_KEYS.MODEL, DEFAULT_MODEL) || DEFAULT_MODEL).trim(); } function setApiConfig() { const apiKey = window.prompt('请输入火山方舟 API Key。密钥只会保存在 Tampermonkey 本地存储中。', getApiKey()); if (apiKey === null) return; const normalizedKey = apiKey.trim(); if (!normalizedKey) { window.alert('API Key 不能为空。'); return; } const model = window.prompt('请输入火山方舟模型 Endpoint ID。', getModel()); if (model === null) return; GM_setValue(STORAGE_KEYS.API_KEY, normalizedKey); GM_setValue(STORAGE_KEYS.MODEL, model.trim() || DEFAULT_MODEL); missingApiKeyShown = false; window.alert('配置已保存,刷新 X 页面后生效。'); } function clearApiConfig() { GM_setValue(STORAGE_KEYS.API_KEY, ''); window.alert('API Key 已清空。'); } function getApiConfig(originalElement) { const apiKey = getApiKey(); if (apiKey) { return { apiKey, model: getModel() }; } if (!missingApiKeyShown) { missingApiKeyShown = true; setTranslation({ element: originalElement, translatedText: '请先在 Tampermonkey 菜单中选择“配置火山方舟 API Key”。密钥只会保存在本机浏览器,不会写入脚本源码。' }); setTimeout(() => { if (window.confirm('X 翻译脚本需要先配置火山方舟 API Key。现在配置吗?')) { setApiConfig(); } }, 300); } return null; } // 工具函数:提取纯文本,处理嵌套和特殊字符 function getPlainText(element) { if (!element) return ''; return Array.from(element.childNodes) .map(node => { if (node.nodeType === Node.TEXT_NODE) return node.textContent.trim(); if (node.nodeName === 'SPAN' || node.nodeName === 'DIV' || node.nodeName === 'A') return getPlainText(node); return ''; }) .join(' ') .replace(/\s+/g, ' ') .trim(); } // 工具函数:设置翻译结果到元素,支持 Markdown function setTranslation({ element, translatedText }) { if (!element || !translatedText || translatedText === 'Translation failed') return; // 检查是否已插入翻译,避免重复 // 检查下一个兄弟元素 let nextElement = element.nextSibling; while (nextElement) { if (nextElement.nodeType === Node.ELEMENT_NODE && nextElement.classList && nextElement.classList.contains('translation-container')) { console.log('Translation already exists, skipping...'); return; } nextElement = nextElement.nextSibling; } // 检查父元素的所有子元素 if (element.parentNode) { const siblings = element.parentNode.querySelectorAll('.translation-container'); for (const sibling of siblings) { if (sibling.previousElementSibling === element) { console.log('Translation already exists (found by parent query), skipping...'); return; } } } const translationContainer = document.createElement('div'); translationContainer.className = 'translation-container'; // 根据配置设置初始状态 if (CONFIG.CARD.INITIAL_STATE === 'expanded') { if (CONFIG.CARD.AUTO_EXPAND) { setTimeout(() => { translationContainer.classList.add('show'); }, CONFIG.CARD.EXPAND_DELAY); } } translationContainer.style.cssText = ` margin: ${STYLES.TRANSLATION_CONTAINER.margin}; padding: ${STYLES.TRANSLATION_CONTAINER.padding}; background-color: ${STYLES.TRANSLATION_CONTAINER.backgroundColor}; border: ${STYLES.TRANSLATION_CONTAINER.border}; border-radius: ${STYLES.TRANSLATION_CONTAINER.borderRadius}; font-family: ${STYLES.TRANSLATION_CONTAINER.fontFamily}; font-size: ${STYLES.TRANSLATION_CONTAINER.fontSize}; line-height: ${STYLES.TRANSLATION_CONTAINER.lineHeight}; color: ${STYLES.TRANSLATION_CONTAINER.color}; transition: opacity ${CONFIG.CARD.ANIMATION_DURATION}ms ease-in-out, max-height ${CONFIG.CARD.ANIMATION_DURATION}ms ease-in-out; `; try { if (typeof marked !== 'undefined') { marked.setOptions({ breaks: true, gfm: true, headerIds: false, mangle: false, }); const rawHtml = marked.parse(translatedText); translationContainer.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(rawHtml) : rawHtml; } else { console.warn('Marked library not loaded, falling back to basic formatting'); const contentWrapper = document.createElement('div'); contentWrapper.className = 'content-wrapper'; contentWrapper.innerHTML = translatedText.replace(/\n/g, '
'); translationContainer.appendChild(contentWrapper); } } catch (e) { console.error('Error rendering Markdown:', e); translationContainer.innerHTML = translatedText.replace(/\n/g, '
'); } // 添加折叠按钮 - 移到内容设置之后 const collapseBtn = document.createElement('button'); collapseBtn.className = 'collapse-btn'; collapseBtn.textContent = '隐藏'; collapseBtn.onclick = function() { if (translationContainer.classList.contains('show')) { translationContainer.classList.remove('show'); collapseBtn.textContent = '展示'; } else { translationContainer.classList.add('show'); collapseBtn.textContent = '隐藏'; } }; translationContainer.appendChild(collapseBtn); const parent = element.parentNode; if (parent) { const nextSibling = element.nextSibling; if (nextSibling) { parent.insertBefore(translationContainer, nextSibling); } else { parent.appendChild(translationContainer); } // 使用 requestAnimationFrame 确保 DOM 更新后再添加显示类 requestAnimationFrame(() => { translationContainer.classList.add('show'); }); } else { console.warn('No parent node found for translation insertion'); } } function getPostElements() { return document.querySelectorAll('article[data-testid="tweet"], div[data-testid="tweet"]'); // 扩展选择器,兼容可能的变化 } function getTweetTextElement(tweetElement) { const selectors = [ 'div[data-testid="tweetText"]', 'div.css-146c3p1.r-bcqeeo.r-1ttztb7', 'span[data-testid="tweet-text"]', 'div[data-testid="newTweetText"]' ]; for (const selector of selectors) { const textElement = tweetElement.querySelector(selector); if (textElement) { const text = getPlainText(textElement); if (text && (text.match(/[a-zA-Z@]/) || text.length > 5)) { // 宽松验证 console.log('Found tweet text with selector:', selector, 'Text:', text, 'Element:', textElement); return { text, element: textElement }; } } } return { text: 'No post text found', element: null }; } function translateText(text, originalElement) { console.log('Starting translation for text:', text); if (!text || text === 'No post text found') { console.warn('No valid text to translate'); return; } const apiConfig = getApiConfig(originalElement); if (!apiConfig) return; // 组合输入文本 const combinedInput = `${USER_PROMPT}${text}`; // 构建请求体 const requestBody = { model: apiConfig.model, messages: [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": combinedInput} ] }; console.log('Sending GM.xmlHttpRequest to:', API_ENDPOINT, 'with body:', JSON.stringify(requestBody)); GM.xmlHttpRequest({ method: 'POST', url: API_ENDPOINT, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiConfig.apiKey}` }, data: JSON.stringify(requestBody), timeout: 10000, // 保持10秒超时,优化性能 onload: function(response) { console.log('GM.xmlHttpRequest response status:', response.status); console.log('Raw response:', response.responseText); if (response.status === 200) { try { const responseJson = JSON.parse(response.responseText); // 从响应中提取 AI 生成的文本 (适配 Volces API 的返回格式) const translatedText = responseJson.choices?.[0]?.message?.content || 'Translation failed'; console.log('Parsed translated text:', translatedText); setTranslation({ element: originalElement, translatedText }); } catch (e) { console.error('Failed to parse response:', e, 'Raw response:', response.responseText); } } else { console.error('API request failed with status: ' + response.status + ', response: ' + response.responseText); } }, onerror: function(error) { console.error('GM.xmlHttpRequest error: ', error); }, onabort: function() { console.error('GM.xmlHttpRequest aborted'); }, ontimeout: function() { console.error('GM.xmlHttpRequest timed out'); } }); console.log('GM.xmlHttpRequest initiated'); } // 使用 MutationObserver 监听 DOM 变化 function observeTweets() { const targetNode = document.querySelector('main') || document.body; // 更具体的目标 if (!targetNode) { console.warn('No main or body element found, observing document.body'); } const observer = new MutationObserver((mutations) => { mutations.forEach(mutation => { if (mutation.addedNodes.length) { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && (node.matches('article[data-testid="tweet"]') || node.matches('div[data-testid="tweet"]'))) { processTweet(node); } else if (node.querySelector && !node.matches('div.translation-container')) { // 只处理新添加的推文节点,避免重复处理 const tweets = Array.from(node.querySelectorAll('article[data-testid="tweet"], div[data-testid="tweet"]')) .filter(tweet => !tweet.nextElementSibling?.classList.contains('translation-container')); tweets.forEach(tweet => processTweet(tweet)); } }); } }); }); observer.observe(targetNode, { childList: true, subtree: true }); // 移除 attributes 和 characterData,减少性能开销 // 初始处理现有帖子 console.log('Processing existing tweets...'); getPostElements().forEach(tweet => { setTimeout(() => processTweet(tweet), 500); // 减少延迟到0.5秒,优化加载速度 }); } function processTweet(tweetElement, attempt = 0) { if (attempt > 2) { // 减少重试次数到2次,优化性能 console.error('Failed to process tweet after 2 retries'); return; } const { text, element } = getTweetTextElement(tweetElement); console.log('Processing tweet:', text, 'Attempt:', attempt); if (text && element && text !== 'No post text found') { translateText(text, element); } else { console.warn('No valid tweet text found, retrying in 0.5s...', tweetElement); setTimeout(() => processTweet(tweetElement, attempt + 1), 500); // 减少重试延迟到0.5秒 } } (function() { 'use strict'; console.log('Script starting on:', window.location.href); setTimeout(() => { console.log('Starting tweet observation after delay, URL:', window.location.href); observeTweets(); }, 1000); // 减少初始延迟到1秒,优化加载速度 })();