Полезные утилиты для шикимори + GUI
// ==UserScript== // @name ShikiUtils // @icon https://raw.githubusercontent.com/shikigraph/Fumo/refs/heads/main/Fumo.png // @namespace https://shikimori.one // @version 4.2 // @description Полезные утилиты для шикимори + GUI // @author LifeH // @match https://shikimori.one/* // @match https://shikimori.rip/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_registerMenuCommand // @require https://cdn.jsdelivr.net/npm/@melloware/[email protected]/dist/umd/coloris.min.js // @require https://update.greasyfork.org/scripts/552841/1687209/ShikiTreeLib.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/d3.min.js // @resource colorisCSS https://cdn.jsdelivr.net/npm/@melloware/[email protected]/dist/coloris.min.css // @license MIT // ==/UserScript== (function () { (function notice() { const NOTICE_KEY = "ShikiUtils_4.0_-notice"; if (!localStorage.getItem(NOTICE_KEY)) { const modal = document.createElement("div"); modal.style = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 999999; display: flex; align-items: center; justify-content: center; font-family: 'Inter', 'Segoe UI', sans-serif; `; modal.innerHTML = ` <div style="background: #1c1c1f; color: #ddd; padding: 25px 35px; border-radius: 14px; max-width: 520px; box-shadow: 0 0 25px rgba(0,0,0,0.6); font-size: 15px; line-height: 1.6; border: 1px solid rgba(255,255,255,0.1); position: relative; text-align: left; animation: fadeIn 0.4s ease;"> <h2 style=" margin-top: 0; color: #9fdb88; font-size: 22px; text-align: center; "> Крупное обновление <span style="color:#aaf;">ShikiUtils v4.0+!</span> </h2> <p> Добавлено GUI с настройками и новые функции. </p> <p style="margin-top:10px;"> <b>Где теперь настройки?</b><br> Открой Настройки -> Прочее<br> или выбери пункт <b>"Настройки"</b> в меню Tampermonkey. </p> <p style="margin-top:10px;"> Подробное описание всех функций, changelog и место для обратной связи:<br> <a href="https://shikimori.one/forum/site/610497" target="_blank" style="color: #80cfff; text-decoration: none; font-weight: 500;"> Тута -> Топик на форуме </a> </p> <div style="text-align:center; margin-top:20px;"> <button id="closeShikiUtilsNotice" style="padding: 8px 18px; background: linear-gradient(90deg, #5cb85c, #4cae4c); border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 14px; transition: background 0.2s ease, transform 0.1s ease;"> Окак</button> </div> <img src="https://media.tenor.com/r6TGLs81M4UAAAAi/touhou-sakuya.gif" style="position: absolute; width: 64px; height: 64px; top: 0; left: 0; animation: moveAround 6s linear infinite alternate, spin 4s linear infinite; pointer-events: none; user-select: none; "> <style> @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } #closeShikiUtilsNotice:hover { background: linear-gradient(90deg, #6ed36e, #5cc45c); transform: scale(1.03); } @keyframes moveAround { 0% { top: 0; left: 0; } 25% { top: 0; left: calc(100% - 64px); } 50% { top: calc(100% - 64px); left: calc(100% - 64px); } 75% { top: calc(100% - 64px); left: 0; } 100% { top: 0; left: 0; } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } </style> </div> `; document.body.appendChild(modal); document .getElementById("closeShikiUtilsNotice") .addEventListener("click", () => { modal.remove(); localStorage.setItem(NOTICE_KEY, "true"); }); } })(); function ready(fn) { document.addEventListener("page:load", fn); document.addEventListener("turbolinks:load", fn); if (document.readyState !== "loading") fn(); else document.addEventListener("DOMContentLoaded", fn); } let username = null; let userId = null; const userDataEl = document.querySelector("[data-user]"); //todo переделать под whoami function updateUserData() { if (userDataEl) { try { const userData = JSON.parse(userDataEl.getAttribute("data-user")); username = userData.url ? userData.url.split("/").pop() : null; userId = userData.id || null; sessionStorage.setItem("username", username); sessionStorage.setItem("userId", userId); } catch (e) { console.error("[updateUserData]:", e); username = sessionStorage.getItem("username") || null; userId = sessionStorage.getItem("userId") || null; } } else { username = sessionStorage.getItem("username") || username; userId = sessionStorage.getItem("userId") || userId; } } function getUsername() { updateUserData(); return username; } function getUserId() { updateUserData(); return userId; } ("use strict"); GM_registerMenuCommand("Настройки", () => { try { window.location.href = `https://shikimori.one/${getUsername()}/edit/misc`; } catch (err) { console.error("[ShikiUtils]", err); } }); const cssCopyIcon = ` <svg width="16px" height="16px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Dribbble-Light-Preview" transform="translate(-220.000000, -1239.000000)" fill="#000000"><g id="icons" transform="translate(56.000000, 160.000000)"> <path d="M183.7248,1085.149 L178.2748,1079.364 C178.0858,1079.165 177.8238,1079.001 177.5498,1079.001 L165.9998,1079.001 C164.8958,1079.001 163.9998,1080.001 163.9998,1081.105 L163.9998,1088.105 C163.9998,1088.657 164.4478,1089.105 164.9998,1089.105 C165.5528,1089.105 165.9998,1088.657 165.9998,1088.105 L165.9998,1082.105 C165.9998,1081.553 166.4478,1081.001 166.9998,1081.001 L175.9998,1081.001 L175.9998,1085.105 C175.9998,1086.21 176.8958,1087.001 177.9998,1087.001 L181.9998,1087.001 L181.9998,1088.105 C181.9998,1088.657 182.4478,1089.105 182.9998,1089.105 C183.5528,1089.105 183.9998,1088.657 183.9998,1088.105 L183.9998,1085.838 C183.9998,1085.581 183.9018,1085.335 183.7248,1085.149 L183.7248,1085.149 Z M182.9998,1091.001 L179.9998,1091.001 C178.8958,1091.001 177.9998,1092.001 177.9998,1093.105 L177.9998,1094.105 C177.9998,1095.21 178.8958,1096.001 179.9998,1096.001 L181.4998,1096.001 C181.7758,1096.001 181.9998,1096.224 181.9998,1096.501 C181.9998,1096.777 181.7758,1097.001 181.4998,1097.001 L178.9998,1097.001 C178.4528,1097.001 178.0098,1097.493 178.0028,1098.04 C178.0098,1098.585 178.4528,1099.001 178.9998,1099.001 L181.9998,1099.001 L182.0208,1099.001 C183.1138,1099.001 183.9998,1098.219 183.9998,1097.126 L183.9998,1096.084 C183.9998,1094.991 183.1138,1094.001 182.0208,1094.001 L181.9998,1094.001 L180.4998,1094.001 C180.2238,1094.001 179.9998,1093.777 179.9998,1093.501 C179.9998,1093.224 180.2238,1093.001 180.4998,1093.001 L182.9998,1093.001 C183.5528,1093.001 183.9998,1092.605 183.9998,1092.053 L183.9998,1092.027 C183.9998,1091.474 183.5528,1091.001 182.9998,1091.001 L182.9998,1091.001 Z M177.9998,1098.053 C177.9998,1098.048 178.0028,1098.044 178.0028,1098.04 C178.0028,1098.035 177.9998,1098.031 177.9998,1098.027 L177.9998,1098.053 Z M175.9998,1091.001 L172.9998,1091.001 C171.8958,1091.001 170.9998,1092.001 170.9998,1093.105 L170.9998,1094.105 C170.9998,1095.21 171.8958,1096.001 172.9998,1096.001 L174.4998,1096.001 C174.7758,1096.001 174.9998,1096.224 174.9998,1096.501 C174.9998,1096.777 174.7758,1097.001 174.4998,1097.001 L171.9998,1097.001 C171.4528,1097.001 171.0098,1097.493 171.0028,1098.04 C171.0098,1098.585 171.4528,1099.001 171.9998,1099.001 L174.9998,1099.001 L175.0208,1099.001 C176.1138,1099.001 176.9998,1098.219 176.9998,1097.126 L176.9998,1096.084 C176.9998,1094.991 176.1138,1094.001 175.0208,1094.001 L174.9998,1094.001 L173.4998,1094.001 C173.2238,1094.001 172.9998,1093.777 172.9998,1093.501 C172.9998,1093.224 173.2238,1093.001 173.4998,1093.001 L175.9998,1093.001 C176.5528,1093.001 176.9998,1092.605 176.9998,1092.053 L176.9998,1092.027 C176.9998,1091.474 176.5528,1091.001 175.9998,1091.001 L175.9998,1091.001 Z M170.9998,1098.053 C170.9998,1098.048 171.0028,1098.044 171.0028,1098.04 C171.0028,1098.035 170.9998,1098.031 170.9998,1098.027 L170.9998,1098.053 Z M169.9998,1092.027 L169.9998,1092.053 C169.9998,1092.605 169.5528,1093.001 168.9998,1093.001 L167.9998,1093.001 C166.7858,1093.001 165.8238,1094.083 166.0278,1095.336 C166.1868,1096.32 167.1108,1097.001 168.1068,1097.001 L168.9998,1097.001 C169.5528,1097.001 169.9998,1097.474 169.9998,1098.027 L169.9998,1098.053 C169.9998,1098.605 169.5528,1099.001 168.9998,1099.001 L168.1718,1099.001 C166.0828,1099.001 164.2168,1097.473 164.0188,1095.393 C163.7918,1093.008 165.6608,1091.001 167.9998,1091.001 L168.9998,1091.001 C169.5528,1091.001 169.9998,1091.474 169.9998,1092.027 L169.9998,1092.027 Z" id="file_css-[#1767]"> </path> </g> </g> </g> </g></svg> `; const CopyIcon = ` <svg width="16px" height="16px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M17.5 14H19C20.1046 14 21 13.1046 21 12V5C21 3.89543 20.1046 3 19 3H12C10.8954 3 10 3.89543 10 5V6.5M5 10H12C13.1046 10 14 10.8954 14 12V19C14 20.1046 13.1046 21 12 21H5C3.89543 21 3 20.1046 3 19V12C3 10.8954 3.89543 10 5 10Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></g></svg> `; const TreeIcon = ` <?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> <svg fill="#000000" width="16px" height="16px" viewBox="0 0 256 256" id="Flat" xmlns="http://www.w3.org/2000/svg"> <path d="M156,92V80H144a16.01833,16.01833,0,0,0-16,16v64a16.01833,16.01833,0,0,0,16,16h12V164a16.01833,16.01833,0,0,1,16-16h40a16.01833,16.01833,0,0,1,16,16v40a16.01833,16.01833,0,0,1-16,16H172a16.01833,16.01833,0,0,1-16-16V192H144a32.03635,32.03635,0,0,1-32-32V136H84v8a16.01833,16.01833,0,0,1-16,16H36a16.01833,16.01833,0,0,1-16-16V112A16.01833,16.01833,0,0,1,36,96H68a16.01833,16.01833,0,0,1,16,16v8h28V96a32.03635,32.03635,0,0,1,32-32h12V52a16.01833,16.01833,0,0,1,16-16h40a16.01833,16.01833,0,0,1,16,16V92a16.01833,16.01833,0,0,1-16,16H172A16.01833,16.01833,0,0,1,156,92Z"/> </svg> `; const allowedPaths = ["/ranobe", "/animes", "/mangas"]; function animPage() { return window.location.href.includes("/animes"); } GM_addStyle(GM_getResourceText("colorisCSS")); //! %===================Default Config===================% const defaultConfig = { CATButtons: { type: "category", name: "Buttons" }, UserIdCopyBtn: { enabled: true, title: "Copy user ID button", description: "Кнопка под аватаркой пользователя, чтобы скопировать его ID", settings: { btnTitle: { value: "Скопировать ID", title: "Title кнопки", description: "Подсказка при наведении", }, btnStyles: { type: "css", value: `width:16px; height:16px; cursor:pointer; display:flex; align-items:center; justify-content:center; margin:10px; text-align:center; z-index:9999; `, title: "Стили кнопки", description: "", }, svgIcon: { value: CopyIcon || "<svg>...</svg>", title: "SVG иконка", description: "HTML/SVG код иконки", }, }, }, UserCssCopyBtn: { enabled: true, title: "Copy user CSS button", description: "Кнопка под аватаркой пользователя, чтобы скопировать его CSS", settings: { btnTitle: { value: "Скопировать CSS", title: "Title кнопки", description: "Текст подсказки при наведении", }, btnStyles: { type: "css", value: `width:16px; height:16px; cursor:pointer; display:flex; align-items:center; justify-content:center; margin:10px; text-align:center; z-index:9999; `, title: "Стили кнопки", // description: "", }, svgIcon: { value: cssCopyIcon || "<svg>...</svg>", title: "SVG иконка", description: "HTML/SVG код иконки", }, }, }, CommCopyBtn: { enabled: true, title: "Copy comment link button", description: "Кнопка рядом с комментарием, чтобы скопировать ссылку на комментарий", settings: { btnTitle: { value: "Скопировать ссылку", title: "Title кнопки", description: "Подсказка при наведении", }, btnStyles: { type: "css", value: ` height: 14px; margin: 0px 5px; vertical-align: middle; cursor: pointer; display: inline-block; font-size: 13px; text-align: center; width: 24px; `, title: "Стили кнопки", description: "", }, svgIcon: { value: CopyIcon || "<svg>...</svg>", title: "SVG иконка", description: "HTML/SVG код иконки", }, }, }, CommTreeBtn: { enabled: true, title: "Comment tree button", description: "Кнопка рядом с комментарием, чтобы показать древо", settings: { btnTitle: { value: "Показать древо", title: "Title кнопки", description: "Подсказка при наведении", }, btnStyles: { type: "css", value: ` height: 14px; margin: 0px 5px; vertical-align: middle; cursor: pointer; display: inline-block; font-size: 13px; text-align: center; width: 24px; `, title: "Стили кнопки", description: "", }, svgIcon: { value: TreeIcon || "<svg>...</svg>", title: "SVG иконка", description: "HTML/SVG код иконки", }, }, }, ImageIdCopyBtn: { enabled: true, title: "Copy image code button", description: "Кнопка на изображении, чтобы скопировать код изображения", settings: { btnTitle: { value: "Скопировать код изображения", title: "Title кнопки", description: "Подсказка при наведении", }, btnStyles: { type: "css", value: `width:16px; height:16px; cursor:pointer; position:absolute; top:5px; right:5px; z-index:10; `, title: "Стили кнопки", // description: "", }, svgIcon: { value: CopyIcon || "<svg>...</svg>", title: "SVG иконка", description: "HTML/SVG код иконки", }, }, }, ClubCssCopyBtn: { enabled: true, title: "Copy club CSS button", description: "Кнопка над аватаркой клуба, чтобы скопировать его CSS", settings: { btnTitle: { value: "Скопировать CSS клуба", title: "Title кнопки", description: "Подсказка при наведении", }, btnStyles: { type: "css", value: `width:16px; height:16px; cursor:pointer; display:inline-block; text-align:center; position:absolute; top:-30px; left:50%; transform:translateX(-50%); z-index:9999; transition:transform 0.2s ease; `, title: "Стили кнопки", description: "", }, svgIcon: { value: cssCopyIcon || "<svg>...</svg>", title: "SVG иконка", description: "HTML/SVG код иконки", }, }, }, CopyCodeBtn: { enabled: true, title: "Code copy button", description: "Кнопка в блоке кода, чтобы скопировать код", settings: { btnTitle: { value: "Скопировать код", title: "Title кнопки", description: "Подсказка при наведении", }, btnStyles: { type: "css", value: `position:absolute; top:6px; right:6px; width:18px; height:18px; padding:0; display:flex; align-items:center; justify-content:center; font-size:14px; line-height:1; cursor:pointer; background:transparent; border:none; border-radius:4px; transition:background 0.25s ease, transform 0.2s ease; z-index:2; `, }, svgIcon: { value: CopyIcon || "<svg>...</svg>", title: "SVG иконка", description: "HTML/SVG код иконки", }, }, }, CATFilters: { type: "category", name: "Filters" }, ShikiRating: { enabled: true, title: "Shikimori rating filter", description: "Дополнительный фильтр, сортирующий аниме по рейтингу Shikimori", settings: { template: { value: "По рейтингу (Шикимори)", title: "Название фильтра", // description: "название фильтра.", }, }, }, StudioFilter: { enabled: true, title: "Studios filter", description: "Дополнительный фильтр для сортировки аниме по студии", settings: { template: { value: "Показать список", title: "Имя спойлера", // description: "Шаблон текста.", }, }, }, ChineseFilter: { enabled: true, title: "Chinese filter", description: "Дополнительный фильтр, убирающий китайщину", settings: { filterName: { value: "Без китайщины", title: "Название фильтра", }, }, }, ForumCharacterFilter: { enabled: true, title: "Forum Character Filter", description: "Дополнительный фильтр, убирающий персонажей на форуме", settings: { template: { value: "Без персонажей", title: "Имя фильтра", // description: "filterName", }, }, }, hideNews: { enabled: true, title: "News Filter", description: "Фильтры для новостей по ID юзера и тегам", settings: { userid: { type: "ids", value: "123,123", title: "User IDS", description: "Блеклист юзеров", }, tags: { type: "tags", value: "тег1,тег2", title: "теги новостей", description: "Блеклист тегов", }, }, }, workTypeFilter: { enabled: true, title: "Work Type Filter", description: 'позволяет сортировать "тип работы" на странице человека', }, CATHelpers: { type: "category", name: "Helpers" }, NotificationHelperConfig: { enabled: true, title: "Notification helper", description: "Позволяет выбрать несколько уведомлений в почте и удалить их", settings: { highlightColor: { type: "color", value: "#D0E8FF", title: "Цвет выделения", }, deleteColor: { type: "color", value: "#FFB3B3", title: "Цвет удаления", }, throttledColor: { type: "color", value: "#FFFF99", title: "Цвет при 429", }, transitionSpeed: { type: "range", value: 0.3, title: "Скорость transition (сек)", min: 0, max: 10, step: 0.1, }, delay429: { type: "number", value: 10000, title: "Таймаут после 429 (мс)", }, buttonText: { value: "Удалить выбранные", title: "Текст кнопки" }, buttonStyle: { type: "css", title: "Стили кнопки", value: `position: fixed; bottom: 60px; right: 20px; padding: 10px; background: red; color: white; border: none; cursor: pointer; z-index: 1000;`, }, }, }, HistoryHelperConfig: { enabled: true, title: "History helper", description: "Позволяет выбрать несколько записей в истории и удалить их", settings: { highlightColor: { type: "color", value: "#D0E8FF", title: "Цвет выделения", }, deleteColor: { type: "color", value: "#FFB3B3", title: "Цвет удаления", }, throttledColor: { type: "color", value: "#FFFF99", title: "Цвет при 429", }, transitionSpeed: { type: "range", value: 0.3, title: "Скорость transition (сек)", min: 0, max: 10, step: 0.1, }, delay429: { type: "number", value: 10000, title: "Таймаут после 429 (мс)", }, buttonText: { type: "text", value: "Удалить выбранные", title: "Текст кнопки", }, buttonStyle: { type: "css", title: "Стили кнопки", value: `position: fixed; bottom: 60px; right: 20px; padding: 10px; background: red; color: white; border: none; cursor: pointer; z-index: 1000;`, }, }, }, CATMisc: { type: "category", name: "Misc" }, FriendsAVGscore: { enabled: true, title: "Friends average score", description: "Показывает среднюю оценку среди друзей", settings: { template: { value: "У друзей | {avgscore}", title: "Шаблон текста", description: "Используйте {avgscore}, чтобы подставить среднюю оценку.", }, }, }, commentsLoader: { enabled: true, title: "Comments Loader", description: "Добавляет возможность выбирать, сколько комментариев загрузить", }, FriendsHistory: { enabled: true, title: "Friends History", description: "Добавляет историю друзей в списке друзей", settings: { apilimit: { type: "number", value: 50, title: "Количество загружаемой истории у друга(100 максимум)", }, delay: { type: "number", value: 1000, title: "Задержка между запросами", }, }, }, BanCount: { enabled: true, title: "Ban count", description: "Показывает количество банов в истории банов", settings: { template: { value: "История банов - {count}", title: "Шаблон текста", description: "Используйте {count}, чтобы подставить число банов.", }, }, }, watchTime: { enabled: true, title: "Watch time", description: "Показывает время просмотра на странице аниме", settings: { template: { value: "Время просмотра:", title: "шаблон текста", }, }, }, autoSpoiler: { enabled: false, title: "Auto spoiler", description: "Скрывает все изображения под спойлер", settings: { template: { value: "image", title: "название спойлера", // description: "название спойлера.", }, mode: { type: "mode", title: "Тип эффекта", options: ["blur(BETA)", "spoiler"], value: "spoiler", }, }, }, NoAgeLimits: { enabled: true, title: "Custom age", description: "Убирает ограничение на выбор года рождения в настройках", }, removeBlur: { enabled: true, title: "Remove Blur", description: "Убирает цензуру с постеров аниме", }, checkScroll: { enabled: false, title: "Auto loader", description: "Aвтоматическая подгрузка следующих страниц и вставка их содержимого в текущую страницу (BETA)", }, }; //! %=================== CFG ===================% function loadConfig() { let saved = localStorage.getItem("ShikiUtilsConfig"); let parsed = saved ? JSON.parse(saved) : structuredClone(defaultConfig); for (let key in defaultConfig) { const defFunc = defaultConfig[key]; if (defFunc && defFunc.name && Object.keys(defFunc).length === 1) { parsed[key] = structuredClone(defFunc); continue; } if ( !(key in parsed) || typeof parsed[key] !== "object" || parsed[key] === null ) { parsed[key] = structuredClone(defFunc); continue; } const curFunc = parsed[key]; curFunc.title = defFunc.title; curFunc.description = defFunc.description; if (!curFunc.settings || typeof curFunc.settings !== "object") { curFunc.settings = structuredClone(defFunc.settings); } for (let sKey in defFunc.settings) { const defSet = defFunc.settings[sKey]; if ( !curFunc.settings[sKey] || typeof curFunc.settings[sKey] !== "object" ) { curFunc.settings[sKey] = structuredClone(defSet); } else { const curSet = curFunc.settings[sKey]; curSet.type = defSet.type; curSet.title = defSet.title; curSet.description = defSet.description; } } } for (let key in parsed) { if (!(key in defaultConfig)) { delete parsed[key]; } } return parsed; } function saveConfig() { const toSave = {}; for (let key in config) { const item = config[key]; if (item && item.name && Object.keys(item).length === 1) continue; toSave[key] = item; } localStorage.setItem("ShikiUtilsConfig", JSON.stringify(toSave)); } let config = loadConfig(); //! %=================== Builders ===================% function btnBuilder({ tag = "a", classes = [], title = "", dataset = {}, styles = {}, svgIcon = "", onClick = null, }) { const btn = document.createElement(tag); classes.forEach((cls) => btn.classList.add(cls)); if (title) btn.title = title; for (const key in dataset) btn.dataset[key] = dataset[key]; for (const key in styles) btn.style[key] = styles[key]; btn.innerHTML = svgIcon; const transitionStyle = "stroke 0.3s ease, fill 0.3s ease"; btn.addEventListener("mouseenter", () => { const svg = btn.querySelector("svg"); if (svg) { svg.querySelectorAll("path").forEach((path) => { path.style.transition = transitionStyle; let computedStroke = window.getComputedStyle(path).stroke; if ( computedStroke && computedStroke !== "none" && computedStroke !== "rgba(0, 0, 0, 0)" ) { if (!path.dataset.originalStroke) { path.dataset.originalStroke = computedStroke; } path.style.stroke = "var(--link-hover-color)"; } else { let computedFill = window.getComputedStyle(path).fill; if (!path.dataset.originalFill) { path.dataset.originalFill = computedFill; } path.style.fill = "var(--link-hover-color)"; } }); } }); btn.addEventListener("mouseleave", () => { const svg = btn.querySelector("svg"); if (svg) { svg.querySelectorAll("path").forEach((path) => { path.style.transition = transitionStyle; if (path.dataset.originalStroke) { path.style.stroke = path.dataset.originalStroke; } else if (path.dataset.originalFill) { path.style.fill = path.dataset.originalFill; } }); } }); if (onClick) { btn.addEventListener("click", async (e) => { await onClick(e); btn.style.transform = "scale(1.5)"; setTimeout(() => (btn.style.transform = "scale(1)"), 200); }); } return btn; } function helperBuilder({ configKey, itemSelector, checkboxClass, deleteButtonSelector, deleteMethod, deleteUrlAttr, showOnHover, checkboxContainerSelector, }) { if (!document.querySelector(deleteButtonSelector)) return; const cfg = config[configKey].settings; let button = null; let lastChecked = null; let isThrottled = false; function addCheckbox(item) { if (item.querySelector(`.${checkboxClass}`)) return; const container = checkboxContainerSelector ? item.querySelector(checkboxContainerSelector) : item; if (!container) return; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.className = checkboxClass; checkbox.style.marginLeft = "8px"; if (showOnHover) { checkbox.style.display = "none"; item.addEventListener("mouseenter", () => { checkbox.style.display = "inline-block"; }); item.addEventListener("mouseleave", () => { if (!checkbox.checked) checkbox.style.display = "none"; }); } checkbox.addEventListener("click", (event) => { shiftSelect(event, checkbox); updateHighlight(checkbox); }); container.prepend(checkbox); } document.querySelectorAll(itemSelector).forEach(addCheckbox); const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1 && node.matches(itemSelector)) { addCheckbox(node); } else if (node.nodeType === 1) { node.querySelectorAll(itemSelector).forEach(addCheckbox); } }); } }); observer.observe(document.body, { childList: true, subtree: true }); function shiftSelect(event, checkbox) { if (event.shiftKey && lastChecked) { const checkboxes = [...document.querySelectorAll(`.${checkboxClass}`)]; const start = checkboxes.indexOf(lastChecked); const end = checkboxes.indexOf(checkbox); checkboxes .slice(Math.min(start, end), Math.max(start, end) + 1) .forEach((cb) => { cb.checked = lastChecked.checked; updateHighlight(cb); }); } lastChecked = checkbox; updateDeleteButton(); } function updateHighlight(checkbox) { const item = checkbox.closest(itemSelector); item.style.backgroundColor = checkbox.checked ? cfg.highlightColor.value : ""; item.style.transition = `background-color ${cfg.transitionSpeed.value}s ease`; } function updateDeleteButton() { const selectedItems = document.querySelectorAll(`.${checkboxClass}:checked`); if (selectedItems.length > 0) { if (!button) { button = document.createElement("button"); button.id = `${configKey}-delete-button`; button.textContent = cfg.buttonText.value; button.style = cfg.buttonStyle.value; button.addEventListener("click", () => deleteSelected()); document.body.appendChild(button); } } else if (button) { button.remove(); button = null; } } async function deleteSelected() { if (isThrottled) return; const token = document.querySelector('meta[name="csrf-token"]')?.content; if (!token) return; const selectedItems = document.querySelectorAll( `.${checkboxClass}:checked` ); for (const checkbox of selectedItems) { const item = checkbox.closest(itemSelector); const delBtn = item.querySelector(deleteButtonSelector); if (!delBtn) continue; item.style.backgroundColor = cfg.deleteColor.value; item.style.transition = `background-color ${cfg.transitionSpeed.value}s ease`; const deleteUrl = delBtn.getAttribute(deleteUrlAttr); if (!deleteUrl) continue; try { const response = await fetch(deleteUrl, { method: deleteMethod, credentials: "include", headers: { "User-Agent": navigator.userAgent, "X-CSRF-Token": token, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ _method: "delete", authenticity_token: token, }), redirect: "manual", }); if ( response.type === "opaqueredirect" || response.status === 302 || response.ok ) { await new Promise((r) => setTimeout(r, 300)); item.remove(); } else if (response.status === 429) { item.style.backgroundColor = cfg.throttledColor.value; isThrottled = true; setTimeout(() => { isThrottled = false; deleteSelected(); }, cfg.delay429.value); return; } } catch (err) { console.error("[ShikiUtils]", err); } } if (button) { button.remove(); button = null; } } } //! %===================================================== Function =====================================================% //! %=================== Filters ====================% //* %=================== Forum Character Filter ===================% function ForumCharacterFilter() { const menu = document.querySelector(".l-menu.ajax-opacity .b-forums .simple_form.b-form.edit_user_preferences"); const storageKey = "ForumCharacterFilterActive"; let active = localStorage.getItem(storageKey) === "true"; const cfg = config.ForumCharacterFilter; function addCheckbox() { if (!menu || menu.querySelector(".forum-character-filter")) return; const div = document.createElement("div"); div.className = "forum special forum-character-filter"; div.innerHTML = ` <div class="link-with-input"> <input type="checkbox" id="forumCharacterFilterCheckbox"> <a class="link" href="#">${cfg.settings.template.value || "Без персонажей"} </a> </div> `; menu.appendChild(div); const checkbox = div.querySelector("#forumCharacterFilterCheckbox"); checkbox.checked = active; checkbox.addEventListener("change", () => { active = checkbox.checked; localStorage.setItem(storageKey, active); applyFilter(); }); } function applyFilter() { if (!menu) return; document.querySelectorAll("article").forEach((article) => { const meta = article.querySelector('meta[itemprop="name"]'); if (meta && meta.content === "Обсуждение персонажа") { article.style.display = active ? "none" : ""; } }); } function syncCheckbox() { const checkbox = document.querySelector("#forumCharacterFilterCheckbox"); if (checkbox && checkbox.checked !== active) { checkbox.checked = active; } } addCheckbox(); applyFilter(); syncCheckbox(); const observer = new MutationObserver(() => { addCheckbox(); syncCheckbox(); applyFilter(); }); observer.observe(document.body, { childList: true, subtree: true }); } // %=================== Chinese filter ====================% function ChineseFilter() { const storageKey = "ChineseFilterActive"; const idsCacheKey = "ChineseFilterIds"; const idsCacheTimeKey = "ChineseFilterIdsTime"; const idsUrl = "https://raw.githubusercontent.com/shikigraph/Fumo/refs/heads/main/ChineseIds.json"; const updateInterval = 24 * 60 * 60 * 1000; // 24 часа let active = localStorage.getItem(storageKey) === "true"; let ids = []; async function loadIds() { try { const now = Date.now(); const lastUpdate = parseInt(localStorage.getItem(idsCacheTimeKey) || "0", 10); const cached = localStorage.getItem(idsCacheKey); if (cached && now - lastUpdate < updateInterval) { ids = JSON.parse(cached); applyFilter(); return; } await refreshIds(); } catch (e) { console.error("[ShikiUtils]: loadIds err:", e); } } async function refreshIds() { try { const resp = await fetch(idsUrl, { cache: "no-store" }); if (!resp.ok) throw new Error("HTTP " + resp.status); const data = await resp.json(); if (Array.isArray(data)) { ids = data.map((n) => parseInt(n, 10)).filter((n) => !isNaN(n)); localStorage.setItem(idsCacheKey, JSON.stringify(ids)); localStorage.setItem(idsCacheTimeKey, Date.now().toString()); applyFilter(); } } catch (e) { console.error("[ShikiUtils]: refreshIds err :", e); } } function addCheckbox() { const container = document.querySelector(".b-block_list.kinds.anime-params"); if (!container || container.querySelector(".chinese-filter")) return; const li = document.createElement("li"); li.className = "chinese-filter"; const labelText = "Без китайщины"; li.innerHTML = ` <span class="filter item-add fake"></span> <input type="checkbox" id="chineseFilterCheckbox" autocomplete="off"> ${labelText}`; container.appendChild(li); const checkbox = li.querySelector("#chineseFilterCheckbox"); checkbox.checked = active; if (active) li.classList.add("selected"); checkbox.addEventListener("change", () => { active = checkbox.checked; localStorage.setItem(storageKey, active); li.classList.toggle("selected", active); applyFilter(); }); } function applyFilter() { const container = document.querySelector(".b-block_list.kinds.anime-params"); if (!container) return; if (!ids.length) return; document.querySelectorAll("article.c-anime").forEach((article) => { const animeId = parseInt(article.id, 10); if (isNaN(animeId)) return; if (active && ids.includes(animeId)) { article.remove(); } }); } function syncCheckbox() { const checkbox = document.querySelector("#chineseFilterCheckbox"); const li = document.querySelector(".chinese-filter"); if (checkbox) { checkbox.checked = active; if (li) li.classList.toggle("selected-fake", active); } } addCheckbox(); loadIds(); syncCheckbox(); const observer = new MutationObserver(() => { addCheckbox(); syncCheckbox(); applyFilter(); }); observer.observe(document.body, { childList: true, subtree: true }); } //* %=================== Studio Filter ====================% async function StudioFilter() { //todo api url v cfg if (!animPage()) { return; } const hiddenBlocks = document.querySelectorAll(".block.hidden"); let filterBlock = null; hiddenBlocks.forEach((block) => { if ( block.querySelector(".subheadline.m5")?.textContent.includes("Студия") ) { filterBlock = block; } }); if (!filterBlock) { return; } let studioList = filterBlock.querySelector(".b-block_list.studios.anime-params"); if (!studioList) { studioList = document.createElement("ul"); studioList.className = "b-block_list studios anime-params"; filterBlock.appendChild(studioList); } try { const response = await fetch("https://shikimori.one/api/studios", { headers: { "User-Agent": "Mozilla/5.0", }, }); if (!response.ok) { throw new Error(response.status); } const studios = await response.json(); const realStudios = studios.filter((studio) => studio.real); // spoiler const spoilerContainer = document.createElement("div"); spoilerContainer.className = "b-spoiler"; const spoilerLabel = document.createElement("label"); spoilerLabel.textContent = config.StudioFilter.settings.template.value; spoilerLabel.style.cursor = "pointer"; const spoilerContent = document.createElement("div"); spoilerContent.className = "content only-show"; spoilerContent.style.display = "none"; const spoilerInner = document.createElement("div"); spoilerInner.className = "inner"; spoilerInner.appendChild(studioList); spoilerContent.appendChild(spoilerInner); spoilerContainer.appendChild(spoilerLabel); spoilerContainer.appendChild(spoilerContent); filterBlock.appendChild(spoilerContainer); // ev spoilerLabel.addEventListener("click", () => { spoilerLabel.style.display = "none"; spoilerContent.style.display = "block"; spoilerContainer.dispatchEvent(new Event("spoiler:open")); }); realStudios.forEach((studio) => { const studioItem = document.createElement("li"); studioItem.dataset.field = "studio"; studioItem.dataset.value = `${studio.id}-${studio.filtered_name}`; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; studioItem.appendChild(checkbox); studioItem.appendChild(document.createTextNode(` ${studio.name}`)); studioList.appendChild(studioItem); }); filterBlock.classList.remove("hidden"); } catch (error) { console.error("[ShikiUtils]", error); } } //* %=================== Shiki Rating Filter ====================% function ShikiRating() {//todo фикс score_2 const filtersContainer = document.querySelector(".b-block_list.orders.anime-params.subcontent"); if (!filtersContainer) return; const ShikiSort = document.createElement("li"); ShikiSort.setAttribute("data-field", "order"); ShikiSort.setAttribute("data-value", "score_2"); ShikiSort.innerHTML = config.ShikiRating.settings.template.value; const referenceElement = filtersContainer.querySelector('li[data-field="order"][data-value="ranked"]'); if (referenceElement) { filtersContainer.insertBefore(ShikiSort, referenceElement.nextSibling); } else { filtersContainer.appendChild(ShikiSort); } } //* %=================== News Filter ====================% function hideNews() { const blockedUserIds = config.hideNews.settings.userid.value .split(",") .map((id) => id.trim()); const blockedTags = config.hideNews.settings.tags.value .split(",") .map((tag) => tag.trim().toLowerCase()); document .querySelectorAll("article.b-news_wall-topic") .forEach((article) => { const userId = article.getAttribute("data-user_id"); if (blockedUserIds.includes(userId)) { article.style.display = "none"; return; } const tagElements = article.querySelectorAll(".tags .b-anime_status_tag"); const articleTags = Array.from(tagElements).map((el) => el.getAttribute("data-text").toLowerCase() ); if (articleTags.some((tag) => blockedTags.includes(tag))) { article.style.display = "none"; } }); } //* %=================== Work Type Filter ====================% function workTypeFilter() { if (!/\/people\/\d+-.*\/works/.test(location.pathname)) return; const mainContainer = document.querySelector(".l-content"); const contentContainer = document.querySelector(".cc-5"); if (!mainContainer || !contentContainer) return; if (mainContainer.querySelector('[data-shikiutils="works-filter"]')) return; const articles = Array.from(contentContainer.querySelectorAll("article")); if (!articles.length) return; articles.forEach((article, index) => { if (!article.dataset.originalIndex) { article.dataset.originalIndex = index; } }); const typesSet = new Set(); articles.forEach((article) => { const textDiv = article.querySelector(".text"); if (textDiv) { textDiv.textContent .split(",") .map((t) => t.trim()) .forEach((t) => { if (t) typesSet.add(t); }); } }); const types = Array.from(typesSet); if (!types.length) return; const style = document.createElement("style"); //todo css v cfg style.textContent = ` .b-options-floated[data-shikiutils="works-filter"] { position: initial; display: flex; flex-wrap: wrap; margin-bottom: 10px; gap: 4px 0px; } `; document.head.appendChild(style); const filterBlock = document.createElement("div"); filterBlock.className = "b-options-floated mobile-phone"; filterBlock.dataset.shikiutils = "works-filter"; function reorderArticles() { const visible = []; const hidden = []; articles.forEach((article) => { if (article.style.display === "none") hidden.push(article); else visible.push(article); }); visible.sort((a, b) => a.dataset.originalIndex - b.dataset.originalIndex); hidden.sort((a, b) => a.dataset.originalIndex - b.dataset.originalIndex); [...visible, ...hidden].forEach((a) => contentContainer.appendChild(a)); } types.forEach((type) => { const filterBtn = document.createElement("a"); filterBtn.textContent = type; filterBtn.addEventListener("click", () => { const isSelected = filterBtn.classList.contains("selected"); filterBlock .querySelectorAll("a") .forEach((a) => a.classList.remove("selected")); if (!isSelected) { filterBtn.classList.add("selected"); articles.forEach((article) => { const textDiv = article.querySelector(".text"); if (textDiv) { const articleTypes = textDiv.textContent .split(",") .map((t) => t.trim()); article.style.display = articleTypes.includes(type) ? "" : "none"; } }); } else { articles.forEach((article) => (article.style.display = "")); } reorderArticles(); }); filterBlock.appendChild(filterBtn); }); mainContainer.prepend(filterBlock); } //! %=================== Helpers ====================% //* %=================== Notification Helper ====================% function NotificationHelper() { helperBuilder({ configKey: "NotificationHelperConfig", itemSelector: ".b-message, .b-dialog", checkboxClass: "notification-checkbox", deleteButtonSelector: ".item-delete-confirm", deleteMethod: "DELETE", deleteUrlAttr: "action", showOnHover: true, checkboxContainerSelector: "aside.buttons div.main-controls", }); } //* %=================== History Helper ====================% function HistoryHelper() { helperBuilder({ configKey: "HistoryHelperConfig", itemSelector: ".b-user_history-line", checkboxClass: "history-checkbox", deleteButtonSelector: ".destroy", deleteMethod: "POST", deleteUrlAttr: "href", showOnHover: false, checkboxContainerSelector: null, }); } //! %=================== Buttons ====================% //* %=================== Club Css Copy Btn ====================% async function ClubCssCopyBtn() { const cfg = config.ClubCssCopyBtn.settings; document.querySelectorAll(".b-clubs-menu").forEach((menu) => { const stylesObj = Object.fromEntries( cfg.btnStyles.value .split(";") .filter((s) => s) .map((s) => s.split(":").map((x) => x.trim())) ); const button = btnBuilder({ tag: "a", classes: ["copy-club-css", "b-tooltipped"], title: cfg.btnTitle.value, styles: stylesObj, svgIcon: cfg.svgIcon.value, onClick: async () => { const match = window.location.href.match(/\/clubs\/(\d+)-/); if (match) { const clubId = match[1]; try { const clubResponse = await fetch(`https://shikimori.one/api/clubs/${clubId}`); const clubData = await clubResponse.json(); const styleId = clubData.style_id; if (!styleId) throw new Error("no styleId"); const styleResponse = await fetch(`https://shikimori.one/api/styles/${styleId}`); const styleData = await styleResponse.json(); if (!styleData.compiled_css) { throw new Error("no styleData.compiled_css"); } await navigator.clipboard.writeText(styleData.compiled_css); } catch (err) { console.error("[ShikiUtils]", err); } } }, }); menu.prepend(button); }); } //* %=================== Comm Copy Btn ====================% function CommCopyBtn() { const cfg = config.CommCopyBtn.settings; document.querySelectorAll(".b-comment").forEach((comment) => { if (comment.querySelector(".copy-comment-link")) return; const commentId = comment.id; if (!commentId) return; const commentLink = `https://shikimori.one/comments/${commentId}`; const button = btnBuilder({ tag: "span", classes: ["copy-comment-link"], title: cfg.btnTitle.value, styles: {}, svgIcon: cfg.svgIcon.value, onClick: () => navigator.clipboard.writeText(commentLink).catch(console.error), }); button.style.cssText = cfg.btnStyles.value; const mainControls = comment.querySelector(".main-controls"); if (mainControls) mainControls.appendChild(button); }); } function CommTreeBtn() { const cfg = config.CommTreeBtn.settings; document.querySelectorAll(".b-comment").forEach((comment) => { if (comment.querySelector(".comment-tree-btn")) return; const commentId = comment.id; if (!commentId) return; const button = btnBuilder({ tag: "span", classes: ["comment-tree-btn"], title: cfg.btnTitle.value || "Показать древо комментариев", styles: {}, svgIcon: cfg.svgIcon.value, onClick: async () => { try { const visited = new Set(); const nodes = []; const links = []; const commentCache = new Map(); async function getCommentData(id) { if (commentCache.has(id)) return commentCache.get(id); const resp = await fetch(`https://shikimori.one/api/comments/${id}`); if (!resp.ok) return null; const data = await resp.json(); commentCache.set(id, data); return data; } async function parseCommentTree( id, parentId = null, mainAuthor = null, mainResponder = null ) { if (visited.has(id)) return; visited.add(id); const data = await getCommentData(id); if (!data) return; const author = data.user?.nickname || "unknown"; const date = new Date(data.created_at).getFullYear(); if (!nodes.some((n) => n.id === data.id)) { nodes.push({ id: data.id, date, image_url: data.user?.image?.x64 || "", author, weight: 10, }); } if (parentId) { if ( !links.some( (l) => l.source_id === parentId && l.target_id === data.id ) ) { links.push({ source_id: data.id, target_id: parentId, weight: 1, relation: "sequel", }); } } const replies = [ ...data.body.matchAll(/\[replies=([0-9,]+)\]/g), ].flatMap((m) => m[1] .split(",") .map((x) => x.trim()) .filter(Boolean) ); const repliesTo = [ ...data.body.matchAll(/\[comment=(\d+);/g), ].map((m) => m[1]); const quotesTo = [ ...data.body.matchAll(/>\?c(\d+);/g) ].map((m) => m[1]); if (replies.length === 0 && repliesTo.length === 0 && !parentId) { links.push({ source_id: data.id, target_id: data.id, weight: 1, relation: "other", }); return; } for (const repId of replies) { if (!visited.has(repId)) { await parseCommentTree( repId, data.id, mainAuthor || author, mainResponder || author ); } else { if (!links.some((l) => l.source_id === data.id && l.target_id === Number(repId))) { links.push({ source_id: Number(repId), target_id: data.id, weight: 1, relation: "sequel", }); } } } for (const refId of repliesTo) { if (!visited.has(refId)) { await parseCommentTree( refId, null, mainAuthor || author, mainResponder || author ); } if (!links.some((l) => l.source_id === data.id && l.target_id === Number(refId))) { links.push({ source_id: data.id, target_id: Number(refId), weight: 1, relation: "sequel", }); } } for (const quoteId of quotesTo) { if (!visited.has(quoteId)) { await parseCommentTree( quoteId, null, mainAuthor || author, mainResponder || author ); } if (!links.some((l) => l.source_id === data.id && l.target_id === Number(quoteId))) { links.push({ source_id: data.id, target_id: Number(quoteId), weight: 1, relation: "sequel", }); } } } await parseCommentTree(commentId); const data = { current_id: Number(commentId), nodes, links, }; localStorage.setItem("ShikiUtils_CommTreeData", JSON.stringify(data)); localStorage.setItem("shikiDialogFromButton", "true"); window.location.href = "https://shikimori.one/animes/59846/franchise"; } catch (err) { console.error("[ShikiUtils] ошибка при построении:", err); } }, }); button.style.cssText = cfg.btnStyles.value; const mainControls = comment.querySelector(".main-controls"); if (mainControls) mainControls.appendChild(button); }); function CleanUP() { const container = document.querySelector(".graph"); if (container) { container.querySelectorAll("svg").forEach((el) => el.remove()); } document.querySelectorAll(".head.misc").forEach((el) => el.remove()); } async function renderCommentTreeGraph() { if (!window.location.href.includes("/franchise")) return; if (localStorage.getItem("shikiDialogFromButton") !== "true") return; const stored = localStorage.getItem("ShikiUtils_CommTreeData"); if (!stored) return; try { const data = JSON.parse(stored); localStorage.removeItem("ShikiUtils_CommTreeData"); localStorage.removeItem("shikiDialogFromButton"); CleanUP(); const container = document.querySelector(".graph"); const graph = new window.FranchiseGraph(data); setTimeout(() => graph.render_to(container), 2000); } catch (err) { console.error("[ShikiUtils] ошибка при отрисовке:", err); } } ready(() => { setTimeout(() => { renderCommentTreeGraph(); }, 2000); }); } //* %=================== User CSS Copy Btn ===================% async function UserCssCopyBtn() { const cfg = config.UserCssCopyBtn.settings; document.querySelectorAll(".c-brief .avatar").forEach((avatar) => { let btnContainer = avatar.querySelector(".ShikiUtils-buttons"); //todo css в кфг if (!btnContainer) { btnContainer = document.createElement("div"); btnContainer.className = "ShikiUtils-buttons"; btnContainer.style.display = "flex"; btnContainer.style.gap = "10px"; btnContainer.style.alignItems = "center"; btnContainer.style.marginTop = "5px"; btnContainer.style.marginLeft = "5px"; const profileActions = avatar.querySelector(".profile-actions"); if (profileActions) { profileActions.parentNode.insertBefore( btnContainer, profileActions.nextSibling ); } else { avatar.appendChild(btnContainer); } } if (btnContainer.querySelector(".copy-profile-css")) return; const profileHead = avatar.closest(".profile-head"); if (!profileHead) return; const userId = profileHead.dataset.userId; if (!userId) return; const button = btnBuilder({ tag: "a", classes: ["copy-profile-css", "b-tooltipped"], title: cfg.btnTitle.value, dataset: { direction: "top" }, styles: {}, svgIcon: cfg.svgIcon.value, onClick: async () => { try { const userResponse = await fetch(`https://shikimori.one/api/users/${userId}`); const userData = await userResponse.json(); const styleId = userData.style_id; if (!styleId) throw new Error("no styleId"); const styleResponse = await fetch(`https://shikimori.one/api/styles/${styleId}`); const styleData = await styleResponse.json(); await navigator.clipboard.writeText(styleData.compiled_css); } catch (err) { console.error("[ShikiUtils]", err); } }, }); button.style.cssText = cfg.btnStyles.value; btnContainer.appendChild(button); }); } //* %=================== User Id Copy Btn ===================% function UserIdCopyBtn() { const cfg = config.UserIdCopyBtn.settings; document.querySelectorAll(".c-brief .avatar").forEach((avatar) => { let btnContainer = avatar.querySelector(".ShikiUtils-buttons"); if (!btnContainer) { btnContainer = document.createElement("div"); btnContainer.className = "ShikiUtils-buttons"; btnContainer.style.display = "flex"; btnContainer.style.gap = "5px"; btnContainer.style.alignItems = "center"; btnContainer.style.marginTop = "5px"; const profileActions = avatar.querySelector(".profile-actions"); if (profileActions) { profileActions.parentNode.insertBefore(btnContainer, profileActions.nextSibling); } else { avatar.appendChild(btnContainer); } } if (btnContainer.querySelector(".copy-profile-id")) return; const profileHead = avatar.closest(".profile-head"); if (!profileHead) return; const userId = profileHead.dataset.userId; if (!userId) return; const button = btnBuilder({ tag: "a", classes: ["copy-profile-id", "b-tooltipped"], title: "Скопировать ID", dataset: { direction: "top" }, styles: {}, svgIcon: cfg.svgIcon.value, onClick: () => navigator.clipboard .writeText(userId) .catch((err) => console.error("[ShikiUtils]", err)), }); button.style.cssText = cfg.btnStyles.value; btnContainer.appendChild(button); }); } //* %=================== Image Id Copy Btn ===================% function ImageIdCopyBtn() { const cfg = config.ImageIdCopyBtn.settings; document.querySelectorAll(".b-image").forEach((imageWrapper) => { if (imageWrapper.querySelector(".copy-image-id-button")) return; const imageData = imageWrapper.getAttribute("data-attrs"); if (!imageData) return; try { const parsed = JSON.parse(imageData); const imageId = parsed.id; if (!imageId) return; const button = btnBuilder({ tag: "span", classes: ["copy-image-id-button"], title: cfg.btnTitle.value, styles: {}, svgIcon: cfg.svgIcon.value, onClick: (event) => { event.stopPropagation(); event.preventDefault(); return navigator.clipboard .writeText(`[image=${imageId}]`) .catch(console.error); }, }); button.style.cssText = cfg.btnStyles.value; imageWrapper.style.position = "relative"; imageWrapper.appendChild(button); } catch (e) { console.error("[ShikiUtils]", e); } }); } //* %=================== Code Copy Btn ===================% function CopyCodeBtn() { const cfg = config.CopyCodeBtn.settings; document.querySelectorAll("pre.b-code-v2").forEach((pre) => { if (pre.querySelector(".copy-code-button")) return; const codeEl = pre.querySelector("code"); if (!codeEl) return; try { const button = btnBuilder({ tag: "span", classes: ["copy-code-button"], title: cfg.btnTitle.value, styles: {}, svgIcon: cfg.svgIcon.value, onClick: (event) => { event.stopPropagation(); event.preventDefault(); return navigator.clipboard .writeText(codeEl.textContent.trim()) .catch((err) => console.error("[ShikiUtils] CopyCodeBtn :", err)); }, }); button.style.cssText = cfg.btnStyles.value; pre.appendChild(button); } catch (e) { console.error("[ShikiUtils] CopyCodeBtn:", e); } }); } //! %=================== Misc ====================% //* %=================== Watch Time ====================% async function watchTime() { if (!animPage()) return; function elementFinder(doc, keyText) { const lines = doc.querySelectorAll(".b-entry-info .line-container .line"); for (let line of lines) { const key = line.querySelector(".key"); if (key && key.textContent.includes(keyText)) { return line.querySelector(".value"); } } return null; } function parseDur(durationText) { const hoursMatch = /(\d+)\s*час/.exec(durationText); const minsMatch = /(\d+)\s*мин/.exec(durationText); return ( (hoursMatch ? parseInt(hoursMatch[1]) * 60 : 0) + (minsMatch ? parseInt(minsMatch[1]) : 0) ); } function rotEbal(number, one, two, five) { const n1 = Math.abs(number) % 10; const n2 = Math.abs(number) % 100; if (n2 > 10 && n2 < 20) return five; if (n1 > 1 && n1 < 5) return two; if (n1 === 1) return one; return five; } function formatTime(totalMins, zero = false) { const days = Math.floor(totalMins / (24 * 60)); const hours = Math.floor((totalMins % (24 * 60)) / 60); const mins = totalMins % 60; const dayText = rotEbal(days, "день", "дня", "дней"); const hourText = rotEbal(hours, "час", "часа", "часов"); const minsText = rotEbal(mins, "минута", "минуты", "минут"); if (zero) { return `${days} ${dayText}, ${hours} ${hourText}, ${mins} ${minsText}`; } else { const parts = []; if (days > 0) parts.push(`${days} ${dayText}`); if (hours > 0 || (days === 0 && mins === 0)) { parts.push(`${hours} ${hourText}`); } if (mins > 0 || (days === 0 && hours === 0)) { parts.push(`${mins} ${minsText}`); } return parts.join(", "); } } try { const episodesElement = elementFinder(document, "Эпизоды"); const durationElement = elementFinder(document, "Длительность эпизода"); if (!episodesElement || episodesElement.textContent.trim() === "") return; const episodes = episodesElement.textContent.trim(); const duration = durationElement ? durationElement.textContent.trim() : "Неизвестно"; const duratonM = parseDur(duration); const totalTime = parseInt(episodes) * duratonM; if (duratonM && episodes !== "Неизвестно" && totalTime !== duratonM) { if (!document.querySelector(".time-block")) { const timeBlock = document.createElement("div"); timeBlock.classList.add("line", "time-block"); const text = config.watchTime.settings.template.value; timeBlock.innerHTML = ` <div class="key">${text}</div> <div class="value"><span>${formatTime(totalTime)}</span></div> `; if (durationElement && durationElement.parentNode) { durationElement.parentNode.parentNode.appendChild(timeBlock); } } } } catch (error) { console.error("[ShikiUtils]", error); } } //* %=================== Friends History ===================% //todo общая задержка перед появлением function FriendsHistoryTooltip() { let tooltipElem = null; let hideTimeout = null; let fetchTimeout = null; const cache = new Map(); let isTooltipHovered = false; function getTooltip() { if (!tooltipElem) { //todo cfg для тултипа tooltipElem = document.createElement("div"); tooltipElem.className = "tooltip tooltip-left"; tooltipElem.style.position = "absolute"; tooltipElem.style.zIndex = "9999"; tooltipElem.style.display = "none"; tooltipElem.style.pointerEvents = "auto"; tooltipElem.style.transition = "opacity 0.15s ease"; tooltipElem.style.opacity = "0"; tooltipElem.innerHTML = ` <div class="tooltip-inner"> <div class="tooltip-arrow"></div> <div class="clearfix"> <div class="tooltip-details"> <div class="b-catalog_entry-tooltip">Загрузка...</div> </div> </div> </div> `; tooltipElem.addEventListener("mouseenter", () => { isTooltipHovered = true; clearTimeout(hideTimeout); }); tooltipElem.addEventListener("mouseleave", () => { isTooltipHovered = false; hideTooltipWithDelay(); }); document.body.appendChild(tooltipElem); } return tooltipElem; } function hideTooltipWithDelay() { clearTimeout(hideTimeout); hideTimeout = setTimeout(() => { if (!isTooltipHovered) { tooltipElem.style.opacity = "0"; tooltipElem.style.display = "none"; } }, 150); //todo v cfg } document.addEventListener( "mouseover", (e) => { const link = e.target.closest(".db-entry[data-tooltip-url]"); if (!link) return; clearTimeout(hideTimeout); clearTimeout(fetchTimeout); const tooltipUrl = link.dataset.tooltipUrl; if (!tooltipUrl) return; const tooltip = getTooltip(); tooltip.style.display = "block"; tooltip.style.opacity = "0"; tooltip.querySelector(".b-catalog_entry-tooltip").innerHTML = "Загрузка..."; const mouseX = e.pageX; const mouseY = e.pageY; const offset = 20; const screenWidth = window.innerWidth; const screenHeight = window.innerHeight; const scrollY = window.scrollY; const isRightSide = mouseX < screenWidth / 2; tooltip.classList.remove("tooltip-left", "tooltip-right"); tooltip.classList.add(isRightSide ? "tooltip-right" : "tooltip-left"); const tooltipHeight = tooltip.offsetHeight || 200; const tooltipWidth = tooltip.offsetWidth || 300; const offsetY = 75; let topPos = mouseY - tooltipHeight / 2 - offsetY; let leftPos = isRightSide ? mouseX + offset : mouseX - tooltipWidth - offset; if (topPos < scrollY + 10) topPos = scrollY + 10; if (topPos + tooltipHeight > scrollY + screenHeight - 10) { topPos = scrollY + screenHeight - tooltipHeight - 10; } tooltip.style.top = `${topPos}px`; tooltip.style.left = `${Math.max(leftPos, 10)}px`; if (cache.has(tooltipUrl)) { tooltip.querySelector(".b-catalog_entry-tooltip").innerHTML = cache.get(tooltipUrl); tooltip.style.opacity = "1"; } else { fetchTimeout = setTimeout(async () => { try { const resp = await fetch(`${tooltipUrl}/tooltip`); const html = await resp.text(); const tmp = document.createElement("div"); tmp.innerHTML = html; const inner = tmp.querySelector(".b-catalog_entry-tooltip"); const innerHTML = inner ? inner.innerHTML : "Ошибка загрузки"; cache.set(tooltipUrl, innerHTML); tooltip.querySelector(".b-catalog_entry-tooltip").innerHTML = innerHTML; } catch (err) { console.error("Tooltip load error:", err); tooltip.querySelector(".b-catalog_entry-tooltip").innerHTML = "Ошибка загрузки."; } tooltip.style.opacity = "1"; }, 200); //todo v cfg } link.addEventListener( "mouseleave", () => { clearTimeout(fetchTimeout); hideTooltipWithDelay(); }, { once: true } ); }, true ); } async function FriendsHistory() { //todo выбор друзей + логика для 100+ друзей const profileBlock = document.querySelector(".block.is-own-profile"); if (!profileBlock || !location.pathname.endsWith("/friends")) return; const username = getUsername(); const userId = getUserId(); console.log("[ShikiUtils] FriendsHistory: username =", username, "userId =", userId); if (!username || !userId) return; FriendsHistoryTooltip(); const friendsResp = await fetch(`/api/users/${userId}/friends?limit=100`); if (!friendsResp.ok) return; const friends = await friendsResp.json(); if (!friends.length) return; const headline = document.createElement("h2"); headline.className = "subheadline"; headline.textContent = "История друзей"; //todo v cfg profileBlock.appendChild(headline); const historyContainer = document.createElement("div"); historyContainer.className = "history-container"; profileBlock.appendChild(historyContainer); const progressElem = document.createElement("div"); progressElem.style.margin = "5px 0"; progressElem.textContent = `Загружено 0 / ${friends.length} друзей...`; //todo v cfg profileBlock.insertBefore(progressElem, historyContainer); const delay = (ms) => new Promise((r) => setTimeout(r, ms)); function timeAgo(date) { const diffSec = Math.floor((new Date() - date) / 1000); const declension = (num, words) => { num = Math.abs(num) % 100; const n1 = num % 10; if (num > 10 && num < 20) return words[2]; if (n1 > 1 && n1 < 5) return words[1]; if (n1 === 1) return words[0]; return words[2]; }; if (diffSec < 60) { return "несколько секунд назад"; } const minutes = Math.floor(diffSec / 60); if (minutes < 60) { return `${minutes} ${declension(minutes, ["минуту", "минуты", "минут",])} назад`; } const hours = Math.floor(diffSec / 3600); if (hours < 24) { return `${hours} ${declension(hours, ["час", "часа", "часов"])} назад`; } const days = Math.floor(diffSec / 86400); if (days < 7) { return `${days} ${declension(days, ["день", "дня", "дней"])} назад`; } const weeks = Math.floor(days / 7); if (weeks < 5) { return `${weeks} ${declension(weeks, ["неделю", "недели", "недель",])} назад`; } const months = Math.floor(days / 30.44); if (months < 12) { return `${months} ${declension(months, ["месяц", "месяца", "месяцев",])} назад`; } const years = Math.floor(days / 365.25); return `${years} ${declension(years, ["год", "года", "лет"])} назад`; } function getTimeCategory(date) { const now = new Date(); const diffMs = now - date; const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return "сегодня"; if (diffDays === 1) return "вчера"; if (diffDays <= 7) return "в течение недели"; const weeks = Math.floor(diffDays / 7); if (weeks === 1) return "неделю назад"; if (weeks === 2) return "две недели назад"; if (weeks === 3) return "три недели назад"; if (weeks === 4) return "четыре недели назад"; const months = Math.floor(diffDays / 30.44); if (months < 12) { const word = months === 1 ? "месяц" : months >= 2 && months <= 4 ? "месяца" : "месяцев"; return `${months} ${word} назад`; } const years = Math.floor(diffDays / 365.25); const word = years === 1 ? "год" : years >= 2 && years <= 4 ? "года" : "лет"; return `${years} ${word} назад`; } const allEntries = []; const categories = {}; let loadedCount = 0; for (const friend of friends) { try { const resp = await fetch(`/api/users/${friend.id}/history?limit=${config.FriendsHistory.settings.apilimit.value}`); if (!resp.ok) continue; let history; try { history = await resp.json(); } catch { console.warn(`${friend.nickname} 1488 `); continue; } if (!Array.isArray(history)) continue; history.forEach((entry) => { const createdAt = new Date(entry.created_at); if (isNaN(createdAt)) return; allEntries.push({ friend, entry, createdAt }); }); allEntries.sort((a, b) => b.createdAt - a.createdAt); historyContainer.innerHTML = ""; for (const key in categories) delete categories[key]; allEntries.forEach(({ friend, entry, createdAt }) => { const label = getTimeCategory(createdAt); if (!categories[label]) { const dayHeader = document.createElement("div"); dayHeader.className = "mischeadline"; dayHeader.textContent = label; historyContainer.appendChild(dayHeader); categories[label] = dayHeader; } const line = document.createElement("div"); line.className = "b-user_history-line"; line.dataset.id = entry.id; const avatar = friend.image?.x48 || ""; const targetNameEn = entry.target?.name || "—"; const targetNameRu = entry.target?.russian?.trim() || ""; const targetUrl = entry.target?.url || "#"; let targetHTML = ""; if (entry.target) { if (targetNameRu) { targetHTML = ` <a class="db-entry bubbled-processed" href="${targetUrl}" data-tooltip-url="${targetUrl}"> <span class="name-en">${targetNameEn}</span> <span class="name-ru">${targetNameRu}</span> </a>`; } else { targetHTML = ` <a class="db-entry bubbled-processed" href="${targetUrl}" data-tooltip-url="${targetUrl}">${targetNameEn}</a>`; } } line.innerHTML = ` <strong style="margin-right:5px;"><a href="https://shikimori.one/${friend.nickname}">${friend.nickname}</a>:</strong> <a class="id" href="https://shikimori.one/${friend.nickname}/history/${entry.id}">#</a> <span> ${targetHTML} ${entry.description.replace(/Просмотрено и оценено/, "просмотрено и оценено")} </span> <time class="date" datetime="${createdAt.toISOString()}" title="${createdAt.toLocaleString()}"> ${timeAgo(createdAt)} </time> `; const wrapper = document.createElement("div"); wrapper.style.display = "flex"; //todo css v cfg wrapper.style.alignItems = "center"; wrapper.style.gap = "8px"; wrapper.style.marginBottom = "8px"; const avatarElem = document.createElement("a"); avatarElem.href = `https://shikimori.one/${friend.nickname}`; avatarElem.title = friend.nickname; avatarElem.style.marginRight = "8px"; avatarElem.style.flexShrink = "0"; avatarElem.innerHTML = `<img src="${avatar}" alt="${friend.nickname}" style="width:24px;height:24px;border-radius:50%;">`; line.style.lineHeight = "1.3"; line.style.wordBreak = "break-word"; wrapper.appendChild(avatarElem); const content = document.createElement("div"); content.style.flex = "1"; content.appendChild(line); wrapper.appendChild(content); historyContainer.appendChild(wrapper); }); loadedCount++; progressElem.textContent = `Загружено ${loadedCount} / ${friends.length} друзей...`; await delay(config.FriendsHistory.settings.delay.value); } catch (e) { console.error(`[ShikiUtils-FriendsHistory]: ${friend.nickname}:`, e); } } progressElem.textContent = `Загрузка завершена. Загружено ${loadedCount} / ${friends.length}`; } //* %=================== Friends AVG score ====================% async function FriendsAVGscore() { document.querySelectorAll(".b-animes-menu .block").forEach((block) => { let subheadline = block.querySelector(".subheadline.m5"); if (!subheadline || !subheadline.textContent.includes("У друзей")) return; if (subheadline.classList.contains("avg-score-added")) return; let scores = []; block.querySelectorAll(".friend-rate .status").forEach((status) => { let match = status.textContent.match(/–\s*(\d+)/); if (match) scores.push(parseInt(match[1], 10)); }); if (scores.length === 0) return; subheadline.textContent = config.FriendsAVGscore.settings.template.value.replace("{avgscore}", (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)); subheadline.classList.add("avg-score-added"); }); } //* %=================== Auto Spoiler ====================% function autoSpoiler() { const mode = config.autoSpoiler.settings.mode.value; function spoiler(images) { const spoilerDiv = document.createElement("div"); spoilerDiv.className = "b-spoiler_block"; spoilerDiv.dataset.dynamic = "spoiler_block"; const spoilerText = document.createElement("span"); spoilerText.tabIndex = 0; spoilerText.textContent = config.autoSpoiler.settings.template.value; spoilerText.addEventListener("click", () => spoilerDiv.classList.toggle("is-opened") ); const imagesContainer = document.createElement("div"); images.forEach((img) => imagesContainer.appendChild(img)); spoilerDiv.append(spoilerText, imagesContainer); return spoilerDiv; } function group(comment) { const body = comment.querySelector(".body"); if (!body) return; let images = []; Array.from(body.childNodes).forEach((node) => { if ( node.nodeType === Node.ELEMENT_NODE && node.classList.contains("b-image") ) { const img = node.querySelector("img"); if (!img) return; if (mode === "blur") { //todo mouseenter event img.classList.add("b-blur", "is-moderation_censored"); } else if (mode === "spoiler") { images.push(node); } } else if (images.length > 0 && mode === "spoiler") { body.insertBefore(spoiler(images), node); images = []; } }); if (images.length > 0 && mode === "spoiler") { body.appendChild(spoiler(images)); } } function processComments() { document.querySelectorAll(".b-comment").forEach(group); } processComments(); } //* %=================== remove Blur ====================% function removeBlur() { const censoredImages = document.querySelectorAll("img.is-moderation_censored"); censoredImages.forEach((img) => { img.classList.remove("is-moderation_censored"); }); } //* %=================== Ban Count ====================% function BanCount() { if (!window.location.pathname.endsWith("/moderation")) return; let banCount = document.querySelectorAll(".b-ban").length; let headline = document.querySelector(".subheadline.m5"); if (headline) { headline.textContent = config.BanCount.settings.template.value.replace("{count}", banCount); } } //* %=================== NoAge Limits ====================% function NoAgeLimits() { const birthSelect = document.querySelector(".c-column.block_m .block select#user_birth_on_1i"); if (!birthSelect) return; const selectedYear = birthSelect.value; let maxYear = 0; let minYear = Infinity; birthSelect.querySelectorAll("option").forEach((option) => { const year = parseInt(option.value, 10); if (!isNaN(year)) { if (year > maxYear) maxYear = year; if (year < minYear) minYear = year; } }); birthSelect.innerHTML = ""; for (let year = maxYear; year >= 1; year--) { const option = document.createElement("option"); option.value = year; option.textContent = year; if (year == selectedYear) { option.selected = true; } birthSelect.appendChild(option); } } //* %=================== Auto Loader ====================% //todo recode 🥲 let isLoading = false; let currentPage = 15; let currentUrl = ""; function updatePagination() { const currentSpans = document.querySelectorAll(".pagination .link-current"); if (currentSpans.length > 0) { currentSpans.forEach((span) => { span.textContent = `1-${currentPage + 1}`; }); } } function loadNextPage() { if (isLoading) return; const nextButton = document.querySelector(".b-postloader.collapsed a.next"); if (!nextButton) return; if (currentUrl && currentUrl !== nextButton.href) { currentPage = 15; } currentUrl = nextButton.href; const urlObj = new URL(nextButton.href); urlObj.pathname = urlObj.pathname.replace(/\/page\/\d+/, `/page/${currentPage + 1}.json`); let nextPageUrl = urlObj.toString(); console.debug("[ShikiUtils]Loading next page: ", nextPageUrl); isLoading = true; GM_xmlhttpRequest({ method: "GET", url: nextPageUrl, headers: { Accept: "application/json, text/plain, */*", "X-Requested-With": "XMLHttpRequest", }, onload: function (response) { if (response.status === 200) { try { const data = JSON.parse(response.responseText); if (data.content) { const tempContainer = document.createElement("div"); tempContainer.innerHTML = data.content; const entries = tempContainer.querySelectorAll(".cc-entries article"); appendNewContent(entries); updatePagination(); currentPage++; } } catch (e) { console.error("[ShikiUtils]", e); } } else { console.error("[ShikiUtils]", response.status); } isLoading = false; }, onerror: function (error) { console.error("[ShikiUtils]", error); isLoading = false; }, }); } function appendNewContent(entries) { const containers = document.querySelectorAll(".cc-entries"); if (containers.length === 0) return; const container = containers[containers.length - 1]; const existingIds = new Set( [...container.querySelectorAll("article")].map((el) => el.id) ); entries.forEach((entry) => { if (!existingIds.has(entry.id)) { container.appendChild(entry); } }); } function checkScroll() { const nextButton = document.querySelector(".b-postloader.collapsed a.next"); if (!nextButton || !nextButton.href.match(/\/page\/16(\?|$)/)) return; if ( window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 300 ) { loadNextPage(); } } //* %=================== Сomments Loader ====================% function commentsLoader() { const loaders = document.querySelectorAll(".comments-loader"); if (!loaders.length) return; const storageKey = "comments-loader-count"; const defaultValue = 20; const savedValue = parseInt(localStorage.getItem(storageKey)) || defaultValue; loaders.forEach((loader) => { if (loader.dataset.inputDisabled === "true") return; if (loader.querySelector("input.comments-loader-input")) return; const text = loader.textContent.trim(); const match = text.match(/Загрузить ещё\s+(\d+)\s+из\s+(\d+)/); if (!match) return; const [, , totalCount] = match; const input = document.createElement("input"); input.type = "number"; input.min = 1; input.value = savedValue; input.style.width = "50px"; //todo css v cfg input.style.margin = "0 5px"; input.classList.add("comments-loader-input"); loader.textContent = ""; const beforeText = document.createElement("span"); beforeText.textContent = "Загрузить ещё "; const afterText = document.createElement("span"); afterText.textContent = ` из ${totalCount} комментариев`; loader.appendChild(beforeText); loader.appendChild(input); loader.appendChild(afterText); input.addEventListener("click", (e) => e.stopPropagation()); input.addEventListener("keydown", (e) => e.stopPropagation()); const updateUrl = (value) => { const url = loader.getAttribute("data-clickloaded-url-template"); //? if (!url) return; const newUrl = url.replace(/SKIP\/\d+/, `SKIP/${value}`); loader.setAttribute("data-clickloaded-url-template", newUrl); loader.setAttribute("data-limit", value); }; updateUrl(savedValue); input.addEventListener("change", () => { const val = parseInt(input.value); if (isNaN(val) || val <= 0) return; localStorage.setItem(storageKey, val); updateUrl(val); }); loader.addEventListener("click", () => { if (input.parentNode) input.remove(); loader.dataset.inputDisabled = "true"; }); }); } //! %=================== GUI ===================% function createGUI() { //todo make lib const settingsBlock = document.querySelector(".block.edit-page.misc"); if (!settingsBlock || settingsBlock.querySelector(".ShikiUtils-settings")) { return; } const gui = document.createElement("div"); gui.className = "ShikiUtils-settings"; gui.innerHTML = `<h3 class="ShikiUtils-title">ShikiUtils CFG</h3>`; Object.keys(defaultConfig).forEach((key) => { const defFunc = defaultConfig[key]; const funcConfig = config[key] || {}; if ( defFunc.type === "category" || (defFunc.name && Object.keys(defFunc).length === 1) ) { const catLabel = document.createElement("div"); catLabel.className = "category-label"; catLabel.textContent = defFunc.name; gui.appendChild(catLabel); return; } const hasSettings = funcConfig.settings && Object.keys(funcConfig.settings).length > 0; let updateNotice = ""; if (key === "ChineseFilter") { try { const savedIdsRaw = funcConfig.settings?.ids?.value || ""; const defaultIdsRaw = defFunc.settings?.ids?.value || ""; const savedIds = savedIdsRaw .split(",") .map((v) => v.trim()) .filter(Boolean); const defaultIds = defaultIdsRaw .split(",") .map((v) => v.trim()) .filter(Boolean); const newIds = defaultIds.filter((id) => !savedIds.includes(id)); if (newIds.length > 0) { updateNotice = '<span class="update-notice">(доступно обновление)</span>'; //todo убрать // console.log( // "[ShikiUtils]Chinese IDs ", // newIds.join(", ") // ); } } catch (err) { console.warn("[ShikiUtils]", err); } } const wrapper = document.createElement("div"); wrapper.className = "func-block"; wrapper.innerHTML = ` <div class="func-header" data-func="${key}"> <div class="func-header-left"> ${hasSettings ? `<span class="arrow" data-func="${key}"></span>` : `<span class="no-arrow"></span>` } <div> <span class="func-name">${defFunc.title || key} ${updateNotice}</span> <div class="func-description">${defFunc.description || ""}</div> </div> </div> <div class="func-controls"> <button class="reset-btn" data-func="${key}" title="Сбросить настройки">↺</button> <label class="switch"> <input type="checkbox" ${funcConfig.enabled ? "checked" : "" } data-func="${key}"> <span class="slider"></span> </label> </div> </div> <div class="settings-content" data-func="${key}"></div> `; gui.appendChild(wrapper); const settingsContainer = wrapper.querySelector(".settings-content"); if (!hasSettings) { settingsContainer.remove(); return; } Object.entries(defFunc.settings || {}).forEach(([sKey, sData]) => { const savedData = funcConfig.settings?.[sKey] || sData; const row = document.createElement("div"); row.className = "setting-row"; const label = document.createElement("label"); label.textContent = sData.title || sKey; if (sData.description) label.title = sData.description; let input; const type = sData.type || "text"; switch (type) { //todo boolean case "button": input = document.createElement("button"); input.textContent = savedData.value || "Нажать"; input.className = "gui-btn"; break; case "color": input = document.createElement("input"); input.type = "text"; input.value = savedData.value || "#ffffff"; input.className = "color-square coloris"; input.dataset.coloris = ""; input.addEventListener("input", () => { if (!config[key].settings) config[key].settings = {}; if (!config[key].settings[sKey]) config[key].settings[sKey] = {}; config[key].settings[sKey].value = input.value; saveConfig(); }); break; case "number": input = document.createElement("input"); input.type = "number"; input.value = savedData.value; break; case "range": input = document.createElement("input"); input.type = "range"; input.min = savedData.min ?? 0; input.max = savedData.max ?? 2; input.step = savedData.step ?? 0.1; input.value = savedData.value; input.className = "range-slider"; const valLabel = document.createElement("span"); valLabel.className = "range-value"; valLabel.textContent = input.value + "s"; input.addEventListener("input", () => { valLabel.textContent = input.value + "s"; if (!config[key].settings) config[key].settings = {}; if (!config[key].settings[sKey]) config[key].settings[sKey] = {}; config[key].settings[sKey].value = parseFloat(input.value); saveConfig(); }); row.appendChild(label); row.appendChild(input); row.appendChild(valLabel); settingsContainer.appendChild(row); return; case "css": input = document.createElement("textarea"); input.className = "css-textarea"; input.value = savedData.value; input.addEventListener("input", autoResize); autoResize({ target: input }); break; case "tags": case "ids": input = document.createElement("div"); input.className = "tags-container"; let values = savedData.value ? savedData.value .split(",") .map((v) => v.trim()) .filter(Boolean) : []; const renderTags = () => { input.innerHTML = ""; values.forEach((val, idx) => { const tag = document.createElement("span"); tag.className = "tag-item"; tag.textContent = val; const removeBtn = document.createElement("span"); removeBtn.className = "tag-remove"; removeBtn.textContent = "×"; removeBtn.addEventListener("click", () => { values.splice(idx, 1); updateConfig(); }); tag.appendChild(removeBtn); input.appendChild(tag); }); const newInput = document.createElement("input"); newInput.type = "text"; newInput.className = "tag-new-input"; newInput.placeholder = "Добавить..."; newInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && newInput.value.trim()) { values.push(newInput.value.trim()); newInput.value = ""; updateConfig(); } }); input.appendChild(newInput); }; const updateConfig = () => { if (!config[key].settings) config[key].settings = {}; config[key].settings[sKey] = { ...savedData, value: values.join(","), }; saveConfig(); renderTags(); }; renderTags(); break; case "mode": input = document.createElement("select"); input.className = "mode-select"; const options = sData.options || []; options.forEach((opt) => { const option = document.createElement("option"); option.value = opt; option.textContent = opt; if (savedData.value === opt) option.selected = true; input.appendChild(option); }); input.addEventListener("change", () => { if (!config[key].settings) config[key].settings = {}; if (!config[key].settings[sKey]) config[key].settings[sKey] = {}; config[key].settings[sKey].value = input.value; saveConfig(); }); break; default: input = document.createElement("input"); input.type = "text"; input.value = savedData.value; } if ( input && (input.tagName === "INPUT" || input.tagName === "TEXTAREA") ) { input.addEventListener("input", () => { if (!config[key].settings) config[key].settings = {}; if (!config[key].settings[sKey]) config[key].settings[sKey] = {}; config[key].settings[sKey].value = input.type === "number" ? parseFloat(input.value) : input.value; saveConfig(); }); } if (!["range", "tags", "ids"].includes(type)) { row.appendChild(label); row.appendChild(input); settingsContainer.appendChild(row); } else if (type !== "range") { row.appendChild(label); row.appendChild(input); settingsContainer.appendChild(row); } }); }); const messageBtn = document.createElement("button"); messageBtn.textContent = "Написать автору"; messageBtn.className = "gui-btn-message-author-btn"; messageBtn.addEventListener("click", () => { localStorage.setItem("shikiDialogFromButton", "true"); window.location.href = `https://shikimori.one/LifeH/dialogs/${getUsername()}`; }); const globalReset = document.createElement("button"); globalReset.className = "global-reset-btn"; globalReset.textContent = "Сбросить всё"; globalReset.addEventListener("click", () => { if (!confirm("Вы уверены, что хотите сбросить все настройки?")) return; config = structuredClone(defaultConfig); saveConfig(); alert("Все настройки сброшены"); location.reload(); }); const btnContainer = document.createElement("div"); btnContainer.className = "ShikiUtils-buttons"; btnContainer.appendChild(messageBtn); btnContainer.appendChild(globalReset); gui.appendChild(btnContainer); settingsBlock.appendChild(gui); if (typeof Coloris !== "undefined") { // eslint-disable-next-line no-undef Coloris({ el: ".coloris", theme: "default", swatches: ["#ff0000", "#00ff00", "#0000ff"], }); } gui.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { checkbox.addEventListener("change", (e) => { const func = e.target.dataset.func; config[func].enabled = e.target.checked; saveConfig(); }); }); gui.querySelectorAll(".reset-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const func = e.target.dataset.func; if (!confirm(`Сбросить настройки для ${func}?`)) return; if (defaultConfig[func]) { config[func] = structuredClone(defaultConfig[func]); saveConfig(); alert(`Настройки ${func} сброшены`); location.reload(); } }); }); gui.querySelectorAll(".func-header").forEach((header) => { header.addEventListener("click", (e) => { if (e.target.tagName === "INPUT" || e.target.classList.contains("slider") || e.target.classList.contains("reset-btn") ) { return; } const func = header.dataset.func; const content = gui.querySelector(`.settings-content[data-func="${func}"]`); const arrow = gui.querySelector(`.arrow[data-func="${func}"]`); const isVisible = content.style.display === "block"; content.style.display = isVisible ? "none" : "block"; if (isVisible) { arrow.classList.remove("open"); } else { arrow.classList.add("open"); } }); }); function autoResize(e) { const el = e.target; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; } const style = document.createElement("style"); style.textContent = ` :root { --bg: rgb(245, 245, 245); --panel-bg: rgb(255, 255, 255); --accent: rgb(76, 175, 80); --button: #eee;; --accent-hover: rgb(56, 155, 60); --text: rgba(0, 0, 0, 0.9); --text-muted: rgba(61, 61, 61, 0.7); --border: rgb(221, 221, 221); --danger: rgb(244, 68, 68); --danger-hover: rgb(210, 34, 34); --warning: rgb(255, 193, 7); --info: rgb(33, 150, 243); --shadow-light: 0 2px 6px rgba(0,0,0,0.08); --shadow-medium: 0 4px 12px rgba(0,0,0,0.1); --shadow-heavy: 0 8px 20px rgba(0,0,0,0.15); --font-main: "Segoe UI", sans-serif; --font-mono: monospace; --font-size-base: 14px; --font-size-small: 12px; --font-size-large: 16px; --radius-sm: 4px; --radius-md: 8px; --radius-lg: 10px; --radius-full: 50%; --transition-fast: 0.15s ease; --transition-medium: 0.3s ease; --transition-slow: 0.5s ease; --gradient-primary: linear-gradient(90deg, var(--accent), var(--accent-hover)); --gradient-rainbow: linear-gradient(90deg, red, orange, yellow, green, blue, indigo, violet); --rainbow-animation: rainbow 5s linear infinite; } .ShikiUtils-settings .arrow::after { font-family: shikimori; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-feature-settings: 'liga'; text-transform: none; letter-spacing: normal; content: ""; font-size: 16px; line-height: 20px; display: inline-block; vertical-align: middle; transition: transform .2s ease-in-out; margin-top: -9px; } .ShikiUtils-settings { background: var(--bg); padding: 14px; border-radius: var(--radius-lg); border: 1px solid var(--border); margin-top: 15px; display: flex; flex-direction: column; gap: 12px; font-family: var(--font-main); } .ShikiUtils-settings .arrow.open::after { transform: rotate(90deg); } @keyframes rainbow { 0% { color: red; } 16% { color: orange; } 32% { color: yellow; } 48% { color: green; } 64% { color: blue; } 80% { color: indigo; } 100% { color: violet; } } .ShikiUtils-title { margin: 0 auto; font-size: var(--font-size-large); font-weight: 700; text-align: center; letter-spacing: 1px; animation: var(--rainbow-animation); } .func-block { position: relative; border-radius: var(--radius-lg); background: var(--panel-bg); overflow: hidden; border: 1px solid var(--border); box-shadow: var(--shadow-light); transition: transform var(--transition-medium), box-shadow var(--transition-medium); } .func-header { position: relative; border-radius: var(--radius-lg) var(--radius-lg) 0 0; padding: 8px 12px; cursor: pointer; transition: background var(--transition-medium); } .func-header::before { content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 180, 255, 0.2); opacity: 0; transition: opacity var(--transition-medium); border-radius: inherit; pointer-events: none; } .func-header:hover::before { opacity: 1; } .func-block:hover { transform: translateY(-2px); box-shadow: var(--shadow-medium); } .func-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; cursor: pointer; } .func-header-left { display: flex; align-items: flex-start; gap: 8px; } .func-controls { display: flex; align-items: center; gap: 6px; } .reset-btn { background: rgb(238,238,238); border: 1px solid rgb(204,204,204); border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-base); width: 26px; height: 26px; line-height: 18px; text-align: center; transition: all var(--transition-fast); } .reset-btn:hover { background: rgb(221,221,221); transform: scale(1.05); box-shadow: var(--shadow-medium); } .func-name { font-weight: 600; color: var(--text); } .func-description { font-size: var(--font-size-small); color: var(--text-muted); } .settings-content { display: none; padding: 8px 12px 12px 24px; background: rgb(250,250,250); border-top: 1px solid rgb(238,238,238); } .setting-row { margin-bottom: 10px; } .setting-row label { font-size: var(--font-size-small); display: block; margin-bottom: 4px; color: var(--text); cursor: help; } .setting-row input[type="text"], .setting-row input[type="number"], .setting-row input[type="color"], .setting-row textarea { width: 100%; padding: 5px 8px; border: 1px solid rgb(204,204,204); border-radius: var(--radius-sm); font-family: var(--font-mono); transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } .css-textarea { min-height: 70px; resize: vertical; white-space: pre; } .setting-row input:focus, .setting-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 6px rgba(76,175,80,0.3); outline: none; } .arrow { font-size: var(--font-size-small); color: var(--text-muted); margin-top: 3px; transition: transform var(--transition-fast), color var(--transition-fast); } .arrow:hover { color: var(--accent); } .switch { position: relative; display: inline-block; width: 36px; height: 18px; } .switch input { display: none; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: rgb(187,187,187); transition: background-color var(--transition-medium); border-radius: 18px; } .slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: white; transition: transform var(--transition-medium); border-radius: var(--radius-full); } input:checked + .slider { background-color: var(--accent); } input:checked + .slider:before { transform: translateX(18px); } .global-reset-btn { background: var(--danger); color: white; border: none; padding: 8px 14px; border-radius: var(--radius-sm); cursor: pointer; margin-top: 5px; align-self: flex-end; transition: all var(--transition-fast); } .global-reset-btn:hover { background: var(--danger-hover); transform: scale(1.05); box-shadow: var(--shadow-medium); } .tags-container { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } .tag-item { background: rgb(238,238,238); padding: 3px 7px; border-radius: var(--radius-sm); font-size: var(--font-size-small); display: flex; align-items: center; gap: 4px; } .tag-remove { cursor: pointer; color: rgb(136,136,136); font-weight: bold; transition: color var(--transition-fast); } .tag-remove:hover { color: var(--danger); } .tag-new-input { border: 1px solid rgb(204,204,204); border-radius: var(--radius-sm); padding: 3px 7px; font-size: var(--font-size-small); min-width: 60px; } .color-square { width: 32px !important; height: 32px !important; padding: 0; border: 1px solid rgb(170,170,170); border-radius: var(--radius-sm); cursor: pointer; } .range-slider { width: 70%; vertical-align: middle; } .range-value { display: inline-block; min-width: 40px; text-align: right; font-family: var(--font-mono); color: rgb(85,85,85); margin-left: 6px; } .category-label { text-align: center; font-weight: bold; color: var(--text-muted); margin-top: 12px; font-size: var(--font-size-small); border-bottom: 1px solid rgb(204,204,204); } .gui-btn { padding: 4px 8px; border: 1px solid #ccc; border-radius: var(--radius-sm); background: var(--button) cursor: pointer; transition: all var(--transition-fast); } .gui-btn:hover { transform: scale(1.05); box-shadow: var(--shadow-medium); } .ShikiUtils-buttons { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; position: relative; } .gui-btn-message-author-btn { background: var(--button); color: black; border: none; padding: 8px 14px; border-radius: var(--radius-sm); cursor: pointer; transition: all var(--transition-fast); } .gui-btn-message-author-btn:hover { background: rgba(0, 180, 255, 0.2); color: black; transform: scale(1.05); box-shadow: 0 4px 12px rgba(0, 191, 255, 0.6); } .update-notice { color: #e53935; font-size: 12px; font-weight: 500; margin-left: 4px; } `; document.head.appendChild(style); } //! %=================== RUN ===================% function runFunctions() { if (config.FriendsAVGscore.enabled) FriendsAVGscore(); if (config.BanCount.enabled) BanCount(); if (config.workTypeFilter.enabled) workTypeFilter(); if (config.UserCssCopyBtn.enabled) UserCssCopyBtn(); if (config.UserIdCopyBtn.enabled) UserIdCopyBtn(); if (config.ClubCssCopyBtn.enabled) ClubCssCopyBtn(); if (config.CommCopyBtn.enabled) CommCopyBtn(); if (config.ImageIdCopyBtn.enabled) ImageIdCopyBtn(); if (config.CopyCodeBtn.enabled) CopyCodeBtn(); if (config.NoAgeLimits.enabled) NoAgeLimits(); if (config.ShikiRating.enabled) ShikiRating(); if (config.StudioFilter.enabled) StudioFilter(); if (config.NotificationHelperConfig.enabled) NotificationHelper(); if (config.HistoryHelperConfig.enabled) HistoryHelper(); if (config.watchTime.enabled) watchTime(); if (config.ChineseFilter.enabled) ChineseFilter(); // if (config.ForumCharacterFilter.enabled) ForumCharacterFilter(); if (config.FriendsHistory.enabled) FriendsHistory(); if (config.CommTreeBtn.enabled) CommTreeBtn(); domObserver(); } function domObserver() { //todo это пиздец const observer = new MutationObserver(() => { if (config.ForumCharacterFilter.enabled) ForumCharacterFilter(); if (config.CommCopyBtn.enabled) CommCopyBtn(); if (config.ImageIdCopyBtn.enabled) ImageIdCopyBtn(); if (config.CopyCodeBtn.enabled) CopyCodeBtn(); if (config.removeBlur.enabled) removeBlur(); if (config.autoSpoiler.enabled) autoSpoiler(); if (config.checkScroll.enabled && allowedPaths.some((path) => location.pathname.startsWith(path))) { window.addEventListener("scroll", checkScroll); checkScroll(); } if (config.hideNews.enabled) hideNews(); if (config.commentsLoader.enabled) commentsLoader(); if (config.CommTreeBtn.enabled) CommTreeBtn(); }); observer.observe(document.body, { childList: true, subtree: true }); } let fuckturbolinks = false; function fuckturboliks() { const settingsBlock = document.querySelector(".block.edit-page.misc"); if (settingsBlock) { if (!fuckturbolinks) { fuckturbolinks = true; const overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.top = "0"; overlay.style.left = "0"; overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; overlay.style.display = "flex"; overlay.style.alignItems = "center"; overlay.style.justifyContent = "center"; overlay.style.zIndex = "9999"; const text = document.createElement("div"); text.innerText = "Reloading"; text.style.color = "#fff"; text.style.fontSize = "2rem"; text.style.fontWeight = "bold"; overlay.appendChild(text); document.body.appendChild(overlay); console.log("[ShikiUtils] reloading for fix..."); location.reload(); } } else { fuckturbolinks = false; } } function feedbackPage() { if (!window.location.href.includes("/LifeH/dialogs/")) return; if (localStorage.getItem("shikiDialogFromButton") !== "true") return; localStorage.removeItem("shikiDialogFromButton"); const removeClasses = [ "b-comments", "subheadline", "b-options-floated mobile-phone_portrait", "head misc is-own-profile", ]; removeClasses.forEach((cls) => { document .querySelectorAll(`.${cls.replace(/\s+/g, ".")}`) .forEach((el) => el.remove()); }); const waitForEditorAndButton = setInterval(() => { const editorContainer = document.querySelector(".editor-container"); const editor = editorContainer?.querySelector(".ProseMirror"); const submitButton = document.querySelector(".btn-primary.btn-submit.btn"); if (editorContainer && editor && submitButton) { clearInterval(waitForEditorAndButton); const feedbackInner = document.createElement("div"); feedbackInner.className = "b-feedback-inner"; const subheadline = document.createElement("div"); subheadline.className = "subheadline m5"; subheadline.textContent = "Сообщение автору"; const about = document.createElement("div"); about.className = "about"; const prgrph = document.createElement("div"); prgrph.className = "b-prgrph"; prgrph.innerHTML = ` У тебя возникла интересная идея или проблема?<br> Напиши мне в форме ниже, и я, может быть, когда-нибудь тебе отвечу. `; const browserBlock = document.createElement("div"); browserBlock.className = "browser-info"; browserBlock.textContent = `Браузер: ${navigator.userAgent}`; browserBlock.style.cssText = ` margin-top: 10px; padding: 6px 10px; background: #2c2c2c; color: #ccc; border-radius: 6px; font-size: 12px; cursor: pointer; user-select: none; transition: color 0.3s ease; `; browserBlock.title = "Нажми, чтобы скопировать"; browserBlock.addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); try { await navigator.clipboard.writeText(navigator.userAgent); browserBlock.style.color = "#9f9"; setTimeout(() => (browserBlock.style.color = "#ccc"), 800); } catch (err) { console.error("[ShikiUtils] ошибка копирования:", err); } }); prgrph.appendChild(browserBlock); about.appendChild(prgrph); feedbackInner.appendChild(subheadline); feedbackInner.appendChild(about); editorContainer.prepend(feedbackInner); submitButton.addEventListener("click", () => { if (!userDataEl) { console.log("[ShikiUtils] data-user not found "); return; } const checkToast = setInterval(() => { const toast = document.querySelector(".toastify.on.toastify-right.toastify-top"); if (toast) { const text = toast.textContent.trim(); clearInterval(checkToast); if (text.includes("Сообщение отправлено")) { window.location.href = `https://shikimori.one/${getUsername()}/edit/misc`; } } }, 200); document.addEventListener( "turbolinks:before-visit", () => clearInterval(checkToast), { once: true } ); }); } }, 200); } ready(() => { createGUI(); runFunctions(); feedbackPage(); updateUserData(); }); document.addEventListener("turbolinks:load", () => { fuckturboliks(); }); })();