您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
My Anime Listサイト内にあるアニメのタイトルや漫画のタイトルやキャラクター名や声優名を日本語に翻訳します。
// ==UserScript== // @name MAL日本語アニメタイトル翻訳 // @namespace torokesou // @version 1.0 // @description My Anime Listサイト内にあるアニメのタイトルや漫画のタイトルやキャラクター名や声優名を日本語に翻訳します。 // @author torokesou // @icon https://files.catbox.moe/dgwc5b.png // @license MIT // @match https://myanimelist.net/* // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (function() { 'use strict'; const CONFIG = { DELAY_MIN: 500, DELAY_MAX: 1500, SIMILARITY_THRESHOLD: 0.7, MIN_TEXT_LENGTH: 4, MAX_CACHE_SIZE: 5000, MAX_MEMORY_CACHE_SIZE: 1000, STORAGE_KEY: 'mal_japanese_titles', BATCH_SAVE_INTERVAL: 30000, REFRESH_INTERVAL: 24 * 60 * 60 * 1000, }; const titleCache = new Map(); let persistentTitles = new Map(); let unsavedChanges = new Map(); const processedElements = new WeakSet(); const processingElements = new WeakSet(); async function loadPersistentData() { try { const savedData = GM_getValue(CONFIG.STORAGE_KEY, '{}'); const parsed = JSON.parse(savedData); persistentTitles = new Map(Object.entries(parsed)); let count = 0; for (const [url, title] of persistentTitles) { if (count >= CONFIG.MAX_MEMORY_CACHE_SIZE) break; titleCache.set(url, title); count++; } } catch (error) { persistentTitles = new Map(); } } function manageMemoryCache() { if (titleCache.size > CONFIG.MAX_MEMORY_CACHE_SIZE) { const excess = titleCache.size - CONFIG.MAX_MEMORY_CACHE_SIZE; const keysToDelete = Array.from(titleCache.keys()).slice(0, excess); keysToDelete.forEach(key => titleCache.delete(key)); } } function savePersistentData() { if (unsavedChanges.size === 0) return; try { for (const [url, data] of unsavedChanges) { if (data === null) { persistentTitles.delete(url); } else { persistentTitles.set(url, data); } } if (persistentTitles.size > CONFIG.MAX_CACHE_SIZE) { const excess = persistentTitles.size - CONFIG.MAX_CACHE_SIZE; const keysToDelete = Array.from(persistentTitles.keys()).slice(0, excess); keysToDelete.forEach(key => persistentTitles.delete(key)); } const dataToSave = Object.fromEntries(persistentTitles); GM_setValue(CONFIG.STORAGE_KEY, JSON.stringify(dataToSave)); unsavedChanges.clear(); } catch (error) { // Silent error handling } } setInterval(savePersistentData, CONFIG.BATCH_SAVE_INTERVAL); window.addEventListener('beforeunload', savePersistentData); function extractContentNameFromUrl(url) { const animeMatch = url.match(/\/anime\/\d+\/([^\/\?#]+)/); const mangaMatch = url.match(/\/manga\/\d+\/([^\/\?#]+)/); const peopleMatch = url.match(/\/people\/\d+\/([^\/\?#]+)/); const characterMatch = url.match(/\/character\/\d+\/([^\/\?#]+)/); return animeMatch ? animeMatch[1] : (mangaMatch ? mangaMatch[1] : (peopleMatch ? peopleMatch[1] : (characterMatch ? characterMatch[1] : null))); } function getContentType(url) { if (url.includes('/anime/')) return 'anime'; if (url.includes('/manga/')) return 'manga'; if (url.includes('/people/')) return 'people'; if (url.includes('/character/')) return 'character'; return null; } function normalizeText(text) { return text .toLowerCase() .replace(/[^\w\s]/g, '') .replace(/\s+/g, '_') .replace(/_+/g, '_') .replace(/^_|_$/g, ''); } function containsJapanese(text) { return /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/.test(text); } function isPeopleName(text, url) { if (!url.includes('/people/')) return false; const trimmedText = text.trim(); const westernNamePattern = /^[A-Za-z][A-Za-z\s\-'\.]*,\s*[A-Za-z][A-Za-z\s\-'\.]*$/; if (westernNamePattern.test(trimmedText)) { return true; } const singleNamePattern = /^[A-Za-z][A-Za-z\s\-'\.]*$/; if (singleNamePattern.test(trimmedText) && trimmedText.length > 2) { const peopleNameFromUrl = extractContentNameFromUrl(url); if (peopleNameFromUrl) { const normalizedText = normalizeText(trimmedText); const normalizedUrlName = normalizeText(peopleNameFromUrl.replace(/_/g, ' ')); if (normalizedText === normalizedUrlName || normalizedUrlName.includes(normalizedText)) { return true; } } } return false; } function isCharacterName(text, url) { if (!url.includes('/character/')) return false; const trimmedText = text.trim(); const characterNamePattern = /^[A-Za-z][A-Za-z\s\-'\.]*,\s*[A-Za-z][A-Za-z\s\-'\.]*$/; if (characterNamePattern.test(trimmedText)) { return true; } const singleNamePattern = /^[A-Za-z][A-Za-z\s\-'\.]*$/; if (singleNamePattern.test(trimmedText) && trimmedText.length > 2) { const characterNameFromUrl = extractContentNameFromUrl(url); if (characterNameFromUrl) { const normalizedText = normalizeText(trimmedText); const normalizedUrlName = normalizeText(characterNameFromUrl.replace(/_/g, ' ')); if (normalizedText === normalizedUrlName || normalizedUrlName.includes(normalizedText)) { return true; } } } return false; } function isContentTitle(text, url) { const contentType = getContentType(url); if (contentType === 'people') { return isPeopleName(text, url); } else if (contentType === 'character') { return isCharacterName(text, url); } const contentNameFromUrl = extractContentNameFromUrl(url); if (!contentNameFromUrl || !contentType) { return false; } const normalizedText = normalizeText(text); const normalizedUrlName = normalizeText(contentNameFromUrl.replace(/_/g, ' ')); const subPagePattern = contentType === 'anime' ? /\/anime\/\d+\/[^\/]+\/(video|review|stats|characters|staff|news|forum|clubs|pics|episode|stacks|userrecs|featured)/ : /\/manga\/\d+\/[^\/]+\/(review|stats|characters|staff|news|forum|clubs|pics|chapter|stacks|userrecs|featured)/; if (url.match(subPagePattern)) { return false; } if (normalizedText === normalizedUrlName) { return true; } const textIncludesUrl = normalizedText.length > 0 && normalizedUrlName.includes(normalizedText); const urlIncludesText = normalizedUrlName.length > 0 && normalizedText.includes(normalizedUrlName); if (textIncludesUrl || urlIncludesText) { if (normalizedText.length < CONFIG.MIN_TEXT_LENGTH) { return false; } return true; } const textWords = normalizedText.split('_').filter(w => w.length > 1); const urlWords = normalizedUrlName.split('_').filter(w => w.length > 1); if (textWords.length === 0 || urlWords.length === 0) { return false; } const commonWords = textWords.filter(word => urlWords.some(urlWord => { return word === urlWord || (word.length >= 4 && urlWord.length >= 4 && (word.includes(urlWord) || urlWord.includes(word))); }) ); if (commonWords.length < 2 && textWords.length > 1) { return false; } const similarity_ratio = commonWords.length / Math.min(textWords.length, urlWords.length); return similarity_ratio >= CONFIG.SIMILARITY_THRESHOLD; } function isDataStale(data) { if (!data || typeof data !== 'object' || !data.timestamp) { return true; } return (Date.now() - data.timestamp) > CONFIG.REFRESH_INTERVAL; } async function getJapaneseTitle(contentUrl, retryCount = 0) { const maxRetries = 3; if (titleCache.has(contentUrl)) { const cached = titleCache.get(contentUrl); if (cached && !isDataStale(cached)) { return cached.title; } } try { const response = await fetch(contentUrl, { headers: { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-language": "ja", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "cache-control": "no-cache", "pragma": "no-cache", "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "same-origin", "upgrade-insecure-requests": "1" }, mode: "cors", credentials: "include" }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const html = await response.text(); let japaneseTitle = null; try { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); if (contentUrl.includes('/character/')) { const h2Elements = doc.querySelectorAll('h2'); for (const h2 of h2Elements) { const smallElement = h2.querySelector('small'); if (smallElement) { const smallText = smallElement.textContent.trim(); const japaneseMatch = smallText.match(/^\(([^)]+)\)$/); if (japaneseMatch && japaneseMatch[1]) { const extractedName = japaneseMatch[1].trim(); if (extractedName && extractedName.length > 0) { japaneseTitle = extractedName; break; } } } } if (!japaneseTitle) { const characterPatterns = [ /<small>\(([^)]+)\)<\/small>/g, /<small[^>]*>\(([^)]+)\)<\/small>/g, /font-weight:\s*normal[^>]*>\s*<small>\(([^)]+)\)<\/small>/g ]; for (const pattern of characterPatterns) { pattern.lastIndex = 0; const match = pattern.exec(html); if (match && match[1]) { const extractedName = match[1].trim(); if (extractedName && extractedName.length > 0 && !extractedName.includes('<')) { japaneseTitle = extractedName; break; } } } } } else if (contentUrl.includes('/people/')) { let givenName = ''; let familyName = ''; const darkTextSpans = doc.querySelectorAll('span.dark_text'); for (const span of darkTextSpans) { const label = span.textContent.trim(); if (label === 'Given name:') { let nextNode = span.nextSibling; while (nextNode && nextNode.nodeType !== Node.TEXT_NODE) { nextNode = nextNode.nextSibling; } if (nextNode && nextNode.textContent) { const text = nextNode.textContent.trim(); if (text && text.length > 0 && text.length < 50 && !text.includes('<')) { givenName = text; } } if (!givenName && span.parentElement) { const parentText = span.parentElement.textContent; const afterLabel = parentText.split('Given name:')[1]; if (afterLabel) { const cleanText = afterLabel.trim(); if (cleanText && cleanText.length < 50 && !cleanText.includes('<')) { givenName = cleanText; } } } } else if (label === 'Family name:') { let nextNode = span.nextSibling; while (nextNode && nextNode.nodeType !== Node.TEXT_NODE && nextNode.nodeType !== Node.ELEMENT_NODE) { nextNode = nextNode.nextSibling; } if (nextNode && nextNode.nodeType === Node.TEXT_NODE && nextNode.textContent) { const text = nextNode.textContent.trim(); if (text && text.length > 0 && text.length < 30 && !text.includes('<') && !text.includes('div')) { familyName = text; } } else if (nextNode && nextNode.nodeType === Node.ELEMENT_NODE && nextNode.tagName === 'DIV') { familyName = ''; } } } if (!givenName || (familyName === null)) { const givenPatterns = [ /Given name:<\/span>\s*([^\n<>]{1,50}?)(?=\s*<\/div>)/g, /Given name:<\/span>\s*([^\n<>]{1,50}?)\s*<\/div>/g, /"dark_text">Given name:<\/span>\s*([^\n<>]{1,50}?)(?=\s*<)/g ]; const familyPatterns = [ /Family name:<\/span>\s*([^\n<>]{0,30}?)(?=\s*<div)/g, /Family name:<\/span>\s*([^\n<>]{0,30}?)\s*<div/g, /"dark_text">Family name:<\/span>\s*([^\n<>]{0,30}?)(?=\s*<)/g ]; if (!givenName) { for (const pattern of givenPatterns) { pattern.lastIndex = 0; const match = pattern.exec(html); if (match && match[1]) { const text = match[1].trim(); if (text && !text.includes('Alternate') && !text.includes('Birthday')) { givenName = text; break; } } } } if (familyName === null) { for (const pattern of familyPatterns) { pattern.lastIndex = 0; const match = pattern.exec(html); if (match) { const text = match[1] ? match[1].trim() : ''; if (!text || (text.length > 0 && text.length < 30 && !text.includes('Alternate') && !text.includes('Birthday') && !text.includes('Website') && !text.includes('Member'))) { familyName = text; break; } } } } } if (givenName && (givenName.includes('Given name:') || givenName.includes('Alternate') || givenName.includes('Birthday'))) { givenName = givenName.replace('Given name:', '').trim(); if (givenName.includes('Alternate') || givenName.includes('Birthday') || givenName.length > 50) { givenName = ''; } } if (familyName && (familyName.includes('Family name:') || familyName.includes('Given name:') || familyName.includes('Alternate') || familyName.includes('Birthday'))) { familyName = ''; } if (familyName && givenName) { japaneseTitle = `${familyName} ${givenName}`; } else if (givenName) { japaneseTitle = givenName; } } else { const japaneseElements = doc.querySelectorAll('span'); for (const element of japaneseElements) { if (element.textContent.trim() === 'Japanese:') { const parent = element.parentElement; if (parent) { const textContent = parent.textContent.replace('Japanese:', '').trim(); if (textContent && textContent !== '') { japaneseTitle = textContent; break; } } } } if (!japaneseTitle) { const japaneseMatch = html.match(/<span[^>]*>Japanese:<\/span>\s*([^<]+)/); if (japaneseMatch && japaneseMatch[1] && japaneseMatch[1].trim()) { japaneseTitle = japaneseMatch[1].trim(); } } if (!japaneseTitle) { const flexibleMatch = html.match(/Japanese:<\/span>\s*([^\n<]+)/); if (flexibleMatch && flexibleMatch[1] && flexibleMatch[1].trim()) { japaneseTitle = flexibleMatch[1].trim(); } } } } catch (parseError) { // Silent error handling } const dataToStore = { title: japaneseTitle, timestamp: Date.now() }; titleCache.set(contentUrl, dataToStore); unsavedChanges.set(contentUrl, dataToStore); manageMemoryCache(); return japaneseTitle; } catch (error) { if (retryCount < maxRetries) { const delay = 1000 * Math.pow(2, retryCount); await new Promise(resolve => setTimeout(resolve, delay)); return getJapaneseTitle(contentUrl, retryCount + 1); } const failData = { title: null, timestamp: Date.now() }; titleCache.set(contentUrl, failData); unsavedChanges.set(contentUrl, failData); return null; } } function isValidJapaneseTitle(text, originalText, contentType) { if (!text || text.trim() === '') return false; if (containsJapanese(text)) return true; if (contentType === 'people') { if (originalText && text !== originalText) { const isOriginalWesternFormat = /^[A-Za-z\s\-'\.]+,\s*[A-Za-z\s\-'\.]+$/.test(originalText.trim()); const isNewJapaneseFormat = !/,/.test(text.trim()) && text.length > 2; if (isOriginalWesternFormat && isNewJapaneseFormat) { return true; } } } else if (contentType === 'character') { if (originalText && text !== originalText) { const isOriginalWesternFormat = /^[A-Za-z\s\-'\.]+,\s*[A-Za-z\s\-'\.]+$/.test(originalText.trim()); const isNewFormat = text.length > 2 && !/,/.test(text.trim()); if (isOriginalWesternFormat && isNewFormat) { return true; } } } else { if (originalText && text !== originalText) { const normalizedOriginal = normalizeText(originalText); const normalizedNew = normalizeText(text); if (normalizedNew !== normalizedOriginal && text.length > 2) { return true; } } } return false; } function applyStoredTitle(linkElement, targetElement, contentUrl) { const storedData = persistentTitles.get(contentUrl); if (storedData) { const title = typeof storedData === 'string' ? storedData : storedData.title; const originalText = targetElement.textContent.trim(); const contentType = getContentType(contentUrl); if (title && isValidJapaneseTitle(title, originalText, contentType)) { targetElement.textContent = title; linkElement.setAttribute('title', title); linkElement.setAttribute('data-stored-applied', 'true'); return true; } } return false; } async function processContentLink(linkElement) { if (processedElements.has(linkElement) || processingElements.has(linkElement)) { return; } processingElements.add(linkElement); try { const href = linkElement.getAttribute('href'); if (!href || (!href.includes('/anime/') && !href.includes('/manga/') && !href.includes('/people/') && !href.includes('/character/'))) { return; } let textContent = ''; let targetElement = null; const strongElement = linkElement.querySelector('strong'); if (strongElement && strongElement.children.length === 0) { textContent = strongElement.textContent.trim(); targetElement = strongElement; } else { const titleSpan = linkElement.querySelector('span.title'); if (titleSpan && titleSpan.children.length === 0) { textContent = titleSpan.textContent.trim(); targetElement = titleSpan; } else if (linkElement.children.length === 0) { textContent = linkElement.textContent.trim(); targetElement = linkElement; } else if (linkElement.children.length === 1 && linkElement.children[0].tagName === 'STRONG') { textContent = linkElement.textContent.trim(); targetElement = linkElement.children[0]; } } if (!textContent) { return; } if (!isContentTitle(textContent, href)) { return; } const fullUrl = href.startsWith('http') ? href : `https://myanimelist.net${href}`; const appliedStored = applyStoredTitle(linkElement, targetElement, fullUrl); const storedData = persistentTitles.get(fullUrl); const shouldRefetch = !appliedStored || isDataStale(storedData); if (shouldRefetch) { try { await new Promise(resolve => setTimeout(resolve, Math.random() * (CONFIG.DELAY_MAX - CONFIG.DELAY_MIN) + CONFIG.DELAY_MIN) ); const originalText = targetElement.textContent.trim(); const japaneseTitle = await getJapaneseTitle(fullUrl); const contentType = getContentType(fullUrl); if (japaneseTitle && targetElement && isValidJapaneseTitle(japaneseTitle, originalText, contentType)) { targetElement.textContent = japaneseTitle; linkElement.setAttribute('title', japaneseTitle); } } catch (error) { // Silent error handling } } processedElements.add(linkElement); } finally { processingElements.delete(linkElement); } } function findAndProcessContentLinks() { const selectors = [ 'a.hoverinfo_trigger[href*="/anime/"]:not([data-processed])', 'a[href*="/anime/"] strong:not([data-processed])', '.title a[href*="/anime/"]:not([data-processed])', '.picSurround + a[href*="/anime/"]:not([data-processed])', 'a[href*="/anime/"] span.title:not([data-processed])', 'a[href*="/anime/"]:not([data-processed]):not([href*="/anime.php"]):not([href*="/clubs"]):not([href*="/reviews"])', 'a.hoverinfo_trigger[href*="/manga/"]:not([data-processed])', 'a[href*="/manga/"] strong:not([data-processed])', '.title a[href*="/manga/"]:not([data-processed])', '.picSurround + a[href*="/manga/"]:not([data-processed])', 'a[href*="/manga/"] span.title:not([data-processed])', 'a[href*="/manga/"]:not([data-processed]):not([href*="/manga.php"]):not([href*="/clubs"]):not([href*="/reviews"])', 'a[href*="/people/"]:not([data-processed])', 'a[href*="/character/"]:not([data-processed])' ]; selectors.forEach(selector => { const elements = document.querySelectorAll(selector); elements.forEach(async (element) => { let linkElement = element; if (element.tagName === 'STRONG' || (element.tagName === 'SPAN' && element.classList.contains('title'))) { linkElement = element.closest('a[href*="/anime/"], a[href*="/manga/"], a[href*="/people/"], a[href*="/character/"]'); } if (!linkElement || linkElement.getAttribute('data-processed')) { return; } linkElement.setAttribute('data-processed', 'true'); await processContentLink(linkElement); }); }); } async function initialize() { await loadPersistentData(); setTimeout(findAndProcessContentLinks, 100); setTimeout(findAndProcessContentLinks, 1000); } const observer = new MutationObserver((mutations) => { let shouldProcess = false; mutations.forEach((mutation) => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { const hasContentLinks = node.querySelectorAll && (node.querySelectorAll('a[href*="/anime/"]').length > 0 || node.querySelectorAll('a[href*="/manga/"]').length > 0 || node.querySelectorAll('a[href*="/people/"]').length > 0 || node.querySelectorAll('a[href*="/character/"]').length > 0); if (hasContentLinks || (node.tagName === 'A' && node.href && (node.href.includes('/anime/') || node.href.includes('/manga/') || node.href.includes('/people/') || node.href.includes('/character/')))) { shouldProcess = true; break; } } } } }); if (shouldProcess) { setTimeout(findAndProcessContentLinks, 500); } }); observer.observe(document.body, { childList: true, subtree: true }); let currentUrl = window.location.href; window.addEventListener('popstate', () => { setTimeout(findAndProcessContentLinks, 1000); }); setInterval(() => { if (window.location.href !== currentUrl) { currentUrl = window.location.href; setTimeout(findAndProcessContentLinks, 1000); } }, 5000); initialize(); })();