Cute-ify All Web Pages (V7.8.2 "Viewport Aware")

The 100% complete, unabridged version of the Cache-First architecture. Now only processes visible elements in the viewport.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Cute-ify All Web Pages (V7.8.2 "Viewport Aware")
// @namespace    http://tampermonkey.net/
// @version      7.8.2
// @description  The 100% complete, unabridged version of the Cache-First architecture. Now only processes visible elements in the viewport.
// @author       Bytebender
// @match        *://*/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.deleteValue
// @grant        GM_registerMenuCommand
// @connect      *
// @license     MIT
// ==/UserScript==


// 可能可以解决并发问题 // @require      https://raw.githubusercontent.com/Tampermonkey/utils/refs/heads/main/requires/gh_2215_make_GM_xhr_more_parallel_again.js
(function() {
    'use strict';

    console.log('[Cuteify] Script execution started.');

    // --- ⚙️ 用户配置区 START ---
    const config = {
        scanInterval: 2000,
        batchSize: 10,
        processShadowDOM: true,
        enableDebugLogging: true,

        apiKey: 'sk-3P5P8odkGLoPlNPj0QGBCgw8m083aaI8706HTNYXhujTk405',
        baseUrl: 'https://elysia.h-e.top/v1',
        model: 'gpt-4.1-mini',
        prompt: `你是一个文本风格转换专家。用户会提供一段XML,里面包含多个被 <text><![CDATA[...]]></text> 包裹的字符串。
请将每一个字符串都转换成一种略微可爱、俏皮、活泼的风格。你必须保持其核心意思不变。
你的回复必须是一个格式完全正确的XML,且结构与输入完全相同。每一个翻译后的文本都必须同样被 <text><![CDATA[...]]></text> 包裹。
输入的 <text> 元素数量必须与输出的 <text> 元素数量严格相等。除了这个XML结构,不要包含任何其他说明或标记。`,
        maxConcurrency: 1,
        minLengthToProcess: 10,
    };
    // --- ⚙️ 用户配置区 END ---

    const CACHE_PREFIX = 'cutify_cache_';
    const STATE_ATTR = 'data-cutify-state';
    const logger = { log: (...args) => config.enableDebugLogging && console.log('[Cuteify]', ...args), error: (...args) => console.error('[Cuteify]', ...args), };

    let currentBatch = [];
    let activeRequests = 0;

    async function findAndCollectWorkItems(rootNode) {
        const attributesToProcess = ['placeholder', 'title', 'alt'];
        const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null, false);
        let node;
        while (node = walker.nextNode()) {
            if (currentBatch.length >= config.batchSize) { dispatchBatch(); }

            const element = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
            if (!element || element.hasAttribute(STATE_ATTR) || !isVisible(element)) { continue; }

            if (node.nodeType === Node.TEXT_NODE) {
                const parentTag = element.tagName;
                if (parentTag && parentTag !== 'SCRIPT' && parentTag !== 'STYLE' && parentTag !== 'NOSCRIPT' && parentTag !== 'TEXTAREA' && !element.isContentEditable) {
                    const text = node.nodeValue.trim();
                    if (text.length >= config.minLengthToProcess) {
                        const cachedText = await getFromCache(text);
                        if (cachedText) {
                            applyChange({ type: 'text', element: node }, cachedText, 'done');
                        } else {
                            currentBatch.push({ type: 'text', element: node, originalText: text });
                            element.setAttribute(STATE_ATTR, 'queued');
                        }
                    }
                }
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                for (const attr of attributesToProcess) {
                    if (node.hasAttribute(attr)) {
                        const text = node.getAttribute(attr).trim();
                        if (text.length >= config.minLengthToProcess) {
                            const cachedText = await getFromCache(text);
                            if (cachedText) {
                                applyChange({ type: 'attribute', element: node, attr: attr }, cachedText, 'done');
                            } else {
                                currentBatch.push({ type: 'attribute', element: node, attr: attr, originalText: text });
                                node.setAttribute(STATE_ATTR, 'queued');
                            }
                            break;
                        }
                    }
                }
            }
        }
    }

    async function scanAndDispatch() {
        try {
            await findAndCollectWorkItems(document.body);
            if (config.processShadowDOM) {
                const hosts = document.querySelectorAll('*');
                for (const host of hosts) {
                    if (host.shadowRoot) {
                        await findAndCollectWorkItems(host.shadowRoot);
                    }
                }
            }
            if (currentBatch.length > 0) {
                dispatchBatch();
            }
        } catch (e) {
            logger.error("Error during scanAndDispatch:", e);
        }
    }

    function dispatchBatch() {
        const batchToProcess = [...currentBatch];
        currentBatch = [];
        if (batchToProcess.length === 0) return;

        if (activeRequests >= config.maxConcurrency) {
            logger.log(`Concurrency limit (${config.maxConcurrency}) reached. Discarding batch of ${batchToProcess.length} items. They will be retried on next scan.`);
            batchToProcess.forEach(item => {
                const el = item.type === 'text' ? item.element.parentNode : item.element;
                if (el) { el.removeAttribute(STATE_ATTR); }
            });
            return;
        }

        logger.log(`Dispatching a batch of ${batchToProcess.length} items. Active requests: ${activeRequests + 1}/${config.maxConcurrency}`);

        activeRequests++;
        processBatch(batchToProcess).finally(() => {
            activeRequests--;
            logger.log(`A batch finished. Active requests: ${activeRequests}/${config.maxConcurrency}`);
        });
    }

    async function processBatch(batch) {
        batch.forEach(item => {
            const el = item.type === 'text' ? item.element.parentNode : item.element;
            if (el && el.getAttribute(STATE_ATTR) === 'queued') {
                el.setAttribute(STATE_ATTR, 'processing');
            }
        });

        try {
            const uniqueTextsToFetch = [...new Set(batch.map(item => item.originalText))];
            const cuteTexts = await cuteifyBatch(uniqueTextsToFetch);

            const translationMap = new Map();
            uniqueTextsToFetch.forEach((text, i) => translationMap.set(text, cuteTexts[i]));

            const finalizationTasks = batch.map(async (item) => {
                const cuteText = translationMap.get(item.originalText);
                if (cuteText) {
                    await saveToCache(item.originalText, cuteText);
                    applyChange(item, cuteText, 'done');
                } else {
                    applyChange(item, item.originalText, null);
                }
            });
            await Promise.all(finalizationTasks);
        } catch (error) {
            logger.error(`Failed to process a batch:`, error, 'Reverting items to "pending".');
            batch.forEach(item => applyChange(item, item.originalText, null));
        }
    }

    function cuteifyBatch(texts) {
        return new Promise((resolve, reject) => {
            const xmlPayload = '<texts>' + texts.map(t => `<text><![CDATA[${t}]]></text>`).join('') + '</texts>';
            const payload = { model: config.model, messages: [{ role: "system", content: config.prompt },{ role: "user", content: xmlPayload }], temperature: 0.7, stream: false, };
            const requestDetails = { method: "POST", url: `${config.baseUrl}/chat/completions`, headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.apiKey}` }, data: JSON.stringify(payload), timeout: 20000 };
            logger.log("Sending XML batch request with", texts.length, "items.");
            GM_xmlhttpRequest({
                ...requestDetails,
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const content = JSON.parse(response.responseText).choices[0].message.content;
                            const parser = new DOMParser();
                            const xmlDoc = parser.parseFromString(content, "text/xml");
                            if (xmlDoc.getElementsByTagName("parsererror").length > 0) { throw new Error(`AI returned malformed XML: ${xmlDoc.getElementsByTagName("parsererror")[0].innerText}`); }
                            const cuteTextNodes = xmlDoc.querySelectorAll("text");
                            if (cuteTextNodes.length === texts.length) {
                                const cuteTextsArray = Array.from(cuteTextNodes).map(node => node.textContent);
                                resolve(cuteTextsArray);
                            } else { reject(`XML response format invalid: text count mismatch. Expected ${texts.length}, got ${cuteTextNodes.length}`); }
                        } catch (e) { reject(`Response parsing failed: ${e.message}`); }
                    } else { reject(`API request failed, status: ${response.status}`); }
                },
                onerror: (error) => reject(`Network request error: ${JSON.stringify(error)}`),
                ontimeout: () => reject("Request timed out after 60 seconds.")
            });
        });
    }

    /**
     * MODIFIED: This function now checks if an element is truly visible within the browser's viewport.
     * @param {Element} el The element to check.
     * @returns {boolean} True if the element is visible on screen, false otherwise.
     */
    function isVisible(el) {
        if (!el || el.nodeType !== Node.ELEMENT_NODE || !el.isConnected) {
            return false;
        }

        // Use getBoundingClientRect to get geometry and position info.
        // If width or height is 0, it's not visible (this also covers display: none).
        const rect = el.getBoundingClientRect();
        if (rect.width === 0 || rect.height === 0) {
            return false;
        }

        // Check CSS properties that can hide an element without affecting its dimensions.
        const style = window.getComputedStyle(el);
        if (style.visibility === 'hidden' || style.opacity === '0') {
            return false;
        }

        // Finally, check if the element is at least partially within the viewport's bounds.
        const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
        const viewportWidth = window.innerWidth || document.documentElement.clientWidth;

        const isInViewport = (
            rect.top < viewportHeight &&
            rect.bottom > 0 &&
            rect.left < viewportWidth &&
            rect.right > 0
        );

        return isInViewport;
    }


    async function clearCache() {
        logger.log('Starting cache clearing process...');
        const allKeys = await GM.listValues();
        const tasks = allKeys.filter(key => key.startsWith(CACHE_PREFIX)).map(key => GM.deleteValue(key));
        await Promise.all(tasks);
        document.querySelectorAll(`[${STATE_ATTR}]`).forEach(el => el.removeAttribute(STATE_ATTR));
        const clearedCount = tasks.length;
        logger.log(`Cache clearing complete. ${clearedCount} items removed. All state marks reset.`);
        alert(`可爱化缓存已清除!删除了 ${clearedCount} 个项目。\n页面将重新扫描所有文本。`);
        scanAndDispatch();
    }
    GM_registerMenuCommand('清除可爱化缓存 (Clear Cute-ify Cache)', clearCache);

    function applyChange(item, newText, state) {
        try {
            const element = item.type === 'text' ? item.element.parentNode : item.element;
            if (!element || !element.isConnected) return;
            if (item.type === 'text') { item.element.nodeValue = ` ${newText} `; }
            else if (item.type === 'attribute') { element.setAttribute(item.attr, newText); }
            if (state) { element.setAttribute(STATE_ATTR, state); }
            else { element.removeAttribute(STATE_ATTR); }
        } catch (e) { /* Ignore */ }
    }

    function preflightCheck() {
        if (!config.apiKey || config.apiKey.includes('sk-xxxxx')) {
            logger.error("FATAL ERROR: API Key is not configured!");
            return false;
        }
        return true;
    }

    async function getFromCache(key) {
        return await GM.getValue(CACHE_PREFIX + key, null);
    }
    async function saveToCache(key, value) {
        await GM.setValue(CACHE_PREFIX + key, value);
    }

    let scanIntervalId = null;
    let mutationObserver = null;

    function main() {
        if (!preflightCheck()) return;
        logger.log(`V7.8.2 "Viewport Aware" is running! Instant translations for cached text. (b^-^)b`);

        // Add scroll and resize event listeners to re-scan when the viewport changes
        let debounceTimer;
        const debouncedScan = () => {
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(scanAndDispatch, 100); // 100ms debounce
        };
        window.addEventListener('scroll', debouncedScan, { passive: true });
        window.addEventListener('resize', debouncedScan, { passive: true });


        scanAndDispatch();

        scanIntervalId = setInterval(scanAndDispatch, config.scanInterval);
        mutationObserver = new MutationObserver(debouncedScan);
        mutationObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }
})();