PonyTown自动翻译

ponytown自动翻译, 请自行申请彩云小译文本翻译api的token以获得最好的服务

// ==UserScript==
// @name         PonyTown自动翻译
// @namespace    http://tampermonkey.net/
// @version      1.2
// @license      GPL-3.0-only
// @description  ponytown自动翻译, 请自行申请彩云小译文本翻译api的token以获得最好的服务
// @author       Lonel
// @match        https://pony.town/*
// @match        https://event.pony.town/*
// @match        https://eventgreen.pony.town/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // 配置默认值
    const DEFAULT_CONFIG = {
        token: "3975l6lr5pcbvidl6jl2",
        direction: "auto2zh",
        checkInterval: 100,
        batchInterval: 3000,
        batchSize: 5,
        maxQueueLength: 100,
        retryTimes: 0,
        cooldownTime: 10000
    };

    // 初始化配置
    function initConfig() {
        const savedConfig = GM_getValue('ponyTownTranslateConfig');
        if (!savedConfig) {
            GM_setValue('ponyTownTranslateConfig', DEFAULT_CONFIG);
            return DEFAULT_CONFIG;
        }
        const mergedConfig = { ...DEFAULT_CONFIG, ...savedConfig };
        GM_setValue('ponyTownTranslateConfig', mergedConfig);
        return mergedConfig;
    }

    let CONFIG = initConfig();

    let isRateLimited = false;
    let rateLimitEndTime = 0;


    function restartTimers() {
    if (checkTimer) clearInterval(checkTimer);
    if (batchTimer) clearInterval(batchTimer);
    checkTimer = setInterval(checkMessages, CONFIG.checkInterval);
    startBatchProcessor();
    console.log("已重启定时器,应用新配置");
    }

    GM_registerMenuCommand('配置PonyTown翻译', () => {
        const configStr = prompt('请输入翻译配置(JSON格式):', JSON.stringify(CONFIG, null, 2));
        if (configStr) {
            try {
                const newConfig = JSON.parse(configStr);
                if (typeof newConfig.token === 'string' &&
                    typeof newConfig.checkInterval === 'number' &&
                    typeof newConfig.batchInterval === 'number' &&
                    typeof newConfig.batchSize === 'number' &&
                    typeof newConfig.maxQueueLength === 'number' &&
                    typeof newConfig.retryTimes === 'number' &&
                    typeof newConfig.cooldownTime === 'number') {

                    CONFIG = newConfig;
                    GM_setValue('ponyTownTranslateConfig', newConfig);
                    alert('配置已保存');
                } else {
                    alert('配置格式错误,缺少必要参数或参数类型不正确');
                }
            } catch (e) {
                alert('JSON格式错误:' + e.message);
            }
        }
    });

    const container = document.createElement('div');
    container.id = 'translation-controls';
    container.style.display = 'flex';
    container.style.alignItems = 'center';

    const translateBtn = document.createElement('button');
    translateBtn.id = 'translate-btn';
    translateBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" style="width: 24px; height: 24px; vertical-align: middle;"><path fill="currentColor" d="m250.73 210.63l-56-112a12 12 0 0 0-21.46 0l-20.52 41A84.2 84.2 0 0 1 114 126.22A107.5 107.5 0 0 0 139.33 68H160a12 12 0 0 0 0-24h-52V32a12 12 0 0 0-24 0v12H32a12 12 0 0 0 0 24h83.13A83.7 83.7 0 0 1 96 110.35A84 84 0 0 1 83.6 91a12 12 0 1 0-21.81 10A107.6 107.6 0 0 0 78 126.24A83.54 83.54 0 0 1 32 140a12 12 0 0 0 0 24a107.47 107.47 0 0 0 64-21.07a108.4 108.4 0 0 0 45.39 19.44l-24.13 48.26a12 12 0 1 0 21.46 10.73L151.41 196h65.17l12.68 25.36a12 12 0 1 0 21.47-10.73M163.41 172L184 130.83L204.58 172Z"/></svg>`;
    translateBtn.style.cursor = 'pointer';
    translateBtn.style.backgroundColor = '#00000000';
    translateBtn.style.color = 'white';
    translateBtn.style.border = 'none';
    translateBtn.style.borderRadius = '4px';
    translateBtn.style.display = 'flex';
    translateBtn.style.alignItems = 'center';
    translateBtn.style.justifyContent = 'center';

    const select = document.createElement('select');
    select.id = 'language-select';

    const style = document.createElement('style');
    style.textContent = `
        #language-select {
            -webkit-appearance: none;
           -moz-appearance: none;
            appearance: none;
            background: transparent;
            border: none;
            outline: none;
            padding: 0 15px 0 5px;
            margin: 0;
            width: auto;
            font-family: inherit;
            font-size: 14px;
            color: white;
            cursor: pointer;
            font-weight: 500;
            background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
            background-repeat: no-repeat;
            background-position: right center;
            background-size: 16px;
        }
        #language-select option {
            background: #2d2d2d;
            color: white;
            border: none;
            outline: none;
            padding: 8px 12px;
        }
        #language-select option:hover {
            background: #000;
        }
        #language-select::-ms-expand {
            display: none;
        }
        #translate-btn:hover {
            background-color: #000000;
        }
        .translation-controls-added {
            padding-right: 175px !important;
        }
        .translation-controls-added-long {
            padding-right: 195px !important;
        }
    `;
    document.head.appendChild(style);

    const languages = ['Chinese', 'English', 'Russian', 'Spanish', 'Portuguese', 'Indonesian'];
    const languageCodes = ['zh', 'en', 'ru', 'es', 'pt', 'id'];

    languages.forEach((lang, index) => {
        const option = document.createElement('option');
        option.value = languageCodes[index];
        option.textContent = lang;
        select.appendChild(option);
    });

    function adjustSelectWidth() {
        const selectedText = select.options[select.selectedIndex].textContent;
        const tempSpan = document.createElement('span');
        tempSpan.style.visibility = 'hidden';
        tempSpan.style.position = 'absolute';
        tempSpan.style.whiteSpace = 'nowrap';
        tempSpan.style.font = 'inherit';
        tempSpan.style.fontSize = '14px';
        tempSpan.style.padding = '6px 10px';
        tempSpan.textContent = selectedText;
        document.body.appendChild(tempSpan);
        select.style.width = `${tempSpan.getBoundingClientRect().width + 5}px`;
        document.body.removeChild(tempSpan);
    }

    adjustSelectWidth();
    select.addEventListener('change', adjustSelectWidth);

    const hiddenDiv = document.createElement('div');
    hiddenDiv.id = 'translation-data';
    hiddenDiv.style.display = 'none';
    hiddenDiv.dataset.targetLanguage = 'en';
    hiddenDiv.dataset.translationStatus = 'translated';
    document.body.appendChild(hiddenDiv);

    async function caiyunTranslate(texts, direction = CONFIG.direction) {
        if (isRateLimited) {
            const remainingTime = rateLimitEndTime - Date.now();
            if (remainingTime > 0) {
                console.log(`API频率限制中,剩余${Math.ceil(remainingTime / 1000)}秒`);
                return texts.map(text => `[翻译频率限制中,请稍后再试] ${text}`);
            } else {
                isRateLimited = false;
                console.log("API频率限制已解除,恢复翻译服务");
            }
        }

        if (Date.now() - lastErrorTime < CONFIG.cooldownTime) {
            return texts.map(text => `[翻译服务冷却中] ${text}`);
        }

        const url = "https://api.interpreter.caiyunai.com/v1/translator";
        const payload = {
            source: texts,
            trans_type: direction,
            detect: direction.startsWith("auto")
        };

        try {
            const response = await fetch(url, {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                    "x-authorization": "token " + CONFIG.token
                },
                body: JSON.stringify(payload)
            });

            const data = await response.json();
            //console.log(data);
            if (data.message === 'API rate limit exceeded') {
                isRateLimited = true;
                rateLimitEndTime = Date.now() + CONFIG.cooldownTime;
                console.warn(`API频率限制触发,将在${CONFIG.cooldownTime / 1000}秒后重试`);
                setTimeout(() => {
                    isRateLimited = false;
                    console.log("API频率限制已解除,恢复翻译服务");
                }, CONFIG.cooldownTime);
                return texts.map(text => `[翻译频率限制,已自动冷却${CONFIG.cooldownTime / 1000}秒] ${text}`);
            }

            if (!response.ok || !data?.target) {
                throw new Error(`API错误: ${response.status} ${data?.message || '无有效返回'}`);
            }
            return Array.isArray(data.target) ? data.target : texts.map(text => `[翻译异常] ${text}`);
        } catch (error) {
            lastErrorTime = Date.now();
            console.error("翻译API调用失败:", error);
            return texts.map(text => `[翻译失败] ${text}`);
        }
    }


    async function translateChatInput(targetLang) {
        const chatInput = document.querySelector('textarea[aria-label="Chat message"]');
        if (!chatInput || !chatInput.value.trim()) return;

        const cursorPos = chatInput.selectionStart;

        try {
            const originalValue = chatInput.value;
            chatInput.value = "翻译中...";

            const direction = `auto2${targetLang}`;
            const translations = await caiyunTranslate([originalValue], direction);

            if (translations && translations.length > 0 && !translations[0].startsWith("[")) {
                chatInput.value = translations[0];
                chatInput.focus();
                chatInput.selectionEnd = chatInput.value.length;
                console.log(`聊天框内容已翻译为${targetLang}`);
            } else {
                chatInput.value = originalValue;
                chatInput.focus();
                chatInput.selectionStart = chatInput.selectionEnd = cursorPos;
            }
        } catch (error) {
            console.error("翻译聊天框内容时出错:", error);
            chatInput.focus();
            chatInput.selectionStart = chatInput.selectionEnd = cursorPos;
        }
    }

    select.addEventListener('change',async function() {
        const targetLang = select.value;
        const hiddenData = document.getElementById('translation-data');
        hiddenData.dataset.targetLanguage = targetLang;
        console.log('翻译状态更新为:', targetLang);
        //CONFIG.direction = `auto2${targetLang}`;
    });

    translateBtn.addEventListener('click', async function() {
        const targetLang = select.value;
        const hiddenData = document.getElementById('translation-data');
        hiddenData.dataset.translationStatus = 'pending';
        await translateChatInput(targetLang);
    });

    function addControlsToUI() {
        let sendButton = document.querySelector('ui-button[aria-label="Send message"]');
        if (sendButton && sendButton.parentNode) {
            const controlsWrapper = document.createElement('div');
            controlsWrapper.style.display = 'flex';
            controlsWrapper.style.alignItems = 'center';

            controlsWrapper.appendChild(container);
            sendButton.parentNode.insertBefore(controlsWrapper, sendButton.nextSibling);
            return true;
        }
    }

    function adjustInputWidth() {
        const selectedText = select.options[select.selectedIndex].textContent;
        const textareaWrap = document.querySelector('.chat-textarea-wrap');
        if (!textareaWrap) return;
        textareaWrap.classList.remove('translation-controls-added', 'translation-controls-added-long');
        if (selectedText === "Portuguese" || selectedText === "Indonesian") {
            textareaWrap.classList.add('translation-controls-added-long');
        } else {
            textareaWrap.classList.add('translation-controls-added');
        }
    }

    function tryAddControls() {
        const textareaWrap = document.querySelector('.chat-textarea-wrap');
        if (textareaWrap) {
            adjustInputWidth();
            select.addEventListener('change', adjustInputWidth);
            return true;
        }
        return false;
    }

    function waitForGameLoad() {
        const isGameLoaded =
            document.body.className.includes('playing') ||
            document.querySelector('.chat-container') ||
            document.querySelector('.game-container') ||
            document.querySelector('.chat-textarea-wrap');

        if (isGameLoaded) {
            if (addControlsToUI() && tryAddControls()) {
                console.log("翻译按钮已成功添加");
                return true;
            }
        }
        return false;
    }

    let attempt = 0;
    const maxAttempts = 60;
    const intervalId = setInterval(() => {
        if (waitForGameLoad()) {
            clearInterval(intervalId);
        }
        attempt++;
    }, 1000); // 每1秒尝试一次

    container.appendChild(translateBtn);
    container.appendChild(select);

    const translationQueue = [];
    const processedMessages = new Set();
    const retryCounters = new Map();
    let lastErrorTime = 0;
    let batchTimer = null;

    async function processBatch() {
        const batch = [];
        const batchItems = [];
        const takeCount = Math.min(CONFIG.batchSize, translationQueue.length);

        for (let i = 0; i < takeCount; i++) {
            const item = translationQueue.shift();
            const msgId = `${item.timestamp}-${item.text}`;
            if (processedMessages.has(msgId)) continue;

            batch.push(item.text);
            batchItems.push({ ...item, msgId });
            processedMessages.add(msgId);
        }

        if (batch.length === 0) return;
        const translations = await caiyunTranslate(batch);
        for (let i = 0; i < batchItems.length; i++) {
            const { element, text, msgId } = batchItems[i];
            const translation = translations[i];
            if (translation === text || translation.startsWith("[")) {
                console.warn(`翻译无效被跳过: ${text}`);
                continue;
            }
            element.innerHTML += " | [翻译]: " + translation;
        }
        batchItems.forEach((item, index) => {
            if (translations[index].startsWith("[翻译失败]")) {
                const retryCount = retryCounters.get(item.msgId) || 0;
                if (retryCount < CONFIG.retryTimes) {
                    retryCounters.set(item.msgId, retryCount + 1);
                    translationQueue.unshift(item);
                    processedMessages.delete(item.msgId);
                }
            }
        });
        if (translationQueue.length > CONFIG.maxQueueLength) {
            translationQueue.splice(0, translationQueue.length - CONFIG.maxQueueLength);
        }
    }

    function checkMessages() {
        try {
            const chatLog = document.querySelector(".chat-log-scroll-inner");
            if (!chatLog) return;

            const messages = Array.from(chatLog.querySelectorAll(".chat-line.chat-line")).slice(-3);
            if (messages.length === 0) return;

            for (const message of messages) {
                try {
                    if (!message.classList.contains("translated")
                        && !message.classList.contains("chat-line-meta-line")
                        && !message.classList.contains("chat-line-system")
                        && !message.classList.contains("chat-line-announcement")
                        && !message.classList.contains("chat-line-whisper-announcement")
                        && !message.classList.contains("chat-line-party-announcement")){
                        const timestampElement = message.querySelector(".chat-line-timestamp");
                        const nameElement = message.querySelector(".chat-line-name-content");
                        const messageElement = message.querySelector(".chat-line-message");

                        if (!timestampElement || !messageElement) continue;

                        const timestamp = timestampElement.textContent.trim();
                        const name = nameElement?.isConnected ? nameElement.textContent.trim() : "匿名";
                        const text = messageElement.textContent.trim();
                        const msgId = `${timestamp}-${text}`;
                        if (text && !translationQueue.some(item => `${item.timestamp}-${item.text}` === msgId) && !processedMessages.has(msgId)) {
                            message.classList.add("translated");

                            const hasMeaningfulContent = /[a-zA-Z\u4e00-\u9fa5]/.test(text);
                            if (!text || !hasMeaningfulContent) {
                                console.debug(`忽略无意义文本: ${text}`);
                                continue;
                            }

                            translationQueue.push({
                                element: messageElement,
                                text,
                                timestamp,
                                name
                            });
                        }
                    }
                } catch (innerError) {
                    console.warn("单条消息处理错误:", innerError);
                }
            }
        } catch (error) {
            console.error("消息检查出错:", error);
        }
    }

    function startBatchProcessor() {
        if (batchTimer) clearInterval(batchTimer);
        batchTimer = setInterval(() => {
            processBatch().catch(err => console.error("批量处理异常:", err));
        }, CONFIG.batchInterval);
    }

    startBatchProcessor();
    const checkTimer = setInterval(checkMessages, CONFIG.checkInterval);

    window.addEventListener("beforeunload", () => {
        clearInterval(checkTimer);
        if (batchTimer) clearInterval(batchTimer);
    });
})();