DLSite Alt Finder

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();