ASSStat Card

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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();
    }
})();