MAL Rating Hover Provider

Shows MAL rating on hover. Features: Gold Badge (>8), Smart Caching, Color Grading, and Menu option to Clear Cache.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         MAL Rating Hover Provider
// @namespace    http://github.com/quantavil
// @version      3.2
// @description  Shows MAL rating on hover. Features: Gold Badge (>8), Smart Caching, Color Grading, and Menu option to Clear Cache.
// @author       Quantavil 

// --- Domain Wildcards ---
// @match        *://123animes.*/*
// @match        *://9anime.*/*
// @match        *://anicore.*/*
// @match        *://anidap.*/*
// @match        *://anigo.*/*
// @match        *://anihq.*/*
// @match        *://anikai.*/*
// @match        *://anikototv.*/*
// @match        *://animedefenders.*/*
// @match        *://animegers.*/*
// @match        *://animeheaven.*/*
// @match        *://animekai.*/*
// @match        *://animeland.*/*
// @match        *://animelok.*/*
// @match        *://animelon.*/*
// @match        *://animenosub.*/*
// @match        *://animepahe.*/*
// @match        *://animestar.*/*
// @match        *://animetsu.*/*
// @match        *://animex.*/*
// @match        *://animeya.*/*
// @match        *://animeyy.*/*
// @match        *://anime.nexus/*
// @match        *://anime.uniquestream.*/*
// @match        *://anitaro.*/*
// @match        *://anitaku.*/*
// @match        *://aniwave.*/*
// @match        *://aniworld.*/*
// @match        *://gogoanime.*/*
// @match        *://hianime.*/*
// @match        *://justanime.*/*
// @match        *://kawaiifu.*/*
// @match        *://kimoitv.*/*
// @match        *://miruro.*/*
// @match        *://ramenflix.*/*
// @match        *://rivestream.*/*
// @match        *://senshi.*/*
// @match        *://wcostream.*/*
// @match        *://yugenanime.*/*

// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// @grant        GM_openInTab
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        CACHE_PREFIX: 'mal_v5_',
        CACHE_EXPIRY_SUCCESS: 14 * 24 * 60 * 60 * 1000, // 14 Days
        CACHE_EXPIRY_ERROR: 12 * 60 * 60 * 1000,        // 12 Hours
        DEBOUNCE_DELAY: 200,
        LONG_PRESS_DELAY: 200,
        API_INTERVAL: 350,
        MATCH_THRESHOLD: 0.5,
        SELECTORS: {
            ITEM: `
                .flw-item, .film_list-wrap > div, .poster-card, .f-item, .aitem, .anime-item, .ep-item, .anicard,
                .bsx, .bs, .item, .coverListItem,
                .content-card, .new-card-animate, .pe-episode-card, .news-item, .TPostMv, .gallery, .mini-previews,
                .video-block, .card, 
                a[href*="/series/"], a[data-discover], a[href*="/watch/"], .anime-card,
                .vod-item, a[href*="/anime/info/"], .chart2g, .items li,
                .snap-center, [class*="MovieCardSmall"], article.group, app-anime-item,
                div:has(.item-title)
            `,
            TITLE: `
                .film-name, .dynamic-name, .film-name a,
                .title, .d-title, .anime-name, .name, .mv-namevn,
                h2, h3, h5, .content-title, .new-card-title, .pe-title, .news-item-title, .Title,
                .line-clamp-2, .line-clamp-1, .item-title,
                .charttitle2g a
            `
        }
    };

    let hoverTimeout;
    let longPressTimeout;
    let isTouchInteraction = false;
    const KEY_REGEX = /[^a-z0-9]/g;

    // === Request Queue ===
    const requestQueue = {
        queue: [],
        processing: false,
        currentJob: null,

        add(title, cleanT, callback) {
            if ((this.currentJob && this.currentJob.cleanT === cleanT) ||
                this.queue.some(q => q.cleanT === cleanT)) {
                return;
            }
            this.queue.push({ title, cleanT, callback, retries: 0 });
            this.process();
        },
        async process() {
            if (this.processing || this.queue.length === 0) return;
            this.processing = true;

            const job = this.queue.shift();
            this.currentJob = job; // Set current job
            const { cleanT, callback, retries } = job;

            try {
                const data = await fetchMalData(cleanT);

                if (data && data.status === 429) {
                    if (retries < 2) {
                        job.retries++;
                        this.queue.unshift(job);
                        this.currentJob = null; // Clear current before timeout return
                        setTimeout(() => {
                            this.processing = false;
                            this.process();
                        }, 2500);
                        return;
                    } else {
                        callback({ error: true, temp: true });
                    }
                } else {
                    if (!data.temp && !data.error) {
                        const cacheKey = cleanT.toLowerCase().replace(KEY_REGEX, '');
                        // Check if key is valid before caching
                        if (cacheKey.length > 0) setCache(cacheKey, data);
                    }
                    callback(data);
                }
            } catch (e) {
                console.error(e);
                callback({ error: true, temp: true });
            }

            this.currentJob = null; // Clear current job
            setTimeout(() => {
                this.processing = false;
                this.process();
            }, CONFIG.API_INTERVAL);
        }
    };

    // === CSS Styles ===
    GM_addStyle(`
        .mal-container-rel { position: relative !important; }
        
        .mal-rating-badge {
            position: absolute;
            top: 6px; right: 6px;
            background: rgba(18, 20, 32, 0.96);
            backdrop-filter: blur(4px);
            border: 1px solid rgba(255, 255, 255, 0.15);
            color: #e0e0e0;
            padding: 4px 8px;
            border-radius: 6px;
            font-family: sans-serif;
            font-size: 11px;
            font-weight: 700;
            z-index: 9999;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5);
            cursor: pointer;
            display: flex;
            flex-direction: column;
            align-items: flex-end;
            min-width: 40px;
            opacity: 0;
            transform: translateY(-4px);
            animation: malFadeIn 0.2s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
            transition: all 0.2s ease;
            pointer-events: auto; 
            user-select: none;
        }

        .mal-rating-badge:hover, .mal-rating-badge.mobile-active {
            transform: translateY(0) scale(1.05);
            background: rgba(25, 28, 45, 1);
            box-shadow: 0 6px 16px rgba(0,0,0,0.7);
            z-index: 10000;
        }

        .mal-rating-badge .score { 
            font-size: 13px; color: #fff; display: flex; align-items: center; gap: 2px;
        }
        .mal-rating-badge .score::before { content: '★'; font-size: 10px; opacity: 0.8; }
        .mal-rating-badge .members { font-size: 9px; color: #9aa0b0; font-weight: 500; }

        /* Color Grading */
        
        /* 8+ : Golden Yellow */
        .mal-rating-badge.score-gold { border-color: rgba(234, 179, 8, 0.5); background: rgba(30, 25, 10, 0.95); box-shadow: 0 0 8px rgba(234, 179, 8, 0.3); }
        .mal-rating-badge.score-gold .score { color: #facc15; text-shadow: 0 0 5px rgba(250, 204, 21, 0.4); } 
        .mal-rating-badge.score-gold .score::before { color: #fbbf24; font-size: 14px; } /* Bigger star */

        /* 7-8 : Green */
        .mal-rating-badge.score-green { border-color: rgba(74, 222, 128, 0.4); }
        .mal-rating-badge.score-green .score { color: #86efac; } .mal-rating-badge.score-green .score::before { color: #4ade80; }

        /* 6-7 : Orange */
        .mal-rating-badge.score-orange { border-color: rgba(251, 146, 60, 0.4); }
        .mal-rating-badge.score-orange .score { color: #fdba74; } .mal-rating-badge.score-orange .score::before { color: #fb923c; }

        /* 5-6 : Red */
        .mal-rating-badge.score-red { border-color: rgba(248, 113, 113, 0.4); }
        .mal-rating-badge.score-red .score { color: #fca5a5; } .mal-rating-badge.score-red .score::before { color: #f87171; }
        
        /* <5 : Purple */
        .mal-rating-badge.score-purple { border-color: rgba(192, 132, 252, 0.4); background: rgba(20, 10, 30, 0.95); }
        .mal-rating-badge.score-purple .score { color: #d8b4fe; } .mal-rating-badge.score-purple .score::before { color: #c084fc; }

        .mal-rating-badge.loading { 
            background: rgba(0, 0, 0, 0.8); 
            min-width: unset; 
            padding: 6px 10px;
            pointer-events: none;
        }
        .mal-rating-badge.error { background: rgba(80, 80, 80, 0.9); color: #bbb; border-color: rgba(255,255,255,0.1); }

        /* Toast Notification */
        #mal-toast {
            position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
            background: rgba(30,30,30,0.9); color: white; padding: 10px 20px;
            border-radius: 8px; font-family: sans-serif; font-size: 13px;
            box-shadow: 0 4px 10px rgba(0,0,0,0.5); z-index: 9999999;
            opacity: 0; transition: opacity 0.3s ease; pointer-events: none;
        }
        #mal-toast.show { opacity: 1; }

        @media (pointer: coarse) { .mal-rating-badge { padding: 6px 10px; top: 8px; right: 8px; } }
        @keyframes malFadeIn { to { opacity: 1; transform: translateY(0); } }
        @keyframes malPulse { from { opacity: 0.5; } to { opacity: 1; } }
    `);

    // === Logic ===
    function getCache(key) {
        const fullKey = CONFIG.CACHE_PREFIX + key;
        const data = GM_getValue(fullKey);
        if (!data) return null;

        const expiryDuration = data.expiryDuration || CONFIG.CACHE_EXPIRY_SUCCESS;

        if (Date.now() - data.timestamp > expiryDuration) {
            GM_deleteValue(fullKey);
            return null;
        }
        return data.payload;
    }

    function setCache(key, payload) {
        // Double check we aren't caching errors
        if (payload.temp || (payload.error && !payload.found)) return;

        const expiryDuration = payload.found ? CONFIG.CACHE_EXPIRY_SUCCESS : CONFIG.CACHE_EXPIRY_ERROR;
        GM_setValue(CONFIG.CACHE_PREFIX + key, {
            payload,
            timestamp: Date.now(),
            expiryDuration: expiryDuration
        });
    }

    // === Menu Command: Clear Cache ===
    function showToast(msg) {
        let toast = document.getElementById('mal-toast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'mal-toast';
            document.body.appendChild(toast);
        }
        toast.innerText = msg;
        toast.classList.add('show');
        setTimeout(() => toast.classList.remove('show'), 3000);
    }

    function clearMalCache() {
        const keys = GM_listValues();
        let count = 0;
        keys.forEach(key => {
            if (key.startsWith(CONFIG.CACHE_PREFIX)) {
                GM_deleteValue(key);
                count++;
            }
        });
        showToast(`🗑️ Cleared ${count} items from MAL Cache.`);
    }

    GM_registerMenuCommand("🗑️ Clear MAL Cache", clearMalCache);

    function formatMembers(num) {
        if (!num) return '0';
        return num >= 1e6 ? (num / 1e6).toFixed(1) + 'M' : num >= 1e3 ? (num / 1e3).toFixed(1) + 'K' : num;
    }

    function getSimilarity(s1, s2) {
        const len1 = s1.length, len2 = s2.length;
        const maxDist = Math.max(len1, len2);
        if (len1 === 0 || len2 === 0) return maxDist === 0 ? 1 : 0;
        const row = Array(len1 + 1).fill(0).map((_, i) => i);
        for (let i = 1; i <= len2; i++) {
            let prev = i;
            for (let j = 1; j <= len1; j++) {
                const val = (s2[i - 1] === s1[j - 1]) ? row[j - 1] : Math.min(row[j - 1] + 1, prev + 1, row[j] + 1);
                row[j - 1] = prev;
                prev = val;
            }
            row[len1] = prev;
        }
        return 1 - (row[len1] / maxDist);
    }

    function cleanTitle(title) {
        let clean = title
            .replace(/^Title:\s*/i, '') // Remove "Title: " prefix
            .replace(/(\(|\[)\s*(sub|dub|uncensored|tv|bd|blu-ray|4k|hd|special|ova|ona|complete|re-upload).+?(\)|\])/gi, '')
            .replace(/[-:]\s*season\s*\d+/gi, '')
            .replace(/S\d+$/, '')
            .replace(/\s+/g, ' ')
            .trim();

        return clean;
    }

    async function fetchMalData(cleanT) {
        try {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), 8000);
            const res = await fetch(`https://api.jikan.moe/v4/anime?q=${encodeURIComponent(cleanT)}&limit=10`, { signal: controller.signal });
            clearTimeout(timeoutId);
            if (res.status === 429) return { status: 429 };

            const json = await res.json();
            const results = json.data || [];

            let bestMatch = null;
            let highestScore = -1;

            if (results.length > 0) {
                const targetLower = cleanT.toLowerCase();
                const targetKey = targetLower.replace(/[^a-z0-9]/g, '');

                // Keywords that imply we are looking for a special type
                const typeKeywords = ['movie', 'special', 'ova', 'film', 'theater'];
                const hasTypeInQuery = typeKeywords.some(k => targetLower.includes(k));

                results.forEach(item => {
                    const titleLower = (item.title || '').toLowerCase();
                    const titleEngLower = (item.title_english || '').toLowerCase();
                    const titleJapLower = (item.title_japanese || '').toLowerCase();

                    const sim1 = getSimilarity(targetLower, titleLower);
                    const sim2 = getSimilarity(targetLower, titleEngLower);
                    const sim3 = getSimilarity(targetLower, titleJapLower);

                    let score = Math.max(sim1, sim2, sim3);

                    // --- Scoring Adjustments ---

                    // 1. Exact Match Bonus
                    // Check strict alphanumeric match to handle "Fullmetal Alchemist: Brotherhood" vs "Fullmetal Alchemist"
                    const itemKey = titleLower.replace(/[^a-z0-9]/g, '');
                    const itemEngKey = titleEngLower.replace(/[^a-z0-9]/g, '');
                    const itemJapKey = titleJapLower.replace(/[^a-z0-9]/g, '');

                    if (targetKey === itemKey || targetKey === itemEngKey || targetKey === itemJapKey) {
                        score += 0.3; // Major boost for exact match
                    } else if ((itemKey.includes(targetKey) && itemKey.length < targetKey.length + 5) ||
                        (itemEngKey.includes(targetKey) && itemEngKey.length < targetKey.length + 5) ||
                        (itemJapKey.includes(targetKey) && itemJapKey.length < targetKey.length + 5)) {
                        score += 0.1; // Minor boost for very close containment
                    }

                    // 2. Type Penalty
                    // If query doesn't ask for Movie/Special, penalize them.
                    const isTypeVariant = ['Movie', 'Special', 'OVA', 'ONA', 'Music'].includes(item.type);
                    if (isTypeVariant && !hasTypeInQuery) {
                        score -= 0.25;
                    }

                    // 3. Popularity Bias as Tie-Breaker
                    // Add tiny score based on log of members to favor popular shows (TV series) over obscure OVAs in ties
                    if (item.members) {
                        score += (Math.log10(item.members) * 0.01);
                    }

                    if (score > highestScore) {
                        highestScore = score;
                        bestMatch = item;
                    }
                });
            }

            // Threshold check (slightly lower threshold due to penalties potentially lowering valid scores, 
            // but the exact match bonus raises them)
            // We use 0.45 as a safe baseline since purely different titles will have scores < 0.3 usually.
            if (bestMatch && highestScore > 0.45) {
                return {
                    found: true,
                    score: bestMatch.score ? bestMatch.score : 'N/A',
                    members: formatMembers(bestMatch.members),
                    url: bestMatch.url,
                };
            } else {
                return { found: false };
            }

        } catch (e) {
            return { error: true, temp: true };
        }
    }

    // === Render Logic ===
    function renderBadge(container, data) {
        const existing = container.querySelector('.mal-rating-badge');
        if (existing) existing.remove();

        // If it's a temp error, don't show badge (or show loading state if you prefer)
        if (data.temp && !data.loading) return;
        if (data.error && !data.found && !data.loading) return; // Silent fail on error

        const badge = document.createElement('div');
        badge.className = 'mal-rating-badge';
        if (isTouchInteraction) badge.classList.add('mobile-active');

        if (data.loading) {
            badge.classList.add('loading');
            badge.innerText = '• • •';
            badge.style.animation = 'malPulse 0.8s infinite alternate';
        } else if (data.found) {
            badge.title = "View on MyAnimeList";
            badge.innerHTML = `<span class="score">${data.score}</span><span class="members">${data.members}</span>`;

            const numScore = parseFloat(data.score);
            if (!isNaN(numScore)) {
                if (numScore >= 8.0) badge.classList.add('score-gold'); // >8 Golden
                else if (numScore >= 7.0) badge.classList.add('score-green'); // 7-8 Green
                else if (numScore >= 6.0) badge.classList.add('score-orange'); // 6-7 Orange
                else if (numScore >= 5.0) badge.classList.add('score-red'); // 5-6 Red
                else badge.classList.add('score-purple'); // <5 Purple
            } else {
                badge.classList.add('score-purple'); // N/A
            }

            badge.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                GM_openInTab(data.url, { active: true });
            }, { once: true });

        } else {
            badge.classList.add('error');
            badge.innerText = '?';
            badge.title = "Not found (Cached 12h)";
        }

        if (!container.classList.contains('mal-container-rel') && window.getComputedStyle(container).position === 'static') {
            container.classList.add('mal-container-rel');
        }

        if (!data.loading) badge.style.animation = '';
        container.appendChild(badge);
    }

    function processItem(item) {
        if (item.querySelector('.mal-rating-badge')) return;

        const titleEl = item.querySelector(CONFIG.SELECTORS.TITLE);
        let title = item.getAttribute('data-title') || item.getAttribute('aria-label');
        if (!title && titleEl) {
            title = titleEl.getAttribute('title') || titleEl.innerText || titleEl.getAttribute('alt');
        }
        if (!title) return;

        const cleanT = cleanTitle(title);
        if (!cleanT || cleanT.length < 2) return;

        const cacheKey = cleanT.toLowerCase().replace(KEY_REGEX, '');
        if (!cacheKey) return;

        const cachedData = getCache(cacheKey);
        if (cachedData) {
            renderBadge(item, cachedData);
            return;
        }

        const interactionId = Date.now() + Math.random().toString();
        item.dataset.malInteraction = interactionId;
        renderBadge(item, { loading: true });

        requestQueue.add(title, cleanT, (data) => {
            if (document.body.contains(item) && item.dataset.malInteraction === interactionId) {
                renderBadge(item, data);
            }
        });
    }

    // === Event Listeners ===
    document.body.addEventListener('mouseover', function (e) {
        if (isTouchInteraction) return;
        const item = e.target.closest(CONFIG.SELECTORS.ITEM);
        if (!item) return;

        clearTimeout(hoverTimeout);
        hoverTimeout = setTimeout(() => {
            processItem(item);
        }, CONFIG.DEBOUNCE_DELAY);
    });

    document.body.addEventListener('mouseout', (e) => {
        if (isTouchInteraction) return;
        const item = e.target.closest(CONFIG.SELECTORS.ITEM);
        if (item) {
            if (item.contains(e.relatedTarget)) return;
            clearTimeout(hoverTimeout);
            const badge = item.querySelector('.mal-rating-badge.loading');
            if (badge) {
                badge.remove();
                delete item.dataset.malInteraction;
            }
        }
    });

    // Mobile Logic
    let touchStartX = 0;
    let touchStartY = 0;
    document.body.addEventListener('touchstart', (e) => {
        const item = e.target.closest(CONFIG.SELECTORS.ITEM);
        if (!item) return;
        isTouchInteraction = true;
        touchStartX = e.touches[0].clientX;
        touchStartY = e.touches[0].clientY;
        longPressTimeout = setTimeout(() => {
            processItem(item);
            if (navigator.vibrate) navigator.vibrate(40);
        }, CONFIG.LONG_PRESS_DELAY);
    }, { passive: true });

    document.body.addEventListener('touchmove', (e) => {
        if (!longPressTimeout) return;
        if (Math.abs(e.touches[0].clientX - touchStartX) > 15 || Math.abs(e.touches[0].clientY - touchStartY) > 15) {
            clearTimeout(longPressTimeout);
            longPressTimeout = null;
        }
    }, { passive: true });

    document.body.addEventListener('touchend', () => {
        if (longPressTimeout) clearTimeout(longPressTimeout);
        setTimeout(() => { isTouchInteraction = false; }, 600);
    });

})();