EroTok Mini

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();