AniList True Average

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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 });

})();