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