EroDeck Topic Import

Fetch the authenticated EroScripts topic JSON in-browser and queue it for EroDeck through a dedicated sidecar panel.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         EroDeck Topic Import
// @namespace    https://erodeck.local
// @version      2.3
// @description  Fetch the authenticated EroScripts topic JSON in-browser and queue it for EroDeck through a dedicated sidecar panel.
// @license      MIT
// @homepageURL  https://gitlab.com/Archon-Dev/erodeck
// @supportURL   https://discuss.eroscripts.com/t/erodeck-your-local-eroscripts-video-library/256793
// @match        https://discuss.eroscripts.com/t/*
// @grant        GM_xmlhttpRequest
// @connect      127.0.0.1
// @connect      localhost
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const ROOT_ID = 'erodeck-topic-sidecar';
    const STATUS_ID = 'erodeck-topic-sidecar-status';
    const BUTTON_ID = 'erodeck-topic-sidecar-button';
    const PORT_INPUT_ID = 'erodeck-topic-sidecar-port';
    const POLL_INTERVAL_MS = 700;
    const PORT_STORAGE_KEY = 'erodeck.import.port';
    const HOST_STORAGE_KEY = 'erodeck.import.host';
    const DEFAULT_PORT = '3000';
    const DEFAULT_HOST = 'localhost';
    const ERODECK_ICON = `
        <svg width="22" height="22" viewBox="0 0 301.11 253.2" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
            <g>
                <path d="m246.53 34.18 10.428-15.462c2.0981-3.1108 6.2916-3.9262 9.4024-1.8281l0.27836 0.18774c3.1108 2.0981 3.9262 6.2916 1.8281 9.4024l-10.428 15.462c-2.0981 3.1108-6.2916 3.9262-9.4024 1.8281l-0.27836-0.18774c-3.1108-2.0981-3.9262-6.2916-1.8281-9.4024zm19.108 19.021 26.736-7.7948c3.573-1.0417 7.2881 0.99615 8.3298 4.5692l0.12477 0.42793c1.0417 3.573-0.99615 7.2881-4.5692 8.3298l-26.736 7.7948c-3.573 1.0417-7.2881-0.99615-8.3298-4.5692l-0.12476-0.42793c-1.0417-3.573 0.99614-7.2881 4.5692-8.3298zm5.2036 31.01 19.104 2.4195c3.7307 0.4725 6.3538 3.8563 5.8813 7.5871l-0.0384 0.30333c-0.47251 3.7307-3.8564 6.3538-7.5871 5.8813l-19.104-2.4195c-3.7308-0.47251-6.3538-3.8564-5.8813-7.5871l0.0384-0.30333c0.4725-3.7308 3.8564-6.3538 7.5871-5.885z" fill="currentColor" fill-opacity="0.35"/>
                <path d="m230.74 47.102c7.513 2.241 15.03 7.73 18.718 13.88 3.686 6.15 4.984 15.367 3.42 23.05l-36.653 146.38c-2.242 7.514-7.73 15.032-13.88 18.719-6.151 3.686-15.367 4.984-23.05 3.42l-91.452-22.9 47.951-4.487 47.387 11.866c4.343 1.447 7.233 0.598 10.937-1.623 3.705-2.22 5.816-4.369 6.586-8.881l36.654-146.38c1.447-4.342 0.597-7.232-1.623-10.937-2.221-3.704-4.37-5.815-8.882-6.585l-29.704-7.438-1.58-16.89z" fill="currentColor" fill-opacity="0.35"/>
                <path d="m153.34 0.033116c-1.004-0.045562-1.9996-0.043969-2.9785 0.00391l-122.85 11.494c-7.767 1.077-16.029 5.3638-20.605 10.885-4.576 5.521-7.255 14.434-6.873 22.266l14.059 150.24c1.078 7.766 5.3648 16.029 10.885 20.604 5.521 4.576 14.434 7.255 22.266 6.873l122.85-11.496c7.766-1.078 16.027-5.3628 20.604-10.883 4.575-5.521 7.255-14.435 6.873-22.266l-14.059-150.24c-1.077-7.767-5.3648-16.028-10.885-20.605-4.8309-4.0031-12.259-6.5561-19.287-6.875zm1.5996 15.729c2.8262 0.16252 4.9828 1.3978 7.4766 3.4648 3.326 2.756 5.088 5.2013 5.166 9.7773l14.059 150.24c0.772 4.512-0.50572 7.2414-3.2617 10.566s-5.2004 5.0871-9.7774 5.1641l-122.85 11.494c-4.512 0.773-7.2395-0.50277-10.564-3.2598-3.325-2.756-5.0861-5.2003-5.1641-9.7773l-14.059-150.24c-0.773-4.511 0.50572-7.2385 3.2617-10.564 2.756-3.325 5.1984-5.0861 9.7754-5.1641l122.85-11.494c1.1278-0.19325 2.1439-0.25925 3.0859-0.20508z" fill="currentColor"/>
                <path d="m122.41 64.494c14.759 0.14499 27.318 11.5 28.709 26.609 0.572 6.208 0.10796 12.037-1.123 17.516-5.841 26.84-29.805 44.776-41.982 50.211-1.715 0.789-4.6378 1.0587-6.4688 0.5957-12.965-3.119-39.803-16.375-50.447-41.697-2.211-5.162-3.7317-10.81-4.3027-17.018-1.485-16.118 10.302-30.355 26.316-31.83 9.441-0.869 18.315 2.943 24.232 9.5 4.62-7.527 12.647-12.895 22.088-13.764 1.0009-0.09219 1.9946-0.13271 2.9785-0.12305z" fill="currentColor"/>
            </g>
        </svg>
    `;

    function getSavedPort() {
        try {
            const stored = window.localStorage.getItem(PORT_STORAGE_KEY);
            if (stored && /^\d{2,5}$/.test(stored)) {
                return stored;
            }
        } catch {
            // Ignore storage failures.
        }
        return DEFAULT_PORT;
    }

    function savePort(value) {
        try {
            window.localStorage.setItem(PORT_STORAGE_KEY, value);
        } catch {
            // Ignore storage failures.
        }
    }

    function getSavedHost() {
        try {
            const stored = window.localStorage.getItem(HOST_STORAGE_KEY);
            if (stored === 'localhost' || stored === '127.0.0.1') {
                return stored;
            }
        } catch {
            // Ignore storage failures.
        }
        return DEFAULT_HOST;
    }

    function saveHost(value) {
        try {
            window.localStorage.setItem(HOST_STORAGE_KEY, value);
        } catch {
            // Ignore storage failures.
        }
    }

    function getImportCandidates() {
        const preferredHost = getSavedHost();
        const port = getSavedPort();
        const orderedHosts = [preferredHost, 'localhost', '127.0.0.1'].filter(
            (value, index, array) => array.indexOf(value) === index
        );

        return orderedHosts.map((host) => ({
            host,
            url: `http://${host}:${port}/api/import`,
        }));
    }

    function getTopicMeta() {
        const match = window.location.pathname.match(/^\/t\/([^/]+)\/(\d+)(?:\/.*)?$/);
        if (!match) {
            return null;
        }

        const slug = match[1];
        const topicId = Number(match[2]);
        const title =
            document.querySelector('.fancy-title')?.textContent?.trim() ||
            document.querySelector('#topic-title .fancy-title')?.textContent?.trim() ||
            document.querySelector('#topic-title')?.firstChild?.textContent?.trim() ||
            document.querySelector('h1')?.textContent?.trim() ||
            document.title.replace(/\s*-\s*EroScripts.*$/i, '').trim();

        return {
            topicUrl: `${window.location.origin}/t/${slug}/${topicId}`,
            topicId,
            slug,
            title: title || `Topic ${topicId}`,
        };
    }

    function buildTopicJsonUrl(topicUrl) {
        const url = new URL(topicUrl);
        const match = url.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/.*)?$/);
        if (!match) {
            throw new Error('Could not derive topic id from URL');
        }

        url.pathname = `/t/${match[1]}.json`;
        url.searchParams.set('include_raw', 'true');
        return url.toString();
    }

    function ensureStyles() {
        if (document.getElementById(`${ROOT_ID}-styles`)) {
            return;
        }

        const style = document.createElement('style');
        style.id = `${ROOT_ID}-styles`;
        style.textContent = `
            #${ROOT_ID} {
                position: relative;
                margin: 0 0 10px;
                z-index: 20;
                max-width: 320px;
            }

            #${ROOT_ID} .erodeck-sidecar {
                display: grid;
                gap: 10px;
                padding: 10px 12px;
                border-radius: 14px;
                border: 1px solid rgba(127, 59, 190, 0.45);
                background: rgba(24, 18, 38, 0.96);
                box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
            }

            #${ROOT_ID} .erodeck-sidecar-head {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 10px;
                min-width: 0;
            }

            #${ROOT_ID} .erodeck-sidecar-brand {
                display: flex;
                align-items: center;
                gap: 10px;
                min-width: 0;
            }

            #${ROOT_ID} .erodeck-sidecar-mark {
                display: inline-flex;
                align-items: center;
                justify-content: center;
                flex: 0 0 auto;
                width: 28px;
                height: 28px;
                border-radius: 9px;
                color: #f7fbff;
                background: rgba(168, 85, 247, 0.12);
                border: 1px solid rgba(168, 85, 247, 0.22);
            }

            #${ROOT_ID} .erodeck-sidecar-title {
                color: #f8fbff;
                font: 800 11px/1 "Segoe UI", sans-serif;
                letter-spacing: 0.01em;
            }

            #${ROOT_ID} .erodeck-sidecar-port-group {
                display: inline-flex;
                align-items: center;
                gap: 8px;
                flex: 0 0 auto;
                min-height: 28px;
                padding: 0 7px 0 9px;
                border: 1px solid rgba(127, 59, 190, 0.34);
                border-radius: 9px;
                background: rgba(127, 59, 190, 0.12);
            }

            #${ROOT_ID} .erodeck-sidecar-port-label {
                color: rgba(210, 223, 238, 0.6);
                font: 700 9px/1 "Segoe UI", sans-serif;
                letter-spacing: 0.08em;
                text-transform: uppercase;
            }

            #${ROOT_ID} .erodeck-sidecar-button {
                display: inline-flex;
                align-items: center;
                justify-content: center;
                gap: 8px;
                width: 100%;
                min-height: 36px;
                border: 1px solid rgba(148, 72, 219, 0.65);
                border-radius: 11px;
                padding: 8px 12px;
                color: #f7fbff;
                font: 700 12px/1.15 "Segoe UI", sans-serif;
                letter-spacing: 0.01em;
                background: rgba(127, 59, 190, 0.34);
                cursor: pointer;
                transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease;
            }

            #${ROOT_ID} .erodeck-sidecar-button:hover {
                border-color: rgba(168, 85, 247, 0.82);
                background: rgba(148, 72, 219, 0.42);
            }

            #${ROOT_ID} .erodeck-sidecar-button:disabled {
                opacity: 0.72;
                cursor: wait;
            }

            #${ROOT_ID} .erodeck-sidecar-button-icon {
                display: inline-flex;
                align-items: center;
                justify-content: center;
                width: 18px;
                height: 18px;
                color: #f7fbff;
            }

            #${ROOT_ID} .erodeck-sidecar-port {
                width: 44px;
                height: 20px;
                display: block;
                align-self: center;
                margin: 0;
                border: 0;
                border-radius: 6px;
                padding: 0 4px;
                color: #f7fbff;
                font: 700 11px/20px "Segoe UI", sans-serif;
                background: rgba(10, 10, 16, 0.42);
                outline: none;
                text-align: center;
                vertical-align: middle;
                appearance: textfield;
                -moz-appearance: textfield;
                box-sizing: border-box;
            }

            #${ROOT_ID} .erodeck-sidecar-port:focus {
                background: rgba(127, 59, 190, 0.28);
            }

            #${ROOT_ID} .erodeck-sidecar-port::-webkit-outer-spin-button,
            #${ROOT_ID} .erodeck-sidecar-port::-webkit-inner-spin-button {
                -webkit-appearance: none;
                margin: 0;
            }

            #${ROOT_ID} .erodeck-sidecar-status {
                color: rgba(233, 240, 248, 0.9);
                font: 500 10px/1.25 "Segoe UI", sans-serif;
                padding: 0 2px;
                display: none;
            }

            #${ROOT_ID} .erodeck-sidecar-status:not(:empty) {
                display: block;
            }

            #${ROOT_ID} .erodeck-sidecar-status[data-kind="ok"] {
                color: #d7b7ff;
            }

            #${ROOT_ID} .erodeck-sidecar-status[data-kind="error"] {
                color: #ffb0b7;
            }

            #${ROOT_ID} .erodeck-sidecar-status[data-kind="pending"] {
                color: #e8d6ff;
            }
        `;
        document.head.appendChild(style);
    }

    function setStatus(text, kind) {
        const status = document.getElementById(STATUS_ID);
        if (!status) {
            return;
        }
        status.textContent = text;
        status.dataset.kind = kind || '';
    }

    function setButtonBusy(isBusy) {
        const button = document.getElementById(BUTTON_ID);
        if (!button) {
            return;
        }
        button.disabled = isBusy;
        button.setAttribute('aria-busy', isBusy ? 'true' : 'false');
        const label = button.querySelector('.erodeck-sidecar-button-label');
        if (label) {
            label.textContent = isBusy ? 'Queueing topic...' : 'Queue Topic JSON';
        }
    }

    function postToAppUrl(payload, target) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: target.url,
                headers: {
                    'Content-Type': 'application/json',
                },
                data: JSON.stringify(payload),
                onload: (response) => {
                    try {
                        const parsed = JSON.parse(response.responseText || '{}');
                        if (response.status >= 200 && response.status < 300) {
                            saveHost(target.host);
                            resolve(parsed);
                            return;
                        }
                        reject(new Error(parsed.error || `${response.status} ${response.statusText || 'Request failed'}`));
                    } catch (error) {
                        reject(error instanceof Error ? error : new Error('Could not parse EroDeck response'));
                    }
                },
                onerror: () => reject(new Error('Could not reach local EroDeck instance')),
                ontimeout: () => reject(new Error('Local EroDeck instance timed out')),
            });
        });
    }

    async function postToApp(payload) {
        let lastError = null;
        const candidates = getImportCandidates();

        for (const candidate of candidates) {
            try {
                return await postToAppUrl(payload, candidate);
            } catch (error) {
                lastError = error;
            }
        }

        throw lastError || new Error('Could not reach local EroDeck instance');
    }

    async function queueCurrentTopic() {
        const meta = getTopicMeta();
        if (!meta) {
            setStatus('Not an EroScripts topic page.', 'error');
            return;
        }

        setButtonBusy(true);
        setStatus('Fetching authenticated topic JSON...', 'pending');

        try {
            const topicJsonUrl = buildTopicJsonUrl(meta.topicUrl);
            const topicResponse = await fetch(topicJsonUrl, {
                credentials: 'include',
                headers: {
                    Accept: 'application/json',
                },
            });

            if (!topicResponse.ok) {
                throw new Error(`Topic JSON fetch failed with ${topicResponse.status} ${topicResponse.statusText}`);
            }

            const topicPayload = await topicResponse.json();
            setStatus('Sending topic JSON to EroDeck...', 'pending');

            await postToApp({
                topicUrl: meta.topicUrl,
                topicId: meta.topicId,
                slug: meta.slug,
                title: meta.title,
                topicJsonUrl,
                topicPayload,
            });

            setStatus('Queued topic JSON for review in EroDeck.', 'ok');
        } catch (error) {
            setStatus(error instanceof Error ? error.message : 'Could not queue topic JSON.', 'error');
        } finally {
            setButtonBusy(false);
        }
    }

    function buildSidecar() {
        const root = document.createElement('section');
        root.id = ROOT_ID;
        root.innerHTML = `
            <div class="erodeck-sidecar">
                <div class="erodeck-sidecar-head">
                    <div class="erodeck-sidecar-brand">
                        <span class="erodeck-sidecar-mark">${ERODECK_ICON}</span>
                        <strong class="erodeck-sidecar-title">EroDeck</strong>
                    </div>
                    <label class="erodeck-sidecar-port-group" for="${PORT_INPUT_ID}">
                        <span class="erodeck-sidecar-port-label">Port</span>
                        <input id="${PORT_INPUT_ID}" class="erodeck-sidecar-port" type="text" inputmode="numeric" value="${getSavedPort()}" aria-label="EroDeck port" title="EroDeck port" />
                    </label>
                </div>
                <div>
                    <button id="${BUTTON_ID}" type="button" class="erodeck-sidecar-button">
                        <span class="erodeck-sidecar-button-icon">${ERODECK_ICON}</span>
                        <span class="erodeck-sidecar-button-label">Queue Topic JSON</span>
                    </button>
                </div>
                <span id="${STATUS_ID}" class="erodeck-sidecar-status" data-kind=""></span>
            </div>
        `;

        root.querySelector(`#${BUTTON_ID}`)?.addEventListener('click', queueCurrentTopic);
        root.querySelector(`#${PORT_INPUT_ID}`)?.addEventListener('change', (event) => {
            const input = event.currentTarget;
            const cleaned = String(input.value || '').replace(/[^\d]/g, '').slice(0, 5);
            input.value = cleaned || DEFAULT_PORT;
            savePort(input.value);
            setStatus(`Using ${getSavedHost()}:${input.value} (falls back to the other loopback host if needed)`, '');
        });
        return root;
    }

    function isMobileLayout() {
        return (
            window.matchMedia('(max-width: 924px)').matches ||
            /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
        );
    }

    function findMountTarget() {
        const eroscriptsContainer = document.querySelector(
            isMobileLayout() ? '#post_1' : '[class*="with-timeline"]'
        );

        return (
            eroscriptsContainer ||
            document.querySelector('.topic-navigation') ||
            document.querySelector('.timeline-footer-controls') ||
            document.querySelector('.timeline-container') ||
            document.body
        );
    }

    function placeWidget(root) {
        const iveRoot = document.getElementById('ive');

        if (iveRoot?.parentElement) {
            if (iveRoot.nextElementSibling !== root) {
                iveRoot.insertAdjacentElement('afterend', root);
            }
            return;
        }

        const mountTarget = findMountTarget();
        if (!mountTarget) {
            return;
        }

        if (mountTarget.firstElementChild !== root) {
            mountTarget.prepend(root);
        }
    }

    function ensureWidget() {
        const meta = getTopicMeta();
        const existing = document.getElementById(ROOT_ID);

        if (!meta) {
            if (existing) {
                existing.remove();
            }
            return;
        }

        ensureStyles();

        if (!existing) {
            const sidecar = buildSidecar();
            placeWidget(sidecar);
            return;
        }

        placeWidget(existing);
    }

    let lastHref = '';
    const tick = () => {
        if (window.location.href !== lastHref) {
            lastHref = window.location.href;
            ensureWidget();
            setStatus('', '');
            setButtonBusy(false);
        }
    };

    ensureWidget();
    setInterval(tick, POLL_INTERVAL_MS);
})();