DLSite Alt Finder

Show buttons on DLSite favorites/product pages if item is on free alternative sites

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         DLSite Alt Finder
// @namespace    https://www.dlsite.com
// @version      1.10.0
// @description  Show buttons on DLSite favorites/product pages if item is on free alternative sites
// @match        https://www.dlsite.com/*/mypage/wishlist*
// @match        https://www.dlsite.com/*/work/=/product_id/*.html
// @grant        GM_xmlhttpRequest
// @connect      api.asmr-200.com
// @connect      asmr18.fans
// @connect      nyaa.si
// @connect      sukebei.nyaa.si
// @connect      hentaiasmr.moe
// @connect      japaneseasmr.com
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  // ── CONFIG ──────────────────────────────────────────────────────────────────
  const CONFIG = {
    minDelayMs:    1200,  // minimum ms between requests to the same site
    jitterMs:       800,  // additional random jitter per request (0–jitterMs)
    rateLimitBackoffBase: 1500, // initial backoff ms on 429 (doubles each retry)
    rateLimitMaxRetries:     2, // only 429 is worth retrying (site said "slow down")
    requestTimeout:       7000, // ms before a request is abandoned
    cacheExpiry: 7 * 24 * 60 * 60 * 1000, // 1 week
    cachePrefix: 'dlsite-alt:',
    debug: false, // set true to enable console logging (or flip at runtime: dlsiteAltDebug(true))
  };

  // ── I18N ────────────────────────────────────────────────────────────────────
  const CHECKING_TEXT = (() => {
    const lang = navigator.language || 'en';
    if (lang.startsWith('ja'))                                   return '代替サイトを確認中…';
    if (lang.startsWith('zh-TW') || lang.startsWith('zh-Hant')) return '正在查詢替代網站…';
    if (lang.startsWith('zh'))                                   return '正在查询替代网站…';
    if (lang.startsWith('ru'))                                   return 'Поиск альтернатив…';
    if (lang.startsWith('ko'))                                   return '대안 사이트 확인 중…';
    return 'checking alternatives…';
  })();

  // ── CACHE ────────────────────────────────────────────────────────────────────
  function cacheGet(rjCode) {
    try {
      const raw = localStorage.getItem(CONFIG.cachePrefix + rjCode);
      if (!raw) return null;
      const entry = JSON.parse(raw);
      if (Date.now() - entry.ts > CONFIG.cacheExpiry) {
        localStorage.removeItem(CONFIG.cachePrefix + rjCode);
        return null;
      }
      return entry.results;
    } catch { return null; }
  }

  function cacheSet(rjCode, results) {
    try {
      localStorage.setItem(
        CONFIG.cachePrefix + rjCode,
        JSON.stringify({ ts: Date.now(), results })
      );
    } catch { /* quota exceeded or private browsing — silently skip */ }
  }

  // ── LOGGING ──────────────────────────────────────────────────────────────────
  // Only active when CONFIG.debug = true.
  function log(level, ...args) {
    if (!CONFIG.debug) return;
    console[level]('[DLSite Alt]', ...args);
  }

  // ── HELPERS ──────────────────────────────────────────────────────────────────
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

  function gmFetch(url, options = {}) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: options.method || 'GET',
        url,
        timeout: CONFIG.requestTimeout,
        headers: options.headers || {},
        onload(res)   { resolve(res); },
        onerror(err)  { reject(new Error(String(err))); },
        ontimeout()   { reject(new Error('timeout')); },
      });
    });
  }

  function parseXml(text) {
    return new DOMParser().parseFromString(text, 'application/xml');
  }

  // ── PER-SITE RATE LIMITER ────────────────────────────────────────────────────
  // Ensures at least (minDelayMs + random jitter) between consecutive requests.
  class RateLimiter {
    constructor({ minDelayMs = CONFIG.minDelayMs, jitterMs = CONFIG.jitterMs } = {}) {
      this.minDelayMs = minDelayMs;
      this.jitterMs   = jitterMs;
      this.nextAllowed = 0;
    }

    async wait() {
      const jitter = Math.random() * this.jitterMs;
      const gap = this.nextAllowed - Date.now();
      if (gap + jitter > 0) await sleep(gap + jitter);
      this.nextAllowed = Date.now() + this.minDelayMs;
    }
  }

  // Only retries on HTTP 429 (rate limited) — the server explicitly asked us
  // to slow down, so backing off makes sense.
  //
  // Does NOT retry on 5xx, network errors, or timeouts: those indicate the
  // site is down or unreachable; waiting and retrying just makes the user
  // stare at "checking…" longer for no benefit. We fail fast and move on.
  async function fetchWithBackoff(limiter, url, options = {}) {
    let backoff = CONFIG.rateLimitBackoffBase;
    for (let attempt = 0; attempt <= CONFIG.rateLimitMaxRetries; attempt++) {
      await limiter.wait();
      const res = await gmFetch(url, options); // throws on network error / timeout → caller catches
      if (res.status === 429 && attempt < CONFIG.rateLimitMaxRetries) {
        const wait = backoff + Math.random() * 500;
        // Push the limiter forward so the next queued item also waits out this backoff
        limiter.nextAllowed = Date.now() + wait;
        await sleep(wait);
        backoff *= 2;
        continue;
      }
      return res;
    }
    // Exhausted 429 retries — return the last response so the checker sees the 429
    return await gmFetch(url, options);
  }

  // ── SITE CHECKERS ────────────────────────────────────────────────────────────
  // Interface: { name, color, enabled, limiter, check(rjCode) → Promise<{found,url}> }

  const CHECKERS = [

    {
      name: 'asmr.one',
      color: '#e05a3a',
      enabled: true,
      limiter: new RateLimiter({ minDelayMs: 3000, jitterMs: 1000 }),
      async check(rjCode) {
        const numericId = rjCode.replace(/^[A-Za-z]+/, '');
        const res = await fetchWithBackoff(
          this.limiter,
          `https://api.asmr-200.com/api/workInfo/${numericId}`
        );
        if (res.status === 200) return { found: true, url: `https://www.asmr.one/work/${rjCode}` };
        return { found: false, url: '' };
      },
    },

    {
      name: 'asmr18',
      color: '#b5396e',
      enabled: true,
      limiter: new RateLimiter(),
      async check(rjCode) {
        const lower = rjCode.toLowerCase();
        for (const cat of ['boys', 'girls', 'allages']) {
          const url = `https://asmr18.fans/${cat}/${lower}/`;
          const res = await fetchWithBackoff(this.limiter, url);
          if (res.status === 200 && !res.responseText.includes('見つかりませんでした')) {
            return { found: true, url };
          }
        }
        return { found: false, url: '' };
      },
    },

    {
      name: 'nyaa',
      color: '#2d7a2d',
      enabled: true,
      limiter: new RateLimiter(),
      async check(rjCode) {
        const res = await fetchWithBackoff(
          this.limiter,
          `https://nyaa.si/?page=rss&q=${encodeURIComponent(rjCode)}&c=0_0&f=0`
        );
        const xml = parseXml(res.responseText);
        const items = xml.getElementsByTagName('item');
        if (items.length > 0) {
          const linkEl = items[0].getElementsByTagName('link')[0];
          const link = linkEl?.textContent?.trim()
            || `https://nyaa.si/?q=${encodeURIComponent(rjCode)}&c=0_0&f=0`;
          return { found: true, url: link };
        }
        return { found: false, url: '' };
      },
    },

    {
      name: 'sukebei',
      color: '#b5290a',
      enabled: true,
      limiter: new RateLimiter(),
      async check(rjCode) {
        const res = await fetchWithBackoff(
          this.limiter,
          `https://sukebei.nyaa.si/?page=rss&q=${encodeURIComponent(rjCode)}&c=0_0&f=0`
        );
        const xml = parseXml(res.responseText);
        const items = xml.getElementsByTagName('item');
        if (items.length > 0) {
          const linkEl = items[0].getElementsByTagName('link')[0];
          const link = linkEl?.textContent?.trim()
            || `https://sukebei.nyaa.si/?q=${encodeURIComponent(rjCode)}&c=0_0&f=0`;
          return { found: true, url: link };
        }
        return { found: false, url: '' };
      },
    },

    {
      name: 'hentaiasmr',
      color: '#c0392b',
      enabled: true,
      limiter: new RateLimiter(),
      async check(rjCode) {
        const url = `https://hentaiasmr.moe/${rjCode.toLowerCase()}.html`;
        const res = await fetchWithBackoff(this.limiter, url);
        if (res.status !== 200 || res.responseText.includes('Page Not Found')) {
          return { found: false, url: '' };
        }
        return { found: true, url };
      },
    },

    {
      name: 'japaneseasmr',
      color: '#1a6b8a',
      enabled: true,
      limiter: new RateLimiter(),
      async check(rjCode) {
        const searchUrl = `https://japaneseasmr.com/?s=${encodeURIComponent(rjCode)}`;
        const res = await fetchWithBackoff(this.limiter, searchUrl);
        // The page always echoes the search term in the title, so check for
        // the bracketed form "[RJ...]" which only appears inside actual post content
        if (res.status !== 200 || !res.responseText.includes(`[${rjCode}]`)) {
          return { found: false, url: '' };
        }
        // Extract direct post URL (numeric slug) from the HTML
        const m = res.responseText.match(/href="(https:\/\/japaneseasmr\.com\/\d+\/)"/);
        return { found: true, url: m ? m[1] : searchUrl };
      },
    },

  ];

  // ── STYLES ───────────────────────────────────────────────────────────────────
  const STYLE = `
    .dlsite-alt-badges {
      display: flex;
      flex-wrap: wrap;
      gap: 4px;
      margin-top: 6px;
      list-style: none;
      padding: 0;
    }
    .dlsite-alt-badge {
      display: inline-block;
      padding: 2px 8px;
      border-radius: 10px;
      font-size: 11px;
      font-weight: bold;
      color: #fff !important;
      text-decoration: none !important;
      white-space: nowrap;
      opacity: 0.92;
      line-height: 1.6;
    }
    .dlsite-alt-badge:hover {
      opacity: 1;
      text-decoration: underline !important;
    }
    .dlsite-alt-checking {
      color: #888;
      font-size: 11px;
      margin-top: 6px;
    }
    /* Product detail page: give badges more breathing room */
    .work_buy_main .dlsite-alt-badges,
    .work_buy_main .dlsite-alt-checking {
      margin-top: 16px !important;
      margin-bottom: 8px !important;
      padding-left: 8px !important;
      padding-right: 8px !important;
    }
  `;

  function injectStyles() {
    if (document.getElementById('dlsite-alt-style')) return;
    const el = document.createElement('style');
    el.id = 'dlsite-alt-style';
    el.textContent = STYLE;
    document.head.appendChild(el);
  }

  // ── PAGE PARSERS ─────────────────────────────────────────────────────────────
  // Each item: { card, rjCode, title, target }
  //   card   — element marked data-alt-checked to prevent re-processing
  //   target — element where checking text and badges are injected

  // Wishlist page: one item per article card, badges go in the secondary cell
  // (below the sample button, alongside the buy buttons column).
  // IMPORTANT: marks cards synchronously before returning so MutationObserver
  // callbacks can never re-process the same cards.
  function extractWishlistItems() {
    const cards = document.querySelectorAll('article.one_column_work_item:not([data-alt-checked])');
    const items = [];
    for (const card of cards) {
      const link = card.querySelector('a[href*="/work/=/product_id/"]');
      if (!link) continue;
      const m = link.href.match(/product_id\/((?:RJ|VJ|BJ|RE|VE|BE)\d{5,8})/i);
      if (!m) continue;
      card.setAttribute('data-alt-checked', '1');
      const rjCode = m[1].toUpperCase();
      const title = card.querySelector('dt.work_name')?.textContent?.trim() || rjCode;
      const target = card.querySelector('[role="cell"].secondary');
      if (!target) continue;
      items.push({ card, rjCode, title, target });
    }
    return items;
  }

  // Product detail page: single item, badges go below the buy buttons.
  function extractProductItem() {
    const target = document.querySelector('.work_buy_main');
    if (!target || target.hasAttribute('data-alt-checked')) return [];
    const m = location.href.match(/product_id\/((?:RJ|VJ|BJ|RE|VE|BE)\d{5,8})/i);
    if (!m) return [];
    target.setAttribute('data-alt-checked', '1');
    const rjCode = m[1].toUpperCase();
    const title = document.querySelector('#work_name')?.textContent?.trim() || rjCode;
    return [{ card: target, rjCode, title, target }];
  }

  // ── UI ───────────────────────────────────────────────────────────────────────
  // All UI functions operate on `target` — the element to inject into.
  // This decouples rendering from page structure.

  function showChecking(target) {
    const el = document.createElement('div');
    el.className = 'dlsite-alt-checking';
    el.textContent = CHECKING_TEXT;
    target.appendChild(el);
  }

  // For cache hits: inject all found badges at once.
  function injectBadges(target, results) {
    target.querySelector('.dlsite-alt-checking')?.remove();
    const found = results.filter(r => r.found);
    if (found.length === 0) return;
    const container = document.createElement('div');
    container.className = 'dlsite-alt-badges';
    for (const r of found) {
      const a = document.createElement('a');
      a.className = 'dlsite-alt-badge';
      a.href = r.url;
      a.target = '_blank';
      a.rel = 'noopener noreferrer';
      a.textContent = r.name;
      a.style.backgroundColor = r.color;
      container.appendChild(a);
    }
    target.appendChild(container);
  }

  // ── SITE WORKERS ─────────────────────────────────────────────────────────────
  // Each site gets its own queue. Items are dispatched to all queues immediately.
  // A site worker moves to the next item as soon as it finishes the current one,
  // regardless of what other sites are still doing for that item.

  function createWorker(checker) {
    const queue = []; // [{rjCode, onResult}]
    let busy = false;

    async function drain() {
      busy = true;
      while (queue.length > 0) {
        // Pause while tab is in the background to avoid multi-tab throttling.
        // Resumes automatically when the user switches back to this tab.
        if (document.visibilityState === 'hidden') {
          await new Promise(resolve =>
            document.addEventListener('visibilitychange', resolve, { once: true })
          );
        }
        const { rjCode, onResult } = queue.shift();
        try {
          const { found, url } = await checker.check(rjCode);
          log('log', `${checker.name} | ${rjCode} | ${found ? `found → ${url}` : 'not found'}`);
          onResult({ found, url, name: checker.name, color: checker.color });
        } catch (err) {
          log('warn', `${checker.name} | ${rjCode} | error: ${err.message}`);
          onResult({ found: false, url: '', name: checker.name, color: checker.color });
        }
      }
      busy = false;
    }

    return function enqueue(rjCode, onResult) {
      queue.push({ rjCode, onResult });
      if (!busy) drain();
    };
  }

  const WORKERS = CHECKERS
    .filter(c => c.enabled)
    .map(checker => ({ checker, enqueue: createWorker(checker) }));

  // ── UI (progressive) ─────────────────────────────────────────────────────────
  // Badges are added one at a time as each site responds, rather than all at once.

  function addBadge(target, result) {
    let container = target.querySelector('.dlsite-alt-badges');
    if (!container) {
      container = document.createElement('div');
      container.className = 'dlsite-alt-badges';
      target.appendChild(container);
    }
    const a = document.createElement('a');
    a.className = 'dlsite-alt-badge';
    a.href = result.url;
    a.target = '_blank';
    a.rel = 'noopener noreferrer';
    a.textContent = result.name;
    a.style.backgroundColor = result.color;
    container.appendChild(a);
  }

  // ── MAIN ─────────────────────────────────────────────────────────────────────

  function dispatchItem({ rjCode, target }) {
    // Serve instantly from cache
    const cached = cacheGet(rjCode);
    if (cached) {
      log('log', `${rjCode} | cache hit (${cached.filter(r => r.found).length} found)`);
      injectBadges(target, cached);
      return;
    }

    showChecking(target);

    const allResults = [];
    let remaining = WORKERS.length;

    for (const { enqueue } of WORKERS) {
      enqueue(rjCode, result => {
        allResults.push(result);
        if (result.found) addBadge(target, result); // show badge immediately
        remaining--;
        if (remaining === 0) {
          // All sites done for this item
          target.querySelector('.dlsite-alt-checking')?.remove();
          cacheSet(rjCode, allResults);
        }
      });
    }
  }

  // Dispatch all items to all workers immediately (synchronous).
  // Each worker's queue then drains independently at its own rate.
  function processItems(items) {
    for (const item of items) {
      dispatchItem(item);
    }
  }

  // Debounce observer to avoid rapid re-firing on badge/checking DOM mutations
  let observerTimer = null;
  function scheduleCheck() {
    clearTimeout(observerTimer);
    observerTimer = setTimeout(() => {
      const newItems = extractWishlistItems();
      if (newItems.length > 0) processItems(newItems);
    }, 400);
  }

  function init() {
    injectStyles();
    const isProductPage = /\/work\/=\/product_id\//.test(location.pathname);
    const items = isProductPage ? extractProductItem() : extractWishlistItems();
    if (items.length > 0) processItems(items);

    if (!isProductPage) {
      const observer = new MutationObserver(scheduleCheck);
      observer.observe(document.body, { childList: true, subtree: true });
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

})();