Нумерация, отметка прочитанных/пропущенных комиксов и единый JSON-реестр
// ==UserScript==
// @name [DOFANTASY] Comics Counter
// @name:en [DOFANTASY] Comics Counter
// @namespace http://tampermonkey.net/
// @version 1.26
// @description Нумерация, отметка прочитанных/пропущенных комиксов и единый JSON-реестр
// @description:en Numbering, read/missing status tracking, and unified JSON registry for comics
// @author Antigravity
// @match https://www.dofantasy.com/en/categories/comics?locale=en&publisher=dofantasy*
// @icon https://www.google.com/s2/favicons?sz=64&domain=dofantasy.com
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// RU Внедрение стилей
// EN Injecting styles
const STYLE_ID = 'df-comics-counter-styles';
if (!document.getElementById(STYLE_ID)) {
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.textContent = `
div.w-full.lg\\:w-4\\/5 ul.flex.flex-wrap > li {
padding-bottom: 16px !important;
}
.df-card-box {
position: relative !important;
margin-top: 42px !important;
border: 4px solid transparent !important;
border-radius: 12px !important;
padding: 6px !important;
transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
box-sizing: border-box !important;
}
.df-card-box.df-status-read {
border-color: #059669 !important;
box-shadow: 0 0 10px rgba(5, 150, 105, 0.3) !important;
}
.df-card-box.df-status-missing {
border-color: #dc2626 !important;
box-shadow: 0 0 10px rgba(220, 38, 38, 0.3) !important;
}
.df-num-badge {
position: absolute;
top: -38px;
left: 50%;
transform: translateX(-50%);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
color: #ffffff;
background: linear-gradient(180deg, #27272a 0%, #18181b 100%);
padding: 3px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
cursor: pointer;
user-select: none;
white-space: nowrap;
z-index: 30;
transition: all 0.15s ease;
line-height: 1.4;
}
.df-num-badge:hover {
background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%);
border-color: rgba(255, 255, 255, 0.3);
transform: translateX(-50%) scale(1.08);
box-shadow: 0 4px 10px rgba(220, 38, 38, 0.45);
}
.df-num-badge.df-series-badge {
background: #3f3f46 !important;
border-color: rgba(255, 255, 255, 0.2) !important;
cursor: default !important;
transform: translateX(-50%) !important;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3) !important;
}
.df-num-badge.df-series-badge:hover {
background: #3f3f46 !important;
border-color: rgba(255, 255, 255, 0.2) !important;
transform: translateX(-50%) !important;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3) !important;
}
.df-num-badge.df-anchor-badge {
border: 2px solid #eab308 !important;
box-shadow: 0 0 10px rgba(234, 179, 8, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2) !important;
background: linear-gradient(180deg, #3f2c06 0%, #1c1917 100%) !important;
color: #fef08a !important;
}
.df-num-badge.df-anchor-badge:hover {
background: linear-gradient(180deg, #ca8a04 0%, #a16207 100%) !important;
border-color: #fef08a !important;
box-shadow: 0 0 14px rgba(234, 179, 8, 0.8) !important;
}
.df-num-input {
position: absolute;
top: -38px;
left: 50%;
transform: translateX(-50%);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
color: #ffffff;
background: #18181b;
border: 1px solid #ef4444;
border-radius: 8px;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 3px 6px rgba(0, 0, 0, 0.4);
width: 56px;
height: 24px;
text-align: center;
padding: 0;
margin: 0;
outline: none;
z-index: 40;
box-sizing: border-box;
}
.df-status-btn {
position: absolute;
top: -37px;
z-index: 30;
width: 25px;
height: 25px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease, background 0.15s ease;
line-height: 1;
}
.df-status-btn:hover {
transform: scale(1.15);
}
.df-btn-missing {
left: 8px;
background: #18181b;
color: #ef4444;
}
.df-btn-missing:hover {
background: #dc2626;
color: #ffffff;
border-color: #ef4444;
}
.df-btn-read {
right: 8px;
background: #18181b;
color: #10b981;
}
.df-btn-read:hover {
background: #059669;
color: #ffffff;
border-color: #10b981;
}
.df-btn-read svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.df-glyph {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
font-weight: 700;
line-height: 1;
display: inline-block;
}
/* RU Скрытие лишних элементов магазина */
/* EN Hide unnecessary store elements */
div.w-full.lg\\:w-4\\/5 ul.flex.flex-wrap li div.mt-auto {
display: none !important;
}
div.w-full.lg\\:w-4\\/5 ul.flex.flex-wrap li button[aria-label="Add to cart"],
div.w-full.lg\\:w-4\\/5 ul.flex.flex-wrap li button[aria-label="Add to wishlist"] {
display: none !important;
}
`;
document.head.appendChild(styleEl);
}
const STORAGE_KEY_REGISTRY = 'df_comics_registry';
const LEGACY_KEY_STATUS = 'df_comics_status_map';
const LEGACY_KEY_NUMS = 'df_comics_number_offsets';
const BASE_MUSE_NUM = 711;
const GLYPH_QUESTION = '<span class="df-glyph">?</span>';
const SVG_CHECK = `
<svg viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
`;
// RU Локализация тултипов
// EN Tooltip localization
const IS_RU = (navigator.language || navigator.userLanguage || '').toLowerCase().startsWith('ru');
const I18N = {
missing: IS_RU ? 'Недоступно' : 'Not available',
read: IS_RU ? 'Просмотрено' : 'Watched',
editNum: IS_RU ? 'Изменить нумерацию комикса' : 'Change comic number',
anchor: IS_RU ? 'Точка отсчета (#711 Sharing the Muse)' : 'Reference anchor (#711 Sharing the Muse)',
series: IS_RU ? 'Сборник серий' : 'Series bundle',
menuCopy: IS_RU ? 'Скопировать JSON реестра в буфер' : 'Copy JSON registry to clipboard',
menuReset: IS_RU ? 'Сбросить весь реестр' : 'Reset all registry',
copiedAlert: IS_RU ? 'JSON реестра скопирован в буфер обмена!' : 'JSON registry copied to clipboard!'
};
// RU Чтение реестра
// EN Reading registry
function getRegistry() {
try {
let raw = null;
if (typeof GM_getValue === 'function') {
raw = GM_getValue(STORAGE_KEY_REGISTRY, null);
}
if (!raw) {
const lsVal = localStorage.getItem(STORAGE_KEY_REGISTRY);
if (lsVal) {
try {
raw = JSON.parse(lsVal);
} catch {
raw = lsVal;
}
}
}
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch {}
}
if (Array.isArray(raw)) return raw;
} catch {}
return [];
}
// RU Запись реестра
// EN Saving registry
function saveRegistry(list) {
try {
const sorted = list.slice().sort((a, b) => {
const numA = typeof a.number === 'number' ? a.number : -1;
const numB = typeof b.number === 'number' ? b.number : -1;
return numB - numA;
});
if (typeof GM_setValue === 'function') {
GM_setValue(STORAGE_KEY_REGISTRY, sorted);
}
localStorage.setItem(STORAGE_KEY_REGISTRY, JSON.stringify(sorted));
} catch {}
}
// RU Миграция старых данных
// EN Migrating legacy storage
function migrateLegacyStorage() {
try {
const rawOldStatus = localStorage.getItem(LEGACY_KEY_STATUS);
const rawOldNums = localStorage.getItem(LEGACY_KEY_NUMS);
if (rawOldStatus || rawOldNums) {
const oldStatusMap = rawOldStatus ? JSON.parse(rawOldStatus) : {};
const oldNumsMap = rawOldNums ? JSON.parse(rawOldNums) : {};
const currentRegistry = getRegistry();
const registryMap = new Map();
currentRegistry.forEach(item => {
if (item && item.id) registryMap.set(item.id, item);
});
Object.keys(oldStatusMap).forEach(slug => {
if (!registryMap.has(slug)) {
registryMap.set(slug, {
id: slug,
number: oldNumsMap[slug] || null,
author: "Неизвестен",
title: slug,
status: oldStatusMap[slug] || "none",
existsOnSite: false
});
}
});
saveRegistry(Array.from(registryMap.values()));
localStorage.removeItem(LEGACY_KEY_STATUS);
localStorage.removeItem(LEGACY_KEY_NUMS);
if (typeof GM_deleteValue === 'function') {
try { GM_deleteValue(LEGACY_KEY_STATUS); } catch {}
try { GM_deleteValue(LEGACY_KEY_NUMS); } catch {}
}
}
} catch {}
}
// RU Запуск миграции
// EN Run migration
migrateLegacyStorage();
// RU Меню команд
// EN User script commands
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand(I18N.menuCopy, () => {
const registry = getRegistry();
const prettyJson = JSON.stringify(registry, null, 2);
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(prettyJson);
} else {
navigator.clipboard.writeText(prettyJson);
}
alert(I18N.copiedAlert);
});
GM_registerMenuCommand(I18N.menuReset, () => {
saveRegistry([]);
location.reload();
});
}
// RU Защита от повторного входа
// EN Re-entrancy guard
let isUpdating = false;
// RU Обновление карточек каталога
// EN Update catalog cards
function updateComics() {
if (isUpdating) return;
isUpdating = true;
try {
const catalogContainer = document.querySelector('div.w-full.lg\\:w-4\\/5 ul.flex.flex-wrap, ul.flex.flex-wrap.-mx-10');
const cardElements = Array.from(catalogContainer ? catalogContainer.children : [])
.filter(li => li.tagName === 'LI' && li.querySelector('a[href*="/products/"], a[href*="/series/"]'));
if (cardElements.length === 0) return;
const registry = getRegistry();
const registryMap = new Map();
registry.forEach(item => {
if (item && item.id) registryMap.set(item.id, item);
});
const urlParams = new URLSearchParams(window.location.search);
const currentPage = parseInt(urlParams.get('page') || '1', 10);
let museLocalComicIndex = -1;
let comicRunningCount = 0;
const cardsData = cardElements.map((card, i) => {
const cardBox = card.querySelector('div.h-full.flex.flex-col') || card;
const titleEl = cardBox.querySelector('h3') || cardBox.querySelector('h1, h2, h4, [class*="title"]');
const title = titleEl ? titleEl.textContent.trim() : '';
const authorEl = cardBox.querySelector('h4') || cardBox.querySelector('a.underline');
const author = authorEl ? authorEl.textContent.trim() : 'Не указан';
const seriesLink = cardBox.querySelector('a[href*="/series/"]');
const productLink = cardBox.querySelector('a[href*="/products/"]');
const isSeries = Boolean(seriesLink && !productLink);
const linkEl = productLink || seriesLink;
const href = linkEl ? linkEl.href : '';
const comicId = href ? href.split('?')[0].split('/').pop() : title;
let comicIndex = null;
if (!isSeries) {
comicIndex = comicRunningCount++;
// RU Определение эталона
// EN Identify anchor
const isSlugMatch = comicId === 'sharing-the-muse';
const isTitleMatch = title.toLowerCase().includes('sharing the muse') && (!author || author.toLowerCase().includes('montal'));
if (isSlugMatch || isTitleMatch) {
museLocalComicIndex = comicIndex;
}
}
return { cardBox, title, author, comicId, isSeries, comicIndex, index: i };
});
// RU Базовый расчет номеров
// EN Base comic numbering
const totalComics = comicRunningCount;
const calculatedComicNumbers = new Array(totalComics);
if (currentPage === 1) {
for (let c = 0; c < totalComics; c++) {
if (c <= 11) {
calculatedComicNumbers[c] = 729 - c;
} else {
calculatedComicNumbers[c] = 728 - c;
}
}
} else if (currentPage === 2) {
for (let c = 0; c < totalComics; c++) {
calculatedComicNumbers[c] = 712 - c;
}
} else {
const pageStart = 712 - (currentPage - 2) * 14;
for (let c = 0; c < totalComics; c++) {
calculatedComicNumbers[c] = pageStart - c;
}
}
// RU Каскадный сдвиг номеров
// EN Cascade shift for manual overrides
for (let c = totalComics - 1; c >= 0; c--) {
const item = cardsData.find(x => x.comicIndex === c);
const rec = item ? registryMap.get(item.comicId) : null;
if (rec && rec.manualOverride === true && typeof rec.number === 'number') {
const shift = rec.number - calculatedComicNumbers[c];
if (shift !== 0) {
calculatedComicNumbers[c] = rec.number;
for (let above = c - 1; above >= 0; above--) {
calculatedComicNumbers[above] += shift;
}
}
}
}
// RU Устранение коллизий дублирования
// EN Resolve collision duplicates
for (let c = totalComics - 2; c >= 0; c--) {
if (calculatedComicNumbers[c] <= calculatedComicNumbers[c + 1]) {
calculatedComicNumbers[c] = calculatedComicNumbers[c + 1] + 1;
}
}
// RU Отрисовка карточек
// EN Render cards
cardsData.forEach((item) => {
if (!item.cardBox.classList.contains('df-card-box')) {
item.cardBox.classList.add('df-card-box');
}
// RU Обработка сборников
// EN Series handling
if (item.isSeries) {
item.cardBox.classList.remove('df-status-read', 'df-status-missing');
const existingBtnMissing = item.cardBox.querySelector(':scope > .df-btn-missing');
if (existingBtnMissing) existingBtnMissing.remove();
const existingBtnRead = item.cardBox.querySelector(':scope > .df-btn-read');
if (existingBtnRead) existingBtnRead.remove();
let badge = item.cardBox.querySelector(':scope > .df-num-badge');
if (!badge) {
badge = document.createElement('div');
badge.className = 'df-num-badge df-series-badge';
item.cardBox.appendChild(badge);
}
badge.className = 'df-num-badge df-series-badge';
badge.textContent = 'SERIES';
badge.title = I18N.series;
badge.onclick = null;
registryMap.set(item.comicId, {
id: item.comicId,
number: 'SERIES',
author: item.author,
title: item.title,
status: 'none',
existsOnSite: true
});
return;
}
// RU Обработка одиночных комиксов
// EN Single comics handling
const calculatedNum = calculatedComicNumbers[item.comicIndex];
const existingRecord = registryMap.get(item.comicId);
const currentStatus = existingRecord ? existingRecord.status : 'none';
const existingRec = registryMap.get(item.comicId);
registryMap.set(item.comicId, {
id: item.comicId,
number: calculatedNum,
author: item.author,
title: item.title,
status: currentStatus,
existsOnSite: true,
manualOverride: (existingRec && existingRec.manualOverride === true) ? true : undefined
});
const readClass = 'df-status-read';
const missingClass = 'df-status-missing';
if (currentStatus === 'read') {
if (!item.cardBox.classList.contains(readClass)) item.cardBox.classList.add(readClass);
item.cardBox.classList.remove(missingClass);
} else if (currentStatus === 'missing') {
if (!item.cardBox.classList.contains(missingClass)) item.cardBox.classList.add(missingClass);
item.cardBox.classList.remove(readClass);
} else {
item.cardBox.classList.remove(readClass, missingClass);
}
// RU Кнопка пропуска
// EN Missing button
let btnMissing = item.cardBox.querySelector(':scope > .df-btn-missing');
if (!btnMissing) {
btnMissing = document.createElement('div');
btnMissing.className = 'df-status-btn df-btn-missing';
btnMissing.innerHTML = GLYPH_QUESTION;
btnMissing.title = I18N.missing;
item.cardBox.appendChild(btnMissing);
btnMissing.onmousedown = (e) => {
if (e.button === 1) return;
};
btnMissing.onclick = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const rec = registryMap.get(item.comicId) || {
id: item.comicId,
number: calculatedNum,
author: item.author,
title: item.title,
existsOnSite: true
};
if (rec.status === 'missing') {
rec.status = 'none';
item.cardBox.classList.remove('df-status-missing');
} else {
rec.status = 'missing';
item.cardBox.classList.remove('df-status-read');
item.cardBox.classList.add('df-status-missing');
}
registryMap.set(item.comicId, rec);
saveRegistry(Array.from(registryMap.values()));
};
} else {
if (btnMissing.innerHTML !== GLYPH_QUESTION) btnMissing.innerHTML = GLYPH_QUESTION;
if (btnMissing.title !== I18N.missing) btnMissing.title = I18N.missing;
}
// RU Бейдж номера и инлайн-редактирование
// EN Number badge and inline editing
let badge = item.cardBox.querySelector(':scope > .df-num-badge');
if (!badge) {
badge = document.createElement('div');
badge.className = 'df-num-badge';
item.cardBox.appendChild(badge);
}
badge.classList.remove('df-series-badge');
badge.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
badge.style.display = 'none';
const input = document.createElement('input');
input.className = 'df-num-input';
input.type = 'text';
input.value = calculatedNum;
item.cardBox.appendChild(input);
input.focus();
input.select();
let submitted = false;
const finish = (save) => {
if (submitted) return;
submitted = true;
if (save) {
const val = parseInt(input.value.replace(/[^0-9]/g, ''), 10);
if (!isNaN(val)) {
const prevNum = calculatedNum;
const delta = val - prevNum;
if (delta !== 0) {
for (let above = item.comicIndex - 1; above >= 0; above--) {
const aboveItem = cardsData.find(x => x.comicIndex === above);
if (aboveItem) {
const aboveRec = registryMap.get(aboveItem.comicId);
if (aboveRec && aboveRec.manualOverride === true && typeof aboveRec.number === 'number') {
aboveRec.number += delta;
registryMap.set(aboveItem.comicId, aboveRec);
}
}
}
}
const rec = registryMap.get(item.comicId) || {
id: item.comicId,
author: item.author,
title: item.title,
status: currentStatus,
existsOnSite: true
};
rec.number = val;
rec.manualOverride = true;
registryMap.set(item.comicId, rec);
saveRegistry(Array.from(registryMap.values()));
}
}
input.remove();
badge.style.display = '';
updateComics();
};
input.onkeydown = (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
finish(true);
} else if (ev.key === 'Escape') {
ev.preventDefault();
finish(false);
}
};
input.onblur = () => finish(true);
};
if (!item.cardBox.querySelector(':scope > .df-num-input')) {
badge.style.display = '';
const badgeText = '#' + calculatedNum;
if (badge.textContent !== badgeText) {
badge.textContent = badgeText;
}
}
const isAnchorMuse = (item.comicId === 'sharing-the-muse') || item.title.toLowerCase().includes('sharing the muse');
if (isAnchorMuse) {
badge.classList.add('df-anchor-badge');
badge.title = I18N.anchor;
} else {
badge.classList.remove('df-anchor-badge');
badge.title = I18N.editNum;
}
// RU Кнопка прочтения
// EN Read button
let btnRead = item.cardBox.querySelector(':scope > .df-btn-read');
if (!btnRead) {
btnRead = document.createElement('div');
btnRead.className = 'df-status-btn df-btn-read';
btnRead.innerHTML = SVG_CHECK;
btnRead.title = I18N.read;
item.cardBox.appendChild(btnRead);
btnRead.onmousedown = (e) => {
if (e.button === 1) return;
};
btnRead.onclick = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const rec = registryMap.get(item.comicId) || {
id: item.comicId,
number: calculatedNum,
author: item.author,
title: item.title,
existsOnSite: true
};
if (rec.status === 'read') {
rec.status = 'none';
item.cardBox.classList.remove('df-status-read');
} else {
rec.status = 'read';
item.cardBox.classList.remove('df-status-missing');
item.cardBox.classList.add('df-status-read');
}
registryMap.set(item.comicId, rec);
saveRegistry(Array.from(registryMap.values()));
};
} else {
if (btnRead.title !== I18N.read) btnRead.title = I18N.read;
}
});
// RU Сохранение реестра
// EN Save registry
saveRegistry(Array.from(registryMap.values()));
} finally {
isUpdating = false;
}
}
// RU Запуск обновления
// EN Initial run
updateComics();
// RU Навигация истории
// EN History navigation
window.addEventListener('popstate', () => {
setTimeout(updateComics, 100);
});
// RU Наблюдатель DOM
// EN DOM observer
let debounceTimer = null;
const observer = new MutationObserver(() => {
if (isUpdating) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
updateComics();
}, 250);
});
const catalogContainer = document.querySelector('div.w-full.lg\\:w-4\\/5') || document.body;
observer.observe(catalogContainer, { childList: true, subtree: true });
})();