EroTok Mini

Requires the local EroTok GUI/API from GitHub. Adds an EroTok Mini panel for public Erome search, preview, and downloads.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         EroTok Mini
// @namespace    https://github.com/insomniakin/EromeAPI-main
// @version      0.1.2
// @description  Requires the local EroTok GUI/API from GitHub. Adds an EroTok Mini panel for public Erome search, preview, and downloads.
// @author       cjordanhot
// @copyright   2026, cjordanhot
// @license     BSD-2-Clause
// @match        https://www.erome.com/*
// @homepageURL  https://github.com/insomniakin/EromeAPI-main
// @supportURL   https://github.com/insomniakin/EromeAPI-main/issues
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      127.0.0.1
// @connect      localhost
// @connect      github.com
// @run-at       document-idle
// ==/UserScript==

// Copyright (c) 2026 cjordanhot.
// SPDX-License-Identifier: BSD-2-Clause

(function () {
  'use strict';

  const FULL_APP_URL = 'http://127.0.0.1:3000/';
  const GITHUB_URL = 'https://github.com/insomniakin/EromeAPI-main';
  const DEFAULT_API_BASE = 'http://127.0.0.1:3000';
  const SUGGESTED_HASHTAGS = [
    '#tattoos', '#alternative girl', '#egirl', '#redhair', '#outdoor', '#cosplay',
    '#pink hair', '#piercing', '#goth', '#alt style', '#cabelo rosa',
    '#skinny alternative', '#alternativa girl'
  ];
  const STORAGE_KEYS = {
    apiBase: 'erotok.apiBase',
    searchTerms: 'erotok.searchTerms',
    hideTerms: 'erotok.hideTerms',
    selectedTags: 'erotok.selectedTags',
    source: 'erotok.source',
  };

  const state = {
    apiBase: getValue(STORAGE_KEYS.apiBase, DEFAULT_API_BASE),
    searchTerms: getValue(STORAGE_KEYS.searchTerms, ''),
    hideTerms: getValue(STORAGE_KEYS.hideTerms, ''),
    selectedTags: parseHashtagInput(getValue(STORAGE_KEYS.selectedTags, '')),
    source: getValue(STORAGE_KEYS.source, 'search'),
    collapsed: false,
    results: [],
  };

  function getValue(key, fallback) {
    try { return GM_getValue(key, fallback); } catch { return fallback; }
  }

  function setValue(key, value) {
    try { GM_setValue(key, value); } catch {}
  }

  function normalizeText(value) {
    return String(value || '').replace(/\s+/g, ' ').trim();
  }

  function normalizeHashtagLabel(value) {
    return normalizeText(value).replace(/^#+/, '').toLowerCase();
  }

  function uniqueTerms(terms) {
    const seen = new Set();
    return terms.filter((term) => {
      const key = normalizeHashtagLabel(term);
      if (!key || seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }

  function parseHashtagInput(value) {
    const raw = String(value || '').trim();
    if (!raw) return [];
    const terms = [];
    raw.split(/[,;\n\r]+/).forEach((group) => {
      const cleaned = group.trim();
      if (!cleaned) return;
      if ((cleaned.match(/#/g) || []).length > 1) {
        cleaned.split(/(?=#)/g).forEach((part) => {
          const tag = normalizeHashtagLabel(part);
          if (tag) terms.push(tag);
        });
        return;
      }
      const tag = normalizeHashtagLabel(cleaned);
      if (tag) terms.push(tag);
    });
    return uniqueTerms(terms);
  }

  function parseSearchTerms(value) {
    const raw = String(value || '').trim();
    if (!raw) return [];
    const terms = [];
    raw.split(/[,;\n\r]+/).forEach((group) => {
      const cleaned = group.trim();
      if (!cleaned) return;
      if (cleaned.includes('#')) {
        parseHashtagInput(cleaned).forEach((tag) => terms.push(`#${tag}`));
        return;
      }
      cleaned.split(/\s+/).forEach((term) => {
        if (term) terms.push(term);
      });
    });
    return uniqueTerms(terms);
  }

  function selectedHashtagQuery() {
    return state.selectedTags.map((tag) => `#${tag}`).join(', ');
  }

  function searchQuery() {
    return uniqueTerms([
      ...parseSearchTerms(selectedHashtagQuery()),
      ...parseSearchTerms(state.searchTerms),
    ]).join(', ');
  }

  function currentAlbumPath() {
    const match = location.pathname.match(/\/a\/([A-Za-z0-9]+)/);
    return match ? match[1] : '';
  }

  function currentProfileName() {
    const blocked = new Set(['a', 'search', 'explore', 'terms', 'login', 'register']);
    const first = location.pathname.split('/').filter(Boolean)[0] || '';
    return first && !blocked.has(first.toLowerCase()) ? first : '';
  }

  function buildUrl(path, params = {}) {
    const base = state.apiBase.replace(/\/+$/, '');
    const url = new URL(path, `${base}/`);
    Object.entries(params).forEach(([key, value]) => {
      if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
    });
    return url.toString();
  }

  function requestJson(method, path, params = {}, body = null) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method,
        url: buildUrl(path, params),
        headers: body ? { 'Content-Type': 'application/json' } : {},
        data: body ? JSON.stringify(body) : undefined,
        timeout: 30000,
        onload: (response) => {
          let parsed;
          try { parsed = JSON.parse(response.responseText || '{}'); }
          catch { parsed = { ok: false, error: response.responseText || 'Invalid JSON response' }; }
          if (response.status < 200 || response.status >= 300 || parsed.ok === false) {
            reject(new Error(parsed.error || `Request failed with ${response.status}`));
            return;
          }
          resolve(parsed);
        },
        onerror: () => reject(new Error('Could not reach local EroTok server. Start node server.js first.')),
        ontimeout: () => reject(new Error('Local EroTok server timed out.')),
      });
    });
  }

  function albumText(album) {
    return [
      album.title,
      album.url,
      album.username,
      album.description,
      ...(Array.isArray(album.tags) ? album.tags : []),
      ...(Array.isArray(album.matched_hashtags) ? album.matched_hashtags : []),
    ].map((value) => normalizeHashtagLabel(value)).join(' ');
  }

  function visibleAlbums(albums) {
    const hideTerms = parseSearchTerms(state.hideTerms).map(normalizeHashtagLabel).filter(Boolean);
    if (!hideTerms.length) return albums;
    return albums.filter((album) => {
      const text = albumText(album || {});
      return !hideTerms.some((term) => text.includes(term));
    });
  }

  function persistSettings() {
    setValue(STORAGE_KEYS.apiBase, state.apiBase);
    setValue(STORAGE_KEYS.searchTerms, state.searchTerms);
    setValue(STORAGE_KEYS.hideTerms, state.hideTerms);
    setValue(STORAGE_KEYS.selectedTags, selectedHashtagQuery());
    setValue(STORAGE_KEYS.source, state.source);
  }

  function setStatus(message, ok = true) {
    const status = document.getElementById('erotok-mini-status');
    if (!status) return;
    status.textContent = message;
    status.title = message;
    status.dataset.ok = ok ? 'true' : 'false';
  }

  function addHashtagTerms(terms) {
    state.selectedTags = uniqueTerms([...state.selectedTags, ...terms.map(normalizeHashtagLabel)]);
    persistSettings();
    renderHashtagChips();
  }

  function removeHashtagTerm(term) {
    const key = normalizeHashtagLabel(term);
    state.selectedTags = state.selectedTags.filter((tag) => normalizeHashtagLabel(tag) !== key);
    persistSettings();
    renderHashtagChips();
  }

  function renderHashtagChips() {
    const selected = document.getElementById('erotok-selected-tags');
    const suggested = document.getElementById('erotok-suggested-tags');
    if (!selected || !suggested) return;
    selected.textContent = '';
    state.selectedTags.forEach((tag) => {
      const chip = document.createElement('button');
      chip.type = 'button';
      chip.className = 'erotok-chip erotok-chip-active';
      chip.textContent = `#${tag} x`;
      chip.addEventListener('click', () => removeHashtagTerm(tag));
      selected.appendChild(chip);
    });
    suggested.textContent = '';
    SUGGESTED_HASHTAGS.forEach((tag) => {
      const normalized = normalizeHashtagLabel(tag);
      const active = state.selectedTags.includes(normalized);
      const chip = document.createElement('button');
      chip.type = 'button';
      chip.className = `erotok-chip${active ? ' erotok-chip-active' : ''}`;
      chip.textContent = `#${normalized}`;
      chip.addEventListener('click', () => active ? removeHashtagTerm(normalized) : addHashtagTerms([normalized]));
      suggested.appendChild(chip);
    });
  }

  function resultCard(album) {
    const card = document.createElement('article');
    card.className = 'erotok-result-card';
    const title = album.title || 'Untitled album';
    const url = album.url || '';
    const path = albumPathFromUrl(url);
    card.innerHTML = `
      ${album.thumb ? `<img class="erotok-result-thumb" src="${escapeHtml(proxyUrl(album.thumb))}" alt="">` : ''}
      <div class="erotok-result-body">
        <div class="erotok-result-title">${escapeHtml(title)}</div>
        <div class="erotok-result-meta">${Number(album.images || 0)} photos · ${Number(album.videos || 0)} videos · ${Number(album.views || 0)} views</div>
        <div class="erotok-result-actions">
          <a href="${escapeHtml(url || '#')}" target="_blank" rel="noopener noreferrer">Open</a>
          <button type="button" data-action="download" ${path ? '' : 'disabled'}>Download</button>
        </div>
      </div>`;
    const download = card.querySelector('[data-action="download"]');
    if (download && path) download.addEventListener('click', () => downloadAlbum(path, title));
    return card;
  }

  function albumPathFromUrl(url) {
    const match = String(url || '').match(/\/a\/([A-Za-z0-9]+)/);
    return match ? match[1] : '';
  }

  function proxyUrl(url) {
    return buildUrl('/proxy', { url });
  }

  function escapeHtml(value) {
    return String(value || '').replace(/[&<>"']/g, (char) => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[char]));
  }

  function renderResults(albums) {
    const host = document.getElementById('erotok-results');
    if (!host) return;
    host.textContent = '';
    const visible = visibleAlbums(Array.isArray(albums) ? albums : []);
    state.results = visible;
    if (!visible.length) {
      host.innerHTML = '<div class="erotok-empty">No public albums returned.</div>';
      return;
    }
    visible.slice(0, 24).forEach((album) => host.appendChild(resultCard(album)));
  }

  async function runSearch() {
    const keyword = searchQuery() || 'test';
    setStatus(`Searching ${keyword}...`, true);
    const result = await requestJson('GET', '/api/search', { keyword, page: 1, limit: 12, sort: 'default', dir: 'desc' });
    renderResults(result.data || []);
    setStatus(`Search loaded ${(result.data || []).length} album(s).`, true);
  }

  async function runExplore() {
    setStatus('Loading explore...', true);
    const result = await requestJson('GET', '/api/explore', { page: 1, limit: 12, new: 'false', sort: 'default', dir: 'desc' });
    renderResults(result.data || []);
    setStatus(`Explore loaded ${(result.data || []).length} album(s).`, true);
  }

  async function runProfile() {
    const profile = currentProfileName();
    if (!profile) throw new Error('Open a public profile page or type a profile search in the full app.');
    setStatus(`Loading profile ${profile}...`, true);
    const result = await requestJson('GET', '/api/profile', { profile, page: 1, limit: 12, content: 'albums' });
    renderResults((result.data && result.data.albums) || []);
    setStatus(`Profile loaded: ${profile}.`, true);
  }

  async function downloadAlbum(path, title = 'album') {
    setStatus(`Starting download: ${title}`, true);
    const result = await requestJson('POST', '/api/download/jobs', {}, {
      path,
      directory: 'Downloads',
      include_photos: true,
      include_videos: true,
      media_type: 'all',
      skip_downloaded: true,
      overwrite: false,
      max_workers: 4,
    });
    const job = result.data || {};
    setStatus(`Download job ${String(job.id || '').slice(0, 8)} started. Open full app for live progress.`, true);
  }

  async function downloadCurrentAlbum() {
    const path = currentAlbumPath();
    if (!path) throw new Error('This page is not an album page.');
    await downloadAlbum(path, document.title || path);
  }

  function setCollapsed(collapsed) {
    state.collapsed = collapsed;
    const panel = document.getElementById('erotok-mini');
    if (panel) panel.dataset.collapsed = collapsed ? 'true' : 'false';
  }

  function bindPanel() {
    const panel = document.getElementById('erotok-mini');
    if (!panel) return;
    const apiBase = panel.querySelector('#erotok-api-base');
    const searchTerms = panel.querySelector('#erotok-search-terms');
    const hideTerms = panel.querySelector('#erotok-hide-terms');
    const tagInput = panel.querySelector('#erotok-tag-input');

    apiBase.value = state.apiBase;
    searchTerms.value = state.searchTerms;
    hideTerms.value = state.hideTerms;

    apiBase.addEventListener('input', () => { state.apiBase = apiBase.value || DEFAULT_API_BASE; persistSettings(); });
    searchTerms.addEventListener('input', () => { state.searchTerms = searchTerms.value; persistSettings(); });
    hideTerms.addEventListener('input', () => { state.hideTerms = hideTerms.value; persistSettings(); renderResults(state.results); });

    panel.querySelector('#erotok-add-tags').addEventListener('click', () => {
      addHashtagTerms(parseHashtagInput(tagInput.value));
      tagInput.value = '';
    });
    tagInput.addEventListener('keydown', (event) => {
      if (event.key !== 'Enter') return;
      event.preventDefault();
      panel.querySelector('#erotok-add-tags').click();
    });
    panel.querySelector('#erotok-clear-tags').addEventListener('click', () => {
      state.selectedTags = [];
      persistSettings();
      renderHashtagChips();
    });
    panel.querySelector('#erotok-run-search').addEventListener('click', () => runSearch().catch((error) => setStatus(error.message || String(error), false)));
    panel.querySelector('#erotok-run-explore').addEventListener('click', () => runExplore().catch((error) => setStatus(error.message || String(error), false)));
    panel.querySelector('#erotok-run-profile').addEventListener('click', () => runProfile().catch((error) => setStatus(error.message || String(error), false)));
    panel.querySelector('#erotok-download-current').addEventListener('click', () => downloadCurrentAlbum().catch((error) => setStatus(error.message || String(error), false)));
    panel.querySelector('#erotok-collapse').addEventListener('click', () => setCollapsed(!state.collapsed));
    panel.querySelector('#erotok-open-full').addEventListener('click', () => window.open(FULL_APP_URL, '_blank', 'noopener,noreferrer'));
    panel.querySelector('#erotok-upgrade').addEventListener('click', () => window.open(GITHUB_URL, '_blank', 'noopener,noreferrer'));

    renderHashtagChips();
    setStatus('Requires local GUI/API. Start node server.js first.', true);
  }

  function injectPanel() {
    if (document.getElementById('erotok-mini')) return;
    const panel = document.createElement('section');
    panel.id = 'erotok-mini';
    panel.dataset.collapsed = 'false';
    panel.innerHTML = `
      <div class="erotok-head">
        <div>
          <div class="erotok-title">EroTok Mini</div>
          <div class="erotok-subtitle">Local helper for public pages</div>
        </div>
        <button id="erotok-collapse" type="button" title="Collapse panel">_</button>
      </div>
      <div class="erotok-body">
        <div id="erotok-mini-status" class="erotok-status">Ready.</div>
        <label>Local server<input id="erotok-api-base" type="text"></label>
        <label>Keywords / hashtags<textarea id="erotok-search-terms" placeholder="travel, #outdoor, redhair"></textarea></label>
        <div class="erotok-tag-row">
          <input id="erotok-tag-input" type="text" placeholder="#redhair #outdoor or comma separated">
          <button id="erotok-add-tags" type="button">Add</button>
          <button id="erotok-clear-tags" type="button">Clear</button>
        </div>
        <div id="erotok-selected-tags" class="erotok-chips"></div>
        <div class="erotok-help">Tags combine with AND-style filtering in the local backend.</div>
        <div id="erotok-suggested-tags" class="erotok-chips"></div>
        <label>Hide terms<textarea id="erotok-hide-terms" placeholder="skip words, @names, #tags"></textarea></label>
        <div class="erotok-actions">
          <button id="erotok-run-search" type="button">Search</button>
          <button id="erotok-run-explore" type="button">Explore</button>
          <button id="erotok-run-profile" type="button">Profile</button>
          <button id="erotok-download-current" type="button">Download Page</button>
        </div>
        <div class="erotok-upgrade">
          <button id="erotok-open-full" type="button">Open full local app</button>
          <button id="erotok-upgrade" type="button">Upgrade on GitHub</button>
        </div>
        <div id="erotok-results" class="erotok-results"><div class="erotok-empty">Run Search, Explore, or Profile.</div></div>
      </div>`;
    document.body.appendChild(panel);
    bindPanel();
  }

  function injectStyles() {
    if (document.getElementById('erotok-mini-style')) return;
    const style = document.createElement('style');
    style.id = 'erotok-mini-style';
    style.textContent = `
      #erotok-mini {
        position: fixed;
        right: 16px;
        bottom: 16px;
        z-index: 2147483647;
        width: min(390px, calc(100vw - 24px));
        max-height: min(860px, calc(100vh - 24px));
        overflow: hidden;
        border: 1px solid rgba(255,255,255,.15);
        border-radius: 16px;
        background: linear-gradient(180deg, rgba(10,16,32,.98), rgba(4,8,18,.98));
        color: #f7f8f8;
        box-shadow: 0 18px 70px rgba(0,0,0,.55);
        font-family: Inter, Segoe UI, Arial, sans-serif;
      }
      #erotok-mini * { box-sizing: border-box; }
      #erotok-mini[data-collapsed="true"] .erotok-body { display: none; }
      .erotok-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border-bottom: 1px solid rgba(255,255,255,.12); }
      .erotok-title { font-size: 15px; font-weight: 900; }
      .erotok-subtitle { color: #9ca3af; font-size: 11px; }
      .erotok-body { display: grid; gap: 10px; max-height: calc(100vh - 92px); overflow: auto; padding: 12px; }
      #erotok-mini label { display: grid; gap: 6px; color: #9ca3af; font-size: 11px; font-weight: 800; }
      #erotok-mini input, #erotok-mini textarea, #erotok-mini button { border: 1px solid rgba(255,255,255,.13); border-radius: 10px; background: rgba(255,255,255,.06); color: #f7f8f8; font: inherit; padding: 9px 10px; }
      #erotok-mini textarea { min-height: 60px; resize: vertical; }
      #erotok-mini button { cursor: pointer; font-weight: 850; }
      #erotok-mini button:hover { border-color: #22d3ee; color: #fff; }
      .erotok-status { min-height: 34px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; border: 1px solid rgba(255,255,255,.13); border-radius: 10px; padding: 9px 10px; color: #bbf7d0; background: rgba(15,23,42,.9); font-size: 12px; }
      .erotok-status[data-ok="false"] { color: #fecaca; border-color: rgba(248,113,113,.5); }
      .erotok-tag-row { display: grid; grid-template-columns: minmax(0,1fr) auto auto; gap: 6px; }
      .erotok-chips { display: flex; flex-wrap: wrap; gap: 6px; }
      .erotok-chip { width: auto; border-radius: 999px !important; padding: 6px 9px !important; color: #d0d6e0 !important; font-size: 11px !important; }
      .erotok-chip-active { border-color: rgba(34,211,238,.58) !important; color: #a5f3fc !important; background: rgba(34,211,238,.12) !important; }
      .erotok-help { color: #9ca3af; font-size: 11px; line-height: 1.35; }
      .erotok-actions, .erotok-upgrade { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
      #erotok-upgrade { background: linear-gradient(135deg, #818cf8, #22d3ee) !important; color: #07111f !important; }
      .erotok-results { display: grid; gap: 8px; }
      .erotok-empty { color: #9ca3af; font-size: 12px; padding: 8px; text-align: center; }
      .erotok-result-card { display: grid; grid-template-columns: 86px minmax(0,1fr); gap: 8px; border: 1px solid rgba(255,255,255,.12); border-radius: 12px; padding: 8px; background: rgba(255,255,255,.04); }
      .erotok-result-thumb { width: 86px; aspect-ratio: 4/3; object-fit: cover; border-radius: 8px; background: #000; }
      .erotok-result-body { min-width: 0; display: grid; gap: 6px; }
      .erotok-result-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 850; }
      .erotok-result-meta { color: #9ca3af; font-size: 11px; }
      .erotok-result-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
      .erotok-result-actions a { display: grid; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: 10px; color: #a5f3fc; text-decoration: none; font-size: 12px; font-weight: 850; }
      @media (max-width: 520px) { #erotok-mini { right: 8px; bottom: 8px; } .erotok-actions, .erotok-upgrade { grid-template-columns: 1fr; } }
    `;
    document.head.appendChild(style);
  }

  injectStyles();
  injectPanel();
})();