История просмотра/чтения аниме/манги в сайдбаре
ShSH позволяет увидеть свою историю просмотра/чтения тайтла, запрашивая историю взаимодействия.Идея была взята из похожего юзерскрипта. Тот, который стоял у меня благополучно испарился, а второй похожий добавлял целое окно с большой кнопкой, так я решил повторить тот функционал, что видел.
Скриншот работы:
скрипт
v1: 02.02.2026
// ==UserScript==
// @name Shikimori Sidebar History
// @namespace http://shiki.one/
// @version 1.0
// @description Integrates user title interaction history into the sidebar.
// @author NotSoOff
// @match https://shikimori.one/animes/*
// @match https://shikimori.one/mangas/*
// @match https://shikimori.one/ranobe/*
// @match https://shiki.one/animes/*
// @match https://shiki.one/mangas/*
// @match https://shiki.one/ranobe/*
// @match https://shikimori.io/animes/*
// @match https://shikimori.io/mangas/*
// @match https://shikimori.io/ranobe/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
"use strict";
/**
* Injects history entries for a given Shikimori title into the sidebar.
* @param {Number} targetId Shikimori title ID.
* @param {String} targetType "Anime" or "Manga".
* @param {Object} options Accepts user_id / USER_ID / options.current_user / options.current_user.id / csrf_token / CSRF_TOKEN
* @returns Promise or error.
*/
async function injectTitleHistory(targetId, targetType, options = {}) {
// --- Конфиг ---
const CONFIG = {
SCRIPT_NAME: "ShSH",
USER_AGENT: "ShikimoriTitleHistory/1.0",
DEBUG_MODE: true,
ITEMS_TO_SHOW: 3,
FADE_HEIGHT: "15px",
RPS_LIMIT: 5,
};
// --- Логирование ---
const log = (...args) => {
if (CONFIG.DEBUG_MODE)
console.log(`[${CONFIG.SCRIPT_NAME}]`, ...args);
};
const error = (...args) =>
console.error(`[${CONFIG.SCRIPT_NAME}]`, ...args);
// --- Хелперы ---
const createElement = (tag, props = {}, callback) => {
const el = document.createElement(tag);
if (props.id) el.id = props.id;
if (props.class) el.className = props.class;
if (props.text) el.textContent = props.text;
if (props.html) el.innerHTML = props.html;
if (props.style) el.style.cssText = props.style;
if (props.dataset) Object.assign(el.dataset, props.dataset);
if (callback) callback(el);
return el;
};
const formatDate = (dateString) => {
return new Intl.DateTimeFormat([], {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(dateString));
};
// --- Поиск userId ---
const resolveUserId = async () => {
if (options.user_id) return options.user_id;
if (options.USER_ID) return options.USER_ID;
if (options.current_user && options.current_user.id)
return options.current_user.id;
const bodyData = document.body.getAttribute("data-user");
if (bodyData) {
try {
const parsed = JSON.parse(bodyData);
if (parsed && parsed.id) return parsed.id;
} catch (e) {}
}
try {
const response = await fetch("/users/whoami", {
headers: { "User-Agent": CONFIG.USER_AGENT },
});
if (!response.ok) return null;
const user = await response.json();
return user ? user.id : null;
} catch (err) {
return null;
}
};
// --- API ---
async function fetchHistory(userId, tId, tType) {
let historyData = [];
let page = 1;
const typeFormatted =
tType.charAt(0).toUpperCase() + tType.slice(1).toLowerCase();
try {
while (true) {
if (page > CONFIG.RPS_LIMIT) break;
const url = `${location.origin}/api/users/${userId}/history?target_id=${tId}&target_type=${typeFormatted}&limit=100&page=${page}`;
const response = await fetch(url, {
headers: { "User-Agent": CONFIG.USER_AGENT },
});
if (!response.ok) break;
const data = await response.json();
if (!data || data.length === 0) break;
historyData = historyData.concat(data);
if (data.length < 100) break;
page++;
}
} catch (e) {
error("Fetch failed", e);
}
return historyData;
}
// --- Логика рендера ---
const sidebar = document.querySelector(".b-animes-menu");
if (!sidebar) return;
const existing = document.getElementById("custom-history-block");
if (existing) existing.remove();
const container = createElement("div", {
id: "custom-history-block",
class: "block",
style: "position: relative; transition: all 0.3s;",
});
container.append(
createElement("div", {
class: "subheadline",
text: "История изменений",
}),
);
const contentArea = createElement("div", {
class: "history-list-area",
style: "position: relative; overflow: hidden;",
});
// Стили
const styleId = "history-sidebar-styles";
if (!document.getElementById(styleId)) {
document.head.append(
createElement("style", {
id: styleId,
text: `
.history-entry { padding: 8px 0; border-bottom: 1px solid rgba(128,128,128, 0.2); font-size: 13px; }
.history-entry:last-child { border-bottom: none; }
.history-meta { display: flex; justify-content: space-between; color: #888; font-size: 11px; margin-top: 2px; }
.history-del { color: #d64040; cursor: pointer; text-decoration: none; opacity: 0.8; }
.history-del:hover { text-decoration: underline; opacity: 1; }
.history-fade-overlay {
position: absolute; bottom: 0; left: 0; right: 0;
height: ${CONFIG.FADE_HEIGHT};
background: linear-gradient(to bottom, rgba(255,255,255,0), var(--bg-color, #fff));
pointer-events: none; transition: opacity 0.3s;
}
body[data-theme='dark'] .history-fade-overlay, .p-profiles.x-dark .history-fade-overlay {
background: linear-gradient(to bottom, rgba(33,33,33,0), #212121);
}
.history-toggler {
width: 100%; text-align: center; padding: 8px;
cursor: pointer; color: #1d77c3; font-size: 12px;
border-top: 1px solid rgba(128,128,128, 0.1); margin-top: 5px;
user-select: none; /* Чтобы текст не выделялся при клике */
}
.history-toggler:hover { background: rgba(0,0,0,0.02); }
.history-toggler:active { opacity: 0.7; }
`,
}),
);
}
container.append(contentArea);
sidebar.prepend(container);
contentArea.innerHTML =
'<div class="b-ajax" style="margin: 10px auto;"></div>';
const userId = await resolveUserId();
if (!userId || !targetId) {
contentArea.innerHTML =
'<div class="text-center p-2 text-muted">Не удалось определить данные</div>';
return;
}
const history = await fetchHistory(userId, targetId, targetType);
contentArea.innerHTML = "";
if (history.length === 0) {
contentArea.append(
createElement("div", {
class: "text-center text-muted p-2",
text: "История пуста",
}),
);
return;
}
// Рендер записей
history.forEach((entry, index) => {
const isHidden = index >= CONFIG.ITEMS_TO_SHOW;
const row = createElement("div", {
class: "history-entry",
style: isHidden ? "display: none;" : "",
});
const descDiv = createElement("div", {
class: "b-text_with_paragraphs",
html: entry.description,
});
const metaDiv = createElement("div", { class: "history-meta" });
// --- Кнопка удаления ---
metaDiv.append(
createElement("span", {
class: "misc",
text: formatDate(entry.created_at),
}),
createElement(
"a",
{ class: "history-del", text: "Удалить" },
(el) => {
el.addEventListener("click", async (e) => {
e.preventDefault();
if (!confirm("Удалить запись?")) return;
try {
const csrf = "";
if (options.csrf_token) {
csrf = options.csrf_token;
} else if (options.CSRF_TOKEN) {
csrf = options.CSRF_TOKEN;
} else {
csrf = document.querySelector(
'meta[name="csrf-token"]',
)?.content;
}
const res = await fetch(
`/api/user_rates/${entry.target_id}/history/${entry.id}`,
{
method: "DELETE",
headers: {
"X-CSRF-Token": csrf,
"User-Agent": CONFIG.USER_AGENT,
"Content-Type": "application/json",
},
},
);
if (res.ok || res.status === 204) {
row.style.background =
"rgba(255, 0, 0, 0.1)";
row.style.opacity = "0";
log("History entry deleted!");
setTimeout(() => row.remove(), 300);
}
} catch (err) {
error("Delete error", err);
}
});
},
),
);
row.append(descDiv, metaDiv);
contentArea.append(row);
});
// --- Кнопка Показать/Скрыть ---
if (history.length > CONFIG.ITEMS_TO_SHOW) {
// Раскомментируйте это и fade.style.opacity чтобы получить эффект блюра перед кнопкой "Показать всё"
// const fade = createElement("div", {
// class: "history-fade-overlay",
// });
// contentArea.append(fade);
let isExpanded = false;
// Создаем кнопку и сразу вешаем событие через callback
container.append(
createElement(
"div",
{
class: "history-toggler",
text: `Показать всё (${history.length})`,
},
(el) => {
el.addEventListener("click", () => {
isExpanded = !isExpanded;
const entries =
contentArea.querySelectorAll(".history-entry");
entries.forEach((row, idx) => {
if (idx >= CONFIG.ITEMS_TO_SHOW) {
row.style.display = isExpanded
? "block"
: "none";
}
});
// Меняем текст кнопки
el.textContent = isExpanded
? `Скрыть (${history.length})`
: `Показать всё (${history.length})`;
// Скрываем/показываем градиент
// fade.style.opacity = isExpanded ? "0" : "1";
});
},
),
);
}
}
// =========================================================
// STANDALONE INITIALIZATION WRAPPER
// =========================================================
(function initStandalone() {
const path = location.pathname;
// Matches: /animes/123-name or /mangas/456-name
// Capture Group 1: 'animes' or 'mangas'
// Capture Group 2: The ID (numbers)
const match = path.match(/^\/(animes|mangas|ranobe)\/([a-z0-9]+)/);
if (!match) return; // Not an anime/manga page
const typePlural = match[1]; // 'animes' or 'mangas'
const rawId = match[2]; // 000 or z000
// 'z00000' -> '00000'
const id = rawId.replace(/\D/g, "");
if (!id) return;
// Normalize Type ('animes' -> 'Anime')
const typeMap = {
animes: "Anime",
mangas: "Manga",
ranobe: "Manga", // Ranobe shares the Manga API
};
const type = typeMap[typePlural] || "Anime";
console.log(`[ShSH Standalone] Found ${type} ID: ${id}. Injecting...`);
// 3. Run the logic
injectTitleHistory(id, type);
const INIT_FLAG = 's8eyhm_shsh_initialized';
if (!window[INIT_FLAG]) {
console.log(`[ShSH Standalone] Initializing observers.`);
document.addEventListener("page:load", () => initStandalone());
document.addEventListener("turbolinks:load", () => initStandalone());
window[INIT_FLAG] = true;
}
})();
})();Если возникнут вопросы/баги - пишите в топик с тегом


.gif)