您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatic insertion of translations for the Bluesky timeline (emoji support, visual prioritization, automatic detection of "Show more" re-translations, etc.)
当前为
// ==UserScript== // @name TransPostBSKY // @namespace http://tampermonkey.net/ // @version 1.0 // @description Automatic insertion of translations for the Bluesky timeline (emoji support, visual prioritization, automatic detection of "Show more" re-translations, etc.) // @author Ian // @license MIT // @match https://bsky.app/* // @grant GM_xmlhttpRequest // @connect translate.googleapis.com // @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js // ==/UserScript== (function () { 'use strict'; /*─────────────────────────── * CONFIGURATION *──────────────────────────*/ const config = { postSelector: '[data-testid="postText"]', targetLang: 'zh-CN', skipLanguages: new Set(['zh-CN', 'zh-TW']), languages: { 'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en': 'English', 'ja': '日本語', 'ru': 'Русский', 'fr': 'Français', 'de': 'Deutsch' }, translationInterval: 100, maxRetry: 2, concurrentRequests: 3, baseDelay: 30, translationStyle: { color: 'inherit', fontSize: '0.9em', borderLeft: '2px solid #4c9aff', padding: '0 10px', margin: '4px 0', whiteSpace: 'pre-wrap', opacity: '0.8', /**让翻译块独占整行 **/ display: 'block', width: '100%', flex: '0 0 100%' }, viewportPriority: { centerRadius: 200, updateInterval: 500, maxPriorityItems: 5 } }; /*─────────────────────────── * STATE *──────────────────────────*/ let processingQueue = new Set(); let requestQueue = []; let isTranslating = false; const visiblePosts = new Map(); /*─────────────────────────── * UTILS *──────────────────────────*/ const delay = ms => new Promise(res => setTimeout(res, ms)); async function translateAndDetectLanguage(text) { return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${config.targetLang}&dt=t&q=${encodeURIComponent(text)}`, onload: res => { try { const data = JSON.parse(res.responseText); const translated = data[0].map(i => i[0]).join('').trim(); const detectedSourceLang = (data[2] || '').toLowerCase(); resolve({ translated, detectedSourceLang }); } catch { resolve({ translated: text, detectedSourceLang: '' }); } }, onerror: () => resolve({ translated: text, detectedSourceLang: '' }) }); }); } async function translatePost(node, text) { const { translated, detectedSourceLang } = await translateAndDetectLanguage(text); const lang = detectedSourceLang.toLowerCase(); if (lang === config.targetLang.toLowerCase() || config.skipLanguages.has(lang)) { const container = node.nextElementSibling; if (container?.classList.contains('translation-container')) container.remove(); return null; } return translated; } function extractPerfectText(node) { const clone = node.cloneNode(true); clone.querySelectorAll('a, button').forEach(el => { if (!el.innerHTML.match(/[\p{Extended_Pictographic}\p{Emoji_Component}]/gu)) el.remove(); }); clone.innerHTML = clone.innerHTML.replace(/<br\s*\/?>/gi, '\n'); return clone.textContent.replace(/[\u00A0\u200B]+/g, ' ').trim(); } /*─────────────────────────── * TRANSLATION PIPELINE *──────────────────────────*/ function createTranslationContainer() { const container = document.createElement('div'); container.className = 'translation-container'; Object.assign(container.style, config.translationStyle); container.innerHTML = '<div class="loading-spinner"></div>'; return container; } function watchPostChanges(node) { if (node.dataset.transWatcher) return; const observer = new MutationObserver(() => { const updatedText = extractPerfectText(node); if (!updatedText || node.dataset.lastOriginalText === updatedText) return; node.dataset.lastOriginalText = updatedText; const container = node.nextElementSibling; if (container?.classList.contains('translation-container')) { container.innerHTML = '<div class="loading-spinner"></div>'; } requestQueue.unshift({ node, text: updatedText, retryCount: 0 }); processQueue(); }); observer.observe(node, { childList: true, characterData: true, subtree: true }); node.dataset.transWatcher = 'true'; } function processPost(node) { if (processingQueue.has(node) || node.dataset.transProcessed) return; processingQueue.add(node); node.dataset.transProcessed = 'true'; const originalText = extractPerfectText(node); if (!originalText) { processingQueue.delete(node); return; } node.dataset.lastOriginalText = originalText; const container = createTranslationContainer(); node.after(container); const distance = distanceToViewportCenter(node); const req = { node, text: originalText, retryCount: 0 }; distance < config.viewportPriority.centerRadius ? requestQueue.unshift(req) : requestQueue.push(req); watchPostChanges(node); processQueue(); } async function processQueue() { if (isTranslating || requestQueue.length === 0) return; isTranslating = true; requestQueue.sort((a, b) => distanceToViewportCenter(a.node) - distanceToViewportCenter(b.node)); const batch = requestQueue.splice(0, config.concurrentRequests); await Promise.all(batch.map(async ({ node, text }) => { try { const translated = await translatePost(node, text); if (translated) updateTranslation(node, translated); } catch { markTranslationFailed(node); } finally { processingQueue.delete(node); } })); isTranslating = false; if (requestQueue.length > 0) processQueue(); } function updateTranslation(node, translated) { const container = node.nextElementSibling; if (container?.classList.contains('translation-container')) { container.innerHTML = translated.replace(/\n/g, '<br>'); } } function markTranslationFailed(node) { const container = node.nextElementSibling; if (container?.classList.contains('translation-container')) { container.innerHTML = '<span style="color:red">翻译失败</span>'; } } /*─────────────────────────── * VIEWPORT TRACKING *──────────────────────────*/ function getElementCenter(el) { const rect = el.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } function distanceToViewportCenter(el) { const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; const elCenter = visiblePosts.get(el) || getElementCenter(el); return Math.hypot(center.x - elCenter.x, center.y - elCenter.y); } function setupViewportTracker() { const update = () => { document.querySelectorAll(config.postSelector).forEach(node => { const rect = node.getBoundingClientRect(); rect.top < window.innerHeight && rect.bottom > 0 ? visiblePosts.set(node, getElementCenter(node)) : visiblePosts.delete(node); }); }; window.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true }); setInterval(update, config.viewportPriority.updateInterval); } /*─────────────────────────── * MUTATION OBSERVER (new posts) *──────────────────────────*/ function setupMutationObserver() { const observer = new MutationObserver(mutations => { mutations.forEach(m => { m.addedNodes.forEach(node => { if (node.nodeType === 1) node.querySelectorAll(config.postSelector).forEach(processPost); }); }); }); observer.observe(document, { childList: true, subtree: true }); } /*─────────────────────────── * CONTROL PANEL *──────────────────────────*/ function initControlPanel() { const panelHTML = ` <div id="trans-panel"> <div id="trans-icon"><i class="fa-solid fa-language"></i></div> <div id="trans-menu"> <div style="padding:6px 12px;font-weight:bold">Target language</div> ${Object.entries(config.languages).map(([code, name]) => ` <div class="lang-item target" data-lang="${code}">${name}</div> `).join('')} <hr style="margin:8px 0;border:none;border-top:1px solid #ccc;"> <div style="padding:6px 12px;font-weight:bold">No translation of language</div> ${Object.entries(config.languages).map(([code, name]) => ` <div class="lang-item skip ${config.skipLanguages.has(code) ? 'active' : ''}" data-skip="${code}">${name}</div> `).join('')} </div> </div> `; const style = document.createElement('style'); style.textContent = ` #trans-panel{position:fixed;bottom:20px;right:20px;z-index:9999;font-family:sans-serif} #trans-icon{width:40px;height:40px;border-radius:50%;background:rgba(76,154,255,.9);display:flex;align-items:center;justify-content:center;cursor:pointer;transition:.3s;box-shadow:0 4px 6px rgba(0,0,0,.1)} #trans-icon:hover{transform:scale(1.1)} #trans-icon i{color:#fff;font-size:20px} #trans-menu{width:180px;background:rgba(255,255,255,.95);backdrop-filter:blur(10px);border-radius:12px;padding:8px 0;margin-top:10px;opacity:0;visibility:hidden;transform:translateY(10px);transition:.3s;box-shadow:0 8px 24px rgba(0,0,0,.15)} #trans-menu.show{opacity:1;visibility:visible;transform:translateY(0)} .lang-item{padding:10px 16px;font-size:14px;cursor:pointer;transition:background .2s} .lang-item:hover{background:rgba(76,154,255,.1)} .lang-item.target[data-lang="${config.targetLang}"]{color:#4c9aff;font-weight:bold} .lang-item.skip.active{background:rgba(76,154,255,.1)} .loading-spinner{width:16px;height:16px;border:2px solid #ddd;border-top-color:#4c9aff;border-radius:50%;animation:spin 1s linear infinite;margin:5px} @keyframes spin{to{transform:rotate(360deg)}} /* 保证翻译块整行显示 */ .translation-container{display:block;width:100%;flex:0 0 100%} `; document.head.appendChild(style); document.body.insertAdjacentHTML('beforeend', panelHTML); const icon = document.getElementById('trans-icon'); const menu = document.getElementById('trans-menu'); icon.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('show'); }); document.querySelectorAll('.lang-item.target').forEach(item => { item.addEventListener('click', function () { config.targetLang = this.dataset.lang; refreshAllTranslations(); menu.classList.remove('show'); document.querySelectorAll('.lang-item.target').forEach(li => li.style.color = ''); this.style.color = '#4c9aff'; }); }); document.querySelectorAll('.lang-item.skip').forEach(item => { item.addEventListener('click', function () { const lang = this.dataset.skip; config.skipLanguages.has(lang) ? config.skipLanguages.delete(lang) : config.skipLanguages.add(lang); this.classList.toggle('active'); }); }); document.addEventListener('click', e => { if (!e.target.closest('#trans-panel')) menu.classList.remove('show'); }); } /*─────────────────────────── * REFRESH UTIL *──────────────────────────*/ function refreshAllTranslations() { document.querySelectorAll('.translation-container').forEach(el => el.remove()); processingQueue.clear(); requestQueue = []; document.querySelectorAll(config.postSelector).forEach(node => { delete node.dataset.transProcessed; processPost(node); }); } /*─────────────────────────── * INIT *──────────────────────────*/ function init() { initControlPanel(); setupViewportTracker(); setupMutationObserver(); document.querySelectorAll(config.postSelector).forEach(node => { visiblePosts.set(node, getElementCenter(node)); processPost(node); }); } window.addEventListener('load', init); if (document.readyState === 'complete') init(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址