AniList True Average

Calculates and displays a 10% trimmed mean score to filter out extreme outlier ratings.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AniList True Average
// @namespace    https://anilist.co/
// @version      1.0
// @description  Calculates and displays a 10% trimmed mean score to filter out extreme outlier ratings.
// @author       krokshut
// @match        https://anilist.co/anime/*
// @match        https://anilist.co/manga/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=anilist.co
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Cache variables to prevent API spam and instantly survive Vue.js DOM wipes
    let cachedMediaId = null;
    let cachedScore = null;
    let isFetching = false;

    // --- Inject Custom CSS ---
    function injectStyles() {
        if (document.getElementById('trim-score-styles')) return;
        const style = document.createElement('style');
        style.id = 'trim-score-styles';
        style.innerHTML = `
            #custom-trimmed-score { margin-bottom: 14px; }
            #custom-trimmed-score .type { font-size: 1.2rem !important; font-weight: 500; }
            #custom-trimmed-score .value { font-size: 1.3rem !important; color: var(--color-text-lighter, #8596a5) !important; }
            
            .trim-type-container { display: flex; align-items: center; gap: 6px; color: #3db4f2; }
            
            .trim-tooltip-icon {
                display: inline-flex; align-items: center; cursor: help; position: relative;
                color: var(--color-text-lighter, #8596a5); transition: color 0.2s ease;
            }
            .trim-tooltip-icon:hover { color: #3db4f2; }

            .trim-tooltip-icon::after {
                content: attr(data-tooltip); position: absolute; bottom: 150%; left: 50%;
                transform: translateX(-50%); background: rgb(11, 22, 34); color: rgb(159, 173, 189);
                padding: 10px 14px; border-radius: 4px; font-size: 1.2rem; font-weight: 500;
                white-space: normal; width: 220px; text-align: center; box-shadow: 0 2px 10px rgba(0,0,0,0.2);
                opacity: 0; visibility: hidden; transition: opacity 0.2s ease, visibility 0.2s ease, bottom 0.2s ease;
                z-index: 999; pointer-events: none;
            }
            .trim-tooltip-icon:hover::after { opacity: 1; visibility: visible; bottom: 120%; }
        `;
        document.head.appendChild(style);
    }

    // --- Core Math Function ---
    function calculateTrimmedMean(distribution, trimPercent = 0.10) {
        let expanded = [];
        distribution.forEach(d => {
            for (let i = 0; i < d.amount; i++) { expanded.push(d.score); }
        });
        
        if (expanded.length === 0) return null;
        expanded.sort((a, b) => a - b);

        let trimCount = Math.floor(expanded.length * trimPercent);
        let trimmed = expanded.slice(trimCount, expanded.length - trimCount);
        
        if (trimmed.length === 0) return null;

        let sum = trimmed.reduce((a, b) => a + b, 0);
        return (sum / trimmed.length).toFixed(1);
    }

    // --- API Fetch Function ---
    async function fetchScoreDistribution(mediaId) {
        const query = `
        query ($id: Int) {
          Media(id: $id) {
            stats {
              scoreDistribution { score amount }
            }
          }
        }`;

        try {
            const response = await fetch('https://graphql.anilist.co', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
                body: JSON.stringify({ query, variables: { id: mediaId } })
            });
            const data = await response.json();
            return data.data.Media.stats.scoreDistribution;
        } catch (error) {
            console.error("[AniList True Average] Fetch failed:", error);
            return null;
        }
    }

    // --- UI Injection Function ---
    async function injectTrimmedScore() {
        const pathParts = window.location.pathname.split('/');
        const currentMediaId = parseInt(pathParts[2]);

        if (isNaN(currentMediaId)) return;

        // 1. Find where to inject. If the sidebar isn't loaded yet, just exit and wait.
        const dataSets = document.querySelectorAll('.sidebar .data-set');
        let meanScoreElement = null;
        dataSets.forEach(el => {
            if (el.querySelector('.type')?.innerText.trim() === 'Mean Score') {
                meanScoreElement = el;
            }
        });

        if (!meanScoreElement) return; // Sidebar doesn't exist yet
        if (document.getElementById('custom-trimmed-score')) return; // We are already injected

        // 2. Check the Cache. If we navigated to a new anime, reset the cache.
        if (cachedMediaId !== currentMediaId) {
            cachedMediaId = currentMediaId;
            cachedScore = null;
        }

        // 3. Fetch data if we don't have it saved
        if (cachedScore === null) {
            if (isFetching) return; // Don't fire 50 API calls at once
            isFetching = true;
            
            const distribution = await fetchScoreDistribution(currentMediaId);
            if (distribution) {
                cachedScore = calculateTrimmedMean(distribution);
            }
            isFetching = false;
        }

        if (!cachedScore) return; // Math failed or no data

        // Double check it hasn't magically appeared while we were fetching
        if (document.getElementById('custom-trimmed-score')) return;

        // 4. Inject the HTML
        const trimmedElement = document.createElement('div');
        trimmedElement.className = 'data-set';
        trimmedElement.id = 'custom-trimmed-score';
        
        const questionIcon = `
            <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <circle cx="12" cy="12" r="10"></circle>
                <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
                <line x1="12" y1="17" x2="12.01" y2="17"></line>
            </svg>
        `;
        
        trimmedElement.innerHTML = `
            <div class="type trim-type-container">
                <span>Trimmed Average Score</span>
                <span class="trim-tooltip-icon" data-tooltip="Calculated by removing the highest 10% and lowest 10% of votes to filter out review bombing and blind hype.">
                    ${questionIcon}
                </span>
            </div>
            <div class="value">${cachedScore}%</div>
        `;

        meanScoreElement.insertAdjacentElement('afterend', trimmedElement);
    }

    // --- Initialization & SPA Navigation Observer ---
    injectStyles();

    // Just spam the injector whenever the page changes. 
    // Because of the cache, it costs 0 performance and guarantees the element stays on screen.
    const observer = new MutationObserver(() => {
        injectTrimmedScore();
    });

    observer.observe(document.body, { childList: true, subtree: true });

})();