Tag Extractor

Извлекает теги с ExHentai, E-Hentai, VNDB, F95Zone, Island-of-pleasure, DLsite, DMM/Fanza, ErogameScape, Pornolab, Itch.io, Nutaku, JastStore, SaikeyStudios, Getchu, Gyutto, Digiket, DL-Getchu, Fantia, Steam, GOG, Ci-en, DeviantArt, Pixiv, EpicGames, RyuuGames, Otomi-Games, LewdZone, H-Suki, Erotorrent, Gelbooru, Danbooru, AllTheFallen, E621, Rule34, Hitomi, NHentai, HentaiChan, HentaiLib/MangaLib/RanobeLib/AniLib, Shikimori, ReManga, ReadManga/MintManga/SeiManga/LibreBook, AniDB

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Tag Extractor
// @version      2.1
// @description  Извлекает теги с ExHentai, E-Hentai, VNDB, F95Zone, Island-of-pleasure, DLsite, DMM/Fanza, ErogameScape, Pornolab, Itch.io, Nutaku, JastStore, SaikeyStudios, Getchu, Gyutto, Digiket, DL-Getchu, Fantia, Steam, GOG, Ci-en, DeviantArt, Pixiv, EpicGames, RyuuGames, Otomi-Games, LewdZone, H-Suki, Erotorrent, Gelbooru, Danbooru, AllTheFallen, E621, Rule34, Hitomi, NHentai, HentaiChan, HentaiLib/MangaLib/RanobeLib/AniLib, Shikimori, ReManga, ReadManga/MintManga/SeiManga/LibreBook, AniDB
// @match        https://exhentai.org/*
// @match        https://www.exhentai.org/*
// @match        https://e-hentai.org/*
// @match        https://www.e-hentai.org/*
// @match        https://vndb.org/v*
// @match        https://www.vndb.org/v*
// @match        https://f95zone.to/threads/*
// @match        https://www.f95zone.to/threads/*
// @match        https://island-of-pleasure.site/*
// @match        https://www.island-of-pleasure.site/*
// @match        https://dlsite.com/*
// @match        https://www.dlsite.com/*
// @match        https://dlsoft.dmm.co.jp/detail/*
// @match        https://www.dlsoft.dmm.co.jp/detail/*
// @match        https://dlsoft.dmm.com/detail/*
// @match        https://www.dlsoft.dmm.com/detail/*
// @match        https://erogamescape.org/~ap2/ero/toukei_kaiseki/game.php*
// @match        https://www.erogamescape.org/~ap2/ero/toukei_kaiseki/game.php*
// @match        https://erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/game.php*
// @match        https://www.erogamescape.dyndns.org/~ap2/ero/toukei_kaiseki/game.php*
// @match        https://pornolab.net/forum/viewtopic.php*
// @match        https://www.pornolab.net/forum/viewtopic.php*
// @match        https://*.itch.io/*
// @match        https://www.nutaku.net/games/*
// @match        https://nutaku.net/games/*
// @match        https://jaststore.com/games/*
// @match        https://www.jaststore.com/games/*
// @match        https://saikeystudios.com/product/*
// @match        https://www.saikeystudios.com/product/*
// @match        https://getchu.com/item/*
// @match        https://www.getchu.com/item/*
// @match        https://gyutto.com/i/*
// @match        https://www.gyutto.com/i/*
// @match        https://digiket.com/work/show/*
// @match        https://www.digiket.com/work/show/*
// @match        https://dl.getchu.com/i/*
// @match        https://www.dl.getchu.com/i/*
// @match        https://fantia.jp/products/*
// @match        https://www.fantia.jp/products/*
// @match        https://store.steampowered.com/app/*
// @match        https://www.store.steampowered.com/app/*
// @match        https://gog.com/*/game/*
// @match        https://www.gog.com/*/game/*
// @match        https://ci-en.dlsite.com/creator/*
// @match        https://www.ci-en.dlsite.com/creator/*
// @match        https://deviantart.com/*
// @match        https://www.deviantart.com/*
// @match        https://pixiv.net/*
// @match        https://www.pixiv.net/*
// @match        https://store.epicgames.com/p/*
// @match        https://www.store.epicgames.com/p/*
// @match        https://ryuugames.com/*
// @match        https://www.ryuugames.com/*
// @match        https://otomi-games.com/*
// @match        https://www.otomi-games.com/*
// @match        https://lewdzone.com/game/*
// @match        https://www.lewdzone.com/game/*
// @match        https://h-suki.com/*
// @match        https://www.h-suki.com/*
// @match        https://erotorrent.ru/*
// @match        https://www.erotorrent.ru/*
// @match        https://gelbooru.com/*
// @match        https://www.gelbooru.com/*
// @match        https://danbooru.donmai.us/posts/*
// @match        https://www.danbooru.donmai.us/posts/*
// @match        https://booru.allthefallen.moe/posts/*
// @match        https://www.booru.allthefallen.moe/posts/*
// @match        https://e621.net/posts/*
// @match        https://www.e621.net/posts/*
// @match        https://rule34.xxx/*
// @match        https://www.rule34.xxx/*
// @match        https://hitomi.la/*
// @match        https://www.hitomi.la/*
// @match        https://nhentai.net/g/*
// @match        https://www.nhentai.net/g/*
// @match        https://nhentai.online/*
// @match        https://www.nhentai.online/*
// @match        https://nhentai.to/g/*
// @match        https://www.nhentai.to/g/*
// @match        https://hentaichan.live/*
// @match        https://www.hentaichan.live/*
// @match        https://*.hentaichan.live/*
// @match        https://www.*.hentaichan.live/*
// @match        https://x9.h-chan.me/*
// @match        https://www.x9.h-chan.me/*
// @match        https://hentailib.me/*
// @match        https://www.hentailib.me/*
// @match        https://*.hentailib.me/*
// @match        https://www.*.hentailib.me/*
// @match        https://mangalib.me/*
// @match        https://www.mangalib.me/*
// @match        https://*.mangalib.me/*
// @match        https://www.*.mangalib.me/*
// @match        https://ranobelib.me/*
// @match        https://www.ranobelib.me/*
// @match        https://*.ranobelib.me/*
// @match        https://www.*.ranobelib.me/*
// @match        https://anilib.me/*
// @match        https://www.anilib.me/*
// @match        https://*.anilib.me/*
// @match        https://www.*.anilib.me/*
// @match        https://shlib.life/*
// @match        https://www.shlib.life/*
// @match        https://*.shlib.life/*
// @match        https://www.*.shlib.life/*
// @match        https://animelib.org/*
// @match        https://www.animelib.org/*
// @match        https://*.animelib.org/*
// @match        https://www.*.animelib.org/*
// @match        https://shikimori.io/*
// @match        https://www.shikimori.io/*
// @match        https://remanga.org/*
// @match        https://www.remanga.org/*
// @match        https://readmanga.ru/*
// @match        https://www.readmanga.ru/*
// @match        https://*.readmanga.ru/*
// @match        https://www.*.readmanga.ru/*
// @match        https://mintmanga.one/*
// @match        https://www.mintmanga.one/*
// @match        https://*.mintmanga.one/*
// @match        https://www.*.mintmanga.one/*
// @match        https://seimanga.me/*
// @match        https://www.seimanga.me/*
// @match        https://*.seimanga.me/*
// @match        https://www.*.seimanga.me/*
// @match        https://librebook.me/*
// @match        https://www.librebook.me/*
// @match        https://*.librebook.me/*
// @match        https://www.*.librebook.me/*
// @match        https://anidb.net/anime/*
// @match        https://www.anidb.net/anime/*
// @grant        GM_setClipboard
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      archive.org
// @run-at       document-end
// @license      MIT
// @namespace https://greasyfork.org/users/976872
// ==/UserScript==

