NovelAI Tag联想 (Danbooru-API版)

使用所有Danbooru Tag进行联想,并附带部分中文翻译和中文联想功能。

// ==UserScript==
// @name         NovelAI Tag联想 (Danbooru-API版)
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  使用所有Danbooru Tag进行联想,并附带部分中文翻译和中文联想功能。
// @author       Takoro
// @match        https://novelai.net/image*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      danbooru.donmai.us
// @connect      raw.githubusercontent.com
// @license      MIT
// ==/UserScript==

// Gemini YES Gemini YES
(function() {
    'use strict';

    // --- 配置项 ---
    const MAX_SUGGESTIONS = 10;
    const DEBOUNCE_DELAY = 250;
    const API_BASE_URL = 'https://danbooru.donmai.us';
    const TRANSLATION_URL = 'https://raw.githubusercontent.com/Yellow-Rush/zh_CN-Tags/main/danbooru.csv';
    const CACHE_KEY = 'danbooru_translations_cache';
    const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
    const HINT_MESSAGES = [
        "- 按住Ctrl+左键单击Tag可以打开Wiki页面。",
        "- 标紫的是系列/IP名,标绿的是角色名哦(使用英文查询时)。",
        "- Tag后面是数字是对应图片数量~",
        "- Tab和Enter以及方向键都可以试试看。",
        "- Takoro"
    ];
    const TITLE_COLOR = '#e3dccc';

    // --- 全局变量 ---
    let popup = null;
    let selectedIndex = -1;
    let currentMatches = [];
    let isPopupActive = false;
    let apiAbortController = null;
    let activeAutocompletion = null; // 存储当前自动完成的上下文:{ start: number, end: number }
    let translations = new Map();

    // --- 样式注入 ---
    GM_addStyle(`
        .autocomplete-container { position: absolute; background: #0e0f21; border: 1px solid #3B3B52; border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); max-height: 450px; display: flex; flex-direction: column; z-index: 100000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; color: #EAEAEB; min-width: 350px; max-width: 500px; }
        .suggestion-info-display { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #1a1c30; border-bottom: 1px solid #3B3B52; font-size: 13px; line-height: 1.6; min-height: 17px; font-weight: bold; }
        .info-hint { color: #8a8a9e; font-weight: normal; font-size: 12px; margin-left: 10px; white-space: nowrap; }
        .info-title { color: ${TITLE_COLOR}; }
        .suggestion-scroll-wrapper { padding: 7px; overflow-y: auto; }
        .suggestion-flex { display: flex; flex-wrap: wrap; gap: 6px; }
        .suggestion-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 10px; background: #22253f; border: 1px solid transparent; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; font-size: 13px; height: 26px; white-space: nowrap; flex-shrink: 0; }
        .suggestion-item:hover, .suggestion-item.selected { background: #34395f; border-color: #F5F3C2; }
        .suggestion-text { overflow: hidden; text-overflow: ellipsis; color: #EAEAEB; }
        .suggestion-count { color: #8a8a9e; margin-left: 12px; font-size: 12px; }
        .suggestion-item[data-category='4'] .suggestion-text { color: #a6f3a6; } /* Character */
        .suggestion-item[data-category='3'] .suggestion-text { color: #d6bcf5; } /* Copyright/Series */
    `);

    // --- 核心功能函数 ---
    function loadTranslations() {
        const cachedData = GM_getValue(CACHE_KEY);
        if (cachedData && cachedData.timestamp && (Date.now() - cachedData.timestamp < CACHE_DURATION_MS)) {
            translations = new Map(cachedData.translations);
            return;
        }
        GM_xmlhttpRequest({
            method: "GET", url: TRANSLATION_URL,
            onload: function(response) {
                if (response.status === 200) {
                    const lines = response.responseText.split('\n').filter(line => line.trim());
                    lines.forEach(line => {
                        const columns = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);
                        if (columns.length >= 2) {
                            const en = (columns[0] || '').trim().replace(/^"|"$/g, '');
                            const zh = (columns[1] || '').trim().replace(/^"|"$/g, '');
                            if (en && zh) { translations.set(en, zh); }
                        }
                    });
                    GM_setValue(CACHE_KEY, { translations: Array.from(translations.entries()), timestamp: Date.now() });
                }
            }
        });
    }

    function openWikiPage(tagName) { if (!tagName) return; GM_openInTab(`${API_BASE_URL}/wiki_pages/show_or_new?title=${tagName}`, { active: true }); }
    function getRandomHint() { return Math.random() < 0.5 ? "" : HINT_MESSAGES[Math.floor(Math.random() * HINT_MESSAGES.length)]; }

    function searchLocalSuggestions(query, input) {
        if (!translations.size) return;
        const queryLower = query.toLowerCase();
        const rankedItems = [];
        for (const [en, zh] of translations.entries()) {
            const zhLower = zh.toLowerCase();
            let score = 0;
            if (zhLower.startsWith(queryLower)) score = 1; else if (zhLower.includes(queryLower)) score = 2;
            if (score > 0) rankedItems.push({ score: score, data: { en: en, count: undefined, category: 0 } });
        }
        const finalMatches = rankedItems.sort((a, b) => a.score - b.score).map(item => item.data).slice(0, MAX_SUGGESTIONS);
        updatePopup(input, finalMatches);
    }

    function fetchSuggestions(query, input) {
        if (apiAbortController) apiAbortController.abort();
        apiAbortController = new AbortController();
        const params = new URLSearchParams({'search[name_matches]': `${query}*`, 'search[order]': 'count', 'limit': MAX_SUGGESTIONS, 'search[hide_empty]': 'true' });
        GM_xmlhttpRequest({
            method: "GET", url: `${API_BASE_URL}/tags.json?${params.toString()}`,
            signal: apiAbortController.signal,
            onload: function(response) {
                if (response.status === 200) {
                    const matches = JSON.parse(response.responseText).map(tag => ({ en: tag.name, count: tag.post_count, category: tag.category }));
                    updatePopup(input, matches);
                } else { hidePopup(); }
            },
            onerror: () => { hidePopup(); },
        });
    }

    function updatePopup(input, matches) {
        createPopup();
        const hintText = getRandomHint();
        const hintHTML = hintText ? `<span class="info-hint">${hintText}</span>` : '';
        popup.innerHTML = `<div class="suggestion-info-display"><span class="info-title">Did you mean...?</span>${hintHTML}</div><div class="suggestion-scroll-wrapper"><div class="suggestion-flex"></div></div>`;
        const flexContainer = popup.querySelector('.suggestion-flex');
        currentMatches = matches;
        if (matches.length === 0) { hidePopup(); return; }
        matches.forEach((tag, index) => {
            const item = document.createElement('div');
            item.className = 'suggestion-item';
            item.dataset.category = tag.category || '0';
            const enText = tag.en.replace(/_/g, ' ');
            const zhText = translations.get(tag.en);
            const displayText = zhText ? `${enText} (${zhText})` : enText;
            let countHTML = '';
            if (tag.count !== undefined && tag.count !== null) {
                const countText = tag.count > 1000 ? `${(tag.count / 1000).toFixed(1)}k` : tag.count;
                countHTML = `<span class="suggestion-count">${countText}</span>`;
            }
            item.innerHTML = `<span class="suggestion-text">${displayText}</span>${countHTML}`;
            item.addEventListener('mouseover', () => { if (selectedIndex !== index) { selectedIndex = index; updateSelectionUI(); }});
            item.addEventListener('mousedown', (e) => {
                e.preventDefault();
                if (e.ctrlKey) { openWikiPage(tag.en); }
                else if (activeAutocompletion) { insertTag(tag.en); }
            });
            flexContainer.appendChild(item);
        });
        positionPopup(input);
        popup.style.display = 'flex';
        isPopupActive = true;
        if (matches.length > 0) { selectedIndex = 0; updateSelectionUI(); }
    }

    function insertTag(tag) {
        if (!activeAutocompletion) return;
        const input = getActiveInputElement();
        if (!input) { hidePopup(); return; }

        const range = createRangeFromOffsets(input, activeAutocompletion.start, activeAutocompletion.end);
        if (!range) { hidePopup(); return; }

        tag = tag.replace(/_/g, ' ');
        const fullText = input.textContent;
        const textBefore = fullText.substring(0, activeAutocompletion.start);
        const textAfter = fullText.substring(activeAutocompletion.end);

        let textToInsert = tag;
        const lastCharBefore = textBefore.slice(-1);
        if (lastCharBefore && !/[\s({[]/.test(lastCharBefore)) {
            textToInsert = ' ' + textToInsert;
        }

        if (!/^\s*[,)\]}:]/.test(textAfter)) {
            textToInsert += ', ';
        }

        const selection = window.getSelection();
        selection.removeAllRanges();
        selection.addRange(range);
        range.deleteContents();
        const textNode = document.createTextNode(textToInsert);
        range.insertNode(textNode);
        range.setStartAfter(textNode);
        range.collapse(true);
        selection.removeAllRanges();
        selection.addRange(range);

        hidePopup();
    }

    // --- 通用与辅助函数 ---
    function createPopup() { if (!popup) { popup = document.createElement('div'); popup.className = 'autocomplete-container'; document.body.appendChild(popup); } }
    function hidePopup() { if (popup) popup.style.display = 'none'; if (apiAbortController) apiAbortController.abort(); selectedIndex = -1; isPopupActive = false; currentMatches = []; activeAutocompletion = null; }
    const debounce = (func, delay) => { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), delay); }; };
    function getActiveInputElement() { const selection = window.getSelection(); if (!selection.rangeCount) return null; const node = selection.focusNode; const pElement = node.nodeType === 3 ? node.parentElement.closest('p') : node.closest('p'); if (pElement && (pElement.closest('[class*="prompt-input"]') || pElement.closest('[class*="character-prompt-input"]'))) { return pElement; } return null; }
    function updateSelectionUI() { const items = popup.querySelectorAll('.suggestion-item'); items.forEach((item, index) => item.classList.toggle('selected', index === selectedIndex)); const selectedEl = popup.querySelector('.selected'); if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); }

    function createRangeFromOffsets(element, startOffset, endOffset) {
        const range = document.createRange();
        range.selectNode(element);
        range.collapse(true);
        const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
        let charCount = 0;
        let startNode, endNode, startIdx, endIdx;

        while (walker.nextNode()) {
            const node = walker.currentNode;
            const nodeLength = node.textContent.length;
            if (!startNode && startOffset <= charCount + nodeLength) {
                startNode = node;
                startIdx = startOffset - charCount;
            }
            if (!endNode && endOffset <= charCount + nodeLength) {
                endNode = node;
                endIdx = endOffset - charCount;
                break;
            }
            charCount += nodeLength;
        }

        if (startNode && endNode) {
            range.setStart(startNode, startIdx);
            range.setEnd(endNode, endIdx);
            return range;
        }
        return null;
    }

    function positionPopup(input) {
        let rect;
        if (activeAutocompletion) {
            const range = createRangeFromOffsets(input, activeAutocompletion.start, activeAutocompletion.end);
            if (range) {
                if (range.collapsed) {
                    const tempSpan = document.createElement('span');
                    tempSpan.textContent = '\u200b';
                    range.insertNode(tempSpan);
                    rect = tempSpan.getBoundingClientRect();
                    tempSpan.remove();
                } else {
                    rect = range.getBoundingClientRect();
                }
            }
        }
        if (!rect || (rect.width === 0 && rect.height === 0)) {
            rect = input.getBoundingClientRect();
        }
        popup.style.top = `${rect.bottom + window.scrollY + 5}px`;
        popup.style.left = `${rect.left + window.scrollX}px`;
    }

    const handleInput = debounce((e) => {
        const input = getActiveInputElement(); if (!input) { hidePopup(); return; }
        const selection = window.getSelection(); if (!selection.rangeCount || !selection.isCollapsed) { hidePopup(); return; }
        const caretRange = selection.getRangeAt(0);

        const precedingRange = document.createRange();
        precedingRange.selectNodeContents(input);
        precedingRange.setEnd(caretRange.startContainer, caretRange.startOffset);
        const textBeforeCursor = precedingRange.toString();

        const lastDelimiterIndex = Math.max(textBeforeCursor.lastIndexOf(','), textBeforeCursor.lastIndexOf(':'), textBeforeCursor.lastIndexOf('['), textBeforeCursor.lastIndexOf('('), textBeforeCursor.lastIndexOf('{'), textBeforeCursor.lastIndexOf('.'));
        const untrimmedQuery = textBeforeCursor.substring(lastDelimiterIndex + 1);
        const currentQuery = untrimmedQuery.trim();

        if (currentQuery.length < 1 || /^[\d.]+$/.test(currentQuery)) { hidePopup(); return; }

        const cursorEndOffset = textBeforeCursor.length;
        const cursorStartOffset = cursorEndOffset - untrimmedQuery.length;
        activeAutocompletion = { start: cursorStartOffset, end: cursorEndOffset };

        if (/[\u4e00-\u9fa5]/.test(currentQuery)) {
            searchLocalSuggestions(currentQuery, input);
        } else {
            fetchSuggestions(currentQuery, input);
        }
    }, DEBOUNCE_DELAY);

    function handleKeydown(e) {
        if (!isPopupActive) return;
        const keyMap = { 'ArrowDown': 1, 'ArrowRight': 1, 'ArrowUp': -1, 'ArrowLeft': -1 };
        if (keyMap[e.key] !== undefined) {
            e.preventDefault();
            selectedIndex = (selectedIndex + keyMap[e.key] + currentMatches.length) % currentMatches.length;
            updateSelectionUI();
        } else if (e.key === 'Enter' || e.key === 'Tab') {
            e.preventDefault(); e.stopPropagation();
            if (selectedIndex >= 0 && currentMatches[selectedIndex] && activeAutocompletion) {
                insertTag(currentMatches[selectedIndex].en);
            } else { hidePopup(); }
        } else if (e.key === 'Escape') {
            e.preventDefault(); hidePopup();
        }
    }

    function handleClickOutside(e) {
        const input = getActiveInputElement();
        if (isPopupActive && popup && !popup.contains(e.target) && (!input || !input.contains(e.target))) {
            hidePopup();
        }
    }

    function init() {
        console.log('NovelAI Tag联想 (Danbooru-API版) 加载成功~');
        loadTranslations();
        document.addEventListener('input', handleInput);
        document.addEventListener('keydown', handleKeydown, true);
        document.addEventListener('mousedown', handleClickOutside);
    }

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