Calculates and displays a 10% trimmed mean score to filter out extreme outlier ratings.
// ==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 });
})();