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