// ==UserScript==
// @name Shikimori Anime Ratings Display
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Отображает рейтинг аниме на Shikimori
// @author MidTano
// @match https://shikimori.one/*
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const USER_SETTINGS = {
RATING_BAR_HEIGHT: 20,
REQUEST_DELAY: 150,
BATCH_PROCESSING_DELAY: 2000,
MAX_RETRY_ATTEMPTS: 3,
NO_RATING_TEXT: '★ Н/Д',
RATE_LIMIT_WAIT_TIME: 45000,
FETCH_TIMEOUT: 5000,
CACHE_EXPIRATION_DAYS: 15,
CHECK_INTERVAL: 1000
};
const CACHE_KEY = 'shikimori_ratings_cache';
const loadedRatings = new Set();
const loadingEntries = new Set();
const retryAttempts = new Map();
const failedEntries = new Set();
let loadQueue = [];
let isProcessingQueue = false;
let isWaitingAfter429 = false;
let pageLoadingInProgress = false;
const style = document.createElement('style');
style.textContent = `
.inline-anime-rating-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: ${USER_SETTINGS.RATING_BAR_HEIGHT}px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.6) 100%);
z-index: 4;
box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.5);
}
.inline-anime-rating {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
right: 0;
height: ${USER_SETTINGS.RATING_BAR_HEIGHT}px;
color: white;
font-weight: bold;
font-size: 14px;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.9);
z-index: 5;
}
.inline-anime-rating.high {
color: #8AFF8A;
}
.inline-anime-rating.medium {
color: #FFFF8A;
}
.inline-anime-rating.low {
color: #FF8A8A;
}
.inline-anime-rating.failed {
color: #CCCCCC;
}
.inline-anime-rating.waiting {
color: #FFA07A;
}
.inline-anime-rating-loading:after {
content: "...";
animation: loadingDots 1.5s infinite;
}
@keyframes loadingDots {
0% { content: "."; }
33% { content: ".."; }
66% { content: "..."; }
}
`;
document.head.appendChild(style);
function getCachedRating(animeId) {
try {
const cache = JSON.parse(localStorage.getItem(CACHE_KEY)) || {};
if (cache[animeId]) {
const cacheTime = new Date(cache[animeId].timestamp);
const now = new Date();
const diffDays = (now - cacheTime) / (1000 * 60 * 60 * 24);
if (diffDays < USER_SETTINGS.CACHE_EXPIRATION_DAYS) {
return cache[animeId].rating;
}
}
} catch (e) {}
return null;
}
function saveCachedRating(animeId, rating) {
try {
const cache = JSON.parse(localStorage.getItem(CACHE_KEY)) || {};
cache[animeId] = {
rating: rating,
timestamp: new Date().toISOString()
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
} catch (e) {}
}
function cleanupExpiredCache() {
try {
const cache = JSON.parse(localStorage.getItem(CACHE_KEY)) || {};
const now = new Date();
let hasChanges = false;
for (const animeId in cache) {
const cacheTime = new Date(cache[animeId].timestamp);
const diffDays = (now - cacheTime) / (1000 * 60 * 60 * 24);
if (diffDays >= USER_SETTINGS.CACHE_EXPIRATION_DAYS) {
delete cache[animeId];
hasChanges = true;
}
}
if (hasChanges) {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
}
} catch (e) {}
}
function getRatingClass(score) {
const numScore = parseFloat(score);
if (isNaN(numScore)) return '';
if (numScore >= 7.5) return 'high';
if (numScore >= 6) return 'medium';
return 'low';
}
function handleRateLimitError() {
if (isWaitingAfter429) {
return;
}
isWaitingAfter429 = true;
const loadingElements = document.querySelectorAll('.inline-anime-rating-loading');
loadingElements.forEach(el => {
el.textContent = 'Ожидание';
el.classList.add('waiting');
});
setTimeout(() => {
isWaitingAfter429 = false;
const waitingElements = document.querySelectorAll('.inline-anime-rating.waiting');
waitingElements.forEach(el => {
el.textContent = 'Загрузка';
el.classList.remove('waiting');
});
if (!isProcessingQueue && !pageLoadingInProgress) {
setTimeout(processLoadQueue, 1000);
}
}, USER_SETTINGS.RATE_LIMIT_WAIT_TIME);
}
function resetPageState() {
loadedRatings.clear();
loadingEntries.clear();
failedEntries.clear();
retryAttempts.clear();
loadQueue = [];
isProcessingQueue = false;
}
function addRatingToDOM(animeEntry, rating, isLoading = false, isFailed = false, isWaiting = false, isCached = false) {
const animeId = animeEntry.id;
if (!isLoading && !isFailed && !isWaiting) {
const hasRatingInDOM = animeEntry.querySelector('.inline-anime-rating:not(.inline-anime-rating-loading):not(.waiting)');
if (hasRatingInDOM && (loadedRatings.has(animeId) || failedEntries.has(animeId))) {
return false;
}
}
const imageContainer = animeEntry.querySelector('.image-decor');
if (!imageContainer) {
return false;
}
imageContainer.style.position = 'relative';
let ratingBar = imageContainer.querySelector('.inline-anime-rating-bar');
if (!ratingBar) {
ratingBar = document.createElement('div');
ratingBar.className = 'inline-anime-rating-bar';
imageContainer.appendChild(ratingBar);
}
let ratingDisplay = imageContainer.querySelector('.inline-anime-rating');
if (!ratingDisplay) {
ratingDisplay = document.createElement('div');
ratingDisplay.className = 'inline-anime-rating';
imageContainer.appendChild(ratingDisplay);
} else {
ratingDisplay.className = 'inline-anime-rating';
}
if (isWaiting) {
ratingDisplay.textContent = 'Ожидание';
ratingDisplay.classList.add('waiting');
} else if (isLoading) {
ratingDisplay.textContent = 'Загрузка';
ratingDisplay.classList.add('inline-anime-rating-loading');
} else if (isFailed) {
ratingDisplay.textContent = USER_SETTINGS.NO_RATING_TEXT;
ratingDisplay.classList.add('failed');
failedEntries.add(animeId);
loadingEntries.delete(animeId);
} else {
ratingDisplay.textContent = '★ ' + rating;
ratingDisplay.classList.remove('inline-anime-rating-loading');
const ratingClass = getRatingClass(rating);
if (ratingClass) ratingDisplay.classList.add(ratingClass);
loadedRatings.add(animeId);
loadingEntries.delete(animeId);
if (!isCached) {
saveCachedRating(animeId, rating);
}
}
return true;
}
function extractRatingsFromTooltips() {
if (isWaitingAfter429 || pageLoadingInProgress) {
return;
}
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(tooltip => {
const ratingElement = tooltip.querySelector('.rating span');
if (!ratingElement) return;
const rating = ratingElement.textContent.trim();
if (!rating) return;
const animeLink = tooltip.querySelector('a.name');
if (!animeLink) return;
const animeUrl = animeLink.getAttribute('href');
if (!animeUrl) return;
const animeIdMatch = animeUrl.match(/\/animes\/(\d+)-/);
if (!animeIdMatch || !animeIdMatch[1]) return;
const animeId = animeIdMatch[1];
const animeEntry = document.getElementById(animeId);
if (!animeEntry) return;
if (!loadedRatings.has(animeId) && !failedEntries.has(animeId)) {
addRatingToDOM(animeEntry, rating);
}
});
}
async function loadRatingFromTooltip(entry) {
const animeId = entry.id;
if (loadedRatings.has(animeId) || failedEntries.has(animeId) || pageLoadingInProgress) {
return true;
}
const cachedRating = getCachedRating(animeId);
if (cachedRating) {
addRatingToDOM(entry, cachedRating, false, false, false, true);
return true;
}
loadingEntries.add(animeId);
const coverElement = entry.querySelector('.cover');
if (!coverElement || !coverElement.getAttribute('data-tooltip_url')) {
loadingEntries.delete(animeId);
const attempts = (retryAttempts.get(animeId) || 0) + 1;
retryAttempts.set(animeId, attempts);
if (attempts >= USER_SETTINGS.MAX_RETRY_ATTEMPTS) {
addRatingToDOM(entry, '', false, true);
return true;
}
return false;
}
const tooltipUrl = coverElement.getAttribute('data-tooltip_url');
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), USER_SETTINGS.FETCH_TIMEOUT);
const response = await fetch(tooltipUrl, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.status === 429) {
handleRateLimitError();
return false;
}
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const html = await response.text();
if (!html || html.trim() === '') {
throw new Error('Empty response');
}
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const ratingElement = doc.querySelector('.rating span');
if (ratingElement && ratingElement.textContent.trim()) {
addRatingToDOM(entry, ratingElement.textContent.trim());
return true;
} else {
throw new Error('Rating not found in response');
}
} catch (e) {
loadingEntries.delete(animeId);
const attempts = (retryAttempts.get(animeId) || 0) + 1;
retryAttempts.set(animeId, attempts);
if (attempts >= USER_SETTINGS.MAX_RETRY_ATTEMPTS) {
addRatingToDOM(entry, '', false, true);
return true;
}
return false;
}
}
async function processLoadQueue() {
if (isProcessingQueue || loadQueue.length === 0 || isWaitingAfter429 || pageLoadingInProgress) {
return;
}
isProcessingQueue = true;
const entry = loadQueue.shift();
if (!entry.id || loadedRatings.has(entry.id) || failedEntries.has(entry.id)) {
isProcessingQueue = false;
setTimeout(processLoadQueue, 10);
return;
}
const success = await loadRatingFromTooltip(entry);
if (isWaitingAfter429) {
loadQueue.unshift(entry);
isProcessingQueue = false;
return;
}
if (!success && !failedEntries.has(entry.id)) {
loadQueue.push(entry);
}
setTimeout(() => {
isProcessingQueue = false;
if (!pageLoadingInProgress) {
processLoadQueue();
}
}, USER_SETTINGS.REQUEST_DELAY);
}
function updateLoadingStatus(isWaiting) {
document.querySelectorAll('.b-catalog_entry.c-anime').forEach(entry => {
const animeId = entry.id;
if (!animeId || loadedRatings.has(animeId) || failedEntries.has(animeId)) {
return;
}
const ratingElement = entry.querySelector('.inline-anime-rating');
if (ratingElement && ratingElement.classList.contains('inline-anime-rating-loading')) {
if (isWaiting) {
addRatingToDOM(entry, '', false, false, true);
} else {
addRatingToDOM(entry, '', true);
}
}
});
}
function markAllAnimeAsLoading() {
if (isWaitingAfter429 || pageLoadingInProgress) {
return;
}
const animeEntries = document.querySelectorAll('.b-catalog_entry.c-anime');
animeEntries.forEach(entry => {
if (!entry.id || loadedRatings.has(entry.id) || failedEntries.has(entry.id) ||
entry.querySelector('.inline-anime-rating-loading') ||
entry.querySelector('.inline-anime-rating.waiting')) {
return;
}
const cachedRating = getCachedRating(entry.id);
if (cachedRating) {
addRatingToDOM(entry, cachedRating, false, false, false, true);
} else {
addRatingToDOM(entry, '', true);
}
});
}
function queueAllAnimeEntries() {
if (pageLoadingInProgress) {
return;
}
const animeEntries = Array.from(document.querySelectorAll('.b-catalog_entry.c-anime'))
.filter(entry => entry.id && !loadedRatings.has(entry.id) && !failedEntries.has(entry.id));
animeEntries.forEach(entry => {
const animeId = entry.id;
const cachedRating = getCachedRating(animeId);
if (cachedRating) {
addRatingToDOM(entry, cachedRating, false, false, false, true);
}
});
if (isWaitingAfter429) {
updateLoadingStatus(true);
} else {
markAllAnimeAsLoading();
}
const queueIds = loadQueue.map(entry => entry.id);
const newEntries = animeEntries.filter(entry =>
!queueIds.includes(entry.id) &&
!loadedRatings.has(entry.id) &&
!getCachedRating(entry.id));
if (newEntries.length > 0) {
loadQueue = loadQueue.concat(newEntries);
if (!isProcessingQueue && !isWaitingAfter429 && !pageLoadingInProgress) {
processLoadQueue();
}
}
}
function processNewAnimeEntries() {
if (pageLoadingInProgress) {
return;
}
if (isWaitingAfter429) {
updateLoadingStatus(true);
} else {
markAllAnimeAsLoading();
}
queueAllAnimeEntries();
}
function checkAllAnimeRatings() {
const animeElements = document.querySelectorAll('.b-catalog_entry.c-anime');
animeElements.forEach(animeElement => {
const animeId = animeElement.id;
if (!animeId) return;
const ratingDisplay = animeElement.querySelector('.inline-anime-rating');
const ratingBar = animeElement.querySelector('.inline-anime-rating-bar');
if (!ratingDisplay || !ratingBar ||
(ratingDisplay && (ratingDisplay.classList.contains('inline-anime-rating-loading') ||
ratingDisplay.classList.contains('waiting')))) {
if (!ratingDisplay || !ratingBar) {
const imageDecor = animeElement.querySelector('.image-decor');
if (imageDecor) {
if (ratingDisplay) ratingDisplay.remove();
if (ratingBar) ratingBar.remove();
const cachedRating = getCachedRating(animeId);
if (cachedRating) {
addRatingToDOM(animeElement, cachedRating, false, false, false, true);
} else if (!loadingEntries.has(animeId) && !failedEntries.has(animeId)) {
addRatingToDOM(animeElement, '', true);
if (!loadQueue.find(entry => entry.id === animeId)) {
loadQueue.push(animeElement);
}
}
}
}
}
});
if (!isProcessingQueue && !isWaitingAfter429 && loadQueue.length > 0) {
processLoadQueue();
}
}
function detectPageNavigation() {
const origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function() {
this.addEventListener('loadstart', function() {
const url = this._url || arguments[1];
if (url && (url.includes('/page/') || url.includes('.json'))) {
pageLoadingInProgress = true;
}
});
this.addEventListener('loadend', function() {
setTimeout(() => {
pageLoadingInProgress = false;
checkAllAnimeRatings();
}, 500);
});
this._url = arguments[1];
return origOpen.apply(this, arguments);
};
document.addEventListener('click', (e) => {
const paginationLink = e.target.closest('.pagination a');
if (paginationLink) {
resetPageState();
setTimeout(() => {
checkAllAnimeRatings();
}, 1000);
}
});
}
window.addEventListener('popstate', (e) => {
resetPageState();
setTimeout(() => {
pageLoadingInProgress = false;
checkAllAnimeRatings();
}, 500);
});
window.addEventListener('hashchange', (e) => {
resetPageState();
setTimeout(() => {
pageLoadingInProgress = false;
checkAllAnimeRatings();
}, 500);
});
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
resetPageState();
setTimeout(() => {
checkAllAnimeRatings();
}, 500);
}
});
const domReadyCheck = () => {
urlObserver.observe(document, {subtree: true, childList: true});
setInterval(() => {
if (!pageLoadingInProgress && !isWaitingAfter429) {
checkAllAnimeRatings();
}
}, USER_SETTINGS.CHECK_INTERVAL);
};
function forceRestoreRatings() {
loadingEntries.clear();
failedEntries.clear();
const animeElements = document.querySelectorAll('.b-catalog_entry.c-anime');
animeElements.forEach(animeElement => {
const animeId = animeElement.id;
if (!animeId) return;
const imageDecor = animeElement.querySelector('.image-decor');
if (imageDecor) {
const existingBar = imageDecor.querySelector('.inline-anime-rating-bar');
const existingRating = imageDecor.querySelector('.inline-anime-rating');
if (existingBar) existingBar.remove();
if (existingRating) existingRating.remove();
const cachedRating = getCachedRating(animeId);
if (cachedRating) {
addRatingToDOM(animeElement, cachedRating, false, false, false, true);
} else {
addRatingToDOM(animeElement, '', true);
if (!loadQueue.find(entry => entry.id === animeId)) {
loadQueue.push(animeElement);
}
}
}
});
if (!isProcessingQueue && !isWaitingAfter429 && loadQueue.length > 0) {
processLoadQueue();
}
}
setInterval(() => {
if (!pageLoadingInProgress && !isWaitingAfter429) {
const anyRatingExists = document.querySelector('.inline-anime-rating');
const animeExists = document.querySelector('.b-catalog_entry.c-anime');
if (animeExists && !anyRatingExists) {
forceRestoreRatings();
}
}
}, 2000);
const originalPushState = history.pushState;
history.pushState = function() {
originalPushState.apply(this, arguments);
resetPageState();
setTimeout(() => {
checkAllAnimeRatings();
}, 500);
};
const originalReplaceState = history.replaceState;
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
resetPageState();
setTimeout(() => {
checkAllAnimeRatings();
}, 500);
};
function initialize() {
cleanupExpiredCache();
extractRatingsFromTooltips();
markAllAnimeAsLoading();
queueAllAnimeEntries();
setTimeout(() => {
checkAllAnimeRatings();
}, 500);
domReadyCheck();
}
detectPageNavigation();
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(initialize, 200);
} else {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(initialize, 200);
});
}
window.addEventListener('load', () => {
setTimeout(() => {
checkAllAnimeRatings();
}, 500);
});
const BATCH_SIZE = 10;
function processInBatches() {
if (pageLoadingInProgress) return;
const batch = Array.from(document.querySelectorAll('.b-catalog_entry.c-anime:not(.ratings-processed)'));
let count = 0;
batch.forEach(entry => {
if (count < BATCH_SIZE && !loadedRatings.has(entry.id) && !failedEntries.has(entry.id)) {
entry.classList.add('ratings-processed');
const cachedRating = getCachedRating(entry.id);
if (cachedRating) {
addRatingToDOM(entry, cachedRating, false, false, false, true);
} else {
addRatingToDOM(entry, '', true);
}
count++;
}
});
if (count > 0 && !isProcessingQueue && !isWaitingAfter429) {
processNewAnimeEntries();
}
}
setInterval(processInBatches, USER_SETTINGS.BATCH_PROCESSING_DELAY);
})();