EPorner Scraper

Collect video cards from eporner.com into a fullscreen sortable grid.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         EPorner Scraper
// @namespace    http://tampermonkey.net/
// @version      3.1
// @description  Collect video cards from eporner.com into a fullscreen sortable grid.
// @author       gentlemanan
// @license      MIT
// @match        https://www.eporner.com/*
// @match        https://eporner.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// ==/UserScript==

(function () {
    'use strict';

    // ─── Constants ────────────────────────────────────────────────────────────

    const STORAGE_KEY_LIST    = 'ep_scraper_list';
    const STORAGE_KEY_FILTERS = 'ep_scraper_filters';

    // ─── State ────────────────────────────────────────────────────────────────

    let savedList    = loadList();
    let chipFilters  = loadFilters();
    let filterInput    = null;
    let sortKey        = 'lastSeen';
    let sortAsc        = false;
    let filterDuration = 0;   // minimum seconds; 0 = no filter
    let filterSeen     = 0;   // minimum lastSeen ms timestamp; 0 = no filter
    let filterRating   = 0;   // minimum rating percent; 0 = no filter
    let filterViews    = 0;   // minimum view count; 0 = no filter

    // ─── Persistence ──────────────────────────────────────────────────────────

    function loadList() {
        try { return JSON.parse(GM_getValue(STORAGE_KEY_LIST, '{}')); }
        catch { return {}; }
    }

    function saveList() {
        GM_setValue(STORAGE_KEY_LIST, JSON.stringify(savedList));
    }

    function loadFilters() {
        try {
            const parsed = JSON.parse(GM_getValue(STORAGE_KEY_FILTERS, '[]'));
            return Array.isArray(parsed) ? parsed.filter(f => typeof f.value === 'string') : [];
        } catch { return []; }
    }

    function saveFilters() {
        GM_setValue(STORAGE_KEY_FILTERS, JSON.stringify(chipFilters));
    }

    // ─── Styles ───────────────────────────────────────────────────────────────

    GM_addStyle(`
        #ep-toggle {
            position: fixed; right: 12px; bottom: 12px;
            width: 48px; height: 48px;
            background: #e74c3c; border: none;
            color: #fff; cursor: pointer;
            display: flex; align-items: center; justify-content: center;
            border-radius: 50%; font-size: 22px; font-weight: bold;
            z-index: 2147483646;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5);
        }
        #ep-toggle:hover { background: #c0392b; }

        #ep-overlay {
            position: fixed; inset: 0;
            background: #0d0d0d;
            z-index: 2147483647;
            display: flex; flex-direction: column;
            font-family: sans-serif; font-size: 13px; color: #ddd;
            display: none;
        }
        #ep-overlay.open { display: flex; }

        /* ── Toolbar ── */
        #ep-toolbar {
            display: flex; align-items: center; gap: 10px;
            padding: 10px 14px; background: #1a1a1a;
            border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
        }
        #ep-toolbar-title { font-weight: bold; font-size: 15px; color: #fff; }
        #ep-count {
            background: #e74c3c; color: #fff; border-radius: 10px;
            padding: 1px 8px; font-size: 11px; font-weight: bold;
        }
        #ep-filter-input {
            flex: 1; max-width: 340px;
            background: #222; border: 1px solid #3a3a3a; border-radius: 4px;
            color: #ddd; padding: 5px 10px; font-size: 12px;
        }
        #ep-filter-input::placeholder { color: #555; }

        /* ── Sort bar ── */
        #ep-sort-bar {
            display: flex; align-items: center; gap: 6px;
            padding: 6px 14px; background: #161616;
            border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
            flex-wrap: wrap;
        }
        #ep-sort-bar-label { color: #555; font-size: 11px; margin-right: 2px; }
        .ep-sort-btn {
            background: #222; border: 1px solid #333; color: #888;
            border-radius: 4px; padding: 3px 10px; font-size: 11px;
            cursor: pointer; user-select: none; white-space: nowrap;
        }
        .ep-sort-btn:hover { color: #ddd; border-color: #555; }
        .ep-sort-btn.active { background: #1a2a3a; color: #fff; border-color: #2980b9; }

        .ep-sort-bar-divider { width: 1px; height: 18px; background: #2a2a2a; margin: 0 4px; }
        .ep-filter-select {
            background: #222; border: 1px solid #333; color: #888;
            border-radius: 4px; padding: 3px 8px; font-size: 11px;
            cursor: pointer; white-space: nowrap;
            appearance: none; -webkit-appearance: none;
            padding-right: 20px;
            background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23666'/%3E%3C/svg%3E");
            background-repeat: no-repeat; background-position: right 6px center;
        }
        .ep-filter-select:hover { color: #ddd; border-color: #555; }
        .ep-filter-select.active { background-color: #1a2a3a; color: #fff; border-color: #2980b9;
            background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23aaa'/%3E%3C/svg%3E");
        }

        /* ── Chip filter row ── */
        #ep-chip-row {
            display: flex; flex-wrap: wrap; gap: 5px; align-items: center;
            padding: 5px 14px 6px; background: #1a1a1a;
            border-bottom: 1px solid #2a2a2a;
            min-height: 0;
        }
        #ep-chip-row:empty { display: none; }
        .ep-chip {
            display: inline-flex; align-items: center; gap: 4px;
            padding: 2px 8px; border-radius: 12px; font-size: 11px;
            font-weight: bold; cursor: pointer; user-select: none;
            border: 1px solid transparent;
        }
        .ep-chip.pos {
            background: #1a3a2a; color: #82e0aa; border-color: #2ecc71;
        }
        .ep-chip.neg {
            background: #3a1a1a; color: #e07f7f; border-color: #c0392b;
            text-decoration: line-through;
        }
        .ep-chip-remove { font-size: 13px; line-height: 1; opacity: 0.7; }
        .ep-chip:hover .ep-chip-remove { opacity: 1; }

        .ep-tb-btn {
            padding: 6px 14px; border: none; border-radius: 4px;
            cursor: pointer; font-size: 12px; font-weight: bold; color: #fff;
        }
        #ep-btn-settings {
            background: none; border: none; color: #888;
            cursor: pointer; font-size: 18px; line-height: 1; padding: 4px 6px;
        }
        #ep-btn-settings:hover { color: #fff; }
        #ep-btn-close { background: #444; margin-left: auto; }
        #ep-btn-close:hover { background: #666; }

        /* ── Settings panel ── */
        #ep-settings {
            position: absolute; inset: 0;
            background: #111; z-index: 10;
            display: flex; flex-direction: column;
            transform: translateX(100%);
            transition: transform 0.25s ease;
            overflow: hidden;
        }
        #ep-settings.open { transform: translateX(0); }

        #ep-settings-header {
            display: flex; align-items: center; gap: 8px;
            padding: 10px 14px; background: #1a1a1a;
            border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
        }
        #ep-settings-back {
            background: none; border: none; color: #aaa;
            cursor: pointer; font-size: 22px; line-height: 1; padding: 0 4px;
        }
        #ep-settings-back:hover { color: #fff; }
        #ep-settings-heading {
            font-size: 14px; font-weight: bold; color: #fff;
        }

        #ep-settings-body {
            flex: 1; overflow-y: auto; padding: 20px 24px;
            display: flex; flex-direction: column; gap: 12px;
        }

        .ep-settings-row {
            display: flex; align-items: center; justify-content: space-between;
            gap: 16px; padding: 14px 0; border-bottom: 1px solid #1e1e1e;
        }
        .ep-settings-row:last-child { border-bottom: none; }
        .ep-settings-row-info { display: flex; flex-direction: column; gap: 3px; flex: 1; }
        .ep-settings-row-label { font-size: 13px; color: #ddd; font-weight: bold; }
        .ep-settings-row-desc  { font-size: 11px; color: #555; }

        .ep-settings-btn {
            flex-shrink: 0; padding: 7px 18px; border: none; border-radius: 4px;
            cursor: pointer; font-size: 12px; font-weight: bold; color: #fff;
            min-width: 110px; text-align: center;
        }
        .ep-settings-btn.green  { background: #27ae60; }
        .ep-settings-btn.green:hover  { background: #2ecc71; }
        .ep-settings-btn.purple { background: #7d3c98; }
        .ep-settings-btn.purple:hover { background: #9b59b6; }
        .ep-settings-btn.red    { background: #c0392b; }
        .ep-settings-btn.red:hover    { background: #e74c3c; }
        .ep-settings-btn.orange { background: #d35400; }
        .ep-settings-btn.orange:hover { background: #e67e22; }
        .ep-settings-btn:disabled { background: #333; color: #666; cursor: default; }

        /* ── Card grid ── */
        #ep-grid-wrap {
            flex: 1; overflow-y: auto; padding: 14px;
        }
        #ep-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
            gap: 14px;
        }

        .ep-card {
            background: #1a1a1a; border-radius: 6px; overflow: hidden;
            display: flex; flex-direction: column;
            transition: transform 0.15s, box-shadow 0.15s;
            cursor: pointer;
        }
        .ep-card:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(0,0,0,0.6);
        }

        /* Thumbnail area */
        .ep-card-thumb-wrap {
            position: relative; width: 100%; aspect-ratio: 16/9;
            background: #000; overflow: hidden; flex-shrink: 0;
        }
        .ep-card-thumb {
            width: 100%; height: 100%; object-fit: cover; display: block;
        }
        .ep-card-preview {
            position: absolute; inset: 0;
            width: 100%; height: 100%; object-fit: cover;
            opacity: 0; transition: opacity 0.15s ease;
            pointer-events: none;
        }
        .ep-card-thumb-wrap:hover .ep-card-preview { opacity: 1; }

        /* Tag badges overlaid on thumbnail */
        .ep-card-tags {
            position: absolute; bottom: 5px; right: 5px;
            display: flex; gap: 3px; flex-wrap: wrap; justify-content: flex-end;
        }
        .ep-card-tag {
            border-radius: 3px; padding: 1px 5px;
            font-size: 10px; font-weight: bold; cursor: pointer;
            user-select: none; border: 1px solid transparent;
        }
        .ep-card-tag.vr  { background: rgba(120, 0, 180, 0.85); color: #e8c6ff; }
        .ep-card-tag.uhd { background: rgba(180, 120, 0, 0.85); color: #ffe5a0; }
        .ep-card-tag.def { background: rgba(30, 30, 30, 0.85); color: #a0c4e8; }
        .ep-card-tag.active-pos { outline: 2px solid #2ecc71; }
        .ep-card-tag.active-neg { outline: 2px solid #c0392b; opacity: 0.6; }

        /* Card body */
        .ep-card-body {
            padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 5px;
        }
        .ep-card-title {
            font-size: 12px; font-weight: 600; color: #eee; line-height: 1.35;
            display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
            overflow: hidden; text-overflow: ellipsis;
        }
        .ep-card-title a {
            color: inherit; text-decoration: none;
        }
        .ep-card-title a:hover { color: #7fb3d3; }

        .ep-card-stats {
            display: flex; gap: 8px; align-items: center;
            font-size: 11px; color: #777; flex-wrap: wrap;
        }
        .ep-card-stat { display: flex; align-items: center; gap: 3px; }
        .ep-card-stat-icon { font-size: 10px; opacity: 0.7; }

        .ep-card-uploader {
            font-size: 11px;
        }
        .ep-card-uploader-btn {
            background: none; border: none; padding: 0;
            color: #666; cursor: pointer; font-size: 11px;
            font-family: inherit;
        }
        .ep-card-uploader-btn:hover { color: #aaa; }
        .ep-card-uploader-btn.active-pos { color: #82e0aa; }
        .ep-card-uploader-btn.active-neg { color: #e07f7f; }

        .ep-card-lastseen {
            font-size: 10px; color: #444;
        }

        .ep-card-placeholder {
            width: 100%; aspect-ratio: 16/9; background: #1a1a1a;
            display: flex; align-items: center; justify-content: center;
            color: #444; font-size: 28px; flex-shrink: 0;
        }

        .ep-empty-msg {
            grid-column: 1 / -1; text-align: center;
            color: #444; padding: 60px; font-size: 14px;
        }

        /* ── Detail panel ── */
        #ep-detail {
            position: absolute; inset: 0;
            background: #111; z-index: 10;
            display: flex; flex-direction: column;
            transform: translateX(100%);
            transition: transform 0.25s ease;
            overflow: hidden;
        }
        #ep-detail.open { transform: translateX(0); }

        #ep-detail-header {
            display: flex; align-items: center; gap: 8px;
            padding: 10px 14px; background: #1a1a1a;
            border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
        }
        #ep-detail-back {
            background: none; border: none; color: #aaa;
            cursor: pointer; font-size: 22px; line-height: 1; padding: 0 4px;
        }
        #ep-detail-back:hover { color: #fff; }
        #ep-detail-heading {
            flex: 1; font-size: 14px; font-weight: bold; color: #fff;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }

        #ep-detail-body {
            flex: 1; overflow-y: auto; padding: 16px;
            display: flex; flex-direction: column; gap: 16px;
        }
        #ep-detail-thumb-col { width: 100%; flex-shrink: 0; }
        #ep-detail-thumb-wrap {
            width: 100%; aspect-ratio: 16/9;
            background: #000; border-radius: 8px; overflow: hidden;
        }
        #ep-detail-thumb-wrap .ep-card-thumb-wrap { width: 100%; height: 100%; aspect-ratio: unset; }
        #ep-detail-thumb-wrap .ep-card-thumb,
        #ep-detail-thumb-wrap .ep-card-preview { object-fit: contain; }

        #ep-detail-rows-col { display: flex; flex-direction: column; gap: 0; }
        .ep-detail-row {
            display: flex; gap: 10px; align-items: flex-start;
            font-size: 12px; border-bottom: 1px solid #1e1e1e; padding: 7px 0;
        }
        .ep-detail-row:last-child { border-bottom: none; }
        .ep-detail-label {
            width: 90px; flex-shrink: 0; color: #555; font-size: 11px; padding-top: 1px;
        }
        .ep-detail-value { flex: 1; color: #ccc; word-break: break-all; }
        .ep-detail-value a { color: #7fb3d3; text-decoration: none; }
        .ep-detail-value a:hover { text-decoration: underline; }
        .ep-detail-tags { display: flex; gap: 4px; flex-wrap: wrap; }

        #ep-detail-open-btn {
            margin: 0 14px 14px; padding: 10px;
            background: #2980b9; color: #fff; border: none;
            border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: bold;
            flex-shrink: 0;
        }
        #ep-detail-open-btn:hover { background: #3498db; }
    `);

    // ─── Filter & sort helpers ────────────────────────────────────────────────

    function getCardTags(cardEl) {
        return Array.from(cardEl.querySelectorAll('.mvhdico span'))
            .map(s => s.textContent.trim());
    }

    function getCardText(cardEl) {
        const title    = cardEl.querySelector('.mbtit a')?.textContent.trim() ?? '';
        const uploader = cardEl.querySelector('.mb-uploader a')?.textContent.trim() || 'unknown';
        return [title, uploader, ...getCardTags(cardEl)].join(' ');
    }

    function cardHasTags(cardEl) {
        return cardEl.querySelectorAll('.mvhdico span').length > 0;
    }

    function entryMatchesFilter(entry) {
        const text    = entry.searchText ?? '';
        const pattern = filterInput ? filterInput.value.trim() : '';
        if (pattern) {
            try {
                if (!new RegExp(pattern, 'i').test(text)) return false;
            } catch {
                if (!text.toLowerCase().includes(pattern.toLowerCase())) return false;
            }
        }
        if (filterDuration > 0 && durationToSeconds(entry.duration) < filterDuration) return false;
        if (filterRating > 0 && ratingToNum(entry.rating) < filterRating) return false;
        if (filterViews > 0 && viewsToNum(entry.views) < filterViews) return false;
        if (filterSeen > 0) {
            const seen = entry.lastSeen ? new Date(entry.lastSeen).getTime() : 0;
            if (seen < filterSeen) return false;
        }
        return entryMatchesChips(entry);
    }

    // ─── Chip filters ─────────────────────────────────────────────────────────

    function toggleChipFilter(value) {
        const existing = chipFilters.find(f => f.value === value);
        if (!existing) {
            chipFilters.push({ value, negated: false });
        } else if (!existing.negated) {
            existing.negated = true;
        } else {
            chipFilters = chipFilters.filter(f => f.value !== value);
        }
        saveFilters();
        renderChips();
        refreshGrid();
    }

    function chipStateFor(value) {
        const f = chipFilters.find(f => f.value === value);
        if (!f) return null;
        return f.negated ? 'neg' : 'pos';
    }

    function entryMatchesChips(entry) {
        const text = (entry.searchText ?? '').toLowerCase();
        return chipFilters.every(f => {
            const has = text.includes(f.value.toLowerCase());
            return f.negated ? !has : has;
        });
    }

    function applyChipClass(el, value) {
        el.classList.remove('active-pos', 'active-neg');
        const state = chipStateFor(value);
        if (state) el.classList.add(`active-${state}`);
    }

    function renderChips() {
        const row = document.getElementById('ep-chip-row');
        if (!row) return;
        row.innerHTML = '';
        chipFilters.forEach(f => {
            const chip = document.createElement('span');
            chip.className = `ep-chip ${f.negated ? 'neg' : 'pos'}`;
            chip.title = f.negated ? 'Click to remove' : 'Click to negate';
            chip.innerHTML = `${f.negated ? '✕ ' : ''}${f.value}<span class="ep-chip-remove">×</span>`;
            chip.addEventListener('click', () => toggleChipFilter(f.value));
            row.appendChild(chip);
        });
        document.querySelectorAll('.ep-card-tag, .ep-card-uploader-btn').forEach(el => {
            if (el.dataset.filterValue) applyChipClass(el, el.dataset.filterValue);
        });
    }

    function timeAgo(isoStr) {
        if (!isoStr) return '';
        const sec = Math.floor((Date.now() - new Date(isoStr)) / 1000);
        if (sec < 60)                      return `${sec}s ago`;
        if (sec < 3600)                    return `${Math.floor(sec / 60)}m ago`;
        if (sec < 86400)                   return `${Math.floor(sec / 3600)}h ago`;
        if (sec < 86400 * 30)              return `${Math.floor(sec / 86400)}d ago`;
        if (sec < 86400 * 365)             return `${Math.floor(sec / (86400 * 30))}mo ago`;
        return `${Math.floor(sec / (86400 * 365))}y ago`;
    }

    // Parse "MM:SS" or "HH:MM:SS" to total seconds for numeric sort.
    function durationToSeconds(str) {
        if (!str) return 0;
        return str.split(':').reduce((acc, v) => acc * 60 + parseInt(v || 0, 10), 0);
    }

    // Parse "96%" → 96
    function ratingToNum(str) {
        return parseFloat(str) || 0;
    }

    // Parse "1,234" or "1.2K" → number
    function viewsToNum(str) {
        if (!str) return 0;
        const s = str.replace(/,/g, '');
        if (s.endsWith('K')) return parseFloat(s) * 1000;
        if (s.endsWith('M')) return parseFloat(s) * 1000000;
        return parseFloat(s) || 0;
    }

    function getSortValue(entry, key) {
        switch (key) {
            case 'title':    return entry.title.toLowerCase();
            case 'duration': return durationToSeconds(entry.duration);
            case 'rating':   return ratingToNum(entry.rating);
            case 'views':    return viewsToNum(entry.views);
            case 'uploader': return (entry.uploader ?? 'unknown').toLowerCase();
            case 'savedAt':  return entry.savedAt ?? '';
            case 'lastSeen': return entry.lastSeen ?? entry.savedAt ?? '';
            default:         return '';
        }
    }

    // ─── Data extraction ──────────────────────────────────────────────────────

    // e.g. .../16917544/1_240.jpg → .../16917544/16917544-preview.webm
    function thumbToPreview(thumbSrc, id) {
        if (!thumbSrc || !id || !thumbSrc.startsWith('http')) return null;
        const base = thumbSrc.substring(0, thumbSrc.lastIndexOf('/'));
        return `${base}/${id}-preview.webm`;
    }

    function extractEntry(cardEl) {
        const id = cardEl.dataset.id;
        if (!id) return null;

        const linkEl     = cardEl.querySelector('.mbimg a');
        const imgEl      = cardEl.querySelector('.mbimg img');
        const titleEl    = cardEl.querySelector('.mbtit a');
        const durEl      = cardEl.querySelector('.mbtim');
        const rateEl     = cardEl.querySelector('.mbrate');
        const viewsEl    = cardEl.querySelector('.mbvie');
        const uploaderEl = cardEl.querySelector('.mb-uploader a');

        // Prefer data-src over src — the site lazy-loads images so src may be a placeholder GIF.
        const thumbSrc = imgEl
            ? (imgEl.getAttribute('data-src') || (imgEl.src.startsWith('http') ? imgEl.src : null))
            : null;

        const now = new Date().toISOString();
        return {
            id,
            href:         linkEl     ? 'https://www.eporner.com' + linkEl.getAttribute('href') : null,
            thumb:        thumbSrc,
            title:        titleEl    ? titleEl.textContent.trim() : `Video ${id}`,
            duration:     durEl      ? durEl.textContent.trim() : '',
            rating:       rateEl     ? rateEl.textContent.trim() : '',
            views:        viewsEl    ? viewsEl.textContent.trim() : '',
            uploader:     uploaderEl ? uploaderEl.textContent.trim() : 'unknown',
            uploaderHref: uploaderEl ? uploaderEl.getAttribute('href') : null,
            tags:         getCardTags(cardEl),
            searchText:   getCardText(cardEl),
            previewUrl:   thumbSrc ? thumbToPreview(thumbSrc, id) : null,
            foundAt:      window.location.href,
            savedAt:      now,
            lastSeen:     now,
        };
    }

    // ─── Tag class helper ─────────────────────────────────────────────────────

    function tagCssClass(tag) {
        if (tag === 'VR') return 'vr';
        if (tag.startsWith('4K') || tag.includes('2160')) return 'uhd';
        return 'def';
    }

    // ─── Thumb + video preview builder ───────────────────────────────────────

    function buildThumbWrap(thumbSrc, previewUrl, onClick) {
        const wrap = document.createElement('div');
        wrap.className = 'ep-card-thumb-wrap';
        if (onClick) wrap.addEventListener('click', onClick);

        const img = document.createElement('img');
        img.className = 'ep-card-thumb';
        img.src = thumbSrc;
        img.alt = '';
        img.loading = 'lazy';
        wrap.appendChild(img);

        if (previewUrl) {
            let vid = null;
            wrap.addEventListener('mouseenter', () => {
                if (!vid) {
                    vid = document.createElement('video');
                    vid.className = 'ep-card-preview';
                    vid.muted = true; vid.loop = true; vid.playsInline = true; vid.preload = 'none';
                    vid.src = previewUrl;
                    wrap.appendChild(vid);
                }
                vid.play().catch(() => {});
            });
            wrap.addEventListener('mouseleave', () => { if (vid) { vid.pause(); vid.currentTime = 0; } });
        }

        return wrap;
    }

    // ─── Detail panel ─────────────────────────────────────────────────────────

    function showDetail(entry) {
        document.getElementById('ep-detail-heading').textContent = entry.title;

        const thumbWrap = document.getElementById('ep-detail-thumb-wrap');
        thumbWrap.innerHTML = '';
        if (entry.thumb) {
            const previewUrl = entry.previewUrl || thumbToPreview(entry.thumb, entry.id);
            const wrap = buildThumbWrap(entry.thumb, previewUrl, null);
            wrap.style.cssText = 'width:100%;height:100%;aspect-ratio:16/9;border-radius:8px;cursor:default;';
            thumbWrap.appendChild(wrap);
        }

        const tagsHtml = entry.tags
            .map(t => `<span class="ep-card-tag ${tagCssClass(t)}" style="cursor:default">${t}</span>`)
            .join('');
        const uploaderHtml = entry.uploaderHref
            ? `<a href="https://www.eporner.com${entry.uploaderHref}" target="_blank" rel="noopener">${entry.uploader}</a>`
            : entry.uploader;

        document.getElementById('ep-detail-rows').innerHTML = `
            <div class="ep-detail-row">
                <span class="ep-detail-label">Title</span>
                <span class="ep-detail-value">${entry.title}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Tags</span>
                <span class="ep-detail-value ep-detail-tags">${tagsHtml || '—'}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Duration</span>
                <span class="ep-detail-value">${entry.duration || '—'}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Rating</span>
                <span class="ep-detail-value">${entry.rating || '—'}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Views</span>
                <span class="ep-detail-value">${entry.views || '—'}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Uploader</span>
                <span class="ep-detail-value">${uploaderHtml}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Video ID</span>
                <span class="ep-detail-value">${entry.id}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Found on</span>
                <span class="ep-detail-value"><a href="${entry.foundAt}" target="_blank" rel="noopener">${entry.foundAt}</a></span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Saved at</span>
                <span class="ep-detail-value">${entry.savedAt ? new Date(entry.savedAt).toLocaleString() : '—'}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Last seen</span>
                <span class="ep-detail-value">${entry.lastSeen ? new Date(entry.lastSeen).toLocaleString() : '—'}</span>
            </div>
            <div class="ep-detail-row">
                <span class="ep-detail-label">Search text</span>
                <span class="ep-detail-value">${entry.searchText}</span>
            </div>
        `;

        document.getElementById('ep-detail-open-btn').onclick = () =>
            window.open(entry.href, '_blank', 'noopener');

        document.getElementById('ep-detail').classList.add('open');
    }

    function hideDetail() {
        document.getElementById('ep-detail').classList.remove('open');
    }

    // ─── Card rendering ───────────────────────────────────────────────────────

    const SORT_OPTIONS = [
        { key: 'lastSeen', label: 'Last Seen' },
        { key: 'savedAt',  label: 'Saved'    },
        { key: 'title',    label: 'Title'    },
        { key: 'duration', label: 'Duration' },
        { key: 'rating',   label: 'Rating'   },
        { key: 'views',    label: 'Views'    },
        { key: 'uploader', label: 'Uploader' },
    ];

    function renderCard(entry) {
        const card = document.createElement('div');
        card.className = 'ep-card';

        // ── Thumbnail area ──
        if (entry.thumb) {
            const previewUrl = entry.previewUrl || thumbToPreview(entry.thumb, entry.id);
            const thumbWrap = buildThumbWrap(entry.thumb, previewUrl, () => showDetail(entry));

            // Tag badges overlaid on thumbnail
            if (entry.tags.length > 0) {
                const tagsEl = document.createElement('div');
                tagsEl.className = 'ep-card-tags';
                entry.tags.forEach(t => {
                    const badge = document.createElement('span');
                    badge.className = `ep-card-tag ${tagCssClass(t)}`;
                    badge.dataset.filterValue = t;
                    badge.textContent = t;
                    applyChipClass(badge, t);
                    badge.addEventListener('click', e => { e.stopPropagation(); toggleChipFilter(t); });
                    tagsEl.appendChild(badge);
                });
                thumbWrap.appendChild(tagsEl);
            }

            card.appendChild(thumbWrap);
        } else {
            const ph = document.createElement('div');
            ph.className = 'ep-card-placeholder';
            ph.textContent = '▶';
            card.appendChild(ph);
        }

        // ── Card body ──
        const body = document.createElement('div');
        body.className = 'ep-card-body';

        // Title
        const titleEl = document.createElement('div');
        titleEl.className = 'ep-card-title';
        titleEl.innerHTML = `<a href="${entry.href || '#'}" target="_blank" rel="noopener" title="${entry.title}">${entry.title}</a>`;
        body.appendChild(titleEl);

        // Stats row: duration, rating, views
        const statDefs = [['⏱', entry.duration], ['★', entry.rating], ['👁', entry.views]];
        const statsHtml = statDefs.filter(([, v]) => v)
            .map(([icon, v]) => `<span class="ep-card-stat"><span class="ep-card-stat-icon">${icon}</span>${v}</span>`)
            .join('');
        if (statsHtml) {
            const stats = document.createElement('div');
            stats.className = 'ep-card-stats';
            stats.innerHTML = statsHtml;
            body.appendChild(stats);
        }

        // Uploader
        const upDiv = document.createElement('div');
        upDiv.className = 'ep-card-uploader';
        const upBtn = document.createElement('button');
        upBtn.className = 'ep-card-uploader-btn';
        upBtn.dataset.filterValue = entry.uploader;
        upBtn.textContent = entry.uploader;
        applyChipClass(upBtn, entry.uploader);
        upBtn.addEventListener('click', e => { e.stopPropagation(); toggleChipFilter(entry.uploader); });
        upDiv.appendChild(upBtn);
        body.appendChild(upDiv);

        const ago = timeAgo(entry.lastSeen ?? entry.savedAt);
        if (ago) {
            const agoEl = document.createElement('div');
            agoEl.className = 'ep-card-lastseen';
            agoEl.textContent = ago;
            body.appendChild(agoEl);
        }

        card.appendChild(body);
        return card;
    }

    function emptyMsg(text) {
        const el = document.createElement('div');
        el.className = 'ep-empty-msg';
        el.textContent = text;
        return el;
    }

    function refreshGrid() {
        const grid = document.getElementById('ep-grid');
        const bar  = document.getElementById('ep-sort-bar');
        if (!grid) return;

        // Rebuild sort bar
        bar.innerHTML = `<span id="ep-sort-bar-label">Sort:</span>`;
        SORT_OPTIONS.forEach(opt => {
            const btn = document.createElement('button');
            btn.className = `ep-sort-btn${sortKey === opt.key ? ' active' : ''}`;
            const arrow = sortKey === opt.key ? (sortAsc ? ' ▲' : ' ▼') : '';
            btn.textContent = opt.label + arrow;
            btn.addEventListener('click', () => {
                sortAsc = sortKey === opt.key ? !sortAsc : (opt.key === 'title' || opt.key === 'uploader');
                sortKey = opt.key;
                refreshGrid();
            });
            bar.appendChild(btn);
        });

        // Divider
        const div1 = document.createElement('div');
        div1.className = 'ep-sort-bar-divider';
        bar.appendChild(div1);

        // Duration dropdown
        const durSel = document.createElement('select');
        durSel.className = `ep-filter-select${filterDuration > 0 ? ' active' : ''}`;
        durSel.title = 'Filter by duration';
        [['Duration', 0], ['> 5 min', 300], ['> 30 min', 1800], ['> 1 hour', 3600], ['> 2 hours', 7200]]
            .forEach(([label, val]) => {
                const o = document.createElement('option');
                o.value = val; o.textContent = label;
                if (val === filterDuration) o.selected = true;
                durSel.appendChild(o);
            });
        durSel.addEventListener('change', () => {
            filterDuration = parseInt(durSel.value, 10);
            durSel.className = `ep-filter-select${filterDuration > 0 ? ' active' : ''}`;
            refreshGrid();
        });
        bar.appendChild(durSel);

        // Seen dropdown
        const now = Date.now();
        const seenSel = document.createElement('select');
        seenSel.className = `ep-filter-select${filterSeen > 0 ? ' active' : ''}`;
        seenSel.title = 'Filter by last seen';
        [
            ['Last seen', 0],
            ['Last minute', now - 60_000],
            ['Last hour',   now - 3_600_000],
            ['Today',       now - 86_400_000],
            ['This week',   now - 7 * 86_400_000],
            ['This month',  now - 30 * 86_400_000],
        ].forEach(([label, val]) => {
            const o = document.createElement('option');
            o.value = val; o.textContent = label;
            if (val === filterSeen) o.selected = true;
            seenSel.appendChild(o);
        });
        seenSel.addEventListener('change', () => {
            filterSeen = parseInt(seenSel.value, 10);
            seenSel.className = `ep-filter-select${filterSeen > 0 ? ' active' : ''}`;
            refreshGrid();
        });
        bar.appendChild(seenSel);

        // Rating dropdown
        const ratSel = document.createElement('select');
        ratSel.className = `ep-filter-select${filterRating > 0 ? ' active' : ''}`;
        ratSel.title = 'Filter by minimum rating';
        [['Rating', 0], ['≥ 70%', 70], ['≥ 80%', 80], ['≥ 90%', 90], ['≥ 95%', 95]]
            .forEach(([label, val]) => {
                const o = document.createElement('option');
                o.value = val; o.textContent = label;
                if (val === filterRating) o.selected = true;
                ratSel.appendChild(o);
            });
        ratSel.addEventListener('change', () => {
            filterRating = parseInt(ratSel.value, 10);
            ratSel.className = `ep-filter-select${filterRating > 0 ? ' active' : ''}`;
            refreshGrid();
        });
        bar.appendChild(ratSel);

        // Views dropdown
        const viewSel = document.createElement('select');
        viewSel.className = `ep-filter-select${filterViews > 0 ? ' active' : ''}`;
        viewSel.title = 'Filter by minimum views';
        [['Views', 0], ['≥ 1K', 1000], ['≥ 10K', 10000], ['≥ 100K', 100000], ['≥ 1M', 1000000]]
            .forEach(([label, val]) => {
                const o = document.createElement('option');
                o.value = val; o.textContent = label;
                if (val === filterViews) o.selected = true;
                viewSel.appendChild(o);
            });
        viewSel.addEventListener('change', () => {
            filterViews = parseInt(viewSel.value, 10);
            viewSel.className = `ep-filter-select${filterViews > 0 ? ' active' : ''}`;
            refreshGrid();
        });
        bar.appendChild(viewSel);

        grid.innerHTML = '';
        const entries = Object.values(savedList);
        document.getElementById('ep-count').textContent = entries.length;

        if (entries.length === 0) { grid.appendChild(emptyMsg('No videos collected yet. Browse eporner and click Scan.')); return; }

        const visible = entries.filter(entryMatchesFilter);
        if (visible.length === 0) { grid.appendChild(emptyMsg('No matches for current filter.')); return; }

        const cardObserver = new IntersectionObserver((entries, obs) => {
            entries.forEach(({ isIntersecting, target }) => {
                if (!isIntersecting) return;
                obs.unobserve(target);
                const entry = target._epEntry;
                if (entry) target.replaceWith(renderCard(entry));
            });
        }, { root: document.getElementById('ep-grid-wrap'), rootMargin: '200px' });

        visible
            .sort((a, b) => {
                const av = getSortValue(a, sortKey);
                const bv = getSortValue(b, sortKey);
                const cmp = av < bv ? -1 : av > bv ? 1 : 0;
                return sortAsc ? cmp : -cmp;
            })
            .forEach(entry => {
                const placeholder = document.createElement('div');
                placeholder.className = 'ep-card-placeholder';
                placeholder.style.cssText = 'aspect-ratio:16/9;height:auto;';
                placeholder._epEntry = entry;
                cardObserver.observe(placeholder);
                grid.appendChild(placeholder);
            });
    }

    // ─── Overlay DOM ──────────────────────────────────────────────────────────

    function createOverlay() {
        if (document.getElementById('ep-overlay')) return;

        // Floating toggle button
        const toggle = document.createElement('button');
        toggle.id = 'ep-toggle';
        toggle.textContent = '≡';
        toggle.title = 'EPorner Scraper';
        document.body.appendChild(toggle);

        // Fullscreen overlay
        const overlay = document.createElement('div');
        overlay.id = 'ep-overlay';
        overlay.innerHTML = `
            <div id="ep-toolbar">
                <span id="ep-toolbar-title">EPorner Scraper</span>
                <span id="ep-count">0</span>
                <input id="ep-filter-input" type="text" placeholder="Filter by regex, e.g. VR|4K">
                <button id="ep-btn-settings" title="Settings">⚙</button>
                <button class="ep-tb-btn" id="ep-btn-close">✕ Close</button>
            </div>
            <div id="ep-sort-bar"></div>
            <div id="ep-chip-row"></div>
            <div id="ep-grid-wrap">
                <div id="ep-grid"></div>
            </div>
        `;

        // Detail panel (slides over the overlay)
        const detail = document.createElement('div');
        detail.id = 'ep-detail';
        detail.innerHTML = `
            <div id="ep-detail-header">
                <button id="ep-detail-back">&#8592;</button>
                <span id="ep-detail-heading"></span>
            </div>
            <div id="ep-detail-body">
                <div id="ep-detail-thumb-col">
                    <div id="ep-detail-thumb-wrap"></div>
                </div>
                <div id="ep-detail-rows-col">
                    <div id="ep-detail-rows"></div>
                </div>
            </div>
            <button id="ep-detail-open-btn">Open Video ↗</button>
        `;
        overlay.appendChild(detail);

        // Settings panel (slides over the overlay)
        const settings = document.createElement('div');
        settings.id = 'ep-settings';
        settings.innerHTML = `
            <div id="ep-settings-header">
                <button id="ep-settings-back">&#8592;</button>
                <span id="ep-settings-heading">Settings</span>
            </div>
            <div id="ep-settings-body">
                <div class="ep-settings-row">
                    <div class="ep-settings-row-info">
                        <span class="ep-settings-row-label">Scan Page</span>
                        <span class="ep-settings-row-desc">Collect all video cards visible on the current page and refresh metadata for already-saved ones.</span>
                    </div>
                    <button class="ep-settings-btn green" id="ep-btn-scan">Scan Page</button>
                </div>
                <div class="ep-settings-row">
                    <div class="ep-settings-row-info">
                        <span class="ep-settings-row-label">Migrate</span>
                        <span class="ep-settings-row-desc">Backfill missing fields (savedAt, lastSeen, uploader, searchText) on entries captured before schema updates.</span>
                    </div>
                    <button class="ep-settings-btn purple" id="ep-btn-migrate">Migrate</button>
                </div>
                <div class="ep-settings-row">
                    <div class="ep-settings-row-info">
                        <span class="ep-settings-row-label">Trim to Filter</span>
                        <span class="ep-settings-row-desc">Delete all entries that do not match the current chip filters and text filter. Calculates disk savings before committing.</span>
                    </div>
                    <button class="ep-settings-btn orange" id="ep-btn-trim">Analyse</button>
                </div>
                <div class="ep-settings-row">
                    <div class="ep-settings-row-info">
                        <span class="ep-settings-row-label">Clear History</span>
                        <span class="ep-settings-row-desc">Permanently delete all saved videos and reset all chip filters. This cannot be undone.</span>
                    </div>
                    <button class="ep-settings-btn red" id="ep-btn-clear">Clear History</button>
                </div>
                <div class="ep-settings-row">
                    <div class="ep-settings-row-info">
                        <span class="ep-settings-row-label">Export Data</span>
                        <span class="ep-settings-row-desc">Download all saved videos and chip filters as a JSON file.</span>
                    </div>
                    <button class="ep-settings-btn green" id="ep-btn-export">Export</button>
                </div>
                <div class="ep-settings-row">
                    <div class="ep-settings-row-info">
                        <span class="ep-settings-row-label">Import Data</span>
                        <span class="ep-settings-row-desc">Merge a previously exported JSON file into the current collection. Existing entries are kept; duplicates are updated.</span>
                    </div>
                    <button class="ep-settings-btn purple" id="ep-btn-import">Import</button>
                    <input type="file" id="ep-import-file" accept=".json" style="display:none">
                </div>
            </div>
        `;
        overlay.appendChild(settings);

        document.body.appendChild(overlay);

        filterInput = document.getElementById('ep-filter-input');

        const hideSettings = () => settings.classList.remove('open');

        toggle.addEventListener('click', () => overlay.classList.toggle('open'));
        document.getElementById('ep-btn-close').addEventListener('click', () => overlay.classList.remove('open'));
        document.getElementById('ep-detail-back').addEventListener('click', hideDetail);
        document.getElementById('ep-btn-settings').addEventListener('click', () => settings.classList.add('open'));
        document.getElementById('ep-settings-back').addEventListener('click', hideSettings);
        filterInput.addEventListener('input', () => refreshGrid());

        document.getElementById('ep-btn-scan').addEventListener('click', () => {
            const found = scanPage();
            const btn = document.getElementById('ep-btn-scan');
            btn.textContent = `+${found} found`;
            setTimeout(() => { btn.textContent = 'Scan Page'; }, 1500);
        });

        document.getElementById('ep-btn-migrate').addEventListener('click', () => {
            let patched = 0;
            const fallbackDate = new Date().toISOString();
            Object.values(savedList).forEach(entry => {
                let changed = false;
                if (!entry.savedAt)  { entry.savedAt  = fallbackDate; changed = true; }
                if (!entry.lastSeen) { entry.lastSeen = entry.savedAt; changed = true; }
                if (!entry.uploader) { entry.uploader = 'unknown';     changed = true; }
                if (!entry.searchText) {
                    entry.searchText = [entry.title ?? '', entry.uploader, ...(entry.tags ?? [])].join(' ');
                    changed = true;
                }
                if (changed) patched++;
            });
            if (patched > 0) { saveList(); refreshGrid(); }
            const btn = document.getElementById('ep-btn-migrate');
            btn.textContent = patched > 0 ? `Migrated ${patched}` : 'Up to date';
            setTimeout(() => { btn.textContent = 'Migrate'; }, 2000);
        });

        document.getElementById('ep-btn-trim').addEventListener('click', () => {
            const btn = document.getElementById('ep-btn-trim');
            const allEntries = Object.values(savedList);
            const toRemove = allEntries.filter(e => !entryMatchesFilter(e));

            if (toRemove.length === 0) {
                btn.textContent = 'Nothing to trim';
                setTimeout(() => { btn.textContent = 'Analyse'; btn.className = 'ep-settings-btn orange'; }, 2500);
                return;
            }

            const bytes = new TextEncoder().encode(JSON.stringify(toRemove)).length;
            const kb = (bytes / 1024).toFixed(1);

            if (btn.dataset.confirmed !== '1') {
                btn.dataset.confirmed = '1';
                btn.className = 'ep-settings-btn red';
                btn.textContent = `Delete ${toRemove.length} (${kb} KB)?`;
                setTimeout(() => {
                    if (btn.dataset.confirmed === '1') {
                        btn.dataset.confirmed = '0';
                        btn.className = 'ep-settings-btn orange';
                        btn.textContent = 'Analyse';
                    }
                }, 4000);
                return;
            }

            toRemove.forEach(e => delete savedList[e.id]);
            btn.dataset.confirmed = '0';
            saveList();
            refreshGrid();
            btn.className = 'ep-settings-btn orange';
            btn.textContent = `Removed ${toRemove.length}`;
            setTimeout(() => { btn.textContent = 'Analyse'; }, 2000);
        });

        document.getElementById('ep-btn-clear').addEventListener('click', () => {
            if (!confirm('Clear all saved video history? This cannot be undone.')) return;
            savedList = {};
            chipFilters = [];
            saveList();
            saveFilters();
            renderChips();
            refreshGrid();
            hideSettings();
        });

        document.getElementById('ep-btn-export').addEventListener('click', () => {
            const payload = JSON.stringify({ list: savedList, filters: chipFilters }, null, 2);
            const blob = new Blob([payload], { type: 'application/json' });
            const url  = URL.createObjectURL(blob);
            const a    = document.createElement('a');
            a.href     = url;
            a.download = `eporner-scraper-${new Date().toISOString().slice(0, 10)}.json`;
            a.click();
            URL.revokeObjectURL(url);
        });

        document.getElementById('ep-btn-import').addEventListener('click', () => {
            document.getElementById('ep-import-file').click();
        });

        document.getElementById('ep-import-file').addEventListener('change', e => {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = evt => {
                try {
                    const data = JSON.parse(evt.target.result);
                    let imported = 0;
                    if (data.list && typeof data.list === 'object') {
                        Object.values(data.list).forEach(entry => {
                            if (!entry || !entry.id) return;
                            if (savedList[entry.id]) {
                                mergeEntry(savedList[entry.id], entry);
                            } else {
                                savedList[entry.id] = entry;
                            }
                            imported++;
                        });
                    }
                    if (Array.isArray(data.filters)) {
                        data.filters.filter(f => typeof f.value === 'string').forEach(f => {
                            if (!chipFilters.find(c => c.value === f.value)) chipFilters.push(f);
                        });
                        saveFilters();
                        renderChips();
                    }
                    saveList();
                    refreshGrid();
                    const btn = document.getElementById('ep-btn-import');
                    btn.textContent = `Imported ${imported}`;
                    setTimeout(() => { btn.textContent = 'Import'; }, 2000);
                } catch {
                    alert('Invalid JSON file.');
                }
                e.target.value = '';
            };
            reader.readAsText(file);
        });

        renderChips();
        refreshGrid();
    }

    // ─── Page scanning ────────────────────────────────────────────────────────

    function mergeEntry(existing, fresh) {
        const savedAt = existing.savedAt;
        Object.assign(existing, fresh);
        existing.savedAt = savedAt;
    }

    function scanPage() {
        let changed = false;
        let newCount = 0;
        document.querySelectorAll('div.mb.hdy').forEach(cardEl => {
            if (!cardHasTags(cardEl)) return;
            const entry = extractEntry(cardEl);
            if (!entry) return;
            if (savedList[entry.id]) {
                mergeEntry(savedList[entry.id], entry);
            } else {
                savedList[entry.id] = entry;
                newCount++;
            }
            changed = true;
        });
        if (changed) {
            saveList();
            refreshGrid();
        }
        return newCount;
    }

    // ─── Auto-scan on page load ───────────────────────────────────────────────

    function autoScan() {
        const attempt = (tries = 0) => {
            if (document.querySelectorAll('div.mb.hdy').length > 0) {
                scanPage();
            } else if (tries < 10) {
                setTimeout(() => attempt(tries + 1), 800);
            }
        };
        attempt();
    }

    // ─── MutationObserver for lazy-loaded cards ───────────────────────────────

    function observeNewCards() {
        let pending = false;
        let dirty   = false;

        const flush = () => {
            pending = false;
            if (dirty) { dirty = false; saveList(); refreshGrid(); }
        };

        const observer = new MutationObserver(mutations => {
            for (const { addedNodes } of mutations) {
                for (const node of addedNodes) {
                    if (node.nodeType !== Node.ELEMENT_NODE) continue;
                    const cards = node.classList?.contains('mb') && node.classList?.contains('hdy')
                        ? [node]
                        : Array.from(node.querySelectorAll('div.mb.hdy'));
                    for (const card of cards) {
                        if (!cardHasTags(card)) continue;
                        const entry = extractEntry(card);
                        if (!entry) continue;
                        if (savedList[entry.id]) {
                            mergeEntry(savedList[entry.id], entry);
                        } else {
                            savedList[entry.id] = entry;
                        }
                        dirty = true;
                    }
                }
            }
            if (dirty && !pending) {
                pending = true;
                setTimeout(flush, 1000);
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // ─── Boot ─────────────────────────────────────────────────────────────────

    function boot() {
        createOverlay();
        autoScan();
        observeNewCards();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', boot);
    } else {
        boot();
    }
})();