// ==UserScript== // @name Shikimori 404 Fix // @namespace http://tampermonkey.net/ // @version 2.4 // @description Fetch anime info and render 404 pages. // @author 404FT // @updateURL https://raw.githubusercontent.com/404FT/404FIX/refs/heads/main/404FIX.user.js // @downloadURL https://raw.githubusercontent.com/404FT/404FIX/refs/heads/main/404FIX.user.js // @match https://shikimori.one/* // @match https://shikimori.io/* // @match https://shiki.one/* // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; // === ------------ === // === Конфигурация === // === ------------ === const CONFIG = { DEBUG_MODE: true, // Включает/выключает подробные логи в консоли SITE_NAME: window.location.origin, DOMAIN_NAME: window.location.hostname, // Вернет "shiki.one" RATE_LIMIT_MS: 200, // Интервал между запросами к API (1000ms / 5 RPS = 200ms) RELATED_VISIBLE_COUNT: 5, // Сколько связанных произведений показывать сразу SIMILAR_LIMIT: 7, // Сколько похожих аниме показывать COMMENTS_LIMIT: 50, // Макс. кол-во загружаемых комментариев USE_DONOR_CSS: true, // true - брать custom_css с донорской страницы, false - использовать старый метод API USER_AGENT: "TampermonkeyScript/2.3", // User-Agent для запросов TEMPLATE_URL: "https://raw.githubusercontent.com/404FT/404FIX/refs/heads/main/404FIX.html", DONOR_URL: "/animes/62616-sheng-dan-chuanqi-zhu-gong-de-shaizi", JIKAN_BASE: "https://api.jikan.moe/v4", JIKAN_CACHE_TTL: 7 * 24 * 60 * 60 * 1000, // 7 дней }; // ANIME const GRAPHQL_QUERY_ANIME_MAIN = ` query($id: String!) { animes(ids: $id, limit: 1, censored: false) { id malId name russian english kind score status episodes duration descriptionHtml topic { id } poster { id originalUrl mainUrl miniAltUrl } genres { id name russian kind } studios { id name imageUrl } scoresStats { score count } statusesStats { status count } fandubbers fansubbers videos { id url name kind playerUrl imageUrl } screenshots { id originalUrl x166Url x332Url } externalLinks { id kind url } } }`; const GRAPHQL_QUERY_ANIME_DETAILS = ` query($id: String!) { animes(ids: $id, limit: 1, censored: false) { id personRoles { id rolesRu rolesEn person { id name russian url image: poster { id mainUrl originalUrl miniAltUrl } } } characterRoles { id rolesRu rolesEn character { id name russian url image: poster { id mainUrl originalUrl miniAltUrl } } } related { id relationKind relationText anime { id name russian kind url episodes airedOn { year } poster { id mainUrl originalUrl miniAltUrl } } manga { id name russian kind url volumes chapters airedOn { year } poster { id mainUrl originalUrl miniAltUrl } } } } }`; // MANGA const GRAPHQL_QUERY_MANGA_MAIN = ` query($id: String!) { mangas(ids: $id, limit: 1, censored: false) { id malId name russian english kind score status volumes chapters descriptionHtml topic { id } poster { id originalUrl mainUrl miniAltUrl } genres { id name russian kind } publishers { id name } scoresStats { score count } statusesStats { status count } externalLinks { id kind url } } }`; const GRAPHQL_QUERY_MANGA_DETAILS = ` query($id: String!) { mangas(ids: $id, limit: 1, censored: false) { id personRoles { id rolesRu rolesEn person { id name russian url image: poster { id mainUrl originalUrl miniAltUrl } } } characterRoles { id rolesRu rolesEn character { id name russian url image: poster { id mainUrl originalUrl miniAltUrl } } } related { id relationKind relationText anime { id name russian kind url episodes airedOn { year } poster { id mainUrl originalUrl miniAltUrl } } manga { id name russian kind url volumes chapters airedOn { year } poster { id mainUrl originalUrl miniAltUrl } } } } }`; const ANIME_HTML_TEMPLATE = ` {{EN_NAME}} / Аниме {{FETCHED_CSS}} {{FETCHED_JS}}

{{RU_NAME}} / {{EN_NAME}}

