// ==UserScript==
// @name NovelAI Tag联想 (Danbooru-API版)
// @namespace http://tampermonkey.net/
// @version 1.5
// @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 No.1 !!
(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'; // 翻译文件URL
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 isKeyboardNavigation = false;
let apiAbortController = null;
let lastKnownRange = null;
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() {
// console.log("正在加载中文翻译...");
const cachedData = GM_getValue(CACHE_KEY);
if (cachedData && cachedData.timestamp && (Date.now() - cachedData.timestamp < CACHE_DURATION_MS)) {
// console.log("从缓存加载翻译。");
translations = new Map(cachedData.translations);
return;
}
// console.log("从网络获取最新翻译...");
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() });
// console.log(`翻译加载完成,共 ${translations.size} 条。`);
} else { console.error("加载翻译文件失败:", response.statusText); }
},
onerror: function(error) { console.error("加载翻译文件时发生网络错误:", error); }
});
}
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: (error) => { if(error.readyState !== 0) 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', () => {
isKeyboardNavigation = false;
if (selectedIndex !== index) {
selectedIndex = index;
updateSelectionUI();
}
});
item.addEventListener('mousedown', (e) => {
e.preventDefault();
selectedIndex = index;
updateSelectionUI();
if (e.ctrlKey) {
openWikiPage(tag.en);
} else {
const currentInput = getActiveInputElement();
if (currentInput) {
currentInput.focus();
setTimeout(() => insertTag(currentInput, tag.en, lastKnownRange), 0);
}
}
});
flexContainer.appendChild(item);
});
positionPopup(input);
popup.style.display = 'flex';
isPopupActive = true;
if (matches.length > 0) {
selectedIndex = 0;
updateSelectionUI();
}
}
function insertTag(input, tag, rangeToRestore) {
tag = tag.replace(/_/g, ' ');
const selection = window.getSelection();
if (rangeToRestore) { selection.removeAllRanges(); selection.addRange(rangeToRestore); }
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const fullText = node.textContent;
const breakChars = ',{}[]():.';
let start = range.startOffset; while (start > 0 && !breakChars.includes(fullText[start - 1])) start--;
let end = range.startOffset; while (end < fullText.length && !breakChars.includes(fullText[end])) end++;
range.setStart(node, start);
range.setEnd(node, end);
range.deleteContents();
let textToInsert = tag;
const textBeforeInsertion = fullText.substring(0, start);
const lastCharBefore = textBeforeInsertion.slice(-1);
if (lastCharBefore && !/[\s({[]/.test(lastCharBefore)) {
textToInsert = ' ' + textToInsert;
}
const textAfter = fullText.substring(end);
textToInsert += !/^\s*[,)\]}]/.test(textAfter) ? ', ' : '';
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 = []; isKeyboardNavigation = false; }
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 positionPopup(input) { let rect; const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0).cloneRange(); if (range.collapsed) { const tempSpan = document.createElement('span'); tempSpan.appendChild(document.createTextNode('\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`; }
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 saveSelection() { const selection = window.getSelection(); const activeInput = getActiveInputElement(); if (activeInput && selection.rangeCount > 0) { const range = selection.getRangeAt(0); if (activeInput.contains(range.commonAncestorContainer)) { lastKnownRange = range; } } }
const handleInput = debounce((e) => {
const input = getActiveInputElement(); if (!input) { hidePopup(); return; }
const textBeforeCursor = (() => { const sel = window.getSelection(); if (!sel.rangeCount) return ''; const range = sel.getRangeAt(0).cloneRange(); const parent = sel.focusNode.parentElement; if (!parent) return ''; range.selectNodeContents(parent); range.setEnd(sel.focusNode, sel.focusOffset); return range.toString(); })();
if (textBeforeCursor.endsWith(',')) { hidePopup(); return; }
const lastDelimiterIndex = Math.max(textBeforeCursor.lastIndexOf(','), textBeforeCursor.lastIndexOf(':'), textBeforeCursor.lastIndexOf('['), textBeforeCursor.lastIndexOf('('), textBeforeCursor.lastIndexOf('{'), textBeforeCursor.lastIndexOf('.'));
const currentQuery = textBeforeCursor.substring(lastDelimiterIndex + 1).trim();
if (currentQuery.length < 1) { hidePopup(); return; }
// --- 最终修复逻辑 ---
// 如果当前输入的词只包含数字和点,就判断为权重输入,不显示联想。
if (/^[\d.]+$/.test(currentQuery)) {
hidePopup();
return;
}
// --- 修复结束 ---
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();
isKeyboardNavigation = true;
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]) {
const input = getActiveInputElement();
if(input) insertTag(input, currentMatches[selectedIndex].en, window.getSelection().getRangeAt(0));
} else { hidePopup(); }
} else if (e.key === 'Escape') {
e.preventDefault();
hidePopup();
} else { isKeyboardNavigation = false; }
}
function handleClickOutside(e) { const input = getActiveInputElement(); if (isPopupActive && popup && !popup.contains(e.target) && !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);
document.addEventListener('keyup', saveSelection);
document.addEventListener('mouseup', saveSelection);
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }
})();