TransTweetX

TransTweetX offers precise, emoji-friendly translations for Twitter/X feed.

当前为 2025-02-18 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name TransTweetX
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0
  5. // @description TransTweetX offers precise, emoji-friendly translations for Twitter/X feed.
  6. // @author Ian & https://github.com/iaaaannn0/TransTweetX
  7. // @license MIT
  8. // @match https://twitter.com/*
  9. // @match https://x.com/*
  10. // @grant GM_xmlhttpRequest
  11. // @connect translate.googleapis.com
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. const config = {
  19. tweetSelector: '[data-testid="tweetText"]',
  20. targetLang: 'zh-CN',
  21. languages: {
  22. 'zh-CN': '中文',
  23. 'en': 'English',
  24. 'ja': '日本語',
  25. 'ru': 'Русский',
  26. 'fr': 'Français',
  27. 'de': 'Deutsch'
  28. },
  29. translationInterval: 200,
  30. maxRetry: 3,
  31. translationStyle: {
  32. color: 'inherit',
  33. fontSize: '0.9em',
  34. borderLeft: '2px solid #1da1f2',
  35. padding: '0 10px',
  36. margin: '4px 0',
  37. whiteSpace: 'pre-wrap',
  38. opacity: '0.8'
  39. }
  40. };
  41.  
  42. let processingQueue = new Set();
  43. let requestQueue = [];
  44. let isTranslating = false;
  45.  
  46. // 初始化控制面板
  47. function initControlPanel() {
  48. const panelHTML = `
  49. <div id="trans-panel">
  50. <div id="trans-icon"><i class="fa-solid fa-language"></i></div>
  51. <div id="trans-menu">
  52. ${Object.entries(config.languages).map(([code, name]) => `
  53. <div class="lang-item" data-lang="${code}">${name}</div>
  54. `).join('')}
  55. </div>
  56. </div>
  57. `;
  58.  
  59. const style = document.createElement('style');
  60. style.textContent = `
  61. #trans-panel {
  62. position: fixed;
  63. bottom: 20px;
  64. right: 20px;
  65. z-index: 9999;
  66. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  67. }
  68.  
  69. #trans-icon {
  70. width: 40px;
  71. height: 40px;
  72. border-radius: 50%;
  73. background: rgba(29, 161, 242, 0.9);
  74. display: flex;
  75. align-items: center;
  76. justify-content: center;
  77. cursor: pointer;
  78. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  79. backdrop-filter: blur(10px);
  80. box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  81. }
  82.  
  83. #trans-icon:hover {
  84. transform: scale(1.1);
  85. background: rgba(29, 161, 242, 0.95);
  86. }
  87.  
  88. #trans-icon i {
  89. color: white;
  90. font-size: 20px;
  91. }
  92.  
  93. #trans-menu {
  94. width: 150px;
  95. background: rgba(255, 255, 255, 0.9);
  96. backdrop-filter: blur(10px);
  97. border-radius: 12px;
  98. padding: 8px 0;
  99. margin-top: 10px;
  100. opacity: 0;
  101. visibility: hidden;
  102. transform: translateY(10px);
  103. transition: all 0.3s ease;
  104. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  105. }
  106.  
  107. #trans-menu.show {
  108. opacity: 1;
  109. visibility: visible;
  110. transform: translateY(0);
  111. }
  112.  
  113. .lang-item {
  114. padding: 12px 16px;
  115. font-size: 14px;
  116. color: #333;
  117. cursor: pointer;
  118. transition: all 0.2s;
  119. }
  120.  
  121. .lang-item:hover {
  122. background: rgba(29, 161, 242, 0.1);
  123. }
  124.  
  125. .lang-item[data-lang="${config.targetLang}"] {
  126. color: #1da1f2;
  127. font-weight: 500;
  128. }
  129.  
  130. .loading-spinner {
  131. width: 16px;
  132. height: 16px;
  133. border: 2px solid #ddd;
  134. border-top-color: #1da1f2;
  135. border-radius: 50%;
  136. animation: spin 1s linear infinite;
  137. }
  138.  
  139. @keyframes spin {
  140. to { transform: rotate(360deg); }
  141. }
  142.  
  143. @media (prefers-color-scheme: dark) {
  144. #trans-menu {
  145. background: rgba(21, 32, 43, 0.9);
  146. }
  147. .lang-item {
  148. color: #fff;
  149. }
  150. }
  151. `;
  152. document.head.appendChild(style);
  153. document.body.insertAdjacentHTML('beforeend', panelHTML);
  154.  
  155. // 事件绑定
  156. const icon = document.getElementById('trans-icon');
  157. const menu = document.getElementById('trans-menu');
  158.  
  159. icon.addEventListener('click', (e) => {
  160. e.stopPropagation();
  161. menu.classList.toggle('show');
  162. });
  163.  
  164. menu.querySelectorAll('.lang-item').forEach(item => {
  165. item.addEventListener('click', function() {
  166. config.targetLang = this.dataset.lang;
  167. refreshAllTranslations();
  168. menu.classList.remove('show');
  169. });
  170. });
  171.  
  172. document.addEventListener('click', (e) => {
  173. if (!e.target.closest('#trans-panel')) {
  174. menu.classList.remove('show');
  175. }
  176. });
  177. }
  178.  
  179. // 刷新所有翻译
  180. function refreshAllTranslations() {
  181. document.querySelectorAll('.translation-container').forEach(el => el.remove());
  182. processingQueue.clear();
  183. requestQueue = [];
  184. document.querySelectorAll(config.tweetSelector).forEach(tweet => {
  185. delete tweet.dataset.transProcessed;
  186. processTweet(tweet);
  187. });
  188. }
  189.  
  190. // 队列处理系统
  191. async function processQueue() {
  192. if (isTranslating || requestQueue.length === 0) return;
  193. isTranslating = true;
  194.  
  195. while (requestQueue.length > 0) {
  196. const { tweet, text, retryCount } = requestQueue.shift();
  197. try {
  198. const translated = await translateWithEmoji(text);
  199. updateTranslation(tweet, translated);
  200. await delay(config.translationInterval);
  201. } catch (error) {
  202. if (retryCount < config.maxRetry) {
  203. requestQueue.push({ tweet, text, retryCount: retryCount + 1 });
  204. } else {
  205. markTranslationFailed(tweet);
  206. }
  207. }
  208. }
  209.  
  210. isTranslating = false;
  211. }
  212.  
  213. // 精准文本提取
  214. function extractPerfectText(tweet) {
  215. const clone = tweet.cloneNode(true);
  216. clone.querySelectorAll('a, button, [data-testid="card.wrapper"]').forEach(el => {
  217. if (!el.innerHTML.match(/[\p{Extended_Pictographic}\p{Emoji_Component}]/gu)) el.remove();
  218. });
  219.  
  220. clone.innerHTML = clone.innerHTML
  221. .replace(/<br\s*\/?>/gi, '\n')
  222. .replace(/<\/div><div/g, '\n</div><div');
  223.  
  224. clone.querySelectorAll('span, div').forEach(el => {
  225. const style = window.getComputedStyle(el);
  226. if (['block', 'flex'].includes(style.display)) {
  227. el.after(document.createTextNode('\n'));
  228. }
  229. });
  230.  
  231. return clone.textContent
  232. .replace(/\u00A0/g, ' ')
  233. .replace(/^[\s\u200B]+|[\s\u200B]+$/g, '')
  234. .replace(/(\S)[ \t]+\n/g, '$1\n')
  235. .replace(/[ \t]{2,}/g, ' ')
  236. .replace(/(\n){3,}/g, '\n\n')
  237. .trim();
  238. }
  239.  
  240. // Emoji感知翻译
  241. async function translateWithEmoji(text) {
  242. const segments = [];
  243. let lastIndex = 0;
  244. const emojiRegex = /(\p{Extended_Pictographic}|\p{Emoji_Component}+)/gu;
  245.  
  246. for (const match of text.matchAll(emojiRegex)) {
  247. const [emoji] = match;
  248. const index = match.index;
  249. if (index > lastIndex) {
  250. segments.push({ type: 'text', content: text.slice(lastIndex, index) });
  251. }
  252. segments.push({ type: 'emoji', content: emoji });
  253. lastIndex = index + emoji.length;
  254. }
  255.  
  256. if (lastIndex < text.length) {
  257. segments.push({ type: 'text', content: text.slice(lastIndex) });
  258. }
  259.  
  260. const translated = [];
  261. for (const seg of segments) {
  262. if (seg.type === 'emoji') {
  263. translated.push(seg.content);
  264. } else {
  265. const text = seg.content.trim();
  266. if (text) {
  267. translated.push(await translateText(text));
  268. await delay(config.translationInterval);
  269. }
  270. }
  271. }
  272. return translated.join(' ');
  273. }
  274.  
  275. // 核心翻译功能
  276. function translateText(text, retry = 0) {
  277. return new Promise((resolve, reject) => {
  278. if (retry > config.maxRetry) return resolve(text);
  279.  
  280. GM_xmlhttpRequest({
  281. method: 'GET',
  282. url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${config.targetLang}&dt=t&q=${encodeURIComponent(text)}`,
  283. onload: (res) => {
  284. try {
  285. const data = JSON.parse(res.responseText);
  286. resolve(data[0].map(i => i[0]).join('').trim());
  287. } catch {
  288. translateText(text, retry + 1).then(resolve);
  289. }
  290. },
  291. onerror: () => {
  292. translateText(text, retry + 1).then(resolve);
  293. }
  294. });
  295. });
  296. }
  297.  
  298. // 推文处理流程
  299. function processTweet(tweet) {
  300. if (processingQueue.has(tweet) || tweet.dataset.transProcessed) return;
  301. processingQueue.add(tweet);
  302. tweet.dataset.transProcessed = true;
  303.  
  304. const originalText = extractPerfectText(tweet);
  305. if (!originalText) return;
  306.  
  307. const container = createTranslationContainer();
  308. tweet.after(container);
  309.  
  310. requestQueue.push({
  311. tweet,
  312. text: originalText,
  313. retryCount: 0
  314. });
  315.  
  316. processQueue();
  317. }
  318.  
  319. // 创建翻译容器
  320. function createTranslationContainer() {
  321. const container = document.createElement('div');
  322. container.className = 'translation-container';
  323. Object.assign(container.style, config.translationStyle);
  324. container.innerHTML = '<div class="loading-spinner"></div>';
  325. return container;
  326. }
  327.  
  328. // 更新翻译显示
  329. function updateTranslation(tweet, translated) {
  330. const container = tweet.nextElementSibling;
  331. if (container?.classList.contains('translation-container')) {
  332. container.innerHTML = translated.split('\n').join('<br>');
  333. processingQueue.delete(tweet);
  334. }
  335. }
  336.  
  337. // 标记翻译失败
  338. function markTranslationFailed(tweet) {
  339. const container = tweet.nextElementSibling;
  340. if (container?.classList.contains('translation-container')) {
  341. container.innerHTML = '<span style="color:red">翻译失败</span>';
  342. processingQueue.delete(tweet);
  343. }
  344. }
  345.  
  346. // 动态内容监听
  347. function setupMutationObserver() {
  348. const observer = new MutationObserver(mutations => {
  349. mutations.forEach(mutation => {
  350. if (mutation.type === 'childList') {
  351. mutation.addedNodes.forEach(node => {
  352. if (node.nodeType === 1) {
  353. node.querySelectorAll(config.tweetSelector).forEach(processTweet);
  354. }
  355. });
  356. }
  357. else if (mutation.type === 'characterData') {
  358. const tweet = mutation.target.closest(config.tweetSelector);
  359. if (tweet) processTweet(tweet);
  360. }
  361. });
  362. });
  363.  
  364. observer.observe(document, {
  365. childList: true,
  366. subtree: true,
  367. characterData: true
  368. });
  369. }
  370.  
  371. // 工具函数
  372. const delay = ms => new Promise(r => setTimeout(r, ms));
  373.  
  374. // 初始化入口
  375. function init() {
  376. initControlPanel();
  377. setupMutationObserver();
  378. document.querySelectorAll(config.tweetSelector).forEach(processTweet);
  379. }
  380.  
  381. // 启动脚本
  382. window.addEventListener('load', init);
  383. if (document.readyState === 'complete') init();
  384. })();