EroDeck Topic Import

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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