Fetch the authenticated EroScripts topic JSON in-browser and queue it for EroDeck through a dedicated sidecar panel.
// ==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);
})();