ASSStat Card

Статистика карт animeSSS

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         ASSStat Card
// @version      1.1
// @description  Статистика карт animeSSS
// @author       SoulUA
// @match        https://animesss.com/*
// @match        https://animesss.tv/*
// @license      MIT
// @grant        none
// @namespace https://greasyfork.org/users/1467651
// ==/UserScript==

(function () {
    'use strict';

    function clampNumber(value, min, max, fallback) {
        const n = Number(value);
        if (!Number.isFinite(n)) return fallback;
        return Math.min(max, Math.max(min, Math.trunc(n)));
    }

    function readNumberSetting(key, fallback, min, max) {
        return clampNumber(localStorage.getItem(key), min, max, fallback);
    }

    const CONFIG = {
        STATS_CACHE_TTL: 3 * 24 * 60 * 60 * 1000,
        SCAN_CONCURRENCY: 4,
        AUTO_CONCURRENCY: readNumberSetting('ch_auto_concurrency', 2, 1, 6),
        SCAN_DELAY_MIN: 50,
        SCAN_DELAY_MAX: 110,
        AUTO_DELAY_MIN: 80,
        AUTO_DELAY_MAX: 180,
        MUTATION_DEBOUNCE: 350,
        ROOT_MARGIN: '300px',
        COOLDOWN_429: 5000,
        DEBUG: false
    };

    const CARD_SELECTOR = [
        '.remelt__inventory-item',
        '.lootbox__card',
        '.anime-cards__item',
        '.trade__inventory-item',
        '.trade__main-item',
        '.card-filter-list__card',
        '.deck__item',
        '.history__body-item',
        '.card-pack__card'
    ].join(', ');

    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
    const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
    const log = (...args) => CONFIG.DEBUG && console.log('[CardASS]', ...args);

    let toast = { el: null, tid: null };
    let isAutoStatsEnabled = localStorage.getItem('ch_auto_stats') !== 'false';
    let isPinned = localStorage.getItem('ch_is_pinned') !== 'false'; // По умолчанию лупа закреплена

    let serverCooldownUntil = 0;
    let mutationTimer = null;
    let menuEl = null;

    const pendingRoots = new Set();
    const memoryCache = new Map();
    const inflightStats = new Map();

    const autoQueue = [];
    let autoWorkersActive = 0;

    function showToast(msg, type = 'temp', opt = {}) {
        if (window.location.pathname.includes('/pm/')) return;

        if (toast.el) toast.el.remove();

        const el = document.createElement('div');
        el.className = 'ch-toast';
        document.body.appendChild(el);
        toast.el = el;

        if (type === 'progress') {
            el.innerHTML = `
                <div class="ch-spin"></div>
                <span>${opt.cur} / ${opt.tot}</span>
            `;
        } else {
            el.innerHTML = `<span>${msg}</span>`;
        }

        requestAnimationFrame(() => el.classList.add('show'));

        if (toast.tid) clearTimeout(toast.tid);

        if (!opt.sticky) {
            toast.tid = setTimeout(() => {
                el.classList.remove('show');
                setTimeout(() => {
                    if (el.parentNode) el.remove();
                    if (toast.el === el) toast.el = null;
                }, 300);
            }, opt.dur || 2500);
        }
    }

    function readCache(id) {
        const key = `cId:${id}`;
        const mem = memoryCache.get(key);

        if (mem && Date.now() < mem.exp) return mem.data;
        if (mem) memoryCache.delete(key);

        const raw = localStorage.getItem(key);
        if (!raw) return null;

        try {
            const parsed = JSON.parse(raw);
            if (!parsed || Date.now() > parsed.exp) {
                localStorage.removeItem(key);
                return null;
            }
            memoryCache.set(key, parsed);
            return parsed.data;
        } catch (e) {
            localStorage.removeItem(key);
            return null;
        }
    }

    function writeCache(id, data) {
        const key = `cId:${id}`;
        const payload = {
            data,
            exp: Date.now() + CONFIG.STATS_CACHE_TTL
        };
        memoryCache.set(key, payload);
        try {
            localStorage.setItem(key, JSON.stringify(payload));
        } catch (e) {
            log('localStorage write failed', e);
        }
    }

    function gcCache(force = false) {
        const now = Date.now();
        Object.keys(localStorage).forEach(key => {
            if (!key.startsWith('cId:')) return;
            if (force) {
                localStorage.removeItem(key);
                return;
            }
            try {
                const item = JSON.parse(localStorage.getItem(key));
                if (!item || now > item.exp) localStorage.removeItem(key);
            } catch (e) {
                localStorage.removeItem(key);
            }
        });
        if (force) memoryCache.clear();
    }

    async function fetchStats(id, retry = true) {
        const cached = readCache(id);
        if (cached) return cached;

        const now = Date.now();
        if (serverCooldownUntil > now) await sleep(serverCooldownUntil - now);

        try {
            const url = `${window.location.origin}/cards/users/?id=${encodeURIComponent(id)}`;
            const response = await fetch(url, { credentials: 'same-origin' });

            if (response.status === 429) {
                serverCooldownUntil = Date.now() + CONFIG.COOLDOWN_429;
                showToast('Пауза: сервер ограничил запросы', 'temp', { dur: 2500 });
                if (retry) {
                    await sleep(CONFIG.COOLDOWN_429);
                    return fetchStats(id, false);
                }
                return null;
            }

            if (!response.ok) return null;

            const html = await response.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');

            const data = {
                p: doc.querySelector('#owners-count')?.textContent.trim() || '0',
                n: doc.querySelector('#owners-need')?.textContent.trim() || '0',
                t: doc.querySelector('#owners-trade')?.textContent.trim() || '0'
            };

            writeCache(id, data);
            return data;
        } catch (e) {
            log('fetchStats failed', e);
            return null;
        }
    }

    function loadStats(id) {
        const cached = readCache(id);
        if (cached) return Promise.resolve(cached);

        if (inflightStats.has(id)) return inflightStats.get(id);

        const promise = fetchStats(id).finally(() => {
            inflightStats.delete(id);
        });

        inflightStats.set(id, promise);
        return promise;
    }

    function getCID(card) {
        let id = card.getAttribute('data-card-id') || card.getAttribute('card-id') || card.getAttribute('data-id');
        if (id) return id;

        const link = card.tagName === 'A' ? card : card.querySelector('a[href*="/cards/"]');
        if (!link?.href) return null;

        try {
            const url = new URL(link.href, window.location.origin);
            id = url.searchParams.get('id');
            if (id) return id;

            const match = url.pathname.match(/\/cards\/(\d+)/);
            return match ? match[1] : null;
        } catch (e) {
            const match = link.href.match(/(?:\/cards\/(?:users\/\?id=)?|[?&]id=)(\d+)/);
            return match ? match[1] : null;
        }
    }

    function getStatsBox(card) {
        let box = card.querySelector(':scope > .card-stats');
        if (!box) {
            box = document.createElement('div');
            box.className = 'card-stats placeholder';
            box.innerHTML = '<span>Спрос</span>';
            card.appendChild(box);
        }
        return box;
    }

    function renderStats(card, data) {
        const box = getStatsBox(card);
        box.className = 'card-stats mini';
        box.onclick = null;
        box.innerHTML = `
            <div class="stat-row">
                <span title="Имеют">👥 ${data.p}</span>
                <span title="Хотят">❤️ ${data.n}</span>
                <span title="Обмен">🔄 ${data.t}</span>
            </div>
        `;
        return true;
    }

    function tryRenderCached(card, knownId = null) {
        const id = knownId || getCID(card);
        if (!id) return false;

        const cached = readCache(id);
        if (!cached) return false;

        renderStats(card, cached);
        try { cardObserver.unobserve(card); } catch (e) {}
        return true;
    }

    function setError(card, text = 'Ошибка') {
        const box = getStatsBox(card);
        box.className = 'card-stats error';
        box.innerHTML = `<span>${text}</span>`;
        box.onclick = async e => {
            e.preventDefault();
            e.stopPropagation();
            box.className = 'card-stats placeholder';
            box.innerHTML = '<span>Спрос</span>';
            await processCard(card);
        };
    }

    async function applyStats(id, card) {
        const cached = readCache(id);
        if (cached) return renderStats(card, cached);

        const data = await loadStats(id);
        if (!data) {
            setError(card);
            return false;
        }
        return renderStats(card, data);
    }

    async function processCard(card, knownId = null) {
        if (!card || card.dataset.chBusy === '1') return false;
        if (card.closest('.lc_chat_li')) return false;
        if (card.querySelector(':scope > .card-stats.mini')) return true;

        if (tryRenderCached(card, knownId)) return true;

        const box = getStatsBox(card);
        card.dataset.chBusy = '1';
        box.className = 'card-stats placeholder loading';
        box.innerHTML = '';

        try {
            const id = knownId || getCID(card);
            if (!id) {
                setError(card, 'Нет ID');
                return false;
            }
            return await applyStats(id, card);
        } finally {
            delete card.dataset.chBusy;
        }
    }

    function enqueueCard(card) {
        if (!card || card.dataset.chQueued === '1' || card.dataset.chBusy === '1') return;
        if (card.querySelector(':scope > .card-stats.mini')) return;

        const id = getCID(card);
        if (id && tryRenderCached(card, id)) return;

        card.dataset.chQueued = '1';
        autoQueue.push(card);
        startAutoWorkers();
    }

    function startAutoWorkers() {
        while (autoWorkersActive < CONFIG.AUTO_CONCURRENCY && autoQueue.length > 0) {
            runAutoWorker();
        }
    }

    async function runAutoWorker() {
        autoWorkersActive++;
        try {
            while (autoQueue.length > 0) {
                const card = autoQueue.shift();
                try {
                    if (!card || !card.isConnected) continue;
                    if (card.querySelector(':scope > .card-stats.mini')) continue;
                    const id = getCID(card);
                    if (id && tryRenderCached(card, id)) continue;

                    await processCard(card, id);
                    await sleep(rand(CONFIG.AUTO_DELAY_MIN, CONFIG.AUTO_DELAY_MAX));
                } catch (e) {
                    log('auto worker error', e);
                } finally {
                    if (card) delete card.dataset.chQueued;
                }
            }
        } finally {
            autoWorkersActive--;
            if (autoQueue.length > 0) startAutoWorkers();
        }
    }

    const cardObserver = new IntersectionObserver(entries => {
        for (const entry of entries) {
            if (!entry.isIntersecting || !isAutoStatsEnabled) continue;
            const card = entry.target;
            const box = card.querySelector(':scope > .card-stats.placeholder');
            if (!box || box.classList.contains('loading')) continue;

            cardObserver.unobserve(card);
            enqueueCard(card);
        }
    }, { rootMargin: CONFIG.ROOT_MARGIN });

    function addPlaceholder(card) {
        if (!card || card.closest('.lc_chat_li')) return;
        if (card.querySelector(':scope > .card-stats')) return;
        if (tryRenderCached(card)) return;

        const box = document.createElement('div');
        box.className = 'card-stats placeholder';
        box.innerHTML = '<span>Спрос</span>';

        box.onclick = async e => {
            e.preventDefault();
            e.stopPropagation();
            await processCard(card);
        };

        card.appendChild(box);
        cardObserver.observe(card);
    }

    function processRoot(root = document) {
        if (!root) return;
        if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(CARD_SELECTOR)) {
            addPlaceholder(root);
        }
        root.querySelectorAll?.(CARD_SELECTOR).forEach(addPlaceholder);
    }

    function isNearViewport(el) {
        const rect = el.getBoundingClientRect();
        return rect.bottom >= -300 && rect.top <= window.innerHeight + 300;
    }

    function triggerVisibleAutoLoad() {
        if (!isAutoStatsEnabled) return;
        document.querySelectorAll(CARD_SELECTOR).forEach(card => {
            const box = card.querySelector(':scope > .card-stats.placeholder');
            if (box && isNearViewport(card) && !box.classList.contains('loading')) {
                cardObserver.unobserve(card);
                enqueueCard(card);
            }
        });
        startAutoWorkers();
    }

    async function scanAllCards() {
        closeContextMenu();

        const cards = Array.from(document.querySelectorAll(CARD_SELECTOR)).filter(card => {
            if (!card.offsetParent || card.closest('.lc_chat_li')) return false;
            if (card.querySelector(':scope > .card-stats.mini')) return false;
            return true;
        });

        if (!cards.length) {
            showToast('Все проверено или нет новых карт');
            return;
        }

        const mainBtn = document.getElementById('ch-main');
        mainBtn?.classList.add('working');

        const items = cards.map(card => {
            cardObserver.unobserve(card);
            const id = getCID(card);
            return { card, id, cached: id ? Boolean(readCache(id)) : false };
        });

        const total = items.length;
        let done = 0;

        showToast('', 'progress', { cur: 0, tot: total, sticky: true });

        const updateProgress = () => {
            done++;
            const counter = toast.el?.querySelector('span');
            if (counter) counter.textContent = `${done} / ${total}`;
        };

        const noIdItems = items.filter(item => !item.id);
        const cachedItems = items.filter(item => item.id && item.cached);
        const networkItems = items.filter(item => item.id && !item.cached);

        for (const item of cachedItems) {
            await processCard(item.card, item.id);
            updateProgress();
        }

        for (const item of noIdItems) {
            setError(item.card, 'Нет ID');
            updateProgress();
        }

        const workerCount = Math.min(CONFIG.SCAN_CONCURRENCY, networkItems.length);

        async function worker() {
            while (networkItems.length) {
                const item = networkItems.shift();
                try {
                    await processCard(item.card, item.id);
                } catch (e) {
                    log('scan worker error', e);
                    setError(item.card);
                }
                updateProgress();
                await sleep(rand(CONFIG.SCAN_DELAY_MIN, CONFIG.SCAN_DELAY_MAX));
            }
        }

        await Promise.all(Array.from({ length: workerCount }, () => worker()));
        mainBtn?.classList.remove('working');
        showToast('Готово');
    }

    function toggleAutoMode() {
        isAutoStatsEnabled = !isAutoStatsEnabled;
        localStorage.setItem('ch_auto_stats', String(isAutoStatsEnabled));
        updateMainTitle();
        showToast(isAutoStatsEnabled ? 'Авто-прогрузка ВКЛ' : 'Только ручной режим');
        if (isAutoStatsEnabled) triggerVisibleAutoLoad();
    }

    function setAutoConcurrency(value) {
        CONFIG.AUTO_CONCURRENCY = clampNumber(value, 1, 6, 2);
        localStorage.setItem('ch_auto_concurrency', String(CONFIG.AUTO_CONCURRENCY));
        showToast(`Авто-потоки: ${CONFIG.AUTO_CONCURRENCY}`);
        if (isAutoStatsEnabled) {
            triggerVisibleAutoLoad();
            startAutoWorkers();
        }
    }

    function renderWorkerButtons(current) {
        return [1, 2, 3, 4, 5, 6].map(num => `
            <button class="ch-choice ${current === num ? 'active' : ''}" data-auto-workers="${num}" type="button">
                ${num}
            </button>
        `).join('');
    }

    function updateMainTitle() {
        const main = document.getElementById('ch-main');
        if (!main) return;

        let title = isAutoStatsEnabled
            ? `Сканировать. Авто: ВКЛ. Потоки: ${CONFIG.AUTO_CONCURRENCY}.`
            : `Сканировать. Авто: ВЫКЛ. Потоки: ${CONFIG.AUTO_CONCURRENCY}.`;

        title += ` Меню: ПКМ / Долгий тап. ${isPinned ? '(Закреплена)' : '(Плывущая)'}`;
        main.title = title;
    }

    function closeContextMenu() {
        if (menuEl) {
            menuEl.remove();
            menuEl = null;
        }
    }

    function openContextMenu(anchor) {
        closeContextMenu();

        const menu = document.createElement('div');
        menu.className = 'ch-menu';

        menu.innerHTML = `
            <button class="ch-menu-item" data-action="scan" type="button">
                <span>🔍</span>
                <b>Сканировать</b>
            </button>

            <button class="ch-menu-item" data-action="togglePin" type="button">
                <span>${isPinned ? '🧲' : '📌'}</span>
                <b>${isPinned ? 'Сделать плывущей' : 'Закрепить позицию'}</b>
            </button>

            <button class="ch-menu-item" data-action="toggle" type="button">
                <span>${isAutoStatsEnabled ? '🤖' : '🖐️'}</span>
                <b>${isAutoStatsEnabled ? 'Авто-режим включён' : 'Ручной режим'}</b>
            </button>

            <div class="ch-menu-section">
                <div class="ch-menu-title">Авто-потоки</div>
                <div class="ch-choice-grid">
                    ${renderWorkerButtons(CONFIG.AUTO_CONCURRENCY)}
                </div>
            </div>

            <button class="ch-menu-item danger" data-action="clear" type="button">
                <span>🗑️</span>
                <b>Очистить кэш</b>
            </button>
        `;

        document.body.appendChild(menu);
        menuEl = menu;

        const anchorRect = anchor.getBoundingClientRect();
        const menuRect = menu.getBoundingClientRect();

        let left = anchorRect.left + anchorRect.width / 2 - menuRect.width / 2;
        let top = anchorRect.top - menuRect.height - 10;

        left = Math.max(8, Math.min(left, window.innerWidth - menuRect.width - 8));
        if (top < 8) top = anchorRect.bottom + 10;

        menu.style.left = `${left}px`;
        menu.style.top = `${top}px`;

        requestAnimationFrame(() => menu.classList.add('show'));

        menu.onclick = e => {
            const autoButton = e.target.closest('[data-auto-workers]');
            if (autoButton) {
                e.preventDefault();
                e.stopPropagation();
                setAutoConcurrency(autoButton.dataset.autoWorkers);
                updateMainTitle();
                openContextMenu(anchor);
                return;
            }

            const button = e.target.closest('[data-action]');
            if (!button) return;

            e.preventDefault();
            e.stopPropagation();
            const action = button.dataset.action;

            if (action === 'scan') {
                scanAllCards();
            } else if (action === 'togglePin') {
                isPinned = !isPinned;
                localStorage.setItem('ch_is_pinned', String(isPinned));
                updateMainTitle();
                showToast(isPinned ? 'Позиция закреплена' : 'Режим: Плывущая лупа');
                openContextMenu(anchor);
            } else if (action === 'toggle') {
                toggleAutoMode();
                openContextMenu(anchor);
            } else if (action === 'clear') {
                gcCache(true);
                showToast('Кэш статистики очищен');
                closeContextMenu();
            }
        };
    }

    function buildUI() {
        if (document.getElementById('ch-fab-container')) return;

        const css = document.createElement('style');
        css.id = 'ch-style';

        css.textContent = `
            :root {
                --ch-acc: #00e5ff;
                --ch-bg: rgba(18, 18, 22, 0.92);
                --ch-brd: rgba(255, 255, 255, 0.12);
            }

            #ch-fab-container {
                position: fixed;
                z-index: 10000;
                user-select: none;
                touch-action: none;
            }

            .ch-btn {
                display: flex;
                align-items: center;
                justify-content: center;
                width: 50px;
                height: 50px;

                /* --- Ликвидгласс дизайн (Liquid Glass) --- */
                background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.1), rgba(18, 18, 22, 0.4));
                backdrop-filter: blur(14px) saturate(140%);
                -webkit-backdrop-filter: blur(14px) saturate(140%);
                border: 1px solid rgba(255, 255, 255, 0.15);
                border-top: 1px solid rgba(255, 255, 255, 0.4);
                border-left: 1px solid rgba(255, 255, 255, 0.3);
                border-radius: 50%;
                color: var(--ch-acc);
                cursor: pointer;
                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), inset 0 4px 12px rgba(255, 255, 255, 0.15), inset 0 -4px 10px rgba(0, 0, 0, 0.2);
                transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.3s ease, border-color 0.3s ease;
                outline: none;
                font-size: 21px;
            }

            .ch-btn:hover {
                background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.15), rgba(0, 229, 255, 0.1));
                border-color: rgba(0, 229, 255, 0.4);
                border-top-color: rgba(0, 229, 255, 0.6);
                box-shadow: 0 8px 28px rgba(0, 0, 0, 0.6), inset 0 4px 12px rgba(0, 229, 255, 0.2), 0 0 15px rgba(0, 229, 255, 0.25);
            }

            .ch-btn:active {
                transform: scale(0.9);
            }

            #ch-main.working {
                animation: chPulse 1.2s infinite ease-in-out;
                pointer-events: none;
            }

            @keyframes chPulse {
                0% { transform: scale(1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 5px var(--ch-acc); }
                50% { transform: scale(1.1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--ch-acc); }
                100% { transform: scale(1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 5px var(--ch-acc); }
            }

            .ch-menu {
                position: fixed;
                min-width: 215px;
                padding: 8px;
                border: 1px solid var(--ch-brd);
                border-radius: 14px;
                background: var(--ch-bg);
                backdrop-filter: blur(14px);
                -webkit-backdrop-filter: blur(14px);
                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
                z-index: 999999;
                opacity: 0;
                transform: translateY(8px) scale(0.96);
                transition: opacity 0.16s ease, transform 0.16s ease;
            }

            .ch-menu.show { opacity: 1; transform: translateY(0) scale(1); }
            .ch-menu-item {
                width: 100%; display: flex; align-items: center; gap: 10px;
                padding: 10px 12px; border: 0; border-radius: 10px;
                background: transparent; color: #fff; cursor: pointer;
                font-size: 13px; text-align: left;
            }
            .ch-menu-item:hover { background: rgba(255, 255, 255, 0.08); }
            .ch-menu-item span { width: 22px; text-align: center; }
            .ch-menu-item b { font-weight: 700; }
            .ch-menu-item.danger b { color: #ff6b81; }

            .ch-menu-section {
                padding: 8px 4px 6px; margin-top: 4px;
                border-top: 1px solid rgba(255, 255, 255, 0.08);
            }
            .ch-menu-title {
                margin: 0 4px 7px; color: rgba(255, 255, 255, 0.72);
                font-size: 11px; font-weight: 700; text-transform: uppercase;
                letter-spacing: 0.4px;
            }
            .ch-choice-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; }
            .ch-choice {
                height: 28px; border: 1px solid rgba(255, 255, 255, 0.14);
                border-radius: 8px; background: rgba(255, 255, 255, 0.06);
                color: #fff; cursor: pointer; font-size: 12px; font-weight: 700;
            }
            .ch-choice:hover { background: rgba(255, 255, 255, 0.12); }
            .ch-choice.active { background: var(--ch-acc); border-color: var(--ch-acc); color: #071014; }

            .card-stats {
                position: relative; width: 100%; box-sizing: border-box;
                margin-top: 4px; padding: 4px 2px; border: 1px solid var(--ch-brd);
                border-radius: 4px; background: rgba(22, 22, 28, 0.95);
                color: #fff; z-index: 5; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
            }
            .card-stats.placeholder, .card-stats.error {
                height: 26px; display: flex; align-items: center;
                justify-content: center; cursor: pointer; background: rgba(255, 255, 255, 0.05);
            }
            .card-stats.placeholder span, .card-stats.error span { opacity: 0.85; font-size: 11px; font-weight: 700; }
            .card-stats.error span { color: #ff4757; }
            .stat-row { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; width: 100%; gap: 2px 6px; }
            .stat-row span { display: flex; align-items: center; gap: 2px; font-weight: 500; font-size: 10px; letter-spacing: -0.5px; }
            .card-stats.loading::after {
                content: ''; width: 14px; height: 14px; border: 2px solid var(--ch-acc);
                border-top-color: transparent; border-radius: 50%; animation: chSpin 0.8s linear infinite;
            }

            .ch-toast {
                position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%) translateY(20px);
                display: flex; align-items: center; gap: 10px; padding: 8px 20px;
                border: 1px solid var(--ch-acc); border-radius: 30px; background: var(--ch-bg);
                color: #fff; opacity: 0; transition: all 0.3s; z-index: 999999;
                font-size: 14px; font-weight: 700;
            }
            .ch-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
            .ch-spin {
                width: 14px; height: 14px; border: 2px solid #fff;
                border-top-color: var(--ch-acc); border-radius: 50%; animation: chSpin 0.8s linear infinite;
            }
            @keyframes chSpin { to { transform: rotate(360deg); } }
        `;

        document.head.appendChild(css);

        const container = document.createElement('div');
        container.id = 'ch-fab-container';
        document.body.appendChild(container);

        const main = document.createElement('button');
        main.id = 'ch-main';
        main.className = 'ch-btn';
        main.type = 'button';
        main.innerHTML = '🔍';

        container.appendChild(main);

        updateMainTitle();
        restorePanelPosition(container);
        enableDragging(container, main);
        enableContextMenu(main);

        main.onclick = e => {
            e.preventDefault();
            if (main.dataset.moved === '1') { main.dataset.moved = '0'; return; }
            if (main.dataset.menuOpened === '1') { main.dataset.menuOpened = '0'; return; }
            scanAllCards();
        };
    }

    function restorePanelPosition(container) {
        const x = localStorage.getItem('ch_x');
        const y = localStorage.getItem('ch_y');

        if (!x || !y) {
            // Если координаты не сохранены — ставим дефолтную позицию (около строки поиска как на скрине)
            container.style.left = '235px';
            container.style.top = '12px';
            container.style.right = 'auto';
            container.style.bottom = 'auto';
        } else {
            container.style.left = x;
            container.style.top = y;
            container.style.right = 'auto';
            container.style.bottom = 'auto';
        }
    }

    function enableDragging(container, handle) {
        let dragging = false;
        let moved = false;
        let x0 = 0, y0 = 0, l0 = 0, t0 = 0;

        const getPoint = e => e.touches ? e.touches[0] : e;

        const start = e => {
            const point = getPoint(e);
            const rect = container.getBoundingClientRect();
            x0 = point.clientX;
            y0 = point.clientY;
            l0 = rect.left;
            t0 = rect.top;
            dragging = true;
            moved = false;
            handle.dataset.moved = '0';
        };

        const move = e => {
            // Если включено закрепление (isPinned), блокируем возможность перетаскивать
            if (!dragging || isPinned) return;

            const point = getPoint(e);
            const dx = point.clientX - x0;
            const dy = point.clientY - y0;

            if (Math.abs(dx) > 5 || Math.abs(dy) > 5) moved = true;
            if (!moved) return;

            closeContextMenu();

            const rect = container.getBoundingClientRect();
            const maxLeft = window.innerWidth - rect.width;
            const maxTop = window.innerHeight - rect.height;

            const left = Math.min(Math.max(l0 + dx, 0), Math.max(maxLeft, 0));
            const top = Math.min(Math.max(t0 + dy, 0), Math.max(maxTop, 0));

            container.style.left = `${left}px`;
            container.style.top = `${top}px`;
            container.style.right = 'auto';
            container.style.bottom = 'auto';

            handle.dataset.moved = '1';
        };

        const end = () => {
            if (!dragging) return;
            dragging = false;

            if (moved) {
                localStorage.setItem('ch_x', container.style.left);
                localStorage.setItem('ch_y', container.style.top);
                setTimeout(() => { handle.dataset.moved = '0'; }, 250);
            }
        };

        handle.addEventListener('touchstart', start, { passive: true });
        document.addEventListener('touchmove', move, { passive: true });
        document.addEventListener('touchend', end);

        handle.addEventListener('mousedown', start);
        document.addEventListener('mousemove', move);
        document.addEventListener('mouseup', end);
    }

    function enableContextMenu(main) {
        let longPressTimer = null;
        let sx = 0, sy = 0;

        main.addEventListener('contextmenu', e => {
            e.preventDefault();
            e.stopPropagation();
            main.dataset.menuOpened = '1';
            openContextMenu(main);
        });

        main.addEventListener('touchstart', e => {
            const touch = e.touches[0];
            sx = touch.clientX;
            sy = touch.clientY;
            clearTimeout(longPressTimer);
            longPressTimer = setTimeout(() => {
                main.dataset.menuOpened = '1';
                openContextMenu(main);
            }, 550);
        }, { passive: true });

        main.addEventListener('touchmove', e => {
            const touch = e.touches[0];
            if (Math.abs(touch.clientX - sx) > 8 || Math.abs(touch.clientY - sy) > 8) {
                clearTimeout(longPressTimer);
            }
        }, { passive: true });

        main.addEventListener('touchend', () => { clearTimeout(longPressTimer); });

        document.addEventListener('click', e => {
            if (!menuEl) return;
            if (e.target.closest('.ch-menu') || e.target.closest('#ch-main')) return;
            closeContextMenu();
        });

        document.addEventListener('keydown', e => { if (e.key === 'Escape') closeContextMenu(); });
        window.addEventListener('resize', closeContextMenu);
        window.addEventListener('scroll', closeContextMenu, true);
    }

    function observeDomChanges() {
        const domObserver = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === Node.ELEMENT_NODE) pendingRoots.add(node);
                }
            }
            if (!pendingRoots.size) return;
            clearTimeout(mutationTimer);

            mutationTimer = setTimeout(() => {
                const roots = Array.from(pendingRoots);
                pendingRoots.clear();
                roots.forEach(processRoot);
                triggerVisibleAutoLoad();
            }, CONFIG.MUTATION_DEBOUNCE);
        });

        domObserver.observe(document.body, { childList: true, subtree: true });
    }

    function init() {
        gcCache();
        buildUI();
        processRoot(document);
        triggerVisibleAutoLoad();
        observeDomChanges();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})();