Show buttons on DLSite favorites/product pages if item is on free alternative sites
// ==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();
}
})();