`; // === ------- === // === Утилиты === // === ------- === const log = (...args) => console.log("[404FIX]", ...args); const debug = (...args) => CONFIG.DEBUG_MODE && console.log("[404FIX]", ...args); const error = (...args) => console.error("[404FIX]", ...args); // Вспомогательная функция для URL картинок // Если ссылка начинается с http, возвращает как есть. Иначе добавляет домен. const getFullUrl = (path) => { if (!path) return ""; if (path.startsWith("http")) return path; return `${CONFIG.SITE_NAME}/${path}`; }; let loaderInterval; const showLoader = () => { const h1 = document.querySelector(".dialog h1"); const p = document.querySelector(".dialog p"); if (h1 && p) { h1.textContent = "Загрузка данных..."; p.innerHTML = 'Пожалуйста, подождите. Время: 0.0 c.'; const startTime = Date.now(); const timerSpan = document.getElementById("loader-timer"); loaderInterval = setInterval(() => { if (timerSpan) { const elapsed = ((Date.now() - startTime) / 1000).toFixed( 1, ); timerSpan.textContent = elapsed; } }, 100); } }; const hideLoader = () => { clearInterval(loaderInterval); log("Страница загружена, отображаем..."); }; /** * Возвращает соответствующий оценке текст на Шикимори. * @param {Number} score Оценка тайтла * @returns На основе оценки возвращает соотствующий текст (напр. "Более-менее / Нормально / Великолепно"). */ const getScoreText = (score) => { const s = Math.floor(Number(score)); if (s < 1) return "Без оценки"; if (s <= 1) return "Хуже некуда"; if (s <= 2) return "Ужасно"; if (s <= 3) return "Очень плохо"; if (s <= 4) return "Плохо"; if (s <= 5) return "Более-менее"; if (s <= 6) return "Нормально"; if (s <= 7) return "Хорошо"; if (s <= 8) return "Отлично"; if (s <= 9) return "Великолепно"; return "Эпик вин!"; }; // Универсальная функция // Связано: // setupUserRateHandlers // STATUS_DATA // STATUS_CLASSES // Данные для статусов (тексты и классы) const STATUS_DATA = { anime: { planned: "Запланировано", watching: "Смотрю", rewatching: "Пересматриваю", completed: "Просмотрено", on_hold: "Отложено", dropped: "Брошено" }, manga: { planned: "Запланировано", watching: "Читаю", rewatching: "Перечитываю", completed: "Прочитано", on_hold: "Отложено", dropped: "Брошено" }, common: { remove: "Удалить из списка" } }; // CSS классы для контейнера const STATUS_CLASSES = { planned: "planned", watching: "watching", rewatching: "rewatching", completed: "completed", on_hold: "on_hold", dropped: "dropped" }; /** * Генерирует HTML кнопку добавления в список. * @param {number|string} targetId - ID аниме/манги. * @param {string} targetType - "Anime" или "Manga" (с большой буквы, как требует API). * @param {number|string} userId - ID пользователя. * @param {Object|null} currentRate - Объект существующего статуса (или null, если нет). * Ожидается формат: { id, status, score, ... } * @returns {string} HTML строка кнопки. */ const renderUserRateButton = (targetId, targetType, userId, currentRate = null) => { if (!userId || userId == null) return ""; // Если юзер не залогинен, кнопку не рисуем // Нормализация типа для словарей (anime/manga) const typeKey = targetType.toLowerCase(); // Текстовки для этого типа const texts = STATUS_DATA[typeKey] || STATUS_DATA.anime; // Определяем текущее состояние const isExisting = !!(currentRate && currentRate.id); const status = isExisting ? currentRate.status : 'planned'; // дефолт для класса const rateId = isExisting ? currentRate.id : ''; const score = isExisting ? currentRate.score : 0; // Определяем URL и Метод формы const formAction = isExisting ? `/api/v2/user_rates/${rateId}` : '/api/v2/user_rates'; // В оригинале используется hidden input data-method, но мы будем обрабатывать это в JS // Текст текущего статуса const currentStatusText = isExisting ? texts[status] : "Добавить в список"; const containerClass = isExisting ? STATUS_CLASSES[status] : 'planned'; // planned по дефолту для цвета кнопки "Добавить" // Генерируем опции выпадающего списка const optionsHtml = Object.keys(STATUS_CLASSES).map(key => { // Пропускаем текущий статус в списке? Обычно Шики показывает все. return `
`; }).join(''); // Кнопка удаления (только если запись существует) const removeHtml = isExisting ? `
` : ''; // Генерация триггера (разная разметка для "Добавить" и "Редактировать") let triggerHtml = ''; if (isExisting) { triggerHtml = `
`; } else { triggerHtml = `
`; } // Сборка всего HTML return `
${triggerHtml}
${optionsHtml} ${removeHtml}
`; }; // --- LRU Кеш для API и тяжелых объектов --- class LRUCache { constructor(maxSize = 100) { this.cache = new Map(); this.maxSize = maxSize; } get(key) { if (!this.cache.has(key)) return null; const val = this.cache.get(key); // Обновляем позицию элемента (делаем его "недавним") this.cache.delete(key); this.cache.set(key, val); return val; } set(key, value) { // Проверка на утечку памяти (работает в Chromium) if (window.performance && performance.memory) { const { usedJSHeapSize, jsHeapSizeLimit } = performance.memory; if (usedJSHeapSize / jsHeapSizeLimit > 0.5) { debug('⚠️ Память превышает 50%. Сбрасываем половину кеша (LRU).'); this.dropHalf(); } } if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.maxSize) { // Удаляем самый старый элемент (первый добавленный) const oldestKey = this.cache.keys().next().value; this.cache.delete(oldestKey); } this.cache.set(key, value); } dropHalf() { const dropCount = Math.floor(this.cache.size / 2); let i = 0; for (const key of this.cache.keys()) { if (i >= dropCount) break; this.cache.delete(key); i++; } } } class PersistentLRUCache { constructor(namespace, maxSize = 20, ttlMs = 86400000) { // ttlMs = 24 часа по умолчанию this.prefix = `404fix_${namespace}_`; this.keysKey = `${this.prefix}keys`; this.maxSize = maxSize; this.ttlMs = ttlMs; try { this.keys = JSON.parse(localStorage.getItem(this.keysKey) || '[]'); } catch { this.keys = []; } } get(key) { const itemStr = localStorage.getItem(this.prefix + key); if (!itemStr) return null; try { const item = JSON.parse(itemStr); // Проверяем срок годности if (Date.now() > item.exp) { this.delete(key); return null; } // Обновляем позицию (делаем недавно использованным) this.keys = this.keys.filter(k => k !== key); this.keys.push(key); this._saveKeys(); return item.value; } catch { return null; } } set(key, value) { const exp = Date.now() + this.ttlMs; if (!this.keys.includes(key)) { this.keys.push(key); } else { this.keys = this.keys.filter(k => k !== key); this.keys.push(key); } // Выталкиваем самые старые элементы, если превысили лимит while (this.keys.length > this.maxSize) { const oldestKey = this.keys.shift(); localStorage.removeItem(this.prefix + oldestKey); } this._saveKeys(); try { localStorage.setItem(this.prefix + key, JSON.stringify({ value, exp })); } catch (e) { // Защита: если память переполнена - сбрасываем половину const dropCount = Math.ceil(this.keys.length / 2); for(let i = 0; i < dropCount; i++) { const k = this.keys.shift(); localStorage.removeItem(this.prefix + k); } this._saveKeys(); localStorage.setItem(this.prefix + key, JSON.stringify({ value, exp })); } } delete(key) { this.keys = this.keys.filter(k => k !== key); this._saveKeys(); localStorage.removeItem(this.prefix + key); } _saveKeys() { localStorage.setItem(this.keysKey, JSON.stringify(this.keys)); } } // === JIKAN API — ЗАМЕНА АРТОВ (постеры + скриншоты) === const jikanImageCache = new PersistentLRUCache('jikan_images', 40, CONFIG.JIKAN_CACHE_TTL); const fetchJikanMedia = async (malId, isAnime = true) => { if (!malId) return { poster: null, screenshots: [] }; const cacheKey = `mal_${malId}_${isAnime ? 'a' : 'm'}`; let cached = jikanImageCache.get(cacheKey); if (cached) { debug(`📦 Jikan media из кеша для MAL ${malId}`); return cached; } const type = isAnime ? 'anime' : 'manga'; try { log(`🌐 Запрос к Jikan API: ${type}/${malId}`); // 1. Основная информация + большой постер const infoRes = await fetch(`${CONFIG.JIKAN_BASE}/${type}/${malId}`, { headers: { "User-Agent": CONFIG.USER_AGENT } }); if (!infoRes.ok) throw new Error(`Jikan info: ${infoRes.status}`); const infoJson = await infoRes.json(); const poster = infoJson.data?.images?.jpg?.large_image_url || infoJson.data?.images?.jpg?.image_url || null; // 2. Скриншоты / арты const picRes = await fetch(`${CONFIG.JIKAN_BASE}/${type}/${malId}/pictures`, { headers: { "User-Agent": CONFIG.USER_AGENT } }); if (!picRes.ok) throw new Error(`Jikan pictures: ${picRes.status}`); const picsJson = await picRes.json(); const screenshots = (picsJson.data || []) .slice(0, 20) // не больше 20 кадров .map((img, i) => ({ id: `jikan_${malId}_${i}`, originalUrl: img.jpg?.image_url || img.webp?.image_url, x166Url: img.jpg?.image_url || img.webp?.image_url, x332Url: img.jpg?.image_url || img.webp?.image_url, })); const result = { poster, screenshots }; jikanImageCache.set(cacheKey, result); log(`✅ Jikan API успешно: MAL ${malId} | постер: ${!!poster} | кадров: ${screenshots.length}`); return result; } catch (err) { error(`❌ Jikan API ошибка для MAL ${malId}:`, err.message); return { poster: null, screenshots: [] }; } }; // Создаем экземпляры кеша // const apiCache = new LRUCache(50); // Кеш для запросов (similar и т.д.) const similarCache = new PersistentLRUCache('similar', 20, 24 * 60 * 60 * 1000); // Кеш для тяжелых GraphQL данных (храним до 20 элементов 3 дня) const gqlCache = new PersistentLRUCache('gql', 20, 3 * 24 * 60 * 60 * 1000); // === ------------------------- === // === Модуль обработки запросов === // === ------------------------- === // --- Rate Limiter (Ограничитель запросов) --- // const RATE_LIMIT_MS = 200; // 1000ms / 5 RPS = 200ms const requestQueue = []; let isProcessingQueue = false; const processQueue = async () => { if (requestQueue.length === 0) { isProcessingQueue = false; return; } isProcessingQueue = true; const nextRequest = requestQueue.shift(); try { const result = await nextRequest.requestFn(); nextRequest.resolve(result); } catch (e) { nextRequest.reject(e); } setTimeout(processQueue, CONFIG.RATE_LIMIT_MS); }; /** * * @param {String} endpoint API запрос. * @param {Boolean} isWebEndpoint Использовать ли endpoint сайта, который вызывают некоторые фронт-енд функции. Например, комментарии обращаются к внутреннему API сайта, а не API, который описывается в документации. * @returns JSON ответ или ошибку. */ const apiRequest = (endpoint, isWebEndpoint = false) => { return new Promise((resolve, reject) => { const requestFn = async () => { const url = isWebEndpoint ? `${endpoint}` : `/api${endpoint}`; try { const response = await fetch(url, { headers: { "User-Agent": CONFIG.USER_AGENT }, }); if (!response.ok) throw new Error( `API request failed: ${response.status} for ${url}`, ); return await response.json(); } catch (err) { error(err.message); throw err; } }; requestQueue.push({ requestFn, resolve, reject }); if (!isProcessingQueue) processQueue(); }); }; // === ----------------------- === // === Модуль получения данных === // === ----------------------- === /** * Получение текущего пользователя через кешированное значение localStorage или whoami запрос. * @returns Object описывающий залогиненного пользователя, null если пользователь не залогинен. */ const getCurrentUser = async () => { try { // 1. Пытаемся взять ID текущего пользователя из DOM let currentUserId = null; const dataUserAttr = document.body.getAttribute('data-user'); if (dataUserAttr) { try { currentUserId = JSON.parse(dataUserAttr).id; } catch (e) { debug("Ошибка парсинга data-user из текущего DOM", e); } } // 2. Проверяем локальный кеш const cachedUserStr = localStorage.getItem('404fix_current_user'); if (cachedUserStr) { const cachedUser = JSON.parse(cachedUserStr); // Если ID из data-user совпадает с кешем — возвращаем кеш (МИНУС 1 ЗАПРОС!) if (currentUserId && cachedUser.USER_ID === currentUserId) { log("👤 Пользователь загружен из кеша (ID совпал с data-user)."); return cachedUser; } } // 3. Если кеша нет, или ID сменился (перезашли с другого аккаунта) — делаем whoami log("👤 Запрашиваем whoami (кеш пуст или аккаунт изменен)..."); const user = await apiRequest("/users/whoami"); if (!user || !user.id) { localStorage.removeItem('404fix_current_user'); return null; } const userData = { USER_ID: user.id, USER_NICK: user.nickname, USER_URL: user.url || `${CONFIG.SITE_NAME}/${user.nickname}`, USER_AVATAR: user.avatar || user.image?.x48 || "", USER_AVATAR_X16: user.image?.x16 || "", USER_AVATAR_X32: user.image?.x32 || "", USER_AVATAR_X48: user.image?.x48 || "", USER_AVATAR_X64: user.image?.x64 || "", USER_AVATAR_X80: user.image?.x80 || "", USER_AVATAR_X148: user.image?.x148 || "", USER_AVATAR_X160: user.image?.x160 || "", }; // Сохраняем в кеш для следующих страниц localStorage.setItem('404fix_current_user', JSON.stringify(userData)); debug(`👤 Пользователь ${userData.USER_NICK} (${userData.USER_ID}) сохранён в локальное хранилище:`, userData); return userData; } catch (err) { error("Не удалось получить данные пользователя.", err.message); return null; } }; /** * Получает ID стиля пользователя, а затем сам CSS. * @param {Number} userId ID текущего пользователя. * @returns {Promise} Скомпилированный CSS или null в случае ошибки/отсутствия. */ /** * Fixes broken camo URLs in CSS by extracting and using the original URL directly. * Camo URLs format: https://camo-v3.shikimori.io/{hash}?url={original_url} * @param {String} css - CSS string potentially containing camo URLs * @returns {String} - CSS with fixed URLs */ const fixCamoUrls = (css) => { if (!css) return css; try { // Match camo URLs in url() declarations const camoRegex = /url\(['"]?(https?:\/\/camo-v3\.shikimori\.[^\/]+\/[^?]+\?url=([^'")]+))['"]?\)/gi; let fixedCss = css.replace(camoRegex, (match, fullCamoUrl, encodedOriginalUrl) => { try { // Decode the original URL const originalUrl = decodeURIComponent(encodedOriginalUrl); debug(`🔧 Fixing camo URL: ${originalUrl}`); return `url('${originalUrl}')`; } catch (e) { // If decoding fails, return original match debug(`⚠️ Failed to decode camo URL: ${encodedOriginalUrl}`); return match; } }); if (fixedCss !== css) { log(`🔧 Fixed ${(css.match(camoRegex) || []).length} camo URL(s) in CSS`); } return fixedCss; } catch (err) { error("❌ Error fixing camo URLs:", err.message); return css; // Return original CSS if processing fails } }; const getUserStyle = async (userId) => { if (!userId) return null; try { log( `🎨 Запрашиваю данные пользователя ${userId} для получения ID стиля...`, ); const userData = await apiRequest(`/users/${userId}`); const styleId = userData?.style_id; if (styleId) { log(`🎨 ID стиля найден: ${styleId}. Запрашиваю CSS...`); const styleData = await apiRequest(`/styles/${styleId}`); const compiledCss = styleData?.compiled_css; if (compiledCss) { log(`🎨 Пользовательский CSS успешно получен.`); return fixCamoUrls(compiledCss); } else { log( `🎨 Стиль ${styleId} не содержит скомпилированного CSS.`, ); return null; } } else { log( `🎨 У пользователя ${userId} не установлен кастомный стиль.`, ); return null; } } catch (err) { error( "❌ Ошибка при получении пользовательского стиля:", err.message, ); return null; // Возвращаем null, чтобы не прерывать выполнение скрипта } }; /** * Загружает "донорскую" страницу для извлечения свежих ассетов: CSRF-токена, CSS и JS ссылок. * @returns {Promise} Возвращает заполненный object, пустую или неполную структуру, или же ошибку. */ const getPageAssets = async () => { const assets = { CSRF_TOKEN: null, FETCHED_CSS: "", FETCHED_JS: "", USER_DATA: null, CUSTOM_CSS: null }; try { log("📦 Запрашиваю страницу-донор для получения ассетов, пользователя и CSS..."); const response = await fetch(CONFIG.DONOR_URL); if (!response.ok) throw new Error(`Статус ответа: ${response.status}`); const pageHtml = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(pageHtml, "text/html"); // 1. CSRF-токен const tokenElement = doc.querySelector('meta[name="csrf-token"]'); if (tokenElement) assets.CSRF_TOKEN = tokenElement.getAttribute("content"); // 2. Скрипты и Стили const cssLinks = doc.querySelectorAll('head > link[rel="stylesheet"][href^="/packs/"], head > link[rel="stylesheet"][href^="/assets/"]'); if (cssLinks) assets.FETCHED_CSS = Array.from(cssLinks).map((l) => l.outerHTML).join("\n"); const jsScripts = doc.querySelectorAll('head > script[defer][src*="/packs/js/"]'); if (jsScripts) assets.FETCHED_JS = Array.from(jsScripts).map((s) => s.outerHTML).join("\n"); // 3. Данные пользователя из data-user const bodyUser = doc.body.getAttribute('data-user'); if (bodyUser) { try { const rawUser = JSON.parse(bodyUser); if (rawUser.id) { // Пытаемся вытащить ник из URL ("https://shikimori.one/Nickname") const nick = rawUser.url ? rawUser.url.split('/').pop() : 'User'; // Аватарку ищем в шапке const profileImg = doc.querySelector('.menu-dropdown.profile img'); assets.USER_DATA = { USER_ID: rawUser.id, USER_NICK: nick, USER_URL: rawUser.url, USER_AVATAR_X48: profileImg ? profileImg.getAttribute('src') : '', USER_AVATAR_X80: (profileImg && profileImg.getAttribute('srcset')) ? profileImg.getAttribute('srcset').split(' ')[0] : '' }; log(`👤 Извлечены данные пользователя: ${nick}`); } } catch (e) { debug("Ошибка парсинга data-user с донора", e); } } // 4. Custom CSS пользователя const customCssNode = doc.getElementById('custom_css'); if (customCssNode) { assets.CUSTOM_CSS = fixCamoUrls(customCssNode.innerHTML); log("🎨 Кастомный CSS пользователя извлечен из донорской страницы."); } return assets; } catch (err) { error("❌ Ошибка при получении ассетов страницы:", err.message); return assets; } }; /** * * @param {Number} topicId ID топика, откуда запросить комментарии. * @param {Number} maxComments Кол-во комментариев для загрузки. * @returns {Promise} */ const fetchComments = async ( topicId, maxComments = CONFIG.COMMENTS_LIMIT, ) => { if (!topicId) return []; let allComments = [], anchor = null, page = 1, limit = 3, fetched = 0; const initialEndpoint = `/comments?commentable_id=${topicId}&commentable_type=Topic&limit=${limit}&order=created_at&order_direction=desc`; let comments = await apiRequest(initialEndpoint); allComments = allComments.concat(comments); fetched += comments.length; while (fetched < maxComments && comments.length > 0) { anchor = comments[comments.length - 1].id; limit = 10; const webEndpoint = `/comments/fetch/${anchor}/Topic/${topicId}/${ page + 1 }/${limit}`; comments = await apiRequest(webEndpoint, true); allComments = allComments.concat(comments); fetched += comments.length; page++; } return allComments.slice(0, maxComments); }; /** * Получает статус пользователя для конкретного тайтла. * @param {Object} user - Объект пользователя. * @param {number|string} targetId - ID аниме/манги. * @param {string} targetType - "anime" или "manga". * @returns {Promise} Объект рейта или null. */ const fetchUserRate = async (user, targetId, targetType) => { if (!user || !user.USER_ID) return null; // API требует "Anime" или "Manga" с большой буквы const typeUpper = (targetType.toLowerCase() === 'anime') ? 'Anime' : 'Manga'; try { const res = await apiRequest(`/v2/user_rates?user_id=${user.USER_ID}&target_id=${targetId}&target_type=${typeUpper}`); // API возвращает массив. Если статус есть, он первый. if (Array.isArray(res) && res.length > 0) { return res[0]; } return null; } catch (e) { error("[404Fix] fetchUserRate error:", e); return null; } }; /** * @description Получает полные данные сущности через кеш или 2 параллельных GraphQL запроса (Main + Details) */ const getEntityData = async (id, type, displayType, assetsPromise) => { log(`📡 Загрузка данных: ${type} ID: ${id}`); const isAnime = type === "anime"; const isRanobe = displayType === 'ranobe'; const queryMain = isAnime ? GRAPHQL_QUERY_ANIME_MAIN : GRAPHQL_QUERY_MANGA_MAIN; const queryDetails = isAnime ? GRAPHQL_QUERY_ANIME_DETAILS : GRAPHQL_QUERY_MANGA_DETAILS; const missingImg = "/assets/globals/missing_preview.jpg"; // Хелпер для выполнения GraphQL запроса const fetchGQL = async (query) => { const response = await fetch("/api/graphql", { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": CONFIG.USER_AGENT, Accept: "application/json", }, body: JSON.stringify({ query, variables: { id: String(id) } }), }); if (!response.ok) throw new Error(`GQL Error: ${response.status}`); return await response.json(); }; // Локальная функция, которая ДОЖИДАЕТСЯ парсинга донора const fetchUserRateLocal = async () => { const assets = await assetsPromise; const user = assets.USER_DATA; if (!user || !user.USER_ID) return null; const targetType = isAnime ? "Anime" : "Manga"; return apiRequest(`/v2/user_rates?user_id=${user.USER_ID}&target_id=${id}&target_type=${targetType}`); }; const similarCacheKey = `${type}_${id}`; const fetchSimilar = async () => { let cached = similarCache.get(similarCacheKey); if (cached) { log(`📦 Similar загружен из кеша (localStorage) для ${type} ${id}`); return cached; } const res = await apiRequest(`/${type}s/${id}/similar`); similarCache.set(similarCacheKey, res); return res; }; const gqlCacheKey = `${type}_${id}`; const fetchHeavyGQL = async () => { let cached = gqlCache.get(gqlCacheKey); if (cached) { log(`⚡ Основные данные ${type}-${id} загружены из кеша (мгновенно)`); return cached; } // Запрашиваем оба GraphQL, если кеша нет или он протух const [main, details] = await Promise.all([ fetchGQL(queryMain), fetchGQL(queryDetails) ]); const result = { main, details }; gqlCache.set(gqlCacheKey, result); return result; }; // 1. Запускаем все процессы параллельно const [heavyResult, newsResult, similarResult, userRateResult] = await Promise.allSettled([ fetchHeavyGQL(), apiRequest(`/topics?forum=news&linked_type=${isAnime ? "Anime" : "Manga"}&linked_id=${id}&limit=30&order=comments_count&order_direction=desc`), fetchSimilar(), // Функция similarCache из прошлого сообщения fetchUserRateLocal() // Функция с ожиданием assetsPromise из прошлого сообщения ]); // 2. Проверка основных данных if (heavyResult.status === "rejected" || !heavyResult.value.main.data) { throw new Error("Main GraphQL request failed"); } // Достаем данные из ответов (обращаемся к нашему heavyResult) const mainDataRoot = heavyResult.value.main.data; const detailsDataRoot = heavyResult.value.details?.data || null; const mainList = isAnime ? mainDataRoot.animes : mainDataRoot.mangas; const detailsList = detailsDataRoot ? isAnime ? detailsDataRoot.animes : detailsDataRoot.mangas : []; if (!mainList || mainList.length === 0) { throw new Error("404: Entity not found"); } // 3. МЕРДЖИМ (Объединяем) два объекта в один const mainEntity = mainList[0]; const detailsEntity = detailsList[0] || {}; // Если второй запрос упал, будет пустой объект const entity = { ...mainEntity, ...detailsEntity }; // ====================== JIKAN FALLBACK ====================== // Заменяем удалённые РКН-ом арты Shikimori на изображения с MAL if (entity.malId) { log(`🌐 Запрашиваем арты с Jikan API (MAL ${entity.malId})...`); const jikanMedia = await fetchJikanMedia(entity.malId, isAnime); // Постер if (jikanMedia.poster) { entity.poster = entity.poster || {}; entity.poster.originalUrl = jikanMedia.poster; entity.poster.mainUrl = jikanMedia.poster; entity.poster.miniAltUrl = jikanMedia.poster; debug("✅ Постер заменён на Jikan"); } // Скриншоты (приоритет Jikan, т.к. на Shiki их чаще всего вырезали) if (jikanMedia.screenshots.length > 0) { entity.screenshots = jikanMedia.screenshots; debug(`✅ Скриншоты (${jikanMedia.screenshots.length} шт.) взяты с Jikan`); } } // ============================================================ let listStatusData = null; if (userRateResult.status === "fulfilled" && Array.isArray(userRateResult.value)) { // API возвращает массив. Если статус есть, берем первый элемент. if (userRateResult.value.length > 0) { listStatusData = userRateResult.value[0]; } } // 4. Комментарии const topicId = entity.topic ? entity.topic.id : null; let comments = []; if (topicId) { try { comments = await fetchComments(topicId, CONFIG.COMMENTS_LIMIT); } catch (err) {} } // 5. Обработка Ролей const processRoles = () => { const result = { main: [], supporting: [], staff: [] }; if (entity.characterRoles) { entity.characterRoles.forEach((role) => { const char = role.character; if (!char) return; const imgUrl = char.image ? char.image.mainUrl : missingImg; const originalUrl = char.image ? char.image.originalUrl : missingImg; const x48Url = char.image ? char.image.miniAltUrl : missingImg; const mappedRole = { roles: role.rolesEn, roles_russian: role.rolesRu, character: { id: char.id, name: char.name, russian: char.russian, url: char.url, image: { preview: imgUrl, x96: imgUrl, x48: x48Url, original: originalUrl, }, }, }; if (role.rolesEn.includes("Main")) result.main.push(mappedRole); else result.supporting.push(mappedRole); }); } if (entity.personRoles) { entity.personRoles.forEach((role) => { const person = role.person; if (!person) return; const imgUrl = person.image ? person.image.mainUrl : missingImg; const originalUrl = person.image ? person.image.originalUrl : missingImg; const x48Url = person.image ? person.image.miniAltUrl : missingImg; const mappedRole = { roles: role.rolesEn, roles_russian: role.rolesRu, person: { id: person.id, name: person.name, russian: person.russian, url: person.url, image: { preview: imgUrl, x96: imgUrl, x48: x48Url, original: originalUrl, }, }, }; result.staff.push(mappedRole); }); } return result; }; const rolesData = processRoles(); // 6. Обработка Related const processRelated = () => { if (!entity.related) return []; return entity.related .map((rel) => { const item = rel.anime || rel.manga; if (!item) return null; const posterUrl = item.poster ? item.poster.mainUrl : "/assets/globals/missing_mini.png"; const posterX48 = item.poster ? item.poster.miniAltUrl : posterUrl; return { id: rel.id, relationKind: rel.relationKind, relation_russian: rel.relationText, anime: rel.anime ? { id: rel.anime.id, name: rel.anime.name, russian: rel.anime.russian, kind: rel.anime.kind, url: rel.anime.url, episodes: rel.anime.episodes, aired_on: rel.anime.airedOn ? `${rel.anime.airedOn.year}-01-01` : null, image: { preview: posterUrl, x96: posterUrl, x48: posterX48, }, } : null, manga: rel.manga ? { id: rel.manga.id, name: rel.manga.name, russian: rel.manga.russian, kind: rel.manga.kind, url: rel.manga.url, volumes: rel.manga.volumes, chapters: rel.manga.chapters, aired_on: rel.manga.airedOn ? `${rel.manga.airedOn.year}-01-01` : null, image: { preview: posterUrl, x96: posterUrl, x48: posterX48, }, } : null, }; }) .filter(Boolean); }; const similarData = similarResult.status === "fulfilled" ? similarResult.value : []; // 7. Сборка финального объекта const finalData = { // anime / manga / ranobe TYPE: displayType || type, // Anime / Manga / Ranobe TYPE_UP: isAnime ? "Anime" : (isRanobe ? "Ranobe" : "Manga"), // animes / mangas / ranobe TYPE_M: isAnime ? "animes" : (isRanobe ? "ranobe" : "mangas"), // https://shiki.one/api/doc/2.0/user_rates/index LIST_STATUS: listStatusData ? { id: listStatusData.id, status: listStatusData.status, // planned, watching, rewatching, completed, on_hold, dropped score: listStatusData.score, episodes: listStatusData.episodes, chapters: listStatusData.chapters, volumes: listStatusData.volumes, text: listStatusData.text, rewatches: listStatusData.rewatches, created_at: listStatusData.created_at, updated_at: listStatusData.updated_at } : [], INFO: { ID: entity.id, RU_NAME: entity.russian || entity.name, EN_NAME: entity.english || entity.name, TYPE: entity.kind, STATUS: entity.status, SCORE: entity.score, DESCRIPTION: entity.descriptionHtml, TOPIC_ID: topicId, GENRES: entity.genres || [], MYANIMELIST_ID: entity.malId, COUNT_LABEL: isAnime ? "Эпизоды" : (isRanobe ? "Тома / Главы" : "Тома/Главы"), COUNT_VALUE: isAnime ? entity.episodes || "?" : `${entity.volumes || "?"} / ${entity.chapters || "?"}`, DURATION_BLOCK: isAnime ? `
Длительность:
${ entity.duration || "?" } мин.
` : "", ORG_LABEL: isAnime ? "Студия" : (isRanobe ? "Автор оригинала" : "Издатель"), ORGANIZATIONS: isAnime ? entity.studios || [] : entity.publishers || [], }, POSTER: entity.poster ? entity.poster.originalUrl : "", RATINGS: { USER_SCORES: entity.scoresStats || [], USER_STATUS_STATS: entity.statusesStats || [], }, // Медиа и Озвучка (превращаем строки в объекты {name: ...}) VIDEOS: { // GraphQL возвращает массив строк ["a", "b"], а рендерер ждет [{name: "a"}, ...] SUBTITLES: isAnime ? (entity.fansubbers || []).map((n) => ({ name: n })) : [], DUBBING: isAnime ? (entity.fandubbers || []).map((n) => ({ name: n })) : [], LIST: entity.videos || [], }, SCREENSHOTS: entity.screenshots || [], COMMENTS: comments.map((c) => ({ id: c.id, text_preview: c.body ? c.body.substring(0, 100) + "..." : "", user_id: c.user_id, user: c.user ? c.user.nickname : "Guest", created_at: c.created_at, })), NEWS: newsResult.status === "fulfilled" ? newsResult.value.map((t) => ({ id: t.id, topic_title: t.topic_title, link: `/forum/news/${t.id}`, })) : [], EXTERNAL_LINKS: entity.externalLinks ? entity.externalLinks.map((l) => ({ url: l.url, kind: l.kind, site: l.kind.replace(/_/g, " "), })) : [], SIMILAR_ANIMES: isAnime ? similarData.slice(0, 12) : [], SIMILAR_MANGAS: !isAnime ? similarData.slice(0, 12) : [], RELATED: processRelated(), ROLES: rolesData, }; log(`✅ Обработка данных завершена для ${type} ID: ${id}`); debug(finalData); const sizeBytes = new Blob([JSON.stringify(finalData)]).size; log(`⚖️ Размер данных этого тайтла: ${(sizeBytes / 1024).toFixed(2)} KB`); return finalData; }; // === ---------------- === // === Модуль отрисовки === // === ---------------- === /** * @description Генерирует HTML с кнопкой "Показать еще", если элементов больше лимита. * @param {Array} itemsArray - Массив HTML-строк элементов. * @param {number} limit - Сколько элементов показывать сразу. * @param {string} label - Текст кнопки (например, "показать всех"). * @param {boolean} isInline - Если true, скрытый блок будет inline (для тегов), иначе block. * @returns {string} Итоговый HTML. */ const renderExpandable = ( itemsArray, limit = 2, label = "показать всех", ) => { if (!Array.isArray(itemsArray) || itemsArray.length === 0) return ""; if (itemsArray.length <= limit) { return itemsArray.join(""); } const visibleItems = itemsArray.slice(0, limit).join(""); const hiddenItems = itemsArray.slice(limit).join(""); return `
${visibleItems}
+ ${label}
`; }; /** * Рендерит блок связанных произведений. * @param {Array} relatedData - Массив объектов из /api/animes/:id/related. * @param {Object} currentUser - Объект текущего пользователя. * @returns {string} Готовый HTML-блок. */ const renderRelatedBlock = (relatedData, currentUser) => { if (!Array.isArray(relatedData) || relatedData.length === 0) { return '
Нет информации о связанных произведениях.
'; } const visibleCount = CONFIG.RELATED_VISIBLE_COUNT; const visibleItems = relatedData.slice(0, visibleCount); const hiddenItems = relatedData.slice(visibleCount); // Очередь для обновления статусов [ {id, type, domId} ] const updateQueue = []; const renderItem = (item) => { const entry = item.anime || item.manga; if (!entry) return ""; const type = item.anime ? "anime" : "manga"; const typePascalCase = type.charAt(0).toUpperCase() + type.slice(1); const typePlural = entry.url.startsWith("/ranobe") ? "ranobe" : (type === "anime" ? "animes" : "mangas"); const url = getFullUrl(entry.url); const relationText = item.relation_russian; const image = entry.image?.preview ? getFullUrl(entry.image.preview) : "/assets/globals/missing_mini.png"; const image2x = entry.image?.x96 ? getFullUrl(entry.image.x96) : image; const kindText = entry.kind.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); const year = entry.aired_on?.split("-")[0] || entry.released_on?.split("-")[0] || ""; // 1. Генерируем кнопку в состоянии "Добавить" (null) // Генерируем уникальный ID контейнера, чтобы потом найти его и обновить const containerUniqueId = `ur-related-${entry.id}-${Math.floor(Math.random() * 10000)}`; const userId = currentUser ? currentUser.USER_ID : null; const initialButtonHtml = renderUserRateButton(entry.id, typePascalCase, userId, null); // 2. Добавляем в очередь на обновление (если юзер залогинен) if (currentUser) { updateQueue.push({ targetId: entry.id, targetType: typePascalCase, domId: containerUniqueId }); } return `
${entry.russian || entry.name}
${kindText} ${year ? `${year} год` : ""}
${relationText}
${initialButtonHtml}
`; }; let html = `
${visibleItems.map(renderItem).join("")}
`; if (hiddenItems.length > 0) { html += `
+ показать остальное (${hiddenItems.length})
`; html += ``; } // --- САМООБНОВЛЕНИЕ --- // Запускаем асинхронный процесс, который отработает ПОСЛЕ того, как HTML будет вставлен на страницу (document.write). if (updateQueue.length > 0) { setTimeout(async () => { log(`🔄 [Related] Начинаю обновление статусов для ${updateQueue.length} элементов...`); // Используем Promise.all или последовательно (зависит от мощности apiRequest). // apiRequest имеет очередь, так что можно кидать все сразу, они выстроятся. // Вариант: Запускаем все запросы параллельно (в очередь) и обновляем по мере прихода updateQueue.forEach(async (task) => { const rate = await fetchUserRate(currentUser, task.targetId, task.targetType); // Если статус есть (не null), обновляем кнопку if (rate) { const container = document.getElementById(task.domId); if (container) { const newHtml = renderUserRateButton(task.targetId, task.targetType, currentUser.USER_ID, rate); container.innerHTML = newHtml; } } }); log("[Related] Все статусы обновлены"); }, 100); // Небольшая задержка, чтобы DOM точно успел построиться после document.write } return html; }; const renderScreenshotsAndVideos = (screenshots, videos) => { let html = ""; // --- 1. Скриншоты --- if (Array.isArray(screenshots) && screenshots.length > 0) { // Формируем массив HTML-строк для каждого скриншота const screenshotItems = screenshots.map((scr, index) => { const preview = getFullUrl(scr.x166Url); const original = getFullUrl(scr.originalUrl); const title = `Кадр ${index + 1}`; // Можно добавить название аниме, если прокинуть его сюда return ` ${title} `; }); // Оборачиваем в expandable (показываем 4, остальные скрываем) // Важно: isInline = true, чтобы картинки шли в ряд const screenshotsHtml = renderExpandable( screenshotItems, 4, "показать все кадры", ); html += `
Кадры
${screenshotsHtml}
`; } // --- 2. Видео --- if (Array.isArray(videos) && videos.length > 0) { const videoItems = videos.map((vid, index) => { const name = vid.name || vid.kind.toUpperCase(); // Используем playerUrl если есть, иначе url // const link = vid.playerUrl || vid.url; // В твоем примере API imageUrl пустой ("//img..jpg"), поэтому лучше поставить заглушку или убрать картинку const thumb = vid.imageUrl && vid.imageUrl.length > 10 ? vid.imageUrl : "/assets/globals/missing_video.png"; // ВАЖНО: Тут можно поменять target="_blank" на вызов своего плеера return `
${name} ${vid.kind.toUpperCase()}
`; }); // Показываем 3 видео, остальные скрываем const videosHtml = renderExpandable( videoItems, 3, "показать все видео", ); html += `
Видео
${videosHtml}
`; } return html; }; /** * --- УТИЛИТА: Рендер рейтинга --- * Создает DOM-элементы рейтинга и внедряет их в контейнер. * Автоматически удаляет старые элементы с тем же ключом (защита от дублей). * * @param {Object} params * @param {HTMLElement} params.container - Родительский блок (.scores) * @param {number|string} params.score - Числовое значение оценки (например, 8.55) * @param {string} params.key - Уникальный ключ ('anilist', 'shiki', 'mal') * @param {string} params.label - Подпись под рейтингом (например 'AniList') * @param {string} params.mode - 'stars' (звезды) или 'headline' (текст в заголовке) * @param {string} [params.subHeadlineSelector] - Селектор заголовка (нужен только для mode='headline') */ function renderRating({ container, score, key, label, mode = "stars", subHeadlineSelector = ".subheadline", }) { if (!container || score == null || isNaN(score)) return; const numericScore = Number(score); const roundedScore = Math.round(numericScore); const scoreClass = `score-${roundedScore}`; const noticeText = getScoreText(numericScore); // 1. РЕЖИМ "STARS" (Блок со звездами) if (mode === "stars") { // Удаляем старые, если есть (очистка перед рендером) container.querySelector(`.${key}-average-score`)?.remove(); container.querySelector(`.${key}-label`)?.remove(); // Создаем обертку для звезд const rateDiv = document.createElement("div"); rateDiv.className = `b-rate ${key}-average-score`; rateDiv.innerHTML = `
${numericScore}
${noticeText}
`; // Создаем подпись const labelP = document.createElement("p"); labelP.className = `score ${key}-label`; // Стили вынесены в JS, но лучше добавить их в CSS класс labelP.style.marginTop = "2px"; labelP.style.fontSize = "12px"; labelP.style.color = "#999"; labelP.style.textAlign = "center"; labelP.textContent = label; // Вставляем container.appendChild(rateDiv); container.appendChild(labelP); } // 2. РЕЖИМ "HEADLINE" (Текст в заголовке "Оценки людей") else if (mode === "headline") { // Ищем ближайший заголовок или глобальный const header = container .closest(".block") ?.querySelector(subHeadlineSelector) || document.querySelector(subHeadlineSelector); // фоллбэк if (header) { // Удаляем старый, если есть header.querySelector(`[data-rating-key="${key}"]`)?.remove(); const span = document.createElement("span"); span.dataset.ratingKey = key; span.style.marginLeft = "10px"; span.style.fontSize = "14px"; span.style.color = "#777"; span.textContent = `| ${label}: ${numericScore}`; header.appendChild(span); } } } const renderTemplate = (html, data) => { const content_type = data.TYPE; // 'anime' or 'manga' // ^ {{CONTENT_TYPE}} debug(`Data type right now is: ${content_type}`); debug(`Another data type right now is: ${data.INFO.TYPE}`); const isAnime = content_type === "anime"; const sectionName = isAnime ? "Аниме" : "Манга"; // Вставка пользовательского CSS, если он есть if (data.USER_CSS) { html = html.replace( '', ``, ); } // Формат: https://example.com html = html.replaceAll("{{SITE_NAME}}", CONFIG.SITE_NAME || ""); // Формат: example.com html = html.replaceAll("{{DOMAIN_NAME}}", CONFIG.DOMAIN_NAME || ""); // Замены основных плейсхолдеров html = html.replaceAll("{{ID}}", data.INFO.ID || ""); html = html.replaceAll("{{RU_NAME}}", data.INFO.RU_NAME || "N/A"); html = html.replaceAll("{{EN_NAME}}", data.INFO.EN_NAME || "N/A"); html = html.replaceAll("{{TYPE}}", data.INFO.TYPE || "?"); // tv / ova html = html.replaceAll("{{CONTENT_TYPE}}", content_type || "?"); // anime / manga html = html.replaceAll("{{CONTENT_TYPE_UP}}", data.TYPE_UP || "?"); // Anime / Manga html = html.replaceAll("{{CONTENT_TYPE_M}}", data.TYPE_M || "?"); // animes / html = html.replaceAll("{{SECTION_NAME}}", sectionName || "?"); html = html.replaceAll("{{STATUS}}", data.INFO.STATUS || "N/A"); html = html.replaceAll("{{SCORE}}", data.INFO.SCORE || "N/A"); // html = html.replaceAll('{{EPISODES}}', data.INFO.EPISODES || '?'); html = html.replaceAll("{{COUNT_LABEL}}", data.INFO.COUNT_LABEL); html = html.replaceAll("{{COUNT_VALUE}}", data.INFO.COUNT_VALUE); html = html.replaceAll( "{{DURATION_BLOCK}}", data.INFO.DURATION_BLOCK || "? мин.", ); html = html.replaceAll("{{SOURCE}}", data.INFO.SOURCE || "Отсутствует"); html = html.replaceAll("{{POSTER}}", getFullUrl(data.POSTER) || ""); html = html.replaceAll( "{{DESCRIPTION}}", data.INFO.DESCRIPTION || "Описание отсутствует", ); html = html.replaceAll( "{{MYANIMELIST_ID}}", data.INFO.MYANIMELIST_ID || "", ); html = html.replaceAll( "{{COMMENTS_COUNT}}", Array.isArray(data.COMMENTS) ? data.COMMENTS.length : 0, ); const commentsAnchor = Array.isArray(data.COMMENTS) && data.COMMENTS.length > 0 ? data.COMMENTS[0].id : 0; html = html.replaceAll("{{COMMENTS_ANCHOR}}", commentsAnchor); html = html.replaceAll("{{TOPIC_ID}}", data.INFO.TOPIC_ID || ""); html = html.replaceAll( "{{AUTHENTICITY_TOKEN}}", data.ASSETS.CSRF_TOKEN || "", ); html = html.replace("{{FETCHED_CSS}}", data.ASSETS.FETCHED_CSS || ""); html = html.replace("{{FETCHED_JS}}", data.ASSETS.FETCHED_JS || ""); if (data.USER) { html = html.replaceAll("{{USER_ID}}", data.USER.USER_ID); html = html.replaceAll("{{USER_NICK}}", data.USER.USER_NICK); html = html.replaceAll( "{{USER_URL}}", getFullUrl(data.USER.USER_URL), ); html = html.replaceAll( "{{USER_AVATAR}}", getFullUrl(data.USER.USER_AVATAR), ); html = html.replaceAll( "{{USER_AVATAR_X16}}", getFullUrl(data.USER.USER_AVATAR_X16), ); html = html.replaceAll( "{{USER_AVATAR_X32}}", getFullUrl(data.USER.USER_AVATAR_X32), ); html = html.replaceAll( "{{USER_AVATAR_X48}}", getFullUrl(data.USER.USER_AVATAR_X48), ); html = html.replaceAll( "{{USER_AVATAR_X64}}", getFullUrl(data.USER.USER_AVATAR_X64), ); html = html.replaceAll( "{{USER_AVATAR_X80}}", getFullUrl(data.USER.USER_AVATAR_X80), ); html = html.replaceAll( "{{USER_AVATAR_X148}}", getFullUrl(data.USER.USER_AVATAR_X148), ); html = html.replaceAll( "{{USER_AVATAR_X160}}", getFullUrl(data.USER.USER_AVATAR_X160), ); } html = html.replaceAll( "{{RELATED_CONTENT}}", renderRelatedBlock(data.RELATED, data.USER), ); function renderSimilarAnimes(animes) { if (!Array.isArray(animes) || animes.length === 0) return ""; return animes .slice(0, CONFIG.SIMILAR_LIMIT) .map((anime) => { const id = anime.id; const kind = anime.kind === "tv" ? "anime" : anime.kind || "anime"; const url = `/animes/${id}`; const nameEn = anime.name || ""; const nameRu = anime.russian || nameEn; const airedOn = anime.aired_on?.split("-")?.[0] || ""; // ВЫБИРАЕМ ОПТИМАЛЬНОЕ ИЗОБРАЖЕНИЕ: // x96 или preview - идеальны для превью. Original - слишком большой и медленный. const imagePath = anime.image?.x96 || anime.image?.preview || anime.image?.original || ""; if (!imagePath) { return ""; // Пропускаем аниме без изображения } // const imageUrl = `https://shikimori.one${imagePath}`; const imageUrl = getFullUrl(imagePath); const imageHtml = ` ${nameRu} `; return ` `.trim(); }) .join(""); } function renderSimilarAnimesBlock(animes) { const limited = animes.slice(0, 7); const entries = renderSimilarAnimes(limited); return entries ? `
${entries}
` : ""; } // === Похожие аниме === if (data.SIMILAR_ANIMES && Array.isArray(data.SIMILAR_ANIMES)) { html = html.replace( "{{SIMILAR_ANIMES}}", renderSimilarAnimesBlock(data.SIMILAR_ANIMES), ); } else { html = html.replace("{{SIMILAR_ANIMES}}", ""); } /** * @description Рендерит HTML-блок для персонажей. * @param {Array} charactersList - Массив персонажей. * @param {Boolean} isMain - Флаг: true для главных (показать всех), false для второстепенных (скрывать под спойлер). * @returns {string} Готовый HTML-блок. */ const renderCharacters = (charactersList, isMain = true) => { if (!Array.isArray(charactersList) || charactersList.length === 0) { // Если главных героев нет, выводим заглушку. Если нет второстепенных — просто пустоту. if (isMain) { return '
Нет информации о главных героях.
'; } return ""; } const itemsHtml = charactersList.map((role) => { const char = role.character; if (!char) return ""; const url = getFullUrl(char.url); const imagePreview = char.image?.preview ? getFullUrl(char.image.preview) : "/assets/globals/missing_preview.jpg"; const imageX96 = char.image?.x96 ? getFullUrl(char.image.x96) : imagePreview; return ` `; }); let contentHtml = ""; if (isMain) { // Главные герои: показываем всех contentHtml = itemsHtml.join(""); } else { // Второстепенные: прячем под спойлер (лимит 7) contentHtml = renderExpandable(itemsHtml, 7, "показать всех"); } const gridHtml = `
${contentHtml}
`; if (isMain) { return gridHtml; } else { // Для второстепенных добавляем обертку и заголовок, так как они находятся вне основного блока в шаблоне return `
Второстепенные герои
${gridHtml}
`; } }; html = html.replaceAll( "{{MAIN_CHARACTERS}}", renderCharacters(data.ROLES.main, true), ); html = html.replaceAll( "{{SUPPORTING_CHARACTERS}}", renderCharacters(data.ROLES.supporting, false), ); function renderStaffBlock(staff) { if (!Array.isArray(staff) || staff.length === 0) { return '
Нет информации о команде.
'; } // 1) Таблица важности ролей (ближе к Shikimori) const ROLE_PRIORITY = { "Original Creator": 1, Story: 1, Script: 1, Director: 2, "Series Composition": 2, "Episode Director": 3, Storyboard: 3, "Chief Animation Director": 4, "Animation Director": 5, "Character Design": 5, "Chief Producer": 6, Producer: 7, "Key Animation": 8, "2nd Key Animation": 9, "In-Between Animation": 10, }; // 2) Функция определения важности человека function getPersonPriority(role) { return Math.min( ...role.roles.map((r) => ROLE_PRIORITY[r] || 999), ); } // 3) Сортировка staff по важности const sortedStaff = staff .slice() // копия массива .sort((a, b) => getPersonPriority(a) - getPersonPriority(b)) .slice(0, 5); // максимум 5 человек // 4) Рендер return `
${sortedStaff .map((role) => { const p = role.person; const id = p.id; // const url = `https://shikimori.one${p.url}`; const url = getFullUrl(p.url); const imgPreview = p.image?.preview ? `${p.image.preview}` : "/assets/globals/missing/mini.png"; const img2x = p.image?.x96 ? getFullUrl(p.image.x96) : img4x; const img4x = p.image?.x48 ? getFullUrl(p.image.x48) : "/assets/globals/missing/mini@4x.png"; const roleTags = role.roles .map((r) => `
${r}
`) .join(""); return `
${
										p.russian || p.name
									}
${ role.roles.length > 1 ? "Роли:" : "Роль:" }
${roleTags}
`; }) .join("")}
`; } html = html.replace("{{STAFF}}", renderStaffBlock(data.ROLES.staff)); // data.SCREENSHOTS и data.VIDEOS.LIST приходят из getEntityData const mediaBlockHtml = renderScreenshotsAndVideos( data.SCREENSHOTS, data.VIDEOS.LIST, ); html = html.replaceAll( "{{SCREENSHOTS_AND_VIDEOS}}", mediaBlockHtml || "", ); function getRatingTooltip(rating) { if (!rating) return ""; switch (rating) { case "g": return "G - Для всех возрастов"; case "pg": return "PG - Родителям рекомендуется просмотреть перед детьми"; case "pg_13": return "PG-13 - Детям до 13 лет просмотр не желателен"; case "r": return "R - Лицам до 17 лет обязательно присутствие взрослого"; case "r+": return "R+ - Лицам до 17 лет просмотр запрещён"; case "rx": return "Хентай - смотреть только с родителями"; default: return rating; } } html = html.replaceAll("{{RATING}}", data.INFO.RATING || ""); function getRatingNotice(score) { if (!score) return "Нет оценки"; if (score >= 10) return "Эпик вин!"; if (score >= 9) return "Великолепно"; if (score >= 8) return "Отлично"; if (score >= 7) return "Хорошо"; if (score >= 6) return "Нормально"; if (score >= 5) return "Более-менее"; if (score >= 4) return "Плохо"; if (score >= 3) return "Очень плохо"; if (score >= 2) return "Ужасно"; if (score >= 1) return "Хуже некуда"; return "Нет оценки"; } const score = parseFloat(data.INFO.SCORE || 0); const scoreRound = Math.round(score); html = html.replaceAll("{{SCORE}}", score.toFixed(2)); html = html.replaceAll("{{SCORE_ROUND}}", scoreRound); html = html.replaceAll("{{RATING_NOTICE}}", getRatingNotice(score)); html = html.replaceAll( "{{RATING_TOOLTIP}}", getRatingTooltip(data.INFO.RATING), ); html = html.replaceAll("{{ORG_LABEL}}", data.INFO.ORG_LABEL); const orgs = data.INFO.ORGANIZATIONS || []; const orgsHtml = orgs .map( (org) => ` ${ org.imageUrl ? `` : `${org.name}` } `, ) .join(" "); html = html.replaceAll("{{ORGANIZATIONS}}", orgsHtml); function renderGenres(genres) { if (!Array.isArray(genres) || genres.length === 0) return ""; return ( `
Жанры:
` + genres .map((g) => { const en = g.name || ""; const ru = g.russian || en; const id = g.id || ""; const href = `/animes/genre/${id}-${en}`; return `${en}${ru}`; }) .join("\n") + `
` ); } html = html.replaceAll("{{GENRES}}", renderGenres(data.INFO.GENRES)); function renderUserRatingsHTML(userScores) { if (!Array.isArray(userScores) || userScores.length === 0) return ""; const statsArray = userScores.map((item) => [ String(item.score), item.count, ]); const dataStats = JSON.stringify(statsArray).replace( /"/g, """, ); return `
Оценки людей
`; } html = html.replaceAll( "{{USER_RATINGS}}", renderUserRatingsHTML(data.RATINGS.USER_SCORES), ); function renderUserStatusesHTML(userStatuses) { if (!Array.isArray(userStatuses) || userStatuses.length === 0) return ""; const statusNames = { planned: "Запланировано", watching: "Смотрю", completed: "Просмотрено", dropped: "Брошено", on_hold: "Отложено", }; const statusMap = { Запланировано: "planned", Смотрю: "watching", Просмотрено: "completed", Брошено: "dropped", Отложено: "on_hold", }; const statsArray = userStatuses.map((item) => [ statusMap[item.status] || item.status.toLowerCase(), item.count, ]); const total = userStatuses.reduce( (sum, item) => sum + item.count, 0, ); return `
В списках у людей
В списках у ${total} человек
`; } html = html.replaceAll( "{{USER_STATUSES}}", renderUserStatusesHTML(data.RATINGS.USER_STATUS_STATS), ); const userRateButtonHtml = renderUserRateButton( data.INFO.ID, isAnime ? "Anime" : "Manga", data.USER.USER_ID || null, data.LIST_STATUS ); html = html.replaceAll( "{{USER_RATE_BUTTON}}", userRateButtonHtml ); function renderDubbing(dubbing) { if (!Array.isArray(dubbing) || dubbing.length === 0) return ""; const visible = dubbing .slice(0, 5) .map( (d) => `
${d.name}
`, ) .join("\n"); const hidden = dubbing .slice(5) .map( (d) => `
${d.name}
`, ) .join("\n"); if (!hidden) return visible; return `${visible}
+ показать всех
`; } html = html.replaceAll( "{{DUBBING}}", renderDubbing(data.VIDEOS.DUBBING), ); function renderSubtitles(subtitles) { if (!Array.isArray(subtitles) || subtitles.length === 0) return ""; return subtitles .map( (s) => `
${s.name}
`, ) .join("\n"); } html = html.replaceAll( "{{SUBTITLES}}", renderSubtitles(data.VIDEOS.SUBTITLES), ); function renderNewsHTML(newsArray) { if (!Array.isArray(newsArray) || newsArray.length === 0) { log("Массив новостей пуст!"); debug("News array: ", newsArray); return ""; } return ``; } html = html.replaceAll("{{NEWS}}", renderNewsHTML(data.NEWS)); html = html.replaceAll( "{{COMMENTS}}", data.COMMENTS?.map( (c) => `${c.user || "Anon"}: ${c.text_preview}`, ).join("\n") || "", ); function renderExternalLinks(links) { if (!Array.isArray(links) || links.length === 0) return ""; return links .map((l) => { const url = l.url || "#"; const siteName = l.site || "Unknown"; // Use the raw kind for the class. If it's missing, default to 'unknown' const siteClass = l.kind || "unknown"; return ``; }) .join("\n"); } html = html.replaceAll( "{{EXTERNAL_LINKS}}", renderExternalLinks(data.EXTERNAL_LINKS), ); return html; }; // === ---------------- === // === Финальная логика === // === ---------------- === // === Поддержка кнопки "Ответить" === const setupReplyButtons = () => { const textarea = document.querySelector( 'textarea[name="comment[body]"]', ); if (!textarea) { log("Редактор не найден — кнопка Ответить не будет работать"); return false; } document.addEventListener("click", (e) => { const btn = e.target.closest(".item-reply"); if (!btn) return; const comment = btn.closest(".b-comment"); if (!comment) return; const commentId = comment.id.replace("comment-", "") || comment.dataset.track_comment; const userId = comment.dataset.user_id; const nickname = comment.dataset.user_nickname || comment.querySelector(".name a")?.textContent.trim() || "анон"; if (!commentId || !userId) return; e.preventDefault(); const tag = `[comment=${commentId};${userId}], `; const val = textarea.value; const insert = val && !val.endsWith("\n") ? "\n" + tag : tag; textarea.value = val + insert; textarea.focus(); textarea.setSelectionRange( textarea.value.length, textarea.value.length, ); textarea.scrollIntoView({ behavior: "smooth", block: "center" }); // Кнопка "назад" const back = document.querySelector(".return-to-reply"); if (back) { back.style.visibility = "visible"; back.textContent = `к @${nickname}`; back.onclick = () => { comment.scrollIntoView({ behavior: "smooth", block: "center", }); }; } // Визуальный отклик btn.style.opacity = "0.5"; setTimeout(() => (btn.style.opacity = ""), 200); }); log("Кнопка «Ответить» активирована"); return true; }; // === Поддержка кнопки "Цитировать" === const setupQuoteButtons = () => { const textarea = document.querySelector( 'textarea[name="comment[body]"]', ); if (!textarea) { log("Редактор не найден — кнопка Цитировать не будет работать"); return false; } document.addEventListener("click", (e) => { const btn = e.target.closest(".item-quote"); if (!btn) return; const comment = btn.closest(".b-comment"); if (!comment) return; const commentId = comment.id || comment.dataset.track_comment; const userId = comment.dataset.user_id; const nickname = comment.dataset.user_nickname || comment.querySelector(".name a")?.textContent.trim() || "анон"; e.preventDefault(); // Пытаемся получить выделенный текст let selectedText = ""; const selection = window.getSelection(); // Проверяем, есть ли выделение внутри текущего комментария if (selection.rangeCount > 0 && selection.toString().trim()) { const range = selection.getRangeAt(0); const selectedNode = range.commonAncestorContainer; // Проверяем, находится ли выделение внутри этого комментария if (comment.contains(selectedNode)) { selectedText = selection.toString().trim(); } } let quoteText; if (selectedText) { // Если есть выделенный текст - используем его quoteText = selectedText; log( `Цитируется выделенный текст: ${quoteText.substring( 0, 100, )}...`, ); } else { // Если нет выделения - берем весь текст комментария const commentBody = comment.querySelector(".body"); if (!commentBody) return; const commentText = commentBody.textContent || commentBody.innerText; const maxLength = 50000; quoteText = commentText.length > maxLength ? commentText.substring(0, maxLength) + "..." : commentText; log(`Выделения нет, цитируется весь комментарий`); } // Очищаем текст для форматирования const cleanText = quoteText .replace(/\n\s*\n/g, "\n\n") .replace(/[ \t]+/g, " ") .trim(); if (!cleanText) { log("Нет текста для цитирования"); return; } // Формируем тег цитаты const quoteTag = `[quote=${commentId.replace( "comment-", "", )};${userId};${nickname}]${cleanText}[/quote]\n\n`; // Вставляем в текстовое поле const val = textarea.value; const insert = val && !val.endsWith("\n") ? "\n" + quoteTag : quoteTag; textarea.value = val + insert; textarea.focus(); textarea.setSelectionRange( textarea.value.length, textarea.value.length, ); textarea.scrollIntoView({ behavior: "smooth", block: "center" }); // Снимаем выделение после цитирования if (selection.rangeCount > 0) { selection.removeAllRanges(); } // Визуальный отклик btn.style.opacity = "0.5"; setTimeout(() => (btn.style.opacity = ""), 200); log( `Цитата добавлена: комментарий ${commentId}, пользователь ${nickname}`, ); }); // Также добавляем обработку для мобильной версии document.addEventListener("click", (e) => { const btn = e.target.closest(".item-quote-mobile"); if (!btn) return; // Находим соответствующую обычную кнопку const comment = btn.closest(".b-comment"); const mainBtn = comment?.querySelector(".item-quote"); if (mainBtn) { mainBtn.click(); } }); log("Кнопка «Цитировать» активирована (с поддержкой выделения текста)"); return true; }; // --- Если кратко, оно кривое // === Поддержка кнопок списков (Dropdown + API Request) === const setupAddToListButtons = () => { // Словарь для отображения статусов и CSS классов const STATUS_MAP = { planned: { label: "Запланировано", class: "planned" }, watching: { label: "Смотрю", class: "watching" }, rewatching: { label: "Пересматриваю", class: "rewatching" }, completed: { label: "Просмотрено", class: "completed" }, on_hold: { label: "Отложено", class: "on_hold" }, dropped: { label: "Брошено", class: "dropped" }, }; // Единый слушатель на body (делегирование событий) document.body.addEventListener("click", async (e) => { // 1. Клик по ТРИГГЕРУ (открыть/закрыть меню) const trigger = e.target.closest(".b-add_to_list .trigger"); if (trigger) { e.preventDefault(); e.stopPropagation(); // Чтобы не сработал клик "снаружи" const container = trigger.closest(".b-add_to_list"); const expanded = container.querySelector(".expanded-options"); // Закрываем все другие открытые меню на странице document.querySelectorAll(".expanded-options").forEach((el) => { if (el !== expanded) el.style.display = "none"; }); // Тогглим текущее const isVisible = expanded.style.display === "block"; expanded.style.display = isVisible ? "none" : "block"; return; } // 2. Клик по ОПЦИИ (выбор статуса) const option = e.target.closest( ".b-add_to_list .expanded-options .option", ); if (option) { e.preventDefault(); const container = option.closest(".b-add_to_list"); const expanded = container.querySelector(".expanded-options"); const form = container.querySelector("form"); // Получаем данные const newStatus = option.dataset.status; // completed, planned... const targetId = form.querySelector( 'input[name="user_rate[target_id]"]', ).value; const targetType = form.querySelector( 'input[name="user_rate[target_type]"]', ).value; const userId = form.querySelector( 'input[name="user_rate[user_id]"]', ).value; // Если нужно // Визуально обновляем СРАЗУ (оптимистичный UI) updateUI(container, newStatus); // Закрываем меню expanded.style.display = "none"; // Отправляем запрос на сервер try { const csrfToken = document.querySelector( 'meta[name="csrf-token"]', )?.content; const response = await fetch("/api/v2/user_rates", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, // Важно для Rails "User-Agent": CONFIG.USER_AGENT, }, body: JSON.stringify({ user_rate: { target_id: targetId, target_type: targetType, status: newStatus, user_id: userId, }, }), }); if (!response.ok) throw new Error("Failed to update rate"); log(`✅ Статус обновлен на: ${newStatus}`); } catch (err) { error("Ошибка при обновлении статуса:", err); alert("Не удалось обновить статус. Проверьте консоль."); // Можно откатить UI обратно, если нужно } return; } // 3. Клик ВНЕ меню (закрыть всё) if (!e.target.closest(".b-add_to_list")) { document.querySelectorAll(".expanded-options").forEach((el) => { el.style.display = "none"; }); } }); // Вспомогательная функция обновления внешнего вида кнопки function updateUI(container, statusKey) { const map = STATUS_MAP[statusKey] || { label: statusKey, class: "planned", }; // 1. Меняем класс контейнера (цвет кнопки) // Удаляем старые классы статусов Object.values(STATUS_MAP).forEach((s) => container.classList.remove(s.class), ); // Добавляем новый container.classList.add(map.class); // 2. Меняем текст const textSpan = container.querySelector(".trigger .status-name"); if (textSpan) { textSpan.textContent = map.label; textSpan.setAttribute("data-text", map.label); } // 3. Меняем значение в скрытом инпуте (на всякий случай) const input = container.querySelector( 'input[name="user_rate[status]"]', ); if (input) input.value = statusKey; } log("Кнопки «Добавить в список» активированы (Native Fetch)"); }; const setupShowMoreHandlers = () => { document.body.addEventListener("click", (e) => { // Клик по "+ показать всех" if (e.target.matches(".b-show_more")) { const showBtn = e.target; // Ищем общий контейнер const wrapper = showBtn.closest(".expandable-wrapper"); if (!wrapper) return; // Защита, если используется старая верстка где-то const hiddenContent = wrapper.querySelector( ".b-show_more-content", ); const hideBtn = wrapper.querySelector(".hide-more"); if (hiddenContent) { showBtn.style.display = "none"; // Скрываем кнопку "+" hiddenContent.style.display = "inline"; // Показываем контент (inline чтобы не ломать сетку) if (hideBtn) hideBtn.style.display = "block"; // Показываем кнопку "-" } } // Клик по "— спрятать" if (e.target.matches(".hide-more")) { const hideBtn = e.target; const wrapper = hideBtn.closest(".expandable-wrapper"); if (!wrapper) return; const hiddenContent = wrapper.querySelector( ".b-show_more-content", ); const showBtn = wrapper.querySelector(".b-show_more"); if (hiddenContent) { hiddenContent.style.display = "none"; // Скрываем контент hideBtn.style.display = "none"; // Скрываем кнопку "-" if (showBtn) showBtn.style.display = "block"; // Возвращаем кнопку "+" } } }); log("Обработчики Show More активированы (версия 2.0)"); }; // Вспомогательные функции для кнопки избраного async function add_favorite(e, t) { const n = document.querySelector('meta[name="csrf-token"]')?.content; if (!n) return !1; try { return ( await fetch(`/api/favorites/${e}/${t}`, { method: "POST", headers: { "X-CSRF-Token": n, "X-Requested-With": "XMLHttpRequest", Accept: "application/json", }, credentials: "include", }) ).ok; } catch { return !1; } } async function delete_favorite(e, t) { const n = document.querySelector('meta[name="csrf-token"]')?.content; if (!n) return !1; try { return ( await fetch(`/api/favorites/${e}/${t}`, { method: "DELETE", headers: { "X-CSRF-Token": n, "X-Requested-With": "XMLHttpRequest", Accept: "application/json", }, credentials: "include", }) ).ok; } catch { return !1; } } // Кнопка избранного async function setupFavoriteButton() { const JSON_HEADERS = { "X-Requested-With": "XMLHttpRequest", Accept: "application/json", }; const FAVORITE_TEXT = { add: "Добавить в избранное", remove: "Удалить из избранного", }; const fetchJSON = async (url) => { const response = await fetch(url, { method: "GET", headers: JSON_HEADERS, credentials: "include", }); if (!response.ok) { throw new Error(`Request failed: ${url}`); } return response.json(); }; const setButtonState = (button, isFavorite) => { const action = isFavorite ? "remove" : "add"; button.classList.toggle("fav-add", !isFavorite); button.classList.toggle("fav-remove", isFavorite); button.setAttribute("title", FAVORITE_TEXT[action]); button.setAttribute("original-title", FAVORITE_TEXT[action]); if (button.hasAttribute("data-text")) { button.setAttribute("data-text", FAVORITE_TEXT[action]); } }; const resolveFavoritesKey = (type, kind) => { if (type === "Person") { switch (kind) { case "Mangaka": return "mangakas"; case "Seyu": return "seyu"; case "Producer": return "producers"; default: return "people"; } } const base = type.toLowerCase(); return base === "ranobe" ? base : `${base}s`; }; let user; let favourites; try { user = await fetchJSON("/api/users/whoami"); favourites = await fetchJSON(`/api/users/${user.id}/favourites`); } catch (error) { error(error.message); return; } const buttons = document.querySelectorAll( 'a.b-subposter-action[data-remote="true"][href^="/api/favorites/"]', ); buttons.forEach((button) => { const parts = button.getAttribute("href").split("/"); const type = parts.at(-2); const id = Number(parts.at(-1)); const kind = button.getAttribute("data-kind") || ""; const key = resolveFavoritesKey(type, kind); const favList = favourites[key] || []; const isFavorite = favList.some((item) => item.id === id); setButtonState(button, isFavorite); button.addEventListener("click", async (e) => { e.preventDefault(); const adding = button.classList.contains("fav-add"); const success = adding ? await add_favorite(type, id) : await delete_favorite(type, id); if (!success) { error("Failed to toggle favorite"); return; } setButtonState(button, adding); }); }); } const setupUserRateHandlers = () => { // Используем делегирование: вешаем один слушатель на body document.body.addEventListener("click", async (e) => { // 1. КЛИК ПО СТРЕЛКЕ ИЛИ ТЕЛУ КНОПКИ (Открыть/Закрыть меню) const trigger = e.target.closest(".b-add_to_list .trigger"); if (trigger) { e.preventDefault(); e.stopPropagation(); const container = trigger.closest(".b-add_to_list"); const expanded = container.querySelector(".expanded-options"); // Закрываем все остальные открытые меню document.querySelectorAll(".expanded-options").forEach((el) => { if (el !== expanded) el.style.display = "none"; if (el !== expanded) el.closest(".b-add_to_list")?.classList.remove( "expanded", ); }); // Тогглим текущее const isVisible = expanded.style.display === "block"; expanded.style.display = isVisible ? "none" : "block"; container.classList.toggle("expanded", !isVisible); return; } // 2. КЛИК ПО ОПЦИИ (Смена статуса или Удаление) const option = e.target.closest( ".b-add_to_list .expanded-options .option", ); const directAdd = e.target.closest( ".b-add_to_list .trigger .add-trigger", ); const actionElement = option || directAdd; if (actionElement) { e.preventDefault(); const container = actionElement.closest(".b-add_to_list"); const form = container.querySelector("form"); const expanded = container.querySelector(".expanded-options"); const newStatus = actionElement.dataset.status; const targetType = form.querySelector( 'input[name="user_rate[target_type]"]', ).value; const targetId = form.querySelector( 'input[name="user_rate[target_id]"]', ).value; const userId = form.querySelector( 'input[name="user_rate[user_id]"]', ).value; const csrfToken = document.querySelector( 'meta[name="csrf-token"]', )?.content; // --- ЛОГИКА УДАЛЕНИЯ --- if (newStatus === "delete") { const deleteUrl = form.getAttribute("action"); // 1. Сбрасываем внешний вид на "Запланировано" (синяя кнопка) container.className = "b-add_to_list planned"; container.classList.remove("expanded"); // Убираем стрелочку вверх // 2. Восстанавливаем триггер "Добавить в список" const triggerDiv = container.querySelector(".trigger"); triggerDiv.innerHTML = `
`; // 3. ВОССТАНАВЛИВАЕМ СПИСОК ОПЦИЙ (Fix бага с пустым списком) // Нам нужно вернуть список (Смотрю, В планах...), но БЕЗ кнопки удалить const typeKey = targetType.toLowerCase(); const texts = STATUS_DATA[typeKey]; // Берем тексты из глобальной константы const optionsHtml = Object.keys(STATUS_CLASSES).map(key => `
`).join(''); container.querySelector(".expanded-options").innerHTML = optionsHtml; // 4. Отправляем запрос на удаление fetch(deleteUrl, { method: "DELETE", headers: { "X-CSRF-Token": csrfToken, "Content-Type": "application/json", }, }).then(() => { log(`Запись удалена: ${targetId}`); form.setAttribute("action", "/api/v2/user_rates"); }); if (expanded) expanded.style.display = "none"; return; } // --- ЛОГИКА ДОБАВЛЕНИЯ / ИЗМЕНЕНИЯ --- // 1. Оптимистичное обновление UI Object.values(STATUS_CLASSES).forEach((c) => container.classList.remove(c), ); container.classList.add(STATUS_CLASSES[newStatus]); const typeKey = targetType.toLowerCase(); const statusText = STATUS_DATA[typeKey][newStatus]; const triggerDiv = container.querySelector(".trigger"); if (triggerDiv.querySelector(".add-trigger")) { triggerDiv.innerHTML = `
`; } else { const textSpan = triggerDiv.querySelector(".status-name"); if (textSpan) { textSpan.textContent = ""; textSpan.dataset.text = statusText; } } if (expanded) expanded.style.display = "none"; container.classList.remove("expanded"); // 2. Подготовка запроса const currentAction = form.getAttribute("action"); const isPatch = currentAction.match(/\/(\d+)$/); const method = isPatch ? "PATCH" : "POST"; const body = { user_rate: { user_id: userId, target_id: targetId, target_type: targetType, status: newStatus, }, }; try { const resp = await fetch(currentAction, { method: method, headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, }, body: JSON.stringify(body), }); if (!resp.ok) throw new Error("Network error"); const data = await resp.json(); if (method === "POST" && data.id) { form.setAttribute( "action", `/api/v2/user_rates/${data.id}`, ); const optionsDiv = container.querySelector(".expanded-options"); if (!optionsDiv.querySelector(".remove-trigger")) { const removeDiv = document.createElement("div"); removeDiv.className = "option remove-trigger"; removeDiv.dataset.status = "delete"; removeDiv.innerHTML = `
`; optionsDiv.appendChild(removeDiv); } } log(`Статус обновлен: ${newStatus} (ID: ${data.id})`); } catch (err) { error("Ошибка обновления статуса", err); } } // 3. КЛИК СНАРУЖИ if (!e.target.closest(".b-add_to_list")) { document.querySelectorAll(".expanded-options").forEach((el) => { el.style.display = "none"; el .closest(".b-add_to_list") ?.classList.remove("expanded"); }); } }); log("Обработчики UserRates (Universal) активированы"); }; /** * @description Искусственно вызывает события загрузки страницы, чтобы "оживить" JS-компоненты Shikimori. */ const triggerPageLoadEvents = () => { log("⚡️ Вызываю события загрузки страницы (turbolinks:load)..."); // Основное событие для Turbolinks document.dispatchEvent(new Event("turbolinks:load")); // Дополнительное стандартное событие на всякий случай document.dispatchEvent(new Event("DOMContentLoaded")); // Для совместимости со старыми версиями document.dispatchEvent(new Event("page:load")); // Кастомное событие для других скриптов, которым нужно переинициализироваться document.dispatchEvent(new CustomEvent("404fix:restored", { detail: { timestamp: Date.now(), url: window.location.href } })); }; /** * Credits: https://shikimori.one/forum/site/610497-shikiutils * Injects into the .scores block. */ async function injectExtraScores() { // --- НАСТРОЙКИ --- const CFG = { showShikiAvg: true, showAniList: true, displayMode: "stars", // 'stars' или 'headline' labels: { shiki: "Средний балл Шикимори", anilist: "AniList", mal: "MyAnimeList", }, }; const scoreBlock = document.querySelector(".scores"); if (!scoreBlock) return; const originalRate = scoreBlock.querySelector(".b-rate"); // Находим оригинальный блок if ( originalRate && !originalRate.classList.contains("shiki-average-score") && !originalRate.classList.contains("anilist-average-score") ) { // Проверяем, не добавили ли мы уже подпись if (!scoreBlock.querySelector(".mal-label")) { const labelP = document.createElement("p"); labelP.className = "score mal-label"; labelP.style.marginTop = "2px"; labelP.style.fontSize = "12px"; labelP.style.color = "#999"; labelP.style.textAlign = "center"; labelP.textContent = "Оценка MAL"; // Источник "дефолтной" оценки originalRate.insertAdjacentElement("afterend", labelP); } } // ========================================== // 1. SHIKIMORI (Расчет среднего) // ========================================== if (CFG.showShikiAvg) { const statsEl = document.querySelector("#rates_scores_stats"); if (statsEl && statsEl.dataset.stats) { try { const stats = JSON.parse(statsEl.dataset.stats); let total = 0, sum = 0; // Универсальный парсинг (поддерживает и массивы массивов, и объекты) const entries = Array.isArray(stats) ? stats : Object.entries(stats); for (const [s, c] of entries) { const score = Number(s); const count = Number(c); if (!isNaN(score) && !isNaN(count)) { sum += score * count; total += count; } } if (total > 0) { const avg = (sum / total).toFixed(2); // ВЫЗОВ НОВОЙ ФУНКЦИИ renderRating({ container: scoreBlock, score: avg, key: "shiki", label: CFG.labels.shiki, mode: CFG.displayMode, }); // (Опционально) Доп. инфо "Всего оценок" if (!statsEl.querySelector(".total-rates")) { const totalEl = document.createElement("div"); totalEl.className = "total-rates"; totalEl.style.cssText = "margin-top: 5px; color: #999; font-size: 11px; text-align: center;"; totalEl.textContent = `Всего оценок: ${total}`; statsEl.appendChild(totalEl); } } } catch (e) { console.error("Shiki calc error:", e); } } } // ========================================== // 2. ANILIST (Запрос к API) // ========================================== if (CFG.showAniList) { // Поиск названия const nameElement = document.querySelector('meta[property="og:title"]') || document.querySelector( '.b-breadcrumbs .b-link[href*="/animes/"] span', ); let searchTitle = nameElement ? nameElement.getAttribute("content") : document.title; // Очистка от "RuName / EnName" if (searchTitle.includes("/")) searchTitle = searchTitle.split("/")[1].trim(); if (searchTitle) { const isManga = location.pathname.includes("/mangas/") || location.pathname.includes("/ranobe/"); const type = isManga ? "MANGA" : "ANIME"; const query = `query ($search: String) { Media(search: $search, type: ${type}) { averageScore } }`; try { const res = await fetch("https://graphql.anilist.co", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ query, variables: { search: searchTitle }, }), }); const data = await res.json(); const aniScoreRaw = data?.data?.Media?.averageScore; if (aniScoreRaw) { const aniScore = (aniScoreRaw / 10).toFixed(2); // 100 -> 10.0 // ВЫЗОВ НОВОЙ ФУНКЦИИ renderRating({ container: scoreBlock, score: aniScore, key: "anilist", label: CFG.labels.anilist, mode: CFG.displayMode, }); } } catch (e) { console.error("AniList Fetch Error:", e); } } } } /** * Credits: https://shikimori.one/forum/site/610497-shikiutils * Calculates total watch time based on episodes and duration. */ function injectWatchTime() { // --- SETTINGS --- const CFG = { enabled: true, template: "Всего времени:", }; if (!CFG.enabled) return; // Helper: Pluralization (день, дня, дней) const getPluralForm = (number, one, two, five) => { const n = Math.abs(number); const n1 = n % 10; const n2 = n % 100; if (n2 > 10 && n2 < 20) return five; if (n1 > 1 && n1 < 5) return two; if (n1 === 1) return one; return five; }; // Helper: Parse duration string const parseDur = (text) => { const t = text.toLowerCase(); const h = /(\d+)\s*(?:час|hour)/.exec(t); const m = /(\d+)\s*(?:мин|min)/.exec(t); return (h ? parseInt(h[1]) * 60 : 0) + (m ? parseInt(m[1]) : 0); }; // Helper: Format minutes to string const formatTime = (totalMins) => { const days = Math.floor(totalMins / 1440); const hours = Math.floor((totalMins % 1440) / 60); const mins = totalMins % 60; const parts = []; if (days > 0) parts.push( `${days} ${getPluralForm(days, "день", "дня", "дней")}`, ); if (hours > 0) parts.push( `${hours} ${getPluralForm(hours, "час", "часа", "часов")}`, ); if (mins > 0) parts.push( `${mins} ${getPluralForm( mins, "минута", "минуты", "минут", )}`, ); return parts.join(", "); }; try { const infoBlock = document.querySelector(".b-entry-info"); if (!infoBlock) return; // Find necessary lines by key text const findLine = (...keys) => { const lines = infoBlock.querySelectorAll( ".line-container .line", ); for (let line of lines) { const keyEl = line.querySelector(".key"); if (!keyEl) continue; if ( keys.some((k) => keyEl.textContent .toLowerCase() .includes(k.toLowerCase()), ) ) { return line; } } return null; }; const epLine = findLine("Эпизоды", "Episodes"); const durLine = findLine("Длительность", "Duration"); if (!epLine) return; const epValue = parseInt( epLine.querySelector(".value")?.textContent.trim(), ); const durText = durLine ? durLine.querySelector(".value")?.textContent.trim() : "0 мин"; const durMins = parseDur(durText); if (!epValue || !durMins) return; const totalTime = epValue * durMins; // Prevent duplicates if (!document.querySelector(".time-block")) { const timeBlock = document.createElement("div"); timeBlock.className = "line-container time-block"; // Matches template structure timeBlock.innerHTML = `
${CFG.template}
${formatTime(totalTime)}
`; // Insert after duration or at the end of block if (durLine) { durLine.closest(".line-container").after(timeBlock); } else { infoBlock.appendChild(timeBlock); } } } catch (err) { error("WatchTime Error:", err); } } /** * Credits: https://shikimori.one/forum/site/610497-shikiutils * 1. Calculates average score for "Friends" or "Statuses" bars. * 2. Fetches detailed info (episodes/chapters) for friends in the list. */ async function enhanceSidebarStats() { const CFG = { calcAvg: true, // Считать среднее по полоскам fetchDetails: true, // Грузить эпизоды друзей avgTemplate: "Средний балл: {avg}", showZero: true, // Показывать (0 эп.) }; // --- 1. Average Score Calculation (Generic) --- if (CFG.calcAvg) { document .querySelectorAll(".bar.simple.horizontal") .forEach((barBlock) => { // Find the subheadline relative to this bar const parentBlock = barBlock.closest(".block"); const head = parentBlock ? parentBlock.querySelector(".subheadline") : null; if (head && head.querySelector("[data-avg-added]")) return; // Skip if done let sum = 0, total = 0; let hasScore = false; // Try to parse scores from lines (works for "Friends" block if scores are visible like "10") // Or from graph bars (works for "User Ratings") barBlock.querySelectorAll(".line").forEach((line) => { // Try getting score from label (User rates graph) let score = parseInt( line.querySelector(".x_label")?.textContent, ); let count = 0; // If not found, try getting from text (Friends list: "Watching - 10") if (isNaN(score)) { const statusText = line.textContent; const match = statusText.match(/–\s*(\d+)/); if (match) { score = parseInt(match[1]); count = 1; // Each line is 1 friend } } else { // It's a graph bar const bar = line.querySelector(".bar"); count = parseInt(bar?.getAttribute("title")) || parseInt( bar?.querySelector(".value")?.textContent, ); } if (!isNaN(score) && !isNaN(count) && count > 0) { sum += score * count; total += count; hasScore = true; } }); if (hasScore && total > 0) { const avg = (sum / total).toFixed(2); // Inject into headline if (head) { const marker = document.createElement("span"); marker.dataset.avgAdded = "true"; marker.style.fontSize = "12px"; marker.style.color = "#888"; marker.style.marginLeft = "10px"; marker.textContent = `(${avg})`; head.appendChild(marker); } } }); } // --- 2. Fetch Detailed Friend Info --- if (CFG.fetchDetails) { // Need to know WHO we are checking. // Try to get IDs from URL or DOM. const path = window.location.pathname; const animeMatch = path.match(/\/(animes|mangas|ranobe)\/(\d+)/); if (!animeMatch) return; const targetId = animeMatch[2]; const isManga = path.includes("/mangas/") || path.includes("/ranobe/"); const targetType = isManga ? "Manga" : "Anime"; // Find friends block const friendsBlock = document.querySelector( ".b-animes-menu .block", ); // Note: In 404Fix script, this might be the "If you know how to return..." placeholder. // The logic below only works if there are actual friend lines. if (!friendsBlock) return; const friendLines = Array.from( friendsBlock.querySelectorAll( ".b-menu-line.friend-rate, .b-show_more-more .friend-rate", ), ); if (friendLines.length === 0) return; // Get Current User ID for API context? // Actually we need the FRIEND'S ID. // Standard Shikimori renders friend link as // We need to resolve nickname -> ID. // Let's try to get ID from avatar image URL (often contains ID) or we have to fetch profile. for (const line of friendLines) { const userLink = line.querySelector( `a[href^='${CONFIG.SITE_NAME}/']`, ); // or internal link if (!userLink) continue; // Extract ID from avatar if possible to save a request // src=".../users/x48/12345.png" const img = line.querySelector("img"); let friendId = null; if (img && img.src) { const m = img.src.match(/\/users\/[a-z0-9]+\/(\d+)\./); if (m) friendId = m[1]; } // If we have ID, fetch rates if (friendId) { try { const userRates = await apiRequest( `/v2/user_rates?user_id=${friendId}&target_type=${targetType}&target_id=${targetId}`, ); // API returns array. Should be 1 item since we filtered by target_id const rate = userRates[0]; if (rate) { const statusEl = line.querySelector(".status"); // Assuming standard structure if (statusEl) { let text = statusEl.textContent .split("–")[0] .trim(); // "Смотрю" if (rate.score > 0) text += ` – ${rate.score}`; const progress = isManga ? rate.chapters : rate.episodes; if ( progress > 0 || (progress === 0 && CFG.showZero) ) { text += ` (${progress} ${ isManga ? "гл." : "эп." })`; } statusEl.textContent = text; } } } catch (e) { error(`Failed to fetch rate for friend ${friendId}`, e); } } } } } /** * @description Загружает и выполняет все скрипты Shikimori с донорской страницы * для полной активации всех компонентов. */ const executeShikimoriScripts = async () => { try { log("🚀 Загрузка и выполнение скриптов Shikimori..."); // 1. Запрашиваем донорскую страницу снова (или используем кэш) const response = await fetch(CONFIG.DONOR_URL); const html = await response.text(); // 2. Парсим HTML const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); // 3. Находим все скрипты из /packs/js/ (основные скрипты Shikimori) const scripts = doc.querySelectorAll('script[src*="/packs/js/"]'); // 4. Загружаем и выполняем каждый скрипт for (const script of scripts) { const src = script.src; if (!src) continue; try { log(`📜 Загружаю скрипт: ${src}`); // Создаем новый script элемент const newScript = document.createElement("script"); newScript.src = src.startsWith("http") ? src : `${CONFIG.SITE_NAME}${src}`; newScript.type = "application/javascript"; newScript.async = false; // Важно для порядка выполнения // Добавляем в head document.head.appendChild(newScript); // Ждем загрузки скрипта await new Promise((resolve, reject) => { newScript.onload = resolve; newScript.onerror = reject; }); log(`✅ Скрипт загружен: ${src}`); } catch (err) { error(`❌ Ошибка загрузки скрипта ${src}:`, err.message); } } // 5. Также выполняем inline скрипты (если есть) const inlineScripts = doc.querySelectorAll("script:not([src])"); for (const script of inlineScripts) { try { if (script.textContent.trim()) { log("📜 Выполняю inline-скрипт..."); eval(script.textContent); // Осторожно! Но это скрипты Shikimori } } catch (err) { error("❌ Ошибка выполнения inline-скрипта:", err.message); } } log("✅ Все скрипты Shikimori загружены и выполнены"); } catch (err) { error("❌ Ошибка при загрузке скриптов Shikimori:", err); } }; // --- Основная логика --- let renderEntityPage = async (id, type) => { const startTime = performance.now(); try { // 1. СРАЗУ запускаем парсинг донорской страницы const assetsPromise = getPageAssets(); // 2. Запускаем сбор основных данных. Передаем туда assetsPromise! // getEntityData и getPageAssets работают параллельно. const pageDataPromise = getEntityData(id, type, assetsPromise); // Дожидаемся окончания обоих процессов const pageAssets = await assetsPromise; const pageData = await pageDataPromise; pageData.ASSETS = pageAssets; // Извлекаем юзера и CSS const currentUser = pageAssets.USER_DATA; if (currentUser) { pageData.USER = currentUser; // Используем CSS из донора, если разрешено настройками if (CONFIG.USE_DONOR_CSS && pageAssets.CUSTOM_CSS !== null) { pageData.USER_CSS = pageAssets.CUSTOM_CSS; } else { // Fallback: если отключено, можно использовать getUserStyle (если ты его оставишь) // или просто оставить null pageData.USER_CSS = await getUserStyle(currentUser.USER_ID); } } else { pageData.USER_CSS = null; } const renderedHTML = renderTemplate(ANIME_HTML_TEMPLATE, pageData); hideLoader(); /* В будущем эти 3 строки могут сломаться */ document.open(); document.write(renderedHTML); document.close(); setTimeout(async () => { triggerPageLoadEvents(); setupReplyButtons(); setupQuoteButtons(); setupShowMoreHandlers(); setupFavoriteButton(); setupUserRateHandlers(); // Загружаем и выполняем скрипты Shikimori // await executeShikimoriScripts(); // Инициализируем наши обработчики (они могут переопределить стандартные) // setupAddToListButtons(); injectExtraScores(); injectWatchTime(); enhanceSidebarStats(); }, 150); // --- Если сломается, комментируйте 3 строки вверху и меняйте на это --- /* // Парсим HTML и извлекаем ТОЛЬКО BODY const parser = new DOMParser(); const doc = parser.parseFromString(fullRenderedHTML, 'text/html'); const newBody = doc.body; // Заменяем существующий body на новый, сохраняя head document.body.innerHTML = newBody.innerHTML; // Копируем атрибуты из нового body в существующий for (const attr of newBody.attributes) { document.body.setAttribute(attr.name, attr.value); } */ // --- ВНИМАНИЕ, ^ ПОДХОД НЕ ПАНАЦЕЯ! // --- При тестировании, у разработчиков возникали серьёзные проблемы с функционалом. setTimeout(triggerPageLoadEvents, 0); } catch (e) { error(`Ошибка при рендере страницы для аниме ID ${id}:`, e.message); error(e.stack); document.body.innerHTML = `

Error

${e.message}

`; document.body.innerHTML += `

Stack

${e.stack}

`; } finally { const endTime = performance.now(); const duration = (endTime - startTime).toFixed(2); log(`✅ Страница полностью отрисована за ${duration} мс.`); } }; // Ручное востоновление // пример: restorePage(855, "anime") window.restorePage = async (id, type) => { renderEntityPage(id, type); log(`🔄 Ручное восстановление ${type} ID: ${id}`); }; const init = () => { if (document.title.trim() !== "404") return; // Обновляем regex для поддержки ranobe const match = location.pathname.match(/\/(animes|mangas|ranobe)\/([a-z0-9]+)/); if (!match) return; const typePlural = match[1]; let id = match[2]; id = id.replace(/\D/g, ""); // ranobe использует тот же API что и manga const type = typePlural === 'ranobe' ? 'manga' : typePlural.slice(0, -1); // Но для шаблона нам нужен оригинальный тип const displayType = typePlural === 'ranobe' ? 'ranobe' : typePlural.slice(0, -1); showLoader(); renderEntityPage(id, type, displayType); }; // ================================ // ОБРАБОТЧИКИ ДЛЯ TURBOLINKS/PJAX // ================================ document.addEventListener("page:load", init); document.addEventListener("turbolinks:load", init); // Запуск при обычной загрузке if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { // Если страница уже загружена init(); } })();