Shiki Custom Anime Rating 2

Displays custom rating, reviews, dispersion, median, stddev. Fixed data type and HTML generation issues.

// ==UserScript==
// @name         Shiki Custom Anime Rating 2
// @namespace    http://tampermonkey.net/
// @version      6.8
// @author       arch_q with AI support
// @description  Displays custom rating, reviews, dispersion, median, stddev. Fixed data type and HTML generation issues.
// @match        *://shikimori.org/*
// @match        *://shikimori.one/*
// @license      MIT
// @match        *://shikimori.me/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const DISPERSION_PRECISION = 3;
    const STD_DEV_PRECISION = 3;

    function getLocale() {
        return document.querySelector('body')?.getAttribute('data-locale') || 'ru';
    }

    function getLabels() {
        return getLocale() === 'ru'
            ? {
                title: 'Статистика оценок и отзывов',
                median: 'Медиана',
                stddev: 'Стандартное отклонение',
                dispersion: 'Дисперсия',
                reviews: 'Отзывы',
                metaRating: 'Метаоценка'
              }
            : {
                title: 'Score & Review Stats',
                median: 'Median',
                stddev: 'Standard deviation',
                dispersion: 'Dispersion',
                reviews: 'Reviews',
                metaRating: 'Meta rating'
              };
    }

    function getScoreData() {
        const node = document.querySelector("#rates_scores_stats");
        if (!node) return null;
        try {
            return JSON.parse(node.getAttribute("data-stats"));
        } catch (e) {
            console.error("Shiki Custom Rating: Could not parse score data.", e);
            return null;
        }
    }

    function getTotalCount(scoreData) {
        return scoreData.reduce((sum, [, count]) => sum + parseInt(count, 10), 0);
    }

    function calculateAverage(scoreData) {
        const total = getTotalCount(scoreData);
        if (total === 0) return 0;
        const sum = scoreData.reduce((acc, [score, count]) => acc + parseInt(score, 10) * parseInt(count, 10), 0);
        return sum / total;
    }

    function calculateVariance(scoreData, avg) {
        const total = getTotalCount(scoreData);
        if (total <= 1) return 0;
        const sumSqDiff = scoreData.reduce((acc, [score, count]) => acc + parseInt(count, 10) * Math.pow(parseInt(score, 10) - avg, 2), 0);
        return sumSqDiff / total;
    }

    function calculateMedian(scoreData) {
        const scores = [];
        scoreData.forEach(([score, count]) => {
            const numericScore = parseInt(score, 10);
            const numericCount = parseInt(count, 10);
            for (let i = 0; i < numericCount; i++) {
                scores.push(numericScore);
            }
        });
        if (scores.length === 0) return 0;
        scores.sort((a, b) => a - b);
        const mid = Math.floor(scores.length / 2);
        return scores.length % 2 !== 0 ? scores[mid] : (scores[mid - 1] + scores[mid]) / 2;
    }

    function getReviewCounts() {
        const counts = { positive: 0, neutral: 0, negative: 0 };
        const links = document.querySelectorAll(".b-reviews_navigation a");
        if (!links.length) return null;

        links.forEach(link => {
            const href = link.getAttribute('href');
            if (!href) return;
            const countDiv = link.querySelector('.count');
            const count = countDiv ? parseInt(countDiv.textContent.trim(), 10) : 0;

            if (href.endsWith('/positive')) counts.positive = count;
            else if (href.endsWith('/neutral')) counts.neutral = count;
            else if (href.endsWith('/negative')) counts.negative = count;
        });

        return counts;
    }

    function createBlockHtml({ median, stddev, dispersion, totalVotes, reviews, stars, scoreValue }) {
        const L = getLabels();
        // Corrected: Using template literal (backticks ``) instead of JSX
        return `
        <div id="custom-stats-block" class="block">
            <div class="subheadline m5">${L.title}</div>
            <div class="scores" style="margin-bottom: 10px;">
                <div class="b-rate" id="custom-score-rating">
                    <div class="stars-container">
                        <div class="hoverable-trigger"></div>
                        <div class="stars score score-${stars}" style="color: #a22123;"></div>
                        <div class="stars hover"></div>
                        <div class="stars background"></div>
                    </div>
                    <div class="text-score">
                        <div class="score-value score-${stars}">${scoreValue}</div>
                        <div class="score-notice"></div>
                    </div>
                </div>
                <p style="text-align: center; color: #7b8084; margin-top: 5px;">${L.metaRating}</p>
            </div>
            <div style="padding: 10px 0;">
                <p style="text-align: center; color: #7b8084;">${L.median}: <strong>${median}</strong></p>
                <p style="text-align: center; color: #7b8084;">${L.stddev}: <strong>${stddev}</strong></p>
                <p style="text-align: center; color: #7b8084;">${L.dispersion}: <strong>${dispersion}</strong></p>
                <p style="text-align: center; color: #7b8084;">${L.reviews}:
                    <strong style="color: #4f9222;">${reviews.positive}</strong> /
                    <strong style="color: #66a2b3;">${reviews.neutral}</strong> /
                    <strong style="color: #d13639;">${reviews.negative}</strong>
                </p>
                <p style="text-align: center; color: #7b8084;">${getLocale() === 'ru' ? 'На основе' : 'Based on'} <strong>${totalVotes}</strong> ${getLocale() === 'ru' ? 'оценок' : 'votes'}</p>
            </div>
        </div>`;
    }

    function insertStatsBlock() {
        if (document.querySelector('#custom-stats-block')) return;

        const scoreData = getScoreData();
        if (!scoreData || scoreData.length === 0) return;

        const reviews = getReviewCounts();
        if (!reviews) return;

        const avg = calculateAverage(scoreData);
        const variance = calculateVariance(scoreData, avg);
        const stddev = Math.sqrt(variance);
        const median = calculateMedian(scoreData);
        const totalVotes = getTotalCount(scoreData);

        const score = avg / (stddev || 1);
        const clamped = Math.max(1, Math.min(10, score));
        const stars = Math.round(clamped);

        const html = createBlockHtml({
            median,
            stddev: stddev.toFixed(STD_DEV_PRECISION),
            dispersion: variance.toFixed(DISPERSION_PRECISION),
            totalVotes,
            reviews,
            stars,
            scoreValue: clamped.toFixed(2)
        });

        const container = document.querySelector(".c-info-right .scores");
        if (container) {
            // Inserting after the whole .scores block, not inside it, to avoid nesting issues.
            // This matches the appearance in your screenshot where the new block is below the MAL/Shiki ratings.
            container.parentElement.insertAdjacentHTML('beforeend', html);
        } else {
             // Fallback for pages without a score block yet (e.g. anons)
             const infoRight = document.querySelector(".c-info-right");
             if (infoRight) {
                 infoRight.insertAdjacentHTML('beforeend', html);
             }
        }
    }

    // Shikimori uses turbolinks, so we need to observe for navigation changes
    const observer = new MutationObserver((mutations) => {
        // A simple check for URL change is often sufficient and more performant
        if (mutations.some(m => m.type === 'childList' && m.target === document.body)) {
             insertStatsBlock();
        }
    });

    // Initial run on page load
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', insertStatsBlock);
    } else {
        insertStatsBlock();
    }

    // Observe for dynamic content changes
    observer.observe(document.body, { childList: true, subtree: false });

    // Also listen to turbolinks/Turbo events for SPA-like navigation
    document.addEventListener('turbolinks:load', insertStatsBlock);
    document.addEventListener('turbo:load', insertStatsBlock);

})();