Извлекает теги с 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
// ==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();
})();