Enhanced Translation

Translate a webpage Translation with built-in AI into your preferred language, making browsing easier and faster.

目前為 2025-01-15 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Enhanced Translation
// @namespace   your-namespace
// @description Translate a webpage Translation with built-in AI into your preferred language, making browsing easier and faster.
// @version     1.0
// @author      UniverseDev
// @license     GPL-3.0-or-later
// @match       *://*/*
// @grant       none
// ==/UserScript==

(async function () {
    'use strict';

    const CONFIG = {
        targetLanguage: 'en',
        debugMode: true,
        translationAttribute: 'data-gm-translated',
        excludedElementsSelector: 'code, pre, .notranslate, img, svg, video, audio, kbd, samp, var, math, noscript, script, style',
        translationBatchSize: 250, // Reduced batch size for smoother updates
        dynamicContentDebounceDelay: 300,
        translationQueueDebounceDelay: 100, // Debounce for the translation queue processing
        textContainingElementsSelector: 'p, h1, h2, h3, h4, h5, h6, span, a, div, li, dt, dd, blockquote, th, td, summary, figcaption, label, button, textarea, select, option',
        translatableAttributes: ['title', 'placeholder', 'alt', 'aria-label'],
        loadingIndicatorStyle: `
            position: fixed;
            top: 10px;
            left: 10px;
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 8px 15px;
            border-radius: 5px;
            z-index: 10000;
            font-size: 14px;
        `,
        loadingIndicatorText: 'Translating Visible Content...',
        loadingIndicatorUpdatingText: (translatedCount) => `Translating... (${translatedCount} elements)`,
        useIntersectionObserver: true,
        intersectionObserverOptions: {
            rootMargin: '0px',
            threshold: 0.1
        },
        showErrorBanners: true, // Option to disable error banners
    };

    let dynamicContentTimer;
    let translationQueueTimer;
    let pageLanguage;
    let loadingIndicator;
    let translatedElementCount = 0;
    const translationQueue = new Set();
    let isIdleCallbackRunning = false;
    let intersectionObserver;
    let langAttributeObserver;
    const targetLanguageCode = CONFIG.targetLanguage.toLowerCase(); // More descriptive variable name
    const isTrulyVisibleCache = new WeakMap();
    const domManipulationQueue = [];

    function logDebug(message, ...args) {
        if (CONFIG.debugMode) {
            console.log(`${new Date().toLocaleTimeString()} - DEBUG: ${message}`, ...args);
        }
    }

    function normalizeLang(lang) {
        return lang ? lang.toLowerCase().split('-')[0] : '';
    }

    function showTranslationError(message) {
        if (CONFIG.showErrorBanners) {
            const errorBanner = document.createElement('div');
            errorBanner.style.cssText = `position: fixed; bottom: 0; width: 100%; background-color: #f44336; color: #fff; text-align: center; padding: 10px; z-index: 9999;`;
            errorBanner.textContent = `Translation Error: ${message}`;
            document.body.appendChild(errorBanner);
            setTimeout(() => errorBanner.remove(), 5000);
        }
        console.error(`Translation Error: ${message}`);
    }

    const isTranslationSupported = (() => {
        try {
            return 'translation' in self && typeof self.translation.createTranslator === 'function';
        } catch (error) {
            console.error("Error checking translation API:", error);
            return false;
        }
    })();

    if (!isTranslationSupported) {
        logDebug("Translation features are unavailable.");
        return;
    }

    const languageDetector = isTranslationSupported ? await self.translation.createDetector() : null;

    async function detectLanguage(text) { // Centralized language detection
        if (!languageDetector) return 'unknown';
        try {
            const detectionResult = (await languageDetector.detect(text))[0];
            if (!detectionResult || detectionResult.confidence < 0.5) {
                logDebug(`Detected language uncertain: ${detectionResult?.detectedLanguage}, confidence: ${detectionResult?.confidence}`);
                return 'unknown';
            }
            logDebug(`Detected source language: ${detectionResult.detectedLanguage}, confidence: ${(detectionResult.confidence * 100).toFixed(1)}%`);
            return normalizeLang(detectionResult.detectedLanguage) || 'unknown';
        } catch (error) {
            logDebug(`Language detection error: ${error}`);
            return 'unknown';
        }
    }

    const translatorCache = new Map();

    async function getTranslator(sourceLang) {
        const key = `${sourceLang}-${targetLanguageCode}`;
        if (translatorCache.has(key)) {
            return translatorCache.get(key);
        }
        try {
            const translator = await self.translation.createTranslator({
                sourceLanguage: sourceLang,
                targetLanguage: targetLanguageCode,
            });
            translatorCache.set(key, translator);
            return translator;
        } catch (error) {
            logDebug(`Error creating translator for ${key}: ${error.message}`);
            showTranslationError(error.message);
            return null;
        }
    }

    async function translateContent(text, sourceLang) {
        if (!text || text.trim() === '') {
            return text;
        }
        const translator = await getTranslator(sourceLang);
        if (!translator || typeof translator.translate !== 'function') {
            logDebug("Translation API's `translate` method not found or invalid.");
            return text;
        }
        try {
            return await translator.translate(text);
        } catch (error) {
            logDebug(`Error during translation: ${error.message}`);
            showTranslationError(error.message);
            return text;
        }
    }

    function isTrulyVisible(element) {
        if (!element) return false;
        if (isTrulyVisibleCache.has(element)) {
            return isTrulyVisibleCache.get(element);
        }
        const style = window.getComputedStyle(element);
        const isVisible = style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0 && element.offsetParent !== null;
        isTrulyVisibleCache.set(element, isVisible);
        return isVisible;
    }

    function queueDOMManipulation(callback) {
        domManipulationQueue.push(callback);
        if (domManipulationQueue.length === 1) {
            requestAnimationFrame(processDOMManipulationQueue);
        }
    }

    function processDOMManipulationQueue() {
        while (domManipulationQueue.length > 0) {
            const callback = domManipulationQueue.shift();
            callback();
        }
    }

    async function translateTextNode(node, sourceLang) {
        if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
            const originalText = node.textContent;
            const translatedText = await translateContent(originalText, sourceLang);
            if (translatedText && translatedText !== originalText) {
                queueDOMManipulation(() => {
                    node.textContent = translatedText;
                    logDebug(`Translated text node: "${originalText}" to "${translatedText}"`);
                });
            }
        }
    }

    async function translateAttributes(element, sourceLang) {
        const translations = {};
        for (const attribute of CONFIG.translatableAttributes) {
            if (element.hasAttribute(attribute)) {
                const originalValue = element.getAttribute(attribute);
                const translatedValue = await translateContent(originalValue, sourceLang);
                if (translatedValue && translatedValue !== originalValue) {
                    translations[attribute] = translatedValue;
                }
            }
        }
        if (Object.keys(translations).length > 0) {
            queueDOMManipulation(() => {
                for (const attribute in translations) {
                    element.setAttribute(attribute, translations[attribute]);
                    logDebug(`Translated ${attribute}: "${element.getAttribute(attribute)}" to "${translations[attribute]}"`);
                }
            });
        }
    }

    async function translateElementContent(element, sourceLang) {
        const textNodePromises = [];
        for (const node of element.childNodes) {
            if (node.nodeType === Node.TEXT_NODE) {
                textNodePromises.push(translateTextNode(node, sourceLang));
            }
        }
        await Promise.all(textNodePromises);
    }

    function shouldTranslateElement(element) {
        return isTrulyVisible(element) && !element.hasAttribute(CONFIG.translationAttribute); // Check if not already translated
    }

    async function translateElement(element) {
        if (!shouldTranslateElement(element)) {
            return;
        }

        if (!/\w+/.test(element.textContent) && !CONFIG.translatableAttributes.some(attr => element.hasAttribute(attr) && /\w+/.test(element.getAttribute(attr)))) {
            queueDOMManipulation(() => element.setAttribute(CONFIG.translationAttribute, 'true'));
            return;
        }

        let sourceLang = pageLanguage;
        const elementLang = normalizeLang(element.closest('[lang]')?.lang);

        if (elementLang && elementLang !== targetLanguageCode) {
            sourceLang = elementLang;
            logDebug(`Using element-level language: ${sourceLang} for`, element);
        } else if (!sourceLang) {
            const textToDetect = element.textContent.substring(0, 200);
            if (/\w+/.test(textToDetect)) {
                const detectedLang = await detectLanguage(textToDetect);
                if (detectedLang !== 'unknown' && normalizeLang(detectedLang) !== targetLanguageCode) {
                    sourceLang = detectedLang;
                    logDebug(`Detected element language: ${sourceLang} for`, element);
                } else if (detectedLang === targetLanguageCode) {
                    queueDOMManipulation(() => element.setAttribute(CONFIG.translationAttribute, 'true'));
                    return;
                }
            } else {
                queueDOMManipulation(() => element.setAttribute(CONFIG.translationAttribute, 'true'));
                return;
            }
        } else if (normalizeLang(sourceLang) === targetLanguageCode) {
            queueDOMManipulation(() => element.setAttribute(CONFIG.translationAttribute, 'true'));
            return;
        }

        await translateAttributes(element, sourceLang);
        await translateElementContent(element, sourceLang);

        queueDOMManipulation(() => {
            element.setAttribute(CONFIG.translationAttribute, 'true');
        });

        translatedElementCount++;
        requestAnimationFrame(() => {
            if (loadingIndicator && typeof CONFIG.loadingIndicatorUpdatingText === 'function') {
                loadingIndicator.textContent = CONFIG.loadingIndicatorUpdatingText(translatedElementCount);
            }
        });
    }

    async function processBatch(elements) {
        for (const element of elements) {
            if (isTrulyVisible(element)) {
                await translateElement(element);
            }
        }
    }

    function showLoadingIndicator() {
        loadingIndicator = document.createElement('div');
        loadingIndicator.style.cssText = CONFIG.loadingIndicatorStyle;
        loadingIndicator.textContent = CONFIG.loadingIndicatorText;
        document.body.appendChild(loadingIndicator);
    }

    function hideLoadingIndicator() {
        if (loadingIndicator) {
            loadingIndicator.remove();
            loadingIndicator = null;
            translatedElementCount = 0;
        }
    }

    function queryShadowDOM(root, selector) {
        let elements = Array.from(root.querySelectorAll(selector));
        const shadowHosts = root.querySelectorAll('*');
        shadowHosts.forEach(host => {
            if (host.shadowRoot) {
                elements = elements.concat(Array.from(queryShadowDOM(host.shadowRoot, selector))); // Convert NodeList to Array
            }
        });
        return elements;
    }

    async function translateVisibleContent(elements) {
        const visibleElementsToTranslate = elements.filter(el => isTrulyVisible(el));
        logDebug(`Found ${visibleElementsToTranslate.length} initially visible elements to translate.`);
        for (let i = 0; i < visibleElementsToTranslate.length; i += CONFIG.translationBatchSize) {
            const batch = visibleElementsToTranslate.slice(i, i + CONFIG.translationBatchSize);
            await processBatch(batch);
            await new Promise(resolve => setTimeout(resolve, 0));
        }
        logDebug('Initial visible page content translation completed.');
        hideLoadingIndicator();
    }

    async function translatePageContent() {
        showLoadingIndicator();
        pageLanguage = normalizeLang(document.documentElement.lang) || (await detectLanguage(document.body.innerText.substring(0, 500)));
        logDebug(`Page language: ${pageLanguage}`);
        logDebug(`Preferred language: ${targetLanguageCode}`);
        if (pageLanguage === targetLanguageCode) {
            logDebug('Page is already in the preferred language.');
            hideLoadingIndicator();
            return;
        }

        const elementsToTranslate = queryShadowDOM(document.body, `${CONFIG.textContainingElementsSelector}:not(${CONFIG.excludedElementsSelector}):not([${CONFIG.translationAttribute}])`);
        logDebug(`Found ${elementsToTranslate.length} elements to potentially translate (initial).`);

        if (CONFIG.useIntersectionObserver) {
            initIntersectionObserver();
            elementsToTranslate.forEach(element => {
                if (isTrulyVisible(element)) {
                    intersectionObserver.observe(element);
                }
            });
            logDebug('Observing initially visible elements with IntersectionObserver.');
        } else {
            await translateVisibleContent(elementsToTranslate);
        }
    }

    function enqueueTranslatableElement(element) {
        if (element && !element.matches(CONFIG.excludedElementsSelector) && !element.hasAttribute(CONFIG.translationAttribute) && isTrulyVisible(element)) {
            translationQueue.add(element);
            if (!isIdleCallbackRunning) {
                isIdleCallbackRunning = true;
                translationQueueTimer = setTimeout(processTranslationQueue, CONFIG.translationQueueDebounceDelay);
            }
        }
    }

    async function processTranslationQueue() {
        isIdleCallbackRunning = false;
        const elementsToProcess = Array.from(translationQueue);
        translationQueue.clear();
        for (const element of elementsToProcess) {
            await translateElement(element);
        }
    }

    function initIntersectionObserver() {
        intersectionObserver = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const element = entry.target;
                    if (!element.hasAttribute(CONFIG.translationAttribute)) { // Ensure it hasn't been translated while waiting
                        enqueueTranslatableElement(element);
                    }
                    observer.unobserve(element); // Disconnect after enqueuing for translation
                }
            });
        }, CONFIG.intersectionObserverOptions);
        logDebug('IntersectionObserver initialized.');
    }

    async function translateAddedNode(node) {
        if (node.nodeType === Node.ELEMENT_NODE) {
            if (CONFIG.useIntersectionObserver) {
                if (shouldTranslateElement(node)) {
                    intersectionObserver.observe(node);
                }
            } else if (isTrulyVisible(node)) {
                enqueueTranslatableElement(node);
            }

            if (node.shadowRoot) {
                const shadowElements = queryShadowDOM(node.shadowRoot, `*:not(${CONFIG.excludedElementsSelector}):not([${CONFIG.translationAttribute}])`);
                shadowElements.forEach(el => {
                    if (CONFIG.useIntersectionObserver && isTrulyVisible(el)) {
                        intersectionObserver.observe(el);
                    } else if (!CONFIG.useIntersectionObserver && isTrulyVisible(el)) {
                        enqueueTranslatableElement(el);
                    }
                });
            }

            node.querySelectorAll(`*:not(${CONFIG.excludedElementsSelector}):not([${CONFIG.translationAttribute}])`).forEach(child => {
                if (CONFIG.useIntersectionObserver && isTrulyVisible(child)) {
                    intersectionObserver.observe(child);
                } else if (!CONFIG.useIntersectionObserver && isTrulyVisible(child)) {
                    enqueueTranslatableElement(child);
                }
            });
        } else if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() && node.parentElement) {
            if (CONFIG.useIntersectionObserver && isTrulyVisible(node.parentElement)) {
                intersectionObserver.observe(node.parentElement);
            } else if (!CONFIG.useIntersectionObserver && isTrulyVisible(node.parentElement)) {
                enqueueTranslatableElement(node.parentElement);
            }
        }
    }

    async function handleChildListMutation(mutation) {
        for (const addedNode of mutation.addedNodes) {
            await translateAddedNode(addedNode);
        }
    }

    async function handleCharacterDataMutation(mutation) {
        if (mutation.target.parentNode && isTrulyVisible(mutation.target.parentNode)) {
            enqueueTranslatableElement(mutation.target.parentNode);
        }
    }

    async function handleAttributeMutation(mutation) {
        if (mutation.target instanceof Element && CONFIG.translatableAttributes.includes(mutation.attributeName)) {
            if (mutation.attributeName === 'style' || mutation.attributeName === 'class') {
                isTrulyVisibleCache.delete(mutation.target);
            }
            if (isTrulyVisible(mutation.target)) {
                enqueueTranslatableElement(mutation.target);
            }
        }
    }

    async function translateDynamicContent(mutationsList) {
        for (const mutation of mutationsList) {
            switch (mutation.type) {
                case 'childList':
                    await handleChildListMutation(mutation);
                    break;
                case 'characterData':
                    await handleCharacterDataMutation(mutation);
                    break;
                case 'attributes':
                    await handleAttributeMutation(mutation);
                    break;
            }
        }
    }

    function observeDynamicContent() {
        const observer = new MutationObserver(async (mutationsList) => {
            if (mutationsList.length > 0) {
                clearTimeout(dynamicContentTimer);
                dynamicContentTimer = setTimeout(() => {
                    translateDynamicContent(mutationsList);
                }, CONFIG.dynamicContentDebounceDelay);
            }
        });
        observer.observe(document.body, {
            childList: true,
            subtree: true,
            characterData: true,
            attributeFilter: CONFIG.translatableAttributes.concat(['style', 'class']),
            attributes: true,
        });
        logDebug('MutationObserver initialized for dynamic content.');
    }

    function initLangAttributeObserver() {
        langAttributeObserver = new MutationObserver(mutationsList => {
            mutationsList.forEach(mutation => {
                if (mutation.type === 'attributes' && mutation.attributeName === 'lang') {
                    const element = mutation.target;
                    logDebug(`'lang' attribute changed on:`, element);
                    element.removeAttribute(CONFIG.translationAttribute);
                    if (isTrulyVisible(element)) {
                        enqueueTranslatableElement(element);
                    }
                }
            });
        });
        langAttributeObserver.observe(document.documentElement, {
            attributes: true,
            attributeFilter: ['lang'],
            subtree: true
        });
        logDebug('Lang attribute observer initialized.');
    }

    window.addEventListener('load', async () => {
        if (isTranslationSupported) {
            await translatePageContent();
            observeDynamicContent();
            initLangAttributeObserver();
        }
    });
})();