(function () {
    'use strict';

    // ════════════════════════════════════════════════════════════════
    //  ХРАНИЛИЩЕ
    // ════════════════════════════════════════════════════════════════

    // Единый объект хранилища вместо 12 отдельных get/set функций
    const store = {
        get pool()         { try { return JSON.parse(GM_getValue('tep_pool', '[]')); } catch { return []; } },
        set pool(v)        { GM_setValue('tep_pool', JSON.stringify([...new Set(v)])); },
        get pos()          { try { return JSON.parse(GM_getValue('tep_panel_pos', 'null')); } catch { return null; } },
        set pos(v)         { GM_setValue('tep_panel_pos', JSON.stringify(v)); },
        get collapsed()    { return GM_getValue('tep_panel_collapsed', true); },
        set collapsed(v)   { GM_setValue('tep_panel_collapsed', v); },
        get ehCopyAll()    { return GM_getValue('tep_e_hentai_copy_all', false); },
        set ehCopyAll(v)   { GM_setValue('tep_e_hentai_copy_all', v); },
        get booruCopyAll() { return GM_getValue('tep_booru_copy_all', false); },
        set booruCopyAll(v){ GM_setValue('tep_booru_copy_all', v); },
        get nhentaiCopyAll() { return GM_getValue('tep_nhentai_copy_all', false); },
        set nhentaiCopyAll(v){ GM_setValue('tep_nhentai_copy_all', v); },
        get eroReplace()   { return GM_getValue('tep_erogame_replace', true); },
        set eroReplace(v)  { GM_setValue('tep_erogame_replace', v); },
        get rusReplace()   { return GM_getValue('tep_russian_replace', true); },
        set rusReplace(v)  { GM_setValue('tep_russian_replace', v); },
        get pornoReplace() { return GM_getValue('tep_porno_replace', true); },
        set pornoReplace(v){ GM_setValue('tep_porno_replace', v); },
    };

    // ════════════════════════════════════════════════════════════════
    //  УТИЛИТЫ
    // ════════════════════════════════════════════════════════════════

    /**
     * Универсальный хелпер: выбирает уникальные непустые строки из DOM.
     * @param {string}   selector  CSS-селектор
     * @param {Element}  root      корень поиска (по умолчанию — document)
     * @param {Function} mapFn     преобразование элемента → строка
     */
    function tagsFrom(selector, root = document, mapFn = el => el.textContent.trim()) {
        return [...new Set([...root.querySelectorAll(selector)].map(mapFn).filter(Boolean))];
    }

    // ════════════════════════════════════════════════════════════════
    //  ИЗВЛЕЧЕНИЕ ТЕГОВ
    // ════════════════════════════════════════════════════════════════

    const EH_GENDER_SECTIONS = new Set(['female', 'male', 'mixed']);

    function extractTagsEHentai() {
        const root = document.querySelector('#taglist');
        if (!root) return [];
        const tags = [];
        root.querySelectorAll('tr').forEach(row => {
            const tds = row.querySelectorAll('td');
            if (tds.length < 2) return;
            const section = tds[0].textContent.trim().replace(/:$/, '').toLowerCase();
            if (!store.ehCopyAll && !EH_GENDER_SECTIONS.has(section)) return;
            tds[1].querySelectorAll('a').forEach(a => {
                const t = a.textContent.trim();
                if (t) tags.push(t);
            });
        });
        return [...new Set(tags)];
    }

    const extractTagsVNDB    = () => tagsFrom('span[class*="tagspl"] a');
    const extractTagsF95     = () => tagsFrom('a.tagItem');
    const extractTagsIsland  = () => tagsFrom('a', document.getElementById('news-tags-list') ?? undefined);
    const extractTagsDLsite  = () => tagsFrom('a', document.querySelector('div.main_genre') ?? undefined);
    const extractTagsSteam   = () => tagsFrom('a.app_tag');
    const extractTagsGOG     = () => tagsFrom('ul.genres li.genres__item');
    const extractTagsCien    = () => tagsFrom('a.tag.is-genre');
    const extractTagsGetchu  = () => tagsFrom('a[href*="sub_genre_id="], a[href*="search.phtml?category"]');
    const extractTagsDlGetchu = () => tagsFrom('a[href*="genre_id="]');
    const extractTagsRyuuGames = () => tagsFrom('ul.td-tags a[href*="/tag/"]');
    const extractTagsLewdZone  = () => tagsFrom('div.taglist a[rel="tag"]');
    const extractTagsDeviantArt = () => tagsFrom('a[data-tagname]', document,
        a => a.getAttribute('data-tagname') || a.textContent.trim());

    function extractTagsDMM() {
        const uls = document.querySelectorAll('ul.contentsDetailBottom__tableDataList');
        if (!uls.length) return [];
        // Последний ul содержит теги; последний элемент — промо-акция, отбрасываем его
        const tags = tagsFrom('li.contentsDetailBottom__tableDataItem a', uls[uls.length - 1]);
        if (tags.length) tags.pop();
        return tags;
    }

    // ── Константы хранилища и URL ────────────────────────────────
    const ERO_DB_KEY     = 'tep_erodict_data_v1';  // v1 — добавлен словарь byPornolab
    const ERO_SQL_URL    = 'https://archive.org/download/pixiv_data/ja-en-ru.sql';

    // ── In-memory кэш (живёт пока открыта вкладка) ───────────────
    // null = ещё не загружали; false = загрузка упала; object = готово
    let _eroDictCache = null;
	let _lastClipTags = [];

    /** Promise-обёртка над GM_xmlhttpRequest */
    function gmXhr(url, timeoutMs = 30000) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method : 'GET',
                url,
                timeout: timeoutMs,
                onload  : r => (r.status >= 200 && r.status < 300)
                    ? resolve(r.responseText)
                    : reject(new Error(`HTTP ${r.status}`)),
                onerror : ()  => reject(new Error('Ошибка сети')),
                ontimeout: () => reject(new Error('Таймаут')),
            });
        });
    }

    /**
     * Парсит SQL-дамп и возвращает два словаря:
     *   { byJapanese: {jp→entry, …}, byRomaji: {rm→entry, …} }
     * где entry = { en, ruArr[] }
     *
     * Схема: id, japanese, romaji, english, russian, pixiv, pornolab
     * Поля 2–5 могут быть NULL.
     */
    function parseSQLtoDict(sql) {
        const byJapanese = Object.create(null);
        const byRomaji   = Object.create(null);
        const byRussian  = Object.create(null);
        // byPornolab: любая форма тега (lowercase) → pornolab-значение
        const byPornolab = Object.create(null);
        let totalRows = 0;
        // Все поля могут быть NULL — используем QN для всех
        const QN = `(?:'((?:[^'\\\\]|\\\\.|'')*)'|NULL)`;     // quoted or NULL
        // Захватываем все 6 полей после id: japanese, romaji, english, russian, pixiv, pornolab
        const re = new RegExp(`VALUES\\s*\\(\\d+,${QN},${QN},${QN},${QN},${QN},${QN}`, 'gi');
        let m;
        while ((m = re.exec(sql)) !== null) {
            const jp      = m[1] ? m[1].replace(/''/g, "'") : '';
            const rm      = m[2] ? m[2].replace(/''/g, "'") : '';
            const en      = m[3] ? m[3].replace(/''/g, "'") : '';
            const ruRaw   = m[4] ? m[4].replace(/''/g, "'") : '';
            // m[5] = pixiv (не используем)
            const plRaw   = m[6] ? m[6].replace(/''/g, "'") : '';
            if (!en && !ruRaw && !plRaw) continue;
            totalRows++;
            let ruArr = [];
            if (ruRaw) {
                try { ruArr = JSON.parse(ruRaw); } catch {}
            }
            const entry = { en, ruArr };
            if (jp) byJapanese[jp] = entry;
            if (rm) byRomaji[rm]   = entry;
            // Обратный словарь: русский тег → английский
            ruArr.forEach(ruTag => { if (ruTag && en) byRussian[ruTag] = en; });
            // Словарь pornolab: все известные формы тега (lowercase) → pornolab-значение
            if (plRaw) {
                // Ключи — lowercase для регистронезависимого поиска;
                // значение сохраняем в оригинальном регистре (напр. "Fantasy", "Drama")
                if (en)   byPornolab[en.toLowerCase()]  = plRaw;
                if (jp)   byPornolab[jp.toLowerCase()]  = plRaw;
                if (rm)   byPornolab[rm.toLowerCase()]  = plRaw;
                ruArr.forEach(ruTag => { if (ruTag) byPornolab[ruTag.toLowerCase()] = plRaw; });
            }
        }
        return { byJapanese, byRomaji, byRussian, byPornolab, totalRows };
    }

    /**
     * Загружает (или отдаёт из кэша) словарь перевода.
     * Возвращает объект { byJapanese, byRomaji, byRussian, byPornolab } или false при неудаче.
     */
    async function getEroDict() {
        if (_eroDictCache !== null) return _eroDictCache;

        const cachedData = GM_getValue(ERO_DB_KEY, null);

        // Если кэш есть — используем его
        if (cachedData) {
            _eroDictCache = JSON.parse(cachedData);
            return _eroDictCache;
        }

        // Кэша нет — качаем с archive.org
        try {
            toast('⏳ Загрузка базы переводов тегов…', '#1a6fb5');
            const sql  = await gmXhr(ERO_SQL_URL, 120000);
            const dict = parseSQLtoDict(sql);
            GM_setValue(ERO_DB_KEY, JSON.stringify(dict));
            _eroDictCache = dict;
            toast(`✅ База переводов загружена: ${dict.totalRows.toLocaleString()} записей`, '#2a7');
            return _eroDictCache;
        } catch (err) {
            toast('⚠ Не удалось загрузить базу переводов — теги останутся без перевода', '#b52');
            _eroDictCache = false;
            return false;
        }
    }

    /**
     * Переводит один японский тег: ищет запись в byJapanese / byRomaji базы.
     * При промахе или отсутствии английского перевода оставляет тег как есть.
     */
    function lookupEroTag(tag, dict) {
        const entry = dict ? (dict.byJapanese[tag] ?? dict.byRomaji[tag] ?? null) : null;
        const enTag = (entry && entry.en) ? entry.en : tag;
        return [enTag];
    }

    // Проверяет, содержит ли строка японские символы (хирагана, катакана, кандзи)
    function hasJapanese(str) {
        return /[\u3000-\u9FFF\uF900-\uFAFF]/.test(str);
    }

    /**
     * Подменяет японские теги на английские по базе данных.
     * Загружает базу при первом вызове; при ошибке оставляет теги без перевода.
     */
    async function applyJapaneseReplacement(tags) {
        if (!store.eroReplace) return tags;
        const dict = await getEroDict();
        const result = tags.flatMap(t => hasJapanese(t) ? lookupEroTag(t, dict) : [t]);
        return [...new Set(result)];
    }

    // Проверяет, содержит ли строка кириллические символы
    function hasRussian(str) {
        return /[а-яёА-ЯЁ]/.test(str);
    }

    /**
     * Подменяет русские теги на английские по колонке russian базы данных.
     * Данные в russian хранятся в виде JSON-массива строк.
     * При ошибке загрузки базы оставляет теги без перевода.
     */
    async function applyRussianReplacement(tags) {
        if (!store.rusReplace) return tags;
        const dict = await getEroDict();
        if (!dict) return tags;
        const result = tags.map(t => {
            if (!hasRussian(t)) return t;
            const en = dict.byRussian[t];
            return (en) ? en : t;
        });
        return [...new Set(result)];
    }

    /**
     * Подменяет финальные теги на значения из колонки pornolab базы данных.
     * Сравнение регистронезависимое. NULL-значения pornolab пропускаются.
     * Применяется последней — после всех языковых замен.
     */
    async function applyPornolabReplacement(tags) {
        if (!store.pornoReplace) return tags;
        const dict = await getEroDict();
        if (!dict || !dict.byPornolab) return tags;
        // Замена 1-к-1: каждый тег либо заменяется pornolab-значением,
        // либо остаётся как есть — теги не теряются и не схлопываются.
        return [...new Set(tags.map(t => {
            const pl = dict.byPornolab[t.toLowerCase()];
            return pl ? pl : t;
        }))].filter((tag, _, arr) => {
            const lower = tag.toLowerCase();
            if (tag === lower) {
                return !arr.some(other => other !== tag && other.toLowerCase() === lower);
            }
            return true;
        });
    }

    function extractTagsErogamescape() {
        const TARGET = new Set(['タグ', '女の子キャラクター', 'ジャンル', 'シチュエーション', 'エロシーン']);
        const tags = [];
        document.querySelectorAll('th').forEach(th => {
            if (!TARGET.has(th.textContent.trim())) return;
            th.closest('tr')?.querySelectorAll('td a').forEach(a => {
                const t = a.textContent.trim();
                if (t) tags.push(t);
            });
        });
        return [...new Set(tags)];
    }

    function extractTagsPornolab() {
        // Структура title: [...] Название [...] (студия) [...] [год, тег1, тег2, ...] [язык] :: PornoLab.Net
        // Берём предпоследний блок в [], пропускаем первый элемент (год)
        const blocks = [...document.title.matchAll(/\[([^\[\]]+)\]/g)].map(m => m[1]);
        if (blocks.length < 2) return [];
        const parts = blocks[blocks.length - 2].split(',').map(s => s.trim()).filter(Boolean);
        const withoutYear = /^\d{4}(-\d{4})?$/.test(parts[0]) ? parts.slice(1) : parts;
        return [...new Set(withoutYear)];
    }

    // Itch.io: теги хранятся в /data.json рядом с текущим URL
    async function extractTagsItch() {
        const url = location.origin + location.pathname.replace(/\/+$/, '') + '/data.json';
        const resp = await fetch(url);
        if (!resp.ok) return [];
        const data = await resp.json();
        return Array.isArray(data.tags) ? [...new Set(data.tags)] : [];
    }

    function extractTagsNutaku() {
        const SECTIONS = new Set(['Genre', 'Tags']);
        const tags = [];
        document.querySelectorAll('section.flx-column').forEach(section => {
            const titleEl = section.querySelector('span.section-title');
            if (!titleEl || !SECTIONS.has(titleEl.textContent.trim())) return;
            section.querySelectorAll('span.fnt-small-4').forEach(sp => {
                const t = sp.textContent.trim();
                if (t) tags.push(t);
            });
        });
        return [...new Set(tags)];
    }

    // JastStore: жанры/контент — обычные ссылки; платформы — div с SVG-иконкой
    function extractTagsJastStore() {
        const tags = tagsFrom('a[href*="/search?genres="], a[href*="/search?contents="]');
        document.querySelectorAll('.place-content-center').forEach(label => {
            if (label.textContent.trim() !== 'Platform') return;
            label.nextElementSibling?.querySelectorAll('div.inline-flex').forEach(d => {
                const text = [...d.childNodes]
                    .filter(n => n.nodeType === 3)
                    .map(n => n.textContent.trim())
                    .filter(Boolean).join('');
                if (text) tags.push(text);
            });
        });
        return [...new Set(tags)];
    }

    function extractTagsSaikeyStudios() {
        return [...new Set([...tagsFrom('#sk-td-cat a'), ...tagsFrom('#filter-tags .filter-tag-label')])];
    }

    function extractTagsGyutto() {
        const tags = [];
        document.querySelectorAll('dl.BasicInfo').forEach(dl => {
            if (dl.querySelector('dt')?.textContent.trim() !== 'ジャンル') return;
            tagsFrom('a[href*="genre_id="]', dl).forEach(t => tags.push(t));
        });
        return [...new Set(tags)];
    }

    const extractTagsDigiket = () => tagsFrom(
        'a[href*="/game/link/_data/genre="], a[href*="/game/link/_data/A="]'
    );

    // Fantia: текст тегов начинается с «#» — обрезаем его
    const extractTagsFantia = () => tagsFrom('a[href*="?tag="]', document, a => {
        const t = a.textContent.trim();
        return t.startsWith('#') ? t.slice(1).trim() : t;
    });

    function extractTagsPixiv() {
        const tags = [];
        document.querySelectorAll('li').forEach(li => {
            const best = li.querySelector('a.gtm-new-work-translate-tag-event-click')
                      ?? li.querySelector('a.gtm-new-work-romaji-tag-event-click')
                      ?? li.querySelector('a.gtm-new-work-tag-event-click');
            if (best) { const t = best.textContent.trim(); if (t) tags.push(t); }
        });
        return [...new Set(tags)];
    }

    function extractTagsEpicGames() {
        const tags = [];
        document.querySelectorAll('p').forEach(p => {
            if (p.textContent.trim() !== 'Genres') return;
            p.closest('[data-testid="about-metadata-layout-column"]')
                ?.querySelectorAll('a').forEach(a => {
                    const t = a.textContent.trim();
                    if (t) tags.push(t);
                });
        });
        return [...new Set(tags)];
    }

    function extractTagsOtomiGames() {
        const tags = tagsFrom('a[rel="tag"][href*="/tag/"]');
        // Категории из <meta property="article:section">
        document.querySelectorAll('meta[property="article:section"]').forEach(m => {
            m.getAttribute('content')?.split(',').map(s => s.trim()).filter(Boolean)
                .forEach(s => tags.push(s));
        });
        // Категории из JSON-LD articleSection (поддерживаем @graph)
        document.querySelectorAll('script[type="application/ld+json"]').forEach(script => {
            try {
                const data = JSON.parse(script.textContent);
                const flat = data['@graph'] ? data['@graph'] : (Array.isArray(data) ? data : [data]);
                flat.forEach(item => {
                    if (item?.articleSection) {
                        String(item.articleSection).split(',').map(s => s.trim()).filter(Boolean)
                            .forEach(s => tags.push(s));
                    }
                });
            } catch {}
        });
        return [...new Set(tags)];
    }

    function extractTagsHSuki() {
        return [...new Set([
            ...tagsFrom('#product-tags-hsk a'),
            ...tagsFrom('#product-tags-vndb .block.block-dark'),
        ])];
    }

    function extractTagsErotorrent() {
        const tags = [];
        document.querySelectorAll('span.data_b').forEach(span => {
            if (span.textContent.trim() !== 'Теги:') return;
            span.parentElement?.querySelectorAll('a').forEach(a => {
                const t = a.textContent.trim();
                if (t) tags.push(t);
            });
        });
        tagsFrom('div.left_full_cat a').forEach(t => tags.push(t));
        return [...new Set(tags)];
    }

    // Фабрика booru-экстракторов: две ветки (все теги / только general)
    // управляются флагом store.booruCopyAll
    function makeBooruExtractor(allSelector, generalSelector) {
        return () => tagsFrom(store.booruCopyAll ? allSelector : generalSelector);
    }

    const extractTagsGelbooru = makeBooruExtractor(
        '#tag-list li[class*="tag-type-"] a[href*="tags="]',
        '#tag-list li.tag-type-general a[href*="tags="]'
    );
    // Danbooru и AllTheFallen используют идентичную структуру тегов
    const extractTagsDanbooru = makeBooruExtractor(
        '#tag-list a.search-tag',
        '#tag-list li.flex.tag-type-0 a.search-tag'
    );
    const extractTagsAllTheFallen = extractTagsDanbooru;

    const extractTagsE621 = makeBooruExtractor(
        '#tag-list li[data-category] span.tag-list-name',
        '#tag-list li[data-category="general"] span.tag-list-name'
    );
    const extractTagsRule34 = makeBooruExtractor(
        '#tag-sidebar li[class*="tag-type-"] a[href*="page=post"]',
        '#tag-sidebar li.tag-type-general a[href*="page=post"]'
    );

    // Hitomi: теги в ul#tags внутри .gallery-info; убираем символы ♂ и ♀
    function extractTagsHitomi() {
        return tagsFrom('div.gallery-info ul.tags li a', document, a =>
            a.textContent.trim().replace(/[♂♀]/g, '').trim()
        ).filter(Boolean);
    }

    // NHentai (nhentai.net и nhentai.to): div.tag-container с текстовым лейблом
    // и тегами в span.name
    function extractTagsNhentaiStandard() {
        const SKIP = new Set(['pages', 'uploaded']);
        const tags = [];
        document.querySelectorAll('div.tag-container').forEach(container => {
            // Лейбл — первый непустой текстовый узел контейнера
            let label = '';
            for (const node of container.childNodes) {
                if (node.nodeType === 3) {
                    const t = node.textContent.trim().replace(/:$/, '').toLowerCase();
                    if (t) { label = t; break; }
                }
            }
            if (SKIP.has(label)) return;
            if (!store.nhentaiCopyAll && label !== 'tags') return;
            container.querySelectorAll('span.name').forEach(sp => {
                const t = sp.textContent.trim();
                if (t) tags.push(t);
            });
        });
        return [...new Set(tags)];
    }

    // NHentai Online (nhentai.online): ul.post-itens, каждый li имеет <strong>Лейбл:</strong>
    function extractTagsNhentaiOnline() {
        const SKIP = new Set(['pages']);
        const tags = [];
        document.querySelectorAll('ul.post-itens li').forEach(li => {
            const strong = li.querySelector('strong');
            if (!strong) return;
            const label = strong.textContent.trim().replace(/:$/, '').toLowerCase();
            if (SKIP.has(label)) return;
            if (!store.nhentaiCopyAll && label !== 'tags') return;
            li.querySelectorAll('a').forEach(a => {
                const t = a.textContent.trim();
                if (t) tags.push(t);
            });
        });
        return [...new Set(tags)];
    }

    // HentaiChan (hentaichan.live, h-chan.me и зеркала):
    // теги в ul.sidetags > li.sidetag; каждый li содержит 3 ссылки:
    // [+добавить], [-исключить], [сам тег] — берём последнюю
    const extractTagsHentaichan = () => tagsFrom('li.sidetag a:last-child');

    // HentaiLib / MangaLib / RanobeLib / AniLib / ShLib / AnimeLib:
    // берём теги из JSON-LD, чтобы получить весь список независимо от "+ещё N"
    const extractTagsHentaiLib = () => {
        const tags = extractJsonLdTags('genre');
        return tags.length
            ? tags
            : tagsFrom('a[data-type="genre"] span, a[data-type="tag"] span');
    };

    // Shikimori: жанры в <a class="b-tag">, берём русский вариант из <span class="genre-ru">
    const extractTagsShikimori = () => tagsFrom('a.b-tag span.genre-ru');

    // ReManga: жанры и категории — ссылки с href, ведущим на /manga/genres/ или /manga/categories/
    // текст тега начинается с «#» — обрезаем его (аналогично Fantia)
    function extractJsonLdTags(key) {
        const tags = [];
        document.querySelectorAll('script[type="application/ld+json"]').forEach(script => {
            try {
                const data = JSON.parse(script.textContent);
                const items = Array.isArray(data) ? data : [data];
                items.forEach(item => {
                    if (Array.isArray(item?.[key])) tags.push(...item[key]);
                });
            } catch (_) {}
        });
        return [...new Set(tags)].filter(Boolean);
    }

    // ReManga: берём теги из JSON-LD, чтобы получить все жанры/категории независимо от спойлера
    const extractTagsRemanga = () => {
        const tags = extractJsonLdTags('genre');
        return tags.length
            ? tags
            : tagsFrom(
                'a[href*="/manga/genres/"], a[href*="/manga/categories/"]',
                document,
                a => {
                    const t = a.textContent.trim();
                    return t.startsWith('#') ? t.slice(1).trim() : t;
                }
            );
    };

    // ReadManga / MintManga / SeiManga / LibreBook:
    // сначала раскрываем спойлер "Показать все", потом забираем все теги
    const extractTagsReadManga = async () => {
        const toggle = document.querySelector('.cr-tags__toggle');
        if (toggle) {
            if (typeof toggle.click === 'function') {
                toggle.click();
            } else {
                toggle.dispatchEvent(new Event('click', { bubbles: true, cancelable: true }));
            }
            await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
        }

        return tagsFrom('.cr-tags__item', document, a => {
            const span = a.querySelector('span:last-child') || a.querySelector('span:not(.text-secondary)');
            return span ? span.textContent.trim() : '';
        });
    };

    // AniDB: жанры/теги в span.tagname[itemprop="genre"] внутри основного блока информации
    const extractTagsAnidb = () => tagsFrom('span.tagname[itemprop="genre"]');

    // ════════════════════════════════════════════════════════════════
    //  ДИСПЕТЧЕРИЗАЦИЯ ПО ХОСТУ
    // ════════════════════════════════════════════════════════════════

    // Таблица [паттерн-хоста, функция-экстрактор].
    // Совпадение: host === pattern  ИЛИ  host заканчивается на '.' + pattern
    // (это покрывает и www.*, и поддомены вроде *.itch.io)
    const HOST_MAP = [
        ['exhentai.org',            extractTagsEHentai],
        ['e-hentai.org',            extractTagsEHentai],
        ['vndb.org',                extractTagsVNDB],
        ['f95zone.to',              extractTagsF95],
        ['island-of-pleasure.site', extractTagsIsland],
        ['dlsite.com',              extractTagsDLsite],
        ['dlsoft.dmm.co.jp',        extractTagsDMM],
        ['dlsoft.dmm.com',          extractTagsDMM],
        ['erogamescape.org',        extractTagsErogamescape],
        ['erogamescape.dyndns.org', extractTagsErogamescape],
        ['pornolab.net',            extractTagsPornolab],
        ['itch.io',                 extractTagsItch],
        ['nutaku.net',              extractTagsNutaku],
        ['jaststore.com',           extractTagsJastStore],
        ['saikeystudios.com',       extractTagsSaikeyStudios],
        ['getchu.com',              extractTagsGetchu],
        ['gyutto.com',              extractTagsGyutto],
        ['digiket.com',             extractTagsDigiket],
        ['dl.getchu.com',           extractTagsDlGetchu],
        ['fantia.jp',               extractTagsFantia],
        ['store.steampowered.com',  extractTagsSteam],
        ['gog.com',                 extractTagsGOG],
        ['ci-en.dlsite.com',        extractTagsCien],
        ['deviantart.com',          extractTagsDeviantArt],
        ['pixiv.net',               extractTagsPixiv],
        ['store.epicgames.com',     extractTagsEpicGames],
        ['ryuugames.com',           extractTagsRyuuGames],
        ['otomi-games.com',         extractTagsOtomiGames],
        ['lewdzone.com',            extractTagsLewdZone],
        ['h-suki.com',              extractTagsHSuki],
        ['erotorrent.ru',           extractTagsErotorrent],
        ['gelbooru.com',            extractTagsGelbooru],
        ['danbooru.donmai.us',      extractTagsDanbooru],
        ['booru.allthefallen.moe',  extractTagsAllTheFallen],
        ['e621.net',                extractTagsE621],
        ['rule34.xxx',              extractTagsRule34],
        ['hitomi.la',               extractTagsHitomi],
        ['nhentai.net',             extractTagsNhentaiStandard],
        ['nhentai.to',              extractTagsNhentaiStandard],
        ['nhentai.online',          extractTagsNhentaiOnline],
        ['hentaichan.live',         extractTagsHentaichan],
        ['h-chan.me',               extractTagsHentaichan],
        ['hentailib.me',            extractTagsHentaiLib],
        ['mangalib.me',             extractTagsHentaiLib],
        ['ranobelib.me',            extractTagsHentaiLib],
        ['anilib.me',               extractTagsHentaiLib],
        ['shlib.life',              extractTagsHentaiLib],
        ['animelib.org',            extractTagsHentaiLib],
        ['shikimori.io',            extractTagsShikimori],
        ['remanga.org',             extractTagsRemanga],
        ['readmanga.ru',            extractTagsReadManga],
        ['mintmanga.one',           extractTagsReadManga],
        ['seimanga.me',             extractTagsReadManga],
        ['librebook.me',            extractTagsReadManga],
		['anidb.net',               extractTagsAnidb],
    ];

    async function extractTags() {
        const host = location.hostname.replace(/^www\./, '');
        const entry = HOST_MAP.find(([pattern]) =>
            host === pattern || host.endsWith('.' + pattern)
        );
        const tags = entry ? (await entry[1]()) : [];
        return tags.map(t => t.toLowerCase());
    }

    // ════════════════════════════════════════════════════════════════
    //  БУФЕР ОБМЕНА
    // ════════════════════════════════════════════════════════════════

    function copyToClipboard(text) {
        if (typeof GM_setClipboard === 'function') {
            GM_setClipboard(text);
        } else {
            navigator.clipboard?.writeText(text).catch(() => {});
        }
    }

    // ════════════════════════════════════════════════════════════════
    //  TOAST
    // ════════════════════════════════════════════════════════════════

    function toast(msg, color) {
        document.getElementById('tep-toast')?.remove();
        const el = document.createElement('div');
        el.id = 'tep-toast';
        el.textContent = msg;
        el.style.cssText = [
            'position:fixed', 'top:12px', 'left:50%',
            'transform:translateX(-50%)',
            'z-index:2147483647',
            'padding:6px 14px',
            'border-radius:6px',
            'font:12px/1.4 Arial,sans-serif',
            'color:#fff',
            'pointer-events:none',
            'opacity:1',
            'transition:opacity .4s ease',
            `background:${color || '#2a7'}`,
            'box-shadow:0 2px 10px rgba(0,0,0,.4)',
            'white-space:nowrap',
        ].join(';');
        document.body.appendChild(el);
        setTimeout(() => {
            el.style.opacity = '0';
            setTimeout(() => el.remove(), 420);
        }, 2200);
    }

    // ════════════════════════════════════════════════════════════════
    //  ПАНЕЛЬ
    // ════════════════════════════════════════════════════════════════

    function buildPanel() {
        if (document.getElementById('tep-panel')) return;

        // ── Сброс внешних стилей сайта для кнопок панели ────────
        // AniDB (и некоторые другие сайты) добавляют ::before ко всем button через
        // свой CSS (например: button:not(.fancybox-close-small)::before { content: "✓" })
        // что приводит к появлению лишних галочек на кнопках плашки.
        const resetStyle = document.createElement('style');
        resetStyle.textContent = '#tep-panel button::before, #tep-panel button::after { content: none !important; }';
        document.head.appendChild(resetStyle);

        // ── Корневой контейнер ───────────────────────────────────
        const panel = document.createElement('div');
        panel.id = 'tep-panel';

        const savedPos    = store.pos;
        const defaultRight = 12;
        const defaultTop   = 12;

        panel.style.cssText = [
            'position:fixed',
            'z-index:2147483646',
            'display:flex',
            'flex-direction:column',
            'gap:0',
            'background:rgba(20,20,20,.93)',
            'border:1px solid #555',
            'border-radius:8px',
            'box-shadow:0 4px 18px rgba(0,0,0,.55)',
            'font-family:Arial,sans-serif',
            'user-select:none',
            'min-width:110px',
        ].join(';');

        if (savedPos) {
            panel.style.left = savedPos.left + 'px';
            panel.style.top  = savedPos.top  + 'px';
            // Зажимаем в пределы viewport после добавления в DOM
            requestAnimationFrame(() => {
                const pw = panel.offsetWidth  || 120;
                const ph = panel.offsetHeight || 40;
                const l  = parseFloat(panel.style.left);
                const t  = parseFloat(panel.style.top);
                if (l < 0 || t < 0 || l + pw > window.innerWidth || t + ph > window.innerHeight) {
                    panel.style.left  = '';
                    panel.style.right = defaultRight + 'px';
                    panel.style.top   = defaultTop   + 'px';
                    store.pos = null;
                }
            });
        } else {
            panel.style.right = defaultRight + 'px';
            panel.style.top   = defaultTop   + 'px';
        }

        // ── Шапка (drag handle + кнопки) ────────────────────────
        const header = document.createElement('div');
        header.style.cssText = [
            'display:flex',
            'align-items:center',
            'justify-content:space-between',
            'padding:4px 5px 4px 8px',
            'cursor:grab',
            'border-radius:7px 7px 0 0',
            'background:rgba(255,255,255,.06)',
            'border-bottom:1px solid #444',
        ].join(';');

        const title = document.createElement('span');
        title.textContent = '🏷 Tags';
        title.style.cssText = 'font-size:11px;color:#aaa;font-weight:600;pointer-events:none;';

        const HEADER_BTN_CSS = [
            'background:none', 'border:none', 'color:#aaa',
            'cursor:pointer', 'font-size:13px', 'line-height:1',
            'padding:0 2px', 'border-radius:3px', 'transition:color .15s',
        ].join(';');

        const gearBtn = document.createElement('button');
        gearBtn.textContent = '⚙';
        gearBtn.title = 'Настройки';
        gearBtn.style.cssText = HEADER_BTN_CSS + ';margin-right:2px';

        const toggleBtn = document.createElement('button');
        toggleBtn.style.cssText = HEADER_BTN_CSS;

        // hover-эффект для кнопок шапки
        [gearBtn, toggleBtn].forEach(btn => {
            btn.addEventListener('mouseenter', () => { btn.style.color = '#fff'; });
            btn.addEventListener('mouseleave', () => { btn.style.color = settingsOpen && btn === gearBtn ? '#fff' : '#aaa'; });
        });

        header.append(title, gearBtn, toggleBtn);
        panel.appendChild(header);

        // ── Панель настроек ──────────────────────────────────────
        const settingsPanel = document.createElement('div');
        settingsPanel.id = 'tep-settings';
        settingsPanel.style.cssText = [
            'display:none',
            'flex-direction:column',
            'gap:8px',
            'padding:8px 10px',
            'border-bottom:1px solid #444',
            'background:rgba(255,255,255,.04)',
        ].join(';');

        function makeToggleRow(label, tooltip, getValue, setValue) {
            const row = document.createElement('div');
            row.style.cssText = 'display:flex;align-items:center;gap:6px;';

            // Кастомный tooltip
            const hint = document.createElement('span');
            hint.textContent = '❓';
            hint.style.cssText = 'font-size:11px;cursor:help;flex-shrink:0;position:relative;';

            const tip = document.createElement('div');
            tip.style.cssText = [
                'position:fixed', 'z-index:2147483647',
                'background:#1e1e1e', 'color:#ddd',
                'font-size:11px', 'font-family:Arial,sans-serif',
                'font-weight:400', 'line-height:1.5',
                'padding:6px 9px', 'border-radius:6px',
                'border:1px solid #555', 'box-shadow:0 3px 12px rgba(0,0,0,.6)',
                'white-space:pre-line', 'max-width:260px',
                'pointer-events:none', 'opacity:0',
                'transition:opacity .12s', 'display:none',
            ].join(';');
            tip.textContent = tooltip;
            document.body.appendChild(tip);

            let showTimer = null;
            function showTip() {
                const r = hint.getBoundingClientRect();
                tip.style.display = 'block';
                requestAnimationFrame(() => {
                    const tw = tip.offsetWidth, th = tip.offsetHeight;
                    let left = r.right + 6, top = r.top;
                    if (left + tw > window.innerWidth - 8) left = r.left - tw - 6;
                    if (top + th > window.innerHeight - 8) top = window.innerHeight - 8 - th;
                    if (top < 8) top = 8;
                    tip.style.left = left + 'px';
                    tip.style.top  = top  + 'px';
                    tip.style.opacity = '1';
                });
            }
            function hideTip() {
                tip.style.opacity = '0';
                setTimeout(() => { if (tip.style.opacity === '0') tip.style.display = 'none'; }, 120);
            }
            hint.addEventListener('mouseenter', () => { showTimer = setTimeout(showTip, 50); });
            hint.addEventListener('mouseleave', () => { clearTimeout(showTimer); hideTip(); });

            const lbl = document.createElement('span');
            lbl.textContent = label;
            lbl.style.cssText = 'font-size:11px;color:#bbb;flex:1;font-family:Arial,sans-serif;';

            // Тумблер
            const togWrap = document.createElement('label');
            togWrap.style.cssText = [
                'position:relative', 'display:inline-block',
                'width:34px', 'height:18px', 'flex-shrink:0', 'cursor:pointer',
            ].join(';');

            const inp = document.createElement('input');
            inp.type = 'checkbox';
            inp.checked = getValue();
            inp.style.cssText = 'opacity:0;width:0;height:0;position:absolute;';

            const slider = document.createElement('span');
            slider.style.cssText = [
                'position:absolute', 'inset:0', 'border-radius:18px',
                'transition:background .2s',
                'background:' + (inp.checked ? '#2a7a3a' : '#555'),
            ].join(';');

            const knob = document.createElement('span');
            knob.style.cssText = [
                'position:absolute', 'top:2px',
                'left:' + (inp.checked ? '18px' : '2px'),
                'width:14px', 'height:14px', 'border-radius:50%',
                'background:#fff', 'transition:left .2s',
            ].join(';');

            slider.appendChild(knob);
            togWrap.append(inp, slider);

            inp.addEventListener('change', e => {
                e.stopPropagation();
                const val = inp.checked;
                setValue(val);
                slider.style.background = val ? '#2a7a3a' : '#555';
                knob.style.left = val ? '18px' : '2px';
            });

            row.append(hint, lbl, togWrap);
            return row;
        }

        settingsPanel.appendChild(makeToggleRow(
            'Копировать все теги с E_hentai',
            'Включить — копировать ВСЕ теги из всех разделов\nВыключить — копировать теги ТОЛЬКО из разделов: female, male, mixed',
            () => store.ehCopyAll,
            v => { store.ehCopyAll = v; }
        ));
        settingsPanel.appendChild(makeToggleRow(
            'Копировать все теги с Booru досок',
            'Включить - копировать ВСЕ теги из всех разделов\nВыключить - копировать ТОЛЬКО теги из раздела General',
            () => store.booruCopyAll,
            v => { store.booruCopyAll = v; }
        ));
        settingsPanel.appendChild(makeToggleRow(
            'Копировать все теги с nhentai',
            'Включить - копировать ВСЕ теги из всех разделов\nВыключить - копировать ТОЛЬКО теги из раздела Tags',
            () => store.nhentaiCopyAll,
            v => { store.nhentaiCopyAll = v; }
        ));

        // ── Блок замены тегов ────────────────────────────────────
        settingsPanel.appendChild(makeToggleRow(
            'Подменять японские теги на английские',
            'Теги для замены берутся из скачанного локального словаря. Если значение для перевода не найдено, оставляется исходный вариант.',
            () => store.eroReplace,
            v => { store.eroReplace = v; }
        ));

        settingsPanel.appendChild(makeToggleRow(
            'Подменять русские теги на английские',
            'Теги для замены берутся из скачанного локального словаря. Если значение для перевода не найдено, оставляется исходный вариант.',
            () => store.rusReplace,
            v => { store.rusReplace = v; }
        ));

        settingsPanel.appendChild(makeToggleRow(
            'Подменять теги на схожие от pornolab',
            'Теги будут заменены, чтобы соответствовать базе тегов pornolab, влключая теги уже хранимые в буфере и копилке. Замена идёт из скачанного локального словаря. У разных тегов может быть один близкий аналог из pornolab. Дубликаты имён тегов будут удалены, поэтому итоговое количество тегов может быть меньше исходного.',
            () => store.pornoReplace,
            async v => {
                    store.pornoReplace = v;
                    if (v) {
                        if (store.pool.length) {
                            const updated = await applyPornolabReplacement(store.pool);
                            store.pool = updated;
                            toast(`Подмена pornolab применена к копилке: ${updated.length} тегов`, '#2a7');
                        }
                        if (_lastClipTags.length) {
                            const updatedClip = await applyPornolabReplacement(_lastClipTags);
                            _lastClipTags = updatedClip;
                            copyToClipboard(updatedClip.join(', '));
                            toast(`Подмена pornolab применена к буферу: ${updatedClip.length} тегов`, '#2a7');
                        }
                    }
                }
        ));

        panel.appendChild(settingsPanel);

        let settingsOpen = false;
        let _settingsSavedLeft  = null;
        let _settingsSavedRight = null;

        gearBtn.addEventListener('click', e => {
            e.stopPropagation();
            settingsOpen = !settingsOpen;
            settingsPanel.style.display = settingsOpen ? 'flex' : 'none';
            gearBtn.style.color = settingsOpen ? '#fff' : '#aaa';

            if (settingsOpen) {
                // Сохраняем текущую позицию до расширения
                _settingsSavedLeft  = panel.style.left;
                _settingsSavedRight = panel.style.right;

                // Ключевой трюк: временно ставим панель в left:0, чтобы
                // flex-shrink не сжал содержимое и getBoundingClientRect()
                // вернул ЕСТЕСТВЕННУЮ ширину (нестеснённую).
                // Браузер не перерисовывает между строками одного обработчика,
                // поэтому промежуточное положение пользователь не увидит.
                panel.style.right = '';
                panel.style.left  = '0px';

                // Синхронный forced reflow — получаем реальную ширину содержимого
                const naturalW = panel.getBoundingClientRect().width;
                const margin   = 16;
                const maxLeft  = window.innerWidth - margin - naturalW;

                // Вычисляем желаемый left из сохранённой позиции
                let desiredLeft;
                if (_settingsSavedLeft) {
                    // Панель перетаскивалась — позиционирована через left
                    desiredLeft = parseFloat(_settingsSavedLeft);
                } else {
                    // Панель на дефолтной позиции — позиционирована через right
                    const savedRight = parseFloat(_settingsSavedRight) || margin;
                    desiredLeft = window.innerWidth - savedRight - naturalW;
                }

                // Зажимаем в пределы viewport: не выходим ни влево, ни вправо
                panel.style.left = Math.min(Math.max(margin, desiredLeft), maxLeft) + 'px';
            } else {
                // Закрываем настройки — возвращаем исходную позицию
                if (_settingsSavedLeft !== null || _settingsSavedRight !== null) {
                    panel.style.left  = _settingsSavedLeft  ?? '';
                    panel.style.right = _settingsSavedRight ?? '';
                    _settingsSavedLeft  = null;
                    _settingsSavedRight = null;
                }
            }
        });

        // ── Тело (кнопки) ────────────────────────────────────────
        const body = document.createElement('div');
        body.id = 'tep-body';
        body.style.cssText = 'display:flex;flex-direction:column;gap:4px;padding:6px;';

        const BTN_BASE = [
            'display:block', 'width:100%', 'padding:5px 10px',
            'border:none', 'border-radius:5px',
            'font-size:12px', 'font-weight:600',
            'cursor:pointer', 'text-align:center',
            'white-space:nowrap', 'color:#fff',
            'transition:filter .15s ease',
        ].join(';');

        const BUTTONS = [
            {
                id: 'tep-copy', label: '📋 В буфер', bg: '#1a6fb5',
                action: async () => {
                        const tags = await applyPornolabReplacement(await applyRussianReplacement(await applyJapaneseReplacement(await extractTags())));
                        if (!tags.length) { toast('Теги не найдены', '#b52'); return; }
                        _lastClipTags = tags;
                        copyToClipboard(tags.join(', '));
                        toast(`Скопировано: ${tags.length} тегов`, '#2a7');
                    },
            },
            {
                id: 'tep-add', label: '➕ В копилку', bg: '#5a4ab5',
                action: async () => {
                    const tags = await applyPornolabReplacement(await applyRussianReplacement(await applyJapaneseReplacement(await extractTags())));
                    if (!tags.length) { toast('Теги не найдены', '#b52'); return; }
                    const pool   = store.pool;
                    const before = pool.length;
                    const merged = [...new Set([...pool, ...tags])];
                    store.pool = merged;
                    toast(`Добавлено ${merged.length - before} новых (всего ${merged.length})`, '#5a4ab5');
                },
            },
            {
                id: 'tep-paste', label: '📤 Из копилки', bg: '#b57c1a',
                action: () => {
                    const pool = store.pool;
                    if (!pool.length) { toast('Копилка пуста', '#b52'); return; }
                    copyToClipboard(pool.join(', '));
                    toast(`Скопировано из копилки: ${pool.length} тегов`, '#b57c1a');
                },
            },
            {
                id: 'tep-clear', label: '🗑 Очистка', bg: '#7a1a1a',
                action: () => {
                    if (!store.pool.length) { toast('Копилка уже пуста', '#555'); return; }
                    store.pool = [];
                    toast('Копилка очищена', '#7a1a1a');
                },
            },
        ];

        BUTTONS.forEach(({ id, label, bg, action }) => {
            const btn = document.createElement('button');
            btn.id = id;
            btn.textContent = label;
            btn.style.cssText = BTN_BASE + `;background:${bg}`;
            btn.addEventListener('mouseenter', () => { btn.style.filter = 'brightness(1.3)'; });
            btn.addEventListener('mouseleave', () => { btn.style.filter = ''; });
            btn.addEventListener('click', e => {
                e.stopPropagation();
                Promise.resolve().then(action).catch(err => toast('Ошибка: ' + err.message, '#b52'));
            });
            body.appendChild(btn);
        });

        panel.appendChild(body);
        document.body.appendChild(panel);

        // ── Свернуть / Развернуть ────────────────────────────────
        let collapsed = store.collapsed;

        function applyCollapsed() {
            if (collapsed) {
                body.style.display = 'none';
                toggleBtn.textContent = '▼';
                toggleBtn.title = 'Развернуть';
                header.style.borderRadius = '7px';
                header.style.borderBottom = 'none';
            } else {
                body.style.display = 'flex';
                toggleBtn.textContent = '▲';
                toggleBtn.title = 'Свернуть';
                header.style.borderRadius = '7px 7px 0 0';
                header.style.borderBottom = '1px solid #444';
            }
        }
        applyCollapsed();

        toggleBtn.addEventListener('click', e => {
            e.stopPropagation();
            collapsed = !collapsed;
            store.collapsed = collapsed;
            if (collapsed && settingsOpen) {
                settingsOpen = false;
                settingsPanel.style.display = 'none';
                gearBtn.style.color = '#aaa';
            }
            applyCollapsed();
        });

        // ── Drag ─────────────────────────────────────────────────
        let dragging = false;
        let startX, startY, startLeft, startTop;

        function ensureLeftTop() {
            const rect = panel.getBoundingClientRect();
            panel.style.left   = rect.left + 'px';
            panel.style.top    = rect.top  + 'px';
            panel.style.right  = '';
            panel.style.bottom = '';
        }

        header.addEventListener('mousedown', e => {
            if (e.target === toggleBtn || e.target === gearBtn) return;
            e.preventDefault();
            ensureLeftTop();
            dragging  = true;
            startX    = e.clientX;
            startY    = e.clientY;
            startLeft = parseFloat(panel.style.left);
            startTop  = parseFloat(panel.style.top);
            header.style.cursor = 'grabbing';
        });

        document.addEventListener('mousemove', e => {
            if (!dragging) return;
            const rect = panel.getBoundingClientRect();
            panel.style.left = Math.max(0, Math.min(startLeft + e.clientX - startX, window.innerWidth  - rect.width))  + 'px';
            panel.style.top  = Math.max(0, Math.min(startTop  + e.clientY - startY, window.innerHeight - rect.height)) + 'px';
        });

        document.addEventListener('mouseup', () => {
            if (!dragging) return;
            dragging = false;
            header.style.cursor = 'grab';
            store.pos = { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) };
            // Если настройки открыты, обновляем сохранённую позицию,
            // чтобы при закрытии настроек панель не прыгала назад
            if (settingsOpen) {
                _settingsSavedLeft  = panel.style.left;
                _settingsSavedRight = '';
            }
        });
    }

    // ════════════════════════════════════════════════════════════════
    //  ЗАПУСК
    // ════════════════════════════════════════════════════════════════

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

    getEroDict();

})();