// ==UserScript== // @name LANraragi 标签翻译 // @namespace https://github.com/Kelcoin // @version 1.1 // @description 基于 EhTagTranslation 数据库翻译并替换 LANraragi 的标签 // @author Kelcoin // @include https://lanraragi*/* // @include http://lanraragi*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_deleteValue // @connect raw.githubusercontent.com // @connect github.com // @icon https://avatars.githubusercontent.com/u/47356068?s=200&v=4 // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; const style = document.createElement('style'); style.innerHTML = ` #DataTables_Table_0 .caption:not(.caption-tags) { display: block !important; visibility: hidden !important; position: absolute !important; top: -9999px !important; left: -9999px !important; width: 1px !important; height: 1px !important; z-index: -1; overflow: hidden; } #DataTables_Table_0 .caption-tags, #DataTables_Table_0 .caption table, #DataTables_Table_0 .caption tbody, #DataTables_Table_0 .caption tr, #DataTables_Table_0 .caption td, #DataTables_Table_0 .caption div.gt, #DataTables_Table_0 .caption-namespace, #DataTables_Table_0 table.itg { display: none !important; border: none !important; background: none !important; height: 0 !important; width: 0 !important; margin: 0 !important; padding: 0 !important; pointer-events: none !important; } .tippy-content table.itg tbody, .caption-tags table.itg tbody { display: flex !important; flex-direction: column !important; } .tippy-content table.itg tr, .caption-tags table.itg tr { display: flex !important; width: 100% !important; order: 0; } .tippy-content table.itg td, .caption-tags table.itg td { flex: 1; } .tippy-content table.itg td.caption-namespace, .caption-tags table.itg td.caption-namespace { flex: 0 0 auto !important; min-width: 85px; } `; document.head.appendChild(style); const DB_JSONP_URL = 'https://github.com/EhTagTranslation/Database/releases/latest/download/db.text.js'; const CACHE_KEY = 'lrr_ehtag_db_v2'; const UPDATE_INTERVAL = 3 * 24 * 60 * 60 * 1000; const NAMESPACE_MAP = { 'artist-tag': 'artist', 'group-tag': 'group', 'parody-tag': 'parody', 'character-tag': 'character', 'female-tag': 'female', 'male-tag': 'male', 'language-tag': 'language', 'mixed-tag': 'mixed', 'other-tag': 'other', 'cosplayer-tag': 'cosplayer', 'reclass-tag': 'reclass', 'category-tag': 'category', 'location-tag': 'location', 'series-tag': 'series' }; const LABEL_CLASS_MAP = { 'artist': 'artist', 'category': 'category', 'character': 'character', 'cosplayer': 'cosplayer', 'dateadded': 'date_added', 'date-added': 'date_added', 'female': 'female', 'group': 'group', 'language': 'language', 'location': 'location', 'male': 'male', 'mixed': 'mixed', 'other': 'other', 'parody': 'parody', 'series': 'series', 'source': 'source', 'timestamp': 'timestamp', 'uploader': 'uploader' }; const LABEL_TEXT_MAP = { 'artist:': 'artist', 'category:': 'category', 'character:': 'character', 'cosplayer:': 'cosplayer', 'date added:': 'date_added', 'female:': 'female', 'group:': 'group', 'language:': 'language', 'location:': 'location', 'male:': 'male', 'mixed:': 'mixed', 'other:': 'other', 'parody:': 'parody', 'series:': 'series', 'source:': 'source', 'timestamp:': 'timestamp', 'uploader:': 'uploader' }; const NAMESPACE_LABEL_CN = { artist: "艺术家", category: "分类", character: "角色", cosplayer: "Cosplayer", date_added: "添加日期", female: "女性", group: "社团", language: "语言", location: "地点", male: "男性", mixed: "混合", other: "其他", parody: "原作", series: "系列", source: "来源", timestamp: "发布日期", uploader: "发布者" }; const CATEGORY_MAP = { "doujinshi": "同人志", "manga": "漫画", "artist cg": "画师CG", "game cg": "游戏CG", "western": "西方", "image set": "图集", "non-h": "无H", "cosplay": "Cosplay", "asian porn": "亚洲色情", "misc": "杂项", "private": "私有" }; let translationDB = null; function parseEhTagDB(rawObj) { if (!rawObj || typeof rawObj !== "object") throw new Error("DB root is not an object"); const optimizedDB = {}; const nsList = Array.isArray(rawObj.data) ? rawObj.data : (rawObj.data ? Object.entries(rawObj.data).map(([k, v]) => ({ namespace: k, data: v })) : []); for (const nsObj of nsList) { if (!nsObj || !nsObj.data) continue; const nsKey = String(nsObj.namespace).toLowerCase(); optimizedDB[nsKey] = optimizedDB[nsKey] || {}; for (const [tagKey, tagObj] of Object.entries(nsObj.data)) { if (tagObj && tagObj.name) optimizedDB[nsKey][tagKey.toLowerCase()] = tagObj.name; } } if (Object.keys(optimizedDB).length === 0) throw new Error("Parsed DB is empty"); return optimizedDB; } function loadCache() { try { return JSON.parse(window.localStorage.getItem(CACHE_KEY)); } catch (e) { return null; } } function saveCache(data) { try { window.localStorage.setItem(CACHE_KEY, JSON.stringify({ data, time: Date.now() })); } catch (e) {} } function deleteCache() { try { window.localStorage.removeItem(CACHE_KEY); } catch (e) {} } let jsonpLoading = false; let jsonpLoadedOnce = false; function loadDBViaJSONP() { if (jsonpLoading) return; jsonpLoading = true; if (translationDB && jsonpLoadedOnce) { jsonpLoading = false; return; } try { delete window.load_ehtagtranslation_db_text; } catch (e) { window.load_ehtagtranslation_db_text = undefined; } window.load_ehtagtranslation_db_text = function(rawDb) { jsonpLoadedOnce = true; jsonpLoading = false; try { const optimizedDB = parseEhTagDB(rawDb); translationDB = optimizedDB; saveCache(optimizedDB); startObserver(); translateNode(document.body); } catch (err) {} finally { try { delete window.load_ehtagtranslation_db_text; } catch (e) { window.load_ehtagtranslation_db_text = undefined; } } }; const script = document.createElement('script'); script.src = DB_JSONP_URL + '?_=' + Date.now(); script.async = true; script.onerror = function() { jsonpLoading = false; }; (document.head || document.body || document.documentElement).appendChild(script); } function initDB() { const cached = loadCache(); const now = Date.now(); if (cached && cached.data && cached.time && (now - cached.time <= UPDATE_INTERVAL) && Object.keys(cached.data).length >= 3) { translationDB = cached.data; startObserver(); } else { loadDBViaJSONP(); } } function getTranslation(namespace, text) { if (!translationDB || !text) return null; const clean = text.trim().toLowerCase(); return translationDB[namespace]?.[clean] || translationDB['mixed']?.[clean] || null; } function normalizeClassName(cls) { if (!cls) return ""; return cls.replace(/caption-namespace|-tag|_|-/gi, "").trim().toLowerCase(); } function applyNamespaceLabelTranslation(labelTd, ns) { const cn = NAMESPACE_LABEL_CN[ns]; if (cn && !labelTd.dataset.lrrNsLabelTranslated) { labelTd.dataset.lrrNsLabelTranslated = "true"; labelTd.dataset.lrrNsLabelOriginal = labelTd.textContent; labelTd.textContent = cn + ":"; } } function resolveNamespaceFromLabelTd(labelTd) { if (!labelTd) return null; for (const cls of labelTd.classList) { const n = normalizeClassName(cls); if (LABEL_CLASS_MAP[n]) { const ns = LABEL_CLASS_MAP[n]; applyNamespaceLabelTranslation(labelTd, ns); return ns; } } const txt = labelTd.textContent.trim().toLowerCase().replace(/:$/, '') + ':'; if (LABEL_TEXT_MAP[txt]) { const ns = LABEL_TEXT_MAP[txt]; applyNamespaceLabelTranslation(labelTd, ns); return ns; } return null; } function translateElement(element) { if (element.dataset.lrrTranslated) return; const originalText = element.innerText; let namespaceKey = null; for (const [cls, key] of Object.entries(NAMESPACE_MAP)) { if (element.classList.contains(cls)) { namespaceKey = key; break; } } let labelTdRef = null; if (!namespaceKey && element.tagName === 'A' && element.parentElement?.classList.contains('gt')) { const tr = element.closest('tr'); if (tr) { const labelTd = tr.querySelector('td.caption-namespace'); labelTdRef = labelTd; const ns = resolveNamespaceFromLabelTd(labelTd); if (ns) namespaceKey = ns; } } if (!namespaceKey) return; if (labelTdRef) applyNamespaceLabelTranslation(labelTdRef, namespaceKey); if (['source', 'date_added', 'timestamp', 'uploader'].includes(namespaceKey)) return; const newText = namespaceKey === 'category' ? CATEGORY_MAP[originalText.trim().toLowerCase()] : getTranslation(namespaceKey, originalText); if (newText) { element.dataset.lrrOriginalText = originalText; element.textContent = newText; element.title = originalText; element.dataset.lrrTranslated = "true"; } } const BOTTOM_SORT_ORDER = ['date_added', 'timestamp', 'uploader', 'source']; function reorderMetadata(root) { if (!root || !root.querySelectorAll) return; const tbodies = root.querySelectorAll('.caption-tags table.itg tbody, table.itg tbody'); tbodies.forEach(tbody => { if (tbody.closest('#DataTables_Table_0')) return; if (tbody.dataset.lrrSorted) return; const rows = Array.from(tbody.querySelectorAll('tr')); let hasTargetRows = false; rows.forEach(row => { const labelTd = row.querySelector('td.caption-namespace'); if (!labelTd) return; for (let i = 0; i < BOTTOM_SORT_ORDER.length; i++) { const key = BOTTOM_SORT_ORDER[i]; if (labelTd.classList.contains(`${key}-tag`)) { row.style.order = 10 + i; hasTargetRows = true; if (i === 0) { row.style.borderTop = "1px dashed rgba(255,255,255,0.1)"; row.style.marginTop = "4px"; row.style.paddingTop = "2px"; } break; } } }); if (hasTargetRows) { tbody.dataset.lrrSorted = "true"; } }); } function translateNode(node) { if (!translationDB) return; const root = (node && node.querySelectorAll) ? node : document.body; const spans = root.querySelectorAll('span[class$="-tag"]'); const links = root.querySelectorAll('.gt a'); spans.forEach(translateElement); links.forEach(translateElement); reorderMetadata(root); } let lrrObserverStarted = false; function startObserver() { if (lrrObserverStarted) return; lrrObserverStarted = true; translateNode(document.body); const observer = new MutationObserver((mutations) => { let added = 0; for (const m of mutations) added += m.addedNodes.length; if (added > 0) translateNode(document.body); }); observer.observe(document.body, { childList: true, subtree: true }); } window.LRREhTagForceUpdate = function() { deleteCache(); translationDB = null; jsonpLoadedOnce = false; loadDBViaJSONP(); }; if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand("强制更新翻译数据库", () => { if (confirm("确定要删除本地缓存并重新下载最新的翻译数据库吗?")) { window.LRREhTagForceUpdate(); } }); } function bootstrap() { initDB(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); else bootstrap(); })();