AniList True Average

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 });

})();