// ==UserScript== // @name Backloggd Review Collector // @namespace https://github.com/roko-tech/backloggd-reviews-by-language // @version 1.4.0 // @description Collects every review of a Backloggd game written in the language you choose into one sortable panel. // @author roko-tech // @homepageURL https://github.com/roko-tech/backloggd-reviews-by-language // @supportURL https://github.com/roko-tech/backloggd-reviews-by-language/issues // @updateURL https://raw.githubusercontent.com/roko-tech/backloggd-reviews-by-language/main/backloggd-reviews.user.js // @downloadURL https://raw.githubusercontent.com/roko-tech/backloggd-reviews-by-language/main/backloggd-reviews.user.js // @match https://backloggd.com/* // @run-at document-idle // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant unsafeWindow // @noframes // ==/UserScript== (function () { 'use strict'; const PageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const MIN_CONFIDENCE = 0.5; const MIN_ALPHA = 3; const PAGE_INTERVAL_MS = 250; const INTERRUPTION_COOLDOWN_MS = 60_000; const MAX_CONSECUTIVE_INTERRUPTION_RETRIES = 3; const savedLanguages = GM_getValue('languages', null); let languages = Array.isArray(savedLanguages) && savedLanguages.length ? savedLanguages : ['ar']; const normLang = code => String(code || '').trim().toLowerCase().split(/[-_]/)[0]; const NONLATIN = { ar: 'arabic', fa: 'arabic', ur: 'arabic', ps: 'arabic', ckb: 'arabic', he: 'hebrew', iw: 'hebrew', yi: 'hebrew', ru: 'cyrillic', uk: 'cyrillic', bg: 'cyrillic', sr: 'cyrillic', mk: 'cyrillic', be: 'cyrillic', kk: 'cyrillic', el: 'greek', th: 'thai', hi: 'devanagari', mr: 'devanagari', ne: 'devanagari', ko: 'hangul', zh: 'han', ja: 'japanese' }; const scriptForLang = code => NONLATIN[normLang(code)] || 'latin'; let allowedSet, allowedScripts; function rebuildAllowed() { allowedSet = new Set(languages.map(normLang)); allowedScripts = new Set(languages.map(scriptForLang)); if (allowedSet.has('ja')) allowedScripts.add('han'); } rebuildAllowed(); const SCRIPT_RES = { arabic: /\p{Script=Arabic}/gu, hebrew: /\p{Script=Hebrew}/gu, cyrillic: /\p{Script=Cyrillic}/gu, greek: /\p{Script=Greek}/gu, thai: /\p{Script=Thai}/gu, devanagari: /\p{Script=Devanagari}/gu, hangul: /\p{Script=Hangul}/gu, han: /\p{Script=Han}/gu, latin: /\p{Script=Latin}/gu }; function scriptOf(text) { if (/\p{Script=Hiragana}|\p{Script=Katakana}/u.test(text)) return 'japanese'; let best = 'none', bestN = 0; for (const name in SCRIPT_RES) { const count = (text.match(SCRIPT_RES[name]) || []).length; if (count > bestN) { bestN = count; best = name; } } return bestN > 0 ? best : 'none'; } let detector = null; let detectorReady = false; let detectorPending = null; let detectorDeferred = false; async function ensureDetector(viaUserGesture) { if (detectorReady) return detector; if (detectorDeferred && !viaUserGesture) return null; if (detectorPending) return detectorPending; const LanguageDetector = PageWin.LanguageDetector; if (!LanguageDetector) { detectorReady = true; return null; } detectorPending = (async () => { try { const status = await LanguageDetector.availability(); if (status === 'unavailable') { detectorReady = true; return null; } if (status !== 'available' && !viaUserGesture) { detectorPending = null; detectorDeferred = true; if (!ensureDetector._menu) { ensureDetector._menu = true; GM_registerMenuCommand('⬇️ Download accurate language model', () => ensureDetector(true)); } return null; } detector = await LanguageDetector.create(); detectorReady = true; return detector; } catch (error) { console.warn('[BRC] LanguageDetector unavailable, using fallback:', error); detectorReady = true; return null; } })(); return detectorPending; } async function detect(text) { const activeDetector = await ensureDetector(false); if (activeDetector) { try { const result = await activeDetector.detect(text); if (result && result.length) { return { lang: normLang(result[0].detectedLanguage), confidence: result[0].confidence }; } } catch (error) { console.warn('[BRC] detect() failed, using fallback:', error); } } return fallbackDetect(text); } const MAX_CONCURRENT_DETECTIONS = 4; let activeDetections = 0; const detectionQueue = []; function pumpDetectionQueue() { while (activeDetections < MAX_CONCURRENT_DETECTIONS && detectionQueue.length) { activeDetections++; const { text, resolve } = detectionQueue.shift(); detect(text).then( result => { activeDetections--; resolve(result); pumpDetectionQueue(); }, () => { activeDetections--; resolve({ lang: 'und', confidence: 0 }); pumpDetectionQueue(); } ); } } function detectQueued(text) { return new Promise(resolve => { detectionQueue.push({ text, resolve }); pumpDetectionQueue(); }); } const SCRIPT_LANG = { japanese: 'ja', hangul: 'ko', han: 'zh', arabic: 'ar', hebrew: 'he', cyrillic: 'ru', greek: 'el', thai: 'th', devanagari: 'hi' }; const STOPWORDS = { en: ['the', 'and', 'that', 'this', 'with', 'have', 'was', 'but', 'you', 'for', 'not', 'are', 'its', 'what'], es: ['que', 'los', 'las', 'una', 'con', 'por', 'para', 'como', 'pero', 'más', 'muy', 'está', 'este', 'porque'], fr: ['les', 'des', 'une', 'que', 'pas', 'dans', 'est', 'pour', 'qui', 'avec', 'plus', 'mais', 'cette', 'tout'], de: ['und', 'der', 'die', 'das', 'ist', 'nicht', 'ein', 'eine', 'auch', 'aber', 'sich', 'mit', 'wie', 'sehr'], it: ['che', 'non', 'per', 'una', 'con', 'sono', 'come', 'più', 'anche', 'questo', 'perché', 'molto', 'della'], pt: ['que', 'não', 'uma', 'com', 'para', 'mais', 'como', 'mas', 'muito', 'isso', 'este', 'porque', 'também'], nl: ['het', 'een', 'niet', 'dat', 'van', 'met', 'maar', 'ook', 'deze', 'zijn', 'heel', 'omdat', 'voor'] }; function fallbackDetect(text) { const script = scriptOf(text); if (script !== 'latin' && SCRIPT_LANG[script]) { return { lang: SCRIPT_LANG[script], confidence: 0.9 }; } const words = text.toLowerCase().match(/[a-zà-ÿ]+/g) || []; if (words.length < 5) return { lang: 'und', confidence: 0 }; let topLang = 'und', topHits = 0; for (const lang in STOPWORDS) { const stopwords = new Set(STOPWORDS[lang]); let hits = 0; for (const word of words) { if (stopwords.has(word)) hits++; } if (hits > topHits) { topHits = hits; topLang = lang; } } if (topHits === 0) return { lang: 'und', confidence: 0 }; return { lang: topLang, confidence: Math.min(0.85, 0.45 + topHits / words.length) }; } function isMatch(lang, confidence, script) { if (lang && lang !== 'und' && confidence >= MIN_CONFIDENCE) { return allowedSet.has(lang); } return allowedScripts.has(script); } function gameSlug() { const match = location.pathname.match(/^\/games\/([^/]+)(?:\/reviews)?\/?$/); return match ? match[1] : null; } function languageLabel() { return languages.map(normLang).join(', '); } function checkpointKey(slug) { const codes = [...new Set(languages.map(normLang))].sort().join(','); return `crawlCheckpoint:${slug}:${codes}`; } function resultCacheKey(slug) { return checkpointKey(slug).replace(/^crawlCheckpoint:/, 'crawlResults:'); } function reviewIdentity(card) { const link = card.querySelector('.open-review-link, .review-game-link'); const href = link?.getAttribute('href'); if (href) { try { return new URL(href, location.origin).href; } catch (_) { return href; } } const author = ( card.querySelector('.username-link p')?.textContent || card.querySelector('#avatar a[href]')?.getAttribute('href') || '' ).trim(); const date = card.querySelector('time[datetime]')?.getAttribute('datetime') || ''; const text = (card.querySelector('.review-body .card-text')?.textContent || '').trim(); return `${author}\n${date}\n${text}`; } function loadCachedReviews(slug) { const saved = GM_getValue(resultCacheKey(slug), []); if (!Array.isArray(saved)) return []; const loaded = []; const seen = new Set(); const template = document.createElement('template'); for (const entry of saved) { if (!entry || typeof entry.html !== 'string') continue; template.innerHTML = entry.html.trim(); const card = template.content.querySelector('.review-card'); if (!card) continue; const id = typeof entry.id === 'string' && entry.id ? entry.id : reviewIdentity(card); if (!id || seen.has(id)) continue; seen.add(id); loaded.push({ id, html: card.outerHTML, card }); } return loaded; } function saveCachedReviews(slug, reviews) { GM_setValue(resultCacheKey(slug), reviews.map(({ id, html }) => ({ id, html }))); } function normalizeReviewUrl(value, slug) { try { const url = new URL(value, location.origin); const initialPath = `/games/${slug}/reviews/`; if (url.origin !== location.origin || (url.pathname !== initialPath && url.pathname !== '/reviews/fetch/recent.turbo_stream')) { return null; } return url.pathname + url.search; } catch { return null; } } function predictNextReviewUrl(value, slug) { const normalized = normalizeReviewUrl(value, slug); if (!normalized) return null; const url = new URL(normalized, location.origin); const page = Number(url.searchParams.get('page')); if (url.pathname !== '/reviews/fetch/recent.turbo_stream' || !Number.isInteger(page) || page < 1) { return null; } url.searchParams.set('page', String(page + 1)); return normalizeReviewUrl(url.pathname + url.search, slug); } function loadCheckpoint(slug) { const saved = GM_getValue(checkpointKey(slug), null); if (!saved || typeof saved.nextUrl !== 'string' || !Number.isInteger(saved.scanned) || saved.scanned < 0) { return null; } const nextUrl = normalizeReviewUrl(saved.nextUrl, slug); return nextUrl ? { nextUrl, scanned: saved.scanned } : null; } let panel = null; function openPanel() { if (panel) panel.remove(); panel = document.createElement('section'); panel.id = 'brc-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.setAttribute('aria-labelledby', 'brc-panel-title'); panel.innerHTML = '
' + '
' + 'Reviews' + '' + '
' + '' + '' + '
' + '' + '' + '
' + '
' + '
'; document.body.appendChild(panel); panel.querySelector('#brc-language').textContent = languageLabel(); const list = panel.querySelector('#brc-panel-list'); if (allowedScripts.has('arabic') || allowedScripts.has('hebrew')) { list.classList.add('brc-rtl'); } return { status: panel.querySelector('#brc-panel-status'), list, stop: panel.querySelector('#brc-panel-stop'), close: panel.querySelector('#brc-panel-close'), sort: panel.querySelector('#brc-sort') }; } let collectButton = null; function updateCollectButton() { if (collectButton) collectButton.textContent = `📚 Collect ${languageLabel()} reviews`; } function syncCollectButton() { if (collectButton && !collectButton.isConnected) collectButton = null; if (!gameSlug()) { if (collectButton) collectButton.remove(); collectButton = null; return; } if (collectButton) return; collectButton = document.createElement('div'); collectButton.id = 'brc-collect'; collectButton.title = 'Collect all matching reviews across every page'; collectButton.addEventListener('click', collectAllReviews); document.body.appendChild(collectButton); updateCollectButton(); } function parseReviewResponse(html, isTurboStream) { const doc = new DOMParser().parseFromString(html, 'text/html'); const streams = [...doc.querySelectorAll('turbo-stream')]; const valid = isTurboStream ? streams.some(stream => stream.querySelector('template')) : Boolean(doc.querySelector('#reviews-results')); const templates = streams.map(stream => stream.querySelector('template')).filter(Boolean); const roots = templates.length ? templates.map(template => template.content) : [doc]; const cards = roots.flatMap(root => [...root.querySelectorAll('.review-card')]); let nextUrl = null; for (const root of roots) { const frame = root.querySelector('turbo-frame#pagination'); if (frame) { nextUrl = frame.getAttribute('src'); break; } } return { cards, nextUrl, valid }; } async function fetchReviewPage(url, signal) { try { const requestedUrl = new URL(url, location.origin); const isTurboStream = requestedUrl.pathname.endsWith('.turbo_stream'); const response = await fetch(requestedUrl.href, { credentials: 'same-origin', cache: 'no-store', signal, headers: { Accept: isTurboStream ? 'text/vnd.turbo-stream.html, text/html' : 'text/html, application/xhtml+xml' } }); if ([403, 429, 503].includes(response.status)) { return { ok: false, interrupted: true }; } if (!response.ok) return { ok: false }; if (response.redirected) { const finalUrl = new URL(response.url, location.origin); if (finalUrl.origin !== requestedUrl.origin || finalUrl.pathname !== requestedUrl.pathname) { return { ok: false, interrupted: true }; } } const parsed = parseReviewResponse(await response.text(), isTurboStream); if (!parsed.valid) return { ok: false, interrupted: true }; return { ok: true, cards: parsed.cards, nextUrl: parsed.nextUrl }; } catch (error) { if (error?.name === 'AbortError') return { ok: false, aborted: true }; console.warn('[BRC] Review page request failed:', error); return { ok: false }; } } async function judgeCard(card) { const body = card.querySelector('.review-body .card-text'); if (!body) return null; const text = (body.textContent || '').replace(/\s+/g, ' ').trim(); const script = scriptOf(text); if (!allowedScripts.has(script)) return null; let lang = 'und', confidence = 0; if (text.replace(/[^\p{L}]/gu, '').length >= MIN_ALPHA) { ({ lang, confidence } = await detectQueued(text)); } if (!isMatch(lang, confidence, script)) return null; const starsTop = card.querySelector('.star-ratings-static .stars-top'); const ratingWidth = parseFloat(starsTop?.style.width || ''); card.dataset.brcRating = Number.isFinite(ratingWidth) ? String(Math.round(ratingWidth / 10)) : '0'; card.dataset.brcLen = String(text.length); const date = Date.parse(card.querySelector('time[datetime]')?.getAttribute('datetime') || ''); card.dataset.brcDate = String(Number.isFinite(date) ? date : 0); return card; } function prepareClone(card, index) { const review = document.createElement('article'); review.className = 'brc-review-card'; review.dataset.brcIdx = String(index); review.dataset.brcRating = card.dataset.brcRating || '0'; review.dataset.brcLen = card.dataset.brcLen || '0'; review.dataset.brcDate = card.dataset.brcDate || '0'; review.innerHTML = '
' + '' + '' + '' + '
' + '' + '
' + '' + '♥' + '' + '' + '
' + '
' + '' + '
' + '
' + '
' + 'This review contains spoilers.' + '' + '
' + '
' + '' + '
' + '
' + '' + '' + 'Open review ↗' + '
'; const setLink = (target, source) => { const href = source?.getAttribute('href'); if (href) target.setAttribute('href', href); else target.removeAttribute('href'); }; const profileLink = card.querySelector('#avatar a[href], a[href^="/u/"]'); const avatar = card.querySelector('#avatar img'); const author = ( card.querySelector('.username-link p')?.textContent || profileLink?.getAttribute('href')?.split('/').filter(Boolean).pop() || 'Backloggd user' ).trim(); const avatarLink = review.querySelector('.brc-avatar-link'); const authorLink = review.querySelector('.brc-author'); setLink(avatarLink, profileLink); setLink(authorLink, profileLink); authorLink.textContent = author; review.querySelector('.brc-avatar-fallback').textContent = author.slice(0, 1).toUpperCase(); const avatarUrl = avatar?.getAttribute('src') || avatar?.getAttribute('data-src'); if (avatarUrl) { const image = document.createElement('img'); image.src = avatarUrl; image.alt = ''; image.loading = 'lazy'; avatarLink.replaceChildren(image); } const rawRating = parseInt(card.dataset.brcRating || '0', 10); const rating = review.querySelector('.brc-rating'); if (rawRating > 0) { const stars = rawRating / 2; rating.textContent = `${Number.isInteger(stars) ? stars : stars.toFixed(1)} ★`; } else { rating.remove(); } if (!card.querySelector('.game-liked')) review.querySelector('.brc-liked').remove(); const status = (card.querySelector('.game-status .play-type')?.textContent || '').trim(); const statusElement = review.querySelector('.brc-status'); if (status) statusElement.textContent = status; else statusElement.remove(); const platform = ( card.querySelector('a[href*="played_platform"] p')?.textContent || card.querySelector('a[href*="played_platform"]')?.textContent || '' ).trim(); const platformElement = review.querySelector('.brc-platform'); if (platform) platformElement.textContent = platform; else platformElement.remove(); const sourceTime = card.querySelector('time[datetime]'); const time = review.querySelector('.brc-date'); const datetime = sourceTime?.getAttribute('datetime') || ''; time.dateTime = datetime; time.textContent = (sourceTime?.textContent || '').trim(); if (!time.textContent && datetime) { const date = new Date(datetime); if (Number.isFinite(date.getTime())) { time.textContent = date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } } const sourceBody = card.querySelector('.review-body .card-text'); const text = review.querySelector('.brc-review-text'); for (const node of sourceBody?.childNodes || []) { text.appendChild(document.importNode(node, true)); } for (const element of text.querySelectorAll('[id], [data-toggle], [aria-controls]')) { element.removeAttribute('id'); element.removeAttribute('data-toggle'); element.removeAttribute('aria-controls'); } for (const link of text.querySelectorAll('a[href]')) { link.target = '_blank'; link.rel = 'noopener noreferrer'; } const expand = review.querySelector('.brc-expand'); const longReview = (sourceBody?.textContent || '').trim().length > 500 || (sourceBody?.querySelectorAll('br').length || 0) >= 4; if (longReview) text.classList.add('brc-collapsed'); else expand.remove(); if (longReview) { expand.addEventListener('click', () => { const collapsed = text.classList.toggle('brc-collapsed'); expand.textContent = collapsed ? 'Show full review' : 'Show less'; expand.setAttribute('aria-expanded', String(!collapsed)); }); } const spoiler = Boolean(card.querySelector('.spoiler-warning')); const spoilerNote = review.querySelector('.brc-spoiler-note'); if (spoiler) { text.classList.add('brc-spoiler-hidden'); if (longReview) expand.hidden = true; spoilerNote.querySelector('button').addEventListener('click', () => { spoilerNote.remove(); text.classList.remove('brc-spoiler-hidden'); if (longReview) expand.hidden = false; }); } else { spoilerNote.remove(); } const likesSource = card.querySelector('.like-counter a'); const likes = review.querySelector('.brc-likes'); setLink(likes, likesSource); likes.textContent = `♥ ${(likesSource?.textContent || '0 Likes').trim()}`; const commentsSource = card.querySelector('.comments-link'); const comments = review.querySelector('.brc-comments'); setLink(comments, commentsSource); const commentCount = (commentsSource?.textContent || '0').trim(); comments.textContent = `● ${commentCount} comments`; const openSource = card.querySelector('.open-review-link, .review-game-link'); setLink(review.querySelector('.brc-open'), openSource); return review; } function sortPanel(list, key) { const num = (card, name) => parseInt(card.dataset[name] || '0', 10); const tie = (a, b) => num(a, 'brcIdx') - num(b, 'brcIdx'); const compare = { default: (a, b) => tie(a, b), 'date-desc': (a, b) => num(b, 'brcDate') - num(a, 'brcDate') || tie(a, b), 'date-asc': (a, b) => num(a, 'brcDate') - num(b, 'brcDate') || tie(a, b), 'rating-desc': (a, b) => num(b, 'brcRating') - num(a, 'brcRating') || tie(a, b), 'rating-asc': (a, b) => num(a, 'brcRating') - num(b, 'brcRating') || tie(a, b), 'len-desc': (a, b) => num(b, 'brcLen') - num(a, 'brcLen') || tie(a, b), 'len-asc': (a, b) => num(a, 'brcLen') - num(b, 'brcLen') || tie(a, b) }[key]; if (!compare) return; [...list.querySelectorAll('.brc-review-card')] .sort(compare) .forEach(card => list.appendChild(card)); } let collecting = false; async function collectAllReviews() { if (collecting) return; const slug = gameSlug(); if (!slug) { alert('Open a game page on Backloggd first.'); return; } collecting = true; if (collectButton) collectButton.textContent = `📚 Collecting ${languageLabel()}…`; const ui = openPanel(); const key = checkpointKey(slug); const cacheKey = resultCacheKey(slug); const checkpoint = loadCheckpoint(slug); const cachedReviews = checkpoint ? loadCachedReviews(slug) : []; const knownReviewIds = new Set(cachedReviews.map(review => review.id)); const activeControllers = new Set(); let stopped = false; let found = cachedReviews.length; let runFound = 0; let scanned = checkpoint ? checkpoint.scanned : 0; let runScanned = 0; let interrupted = false; let requestFailed = false; let completed = false; if (!checkpoint) GM_setValue(cacheKey, null); for (const saved of cachedReviews) { ui.list.appendChild(prepareClone(saved.card, ui.list.children.length)); } if (checkpoint) { ui.status.textContent = cachedReviews.length ? `Restored ${cachedReviews.length} saved review${cachedReviews.length === 1 ? '' : 's'}; resuming at page ${scanned + 1}…` : `Resuming at page ${scanned + 1}…`; } const stopCollection = () => { stopped = true; for (const controller of activeControllers) controller.abort(); }; const waitForInterruptionCooldown = async attempt => { const deadline = Date.now() + INTERRUPTION_COOLDOWN_MS; while (!stopped) { const remainingMs = deadline - Date.now(); if (remainingMs <= 0) return true; if (panel) { const seconds = Math.ceil(remainingMs / 1000); ui.status.textContent = `⚠️ Backloggd paused before page ${scanned + 1}. ` + `Automatic retry ${attempt} of ${MAX_CONSECUTIVE_INTERRUPTION_RETRIES} in ${seconds}s. ` + `${found} review${found === 1 ? '' : 's'} preserved.`; } await sleep(Math.min(1000, remainingMs)); } return false; }; ui.stop.addEventListener('click', stopCollection); ui.close.addEventListener('click', () => { stopCollection(); if (panel) { panel.remove(); panel = null; } }); ui.sort.addEventListener('change', () => sortPanel(ui.list, ui.sort.value)); try { let nextUrl = checkpoint ? checkpoint.nextUrl : `/games/${slug}/reviews/`; let nextRequestStartAt = 0; let consecutiveInterruptions = 0; let pending = []; const visitedUrls = new Set(); const pacedFetch = async (url, controller) => { const now = Date.now(); const startAt = Math.max(now, nextRequestStartAt); nextRequestStartAt = startAt + PAGE_INTERVAL_MS; if (startAt > now) await sleep(startAt - now); if (stopped || controller.signal.aborted) { return { ok: false, aborted: true }; } return fetchReviewPage(url, controller.signal); }; const launch = url => { const absoluteUrl = new URL(url, location.origin).href; if (visitedUrls.has(absoluteUrl)) return null; visitedUrls.add(absoluteUrl); const controller = new AbortController(); activeControllers.add(controller); const promise = pacedFetch(url, controller) .finally(() => activeControllers.delete(controller)); return { url, absoluteUrl, controller, promise }; }; const cancelPending = forget => { for (const item of pending) { item.controller.abort(); if (forget) visitedUrls.delete(item.absoluteUrl); } pending = []; }; const refill = expectedUrl => { const expectedAbsolute = new URL(expectedUrl, location.origin).href; if (pending.length && pending[0].absoluteUrl !== expectedAbsolute) { cancelPending(true); } if (!pending.length) { const current = launch(expectedUrl); if (!current) return false; pending.push(current); } while (pending.length < 2) { const predicted = predictNextReviewUrl(pending[pending.length - 1].url, slug); if (!predicted) break; const item = launch(predicted); if (!item) break; pending.push(item); } return true; }; while (nextUrl && !stopped) { if (!refill(nextUrl)) { requestFailed = true; break; } const current = pending.shift(); const page = await current.promise; if (stopped) { cancelPending(false); break; } if (page.interrupted) { cancelPending(true); visitedUrls.delete(current.absoluteUrl); consecutiveInterruptions++; if (consecutiveInterruptions <= MAX_CONSECUTIVE_INTERRUPTION_RETRIES) { const cooledDown = await waitForInterruptionCooldown(consecutiveInterruptions); if (!cooledDown) break; nextRequestStartAt = 0; if (panel) ui.status.textContent = `Retrying page ${scanned + 1}…`; continue; } interrupted = true; break; } if (!page.ok) { cancelPending(false); requestFailed = true; break; } consecutiveInterruptions = 0; const followingUrl = page.nextUrl ? normalizeReviewUrl(page.nextUrl, slug) : null; if (page.nextUrl && !followingUrl) { cancelPending(false); requestFailed = true; break; } scanned++; runScanned++; nextUrl = followingUrl; if (nextUrl) { if (!refill(nextUrl)) requestFailed = true; } else { cancelPending(false); } const judged = await Promise.all(page.cards.map(judgeCard)); for (const card of judged) { if (!card) continue; const id = reviewIdentity(card); if (!id || knownReviewIds.has(id)) continue; knownReviewIds.add(id); cachedReviews.push({ id, html: card.outerHTML }); ui.list.appendChild(prepareClone(card, found)); found++; runFound++; } saveCachedReviews(slug, cachedReviews); GM_setValue(key, nextUrl ? { nextUrl, scanned } : null); completed = !nextUrl; if (completed) GM_setValue(cacheKey, null); if (panel) { ui.status.textContent = `Scanned ${scanned} page${scanned === 1 ? '' : 's'} total … ` + `${found} found (${runFound} new this run)`; } if (requestFailed) break; } } finally { for (const controller of activeControllers) controller.abort(); collecting = false; updateCollectButton(); } if (!panel) return; ui.stop.style.display = 'none'; if (interrupted) { ui.status.textContent = `⚠️ Backloggd kept interrupting before page ${scanned + 1}. ` + `Automatic retries stopped; progress and ${found} review${found === 1 ? '' : 's'} are saved. ` + `Run it again later to resume.`; } else if (requestFailed) { ui.status.textContent = `⚠️ A request failed before page ${scanned + 1}. ` + `Progress and ${found} review${found === 1 ? '' : 's'} are saved; run it again to resume.`; } else if (stopped && !completed) { ui.status.textContent = `Stopped before page ${scanned + 1}. Progress and ` + `${found} review${found === 1 ? '' : 's'} are saved.`; } else { ui.status.textContent = `Done — ${found} review${found === 1 ? '' : 's'} total ` + `(${runFound} new this run; scanned ${runScanned} page${runScanned === 1 ? '' : 's'} this run, ` + `${scanned} total).`; } if (!found) { ui.list.innerHTML = completed ? '

No matching reviews found.

' : '

No matching reviews found yet. The crawl is incomplete; run it again to resume.

'; } } function registerMenu() { GM_registerMenuCommand('🌐 Set languages…', () => { const input = prompt( 'Collect ONLY these languages (comma-separated BCP-47 codes).\n' + 'Examples: ar | en | ja | ar, en', languages.join(', ') ); if (input == null) return; const selected = input.split(',').map(normLang).filter(Boolean); if (!selected.length) return; languages = selected; GM_setValue('languages', selected); rebuildAllowed(); updateCollectButton(); }); GM_registerMenuCommand('📚 Collect reviews (all pages)', collectAllReviews); GM_registerMenuCommand('↺ Restart saved crawl for this game', () => { const slug = gameSlug(); if (!slug) { alert('Open a game page on Backloggd first.'); return; } GM_setValue(checkpointKey(slug), null); GM_setValue(resultCacheKey(slug), null); alert(`Saved ${languageLabel()} crawl progress and reviews cleared for this game.`); }); } GM_addStyle(` #brc-collect { position: fixed; right: 16px; bottom: 16px; z-index: 2147483646; padding: 10px 14px; border: 1px solid #7185b4; border-radius: 999px; background: #4a5e8d; color: #fff; box-shadow: 0 4px 18px rgba(0, 0, 0, .45); cursor: pointer; user-select: none; font: 700 12px/1 Roboto, "Noto Sans", sans-serif; } #brc-collect:hover { background: #5b70a3; } #brc-collect:focus-visible, #brc-panel button:focus-visible, #brc-panel select:focus-visible, #brc-panel a:focus-visible { outline: 2px solid #8eb0ff; outline-offset: 2px; } #brc-panel { position: fixed; inset: 3vh auto; left: 50%; z-index: 2147483647; display: flex; width: min(1100px, calc(100vw - 32px)); transform: translateX(-50%); flex-direction: column; overflow: hidden; border: 1px solid #303946; border-radius: 14px; background: #10151b; box-shadow: 0 0 0 100vmax rgba(4, 7, 11, .72), 0 24px 70px rgba(0, 0, 0, .65); color: #dbe5ef; font-family: Roboto, "Noto Sans", "Segoe UI", sans-serif; } #brc-panel-bar { display: flex; flex: 0 0 auto; align-items: center; gap: 14px; min-height: 64px; padding: 11px 14px; border-bottom: 1px solid #29313c; background: #151b22; } .brc-panel-heading { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; } #brc-panel-title { color: #f5f7fa; font-size: 18px; font-weight: 750; letter-spacing: -.01em; } #brc-language { padding: 4px 8px; border: 1px solid #405271; border-radius: 999px; background: #24314a; color: #bcd0ff; font-size: 11px; font-weight: 800; line-height: 1; text-transform: uppercase; } .brc-sort-label { display: flex; flex: 0 0 auto; align-items: center; gap: 7px; color: #8f9cab; font-size: 12px; font-weight: 700; } #brc-sort { min-height: 34px; padding: 0 30px 0 10px; border: 1px solid #3a4655; border-radius: 8px; background: #202833; color: #edf2f7; cursor: pointer; font: 600 12px/1 Roboto, "Noto Sans", sans-serif; } #brc-panel-status { flex: 1 1 260px; min-width: 0; color: #9db2c8; font-size: 12px; font-weight: 500; line-height: 1.35; } .brc-panel-actions { display: flex; flex: 0 0 auto; gap: 8px; } #brc-panel-bar button { min-height: 34px; padding: 0 12px; border: 1px solid #3b4858; border-radius: 8px; background: #242d38; color: #edf2f7; cursor: pointer; font: 700 12px/1 Roboto, "Noto Sans", sans-serif; } #brc-panel-bar button:hover, #brc-sort:hover { border-color: #596b82; background: #303b49; } #brc-panel-close { width: 36px; padding: 0 !important; font-size: 15px !important; } #brc-panel-list { display: grid; flex: 1 1 auto; align-content: start; gap: 12px; overflow: auto; overscroll-behavior: contain; padding: 18px; scrollbar-color: #465465 #151b22; } .brc-review-card { min-width: 0; padding: 16px 17px 14px; border: 1px solid #29333e; border-radius: 12px; background: #171d24; box-shadow: 0 1px 0 rgba(255, 255, 255, .025); color: #d7e0e9; direction: ltr; } .brc-review-card:hover { border-color: #3a4858; background: #192129; } .brc-review-head { display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; align-items: center; gap: 11px; } .brc-avatar-link { display: grid; width: 46px; height: 46px; place-items: center; overflow: hidden; border: 1px solid #354251; border-radius: 11px; background: #26303b; color: #a9bdd1; text-decoration: none; } .brc-avatar-link img { display: block; width: 100%; height: 100%; object-fit: cover; } .brc-avatar-fallback { font-size: 18px; font-weight: 800; } .brc-review-identity { min-width: 0; } .brc-author { display: block; overflow: hidden; color: #f4f7fa; font-size: 14px; font-weight: 800; line-height: 1.35; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; } .brc-author:hover, .brc-review-actions a:hover { color: #9ebaff; } .brc-review-meta { display: flex; align-items: center; gap: 7px; min-width: 0; margin-top: 5px; color: #93a1b0; font-size: 11px; line-height: 1.2; } .brc-rating, .brc-status, .brc-platform { display: inline-flex; align-items: center; min-height: 20px; padding: 0 7px; border-radius: 999px; background: #222b35; white-space: nowrap; } .brc-rating { background: #372538; color: #ff82b2; font-weight: 800; } .brc-liked { color: #82a9ff; font-size: 13px; } .brc-status::before { width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #4ecb72; content: ""; } .brc-platform { overflow: hidden; max-width: 190px; text-overflow: ellipsis; } .brc-date { align-self: start; padding-top: 2px; color: #788696; font-size: 11px; white-space: nowrap; } .brc-review-content { margin-top: 14px; } .brc-review-text { position: relative; color: #e1e7ed; overflow-wrap: anywhere; font: 400 16px/1.85 Tahoma, "Noto Sans Arabic", "Segoe UI", sans-serif; white-space: normal; } #brc-panel-list.brc-rtl .brc-review-text { direction: rtl; text-align: right; unicode-bidi: plaintext; } .brc-review-text.brc-collapsed { max-height: 14.8em; overflow: hidden; -webkit-mask-image: linear-gradient(to bottom, #000 74%, transparent 100%); mask-image: linear-gradient(to bottom, #000 74%, transparent 100%); } .brc-review-text.brc-spoiler-hidden { display: none; } .brc-expand { display: block; margin: 9px 0 0 auto; padding: 5px 0; border: 0; background: transparent; color: #8faeff; cursor: pointer; font: 700 12px/1.2 Roboto, "Noto Sans", sans-serif; } .brc-expand:hover { color: #bfd0ff; } .brc-spoiler-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 14px; border: 1px dashed #5b4b62; border-radius: 9px; background: #211b24; color: #c8b9cd; font-size: 13px; } .brc-spoiler-note button { flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid #68546d; border-radius: 7px; background: #35283a; color: #f1e6f3; cursor: pointer; font: 700 12px/1 Roboto, "Noto Sans", sans-serif; } .brc-review-actions { display: flex; align-items: center; gap: 15px; margin-top: 13px; padding-top: 11px; border-top: 1px solid #252e38; color: #8796a6; direction: ltr; font-size: 11px; } .brc-review-actions a { color: #8796a6; text-decoration: none; } .brc-open { margin-left: auto; color: #9ebaff !important; font-weight: 800; } .brc-empty { margin: 0; padding: 48px 20px; border: 1px dashed #33404d; border-radius: 12px; color: #91a2b4; text-align: center; font-size: 14px; } @media (max-width: 700px) { #brc-panel { inset: 0; left: 0; width: 100vw; transform: none; border: 0; border-radius: 0; } #brc-panel-bar { min-height: 0; flex-wrap: wrap; gap: 10px; padding: 10px; } #brc-panel-title { font-size: 16px; } .brc-sort-label span { display: none; } #brc-sort { max-width: 155px; } #brc-panel-status { order: 4; flex-basis: 100%; } .brc-panel-actions { margin-left: auto; } #brc-panel-list { gap: 9px; padding: 10px; } .brc-review-card { padding: 13px 13px 12px; border-radius: 10px; } .brc-review-head { grid-template-columns: 40px minmax(0, 1fr); gap: 9px; } .brc-avatar-link { width: 40px; height: 40px; border-radius: 9px; } .brc-date { grid-column: 2; grid-row: 2; padding: 0; } .brc-review-meta { flex-wrap: wrap; } .brc-platform { max-width: 140px; } .brc-review-text { font-size: 15px; line-height: 1.8; } .brc-review-actions { flex-wrap: wrap; gap: 10px 14px; } } @media (max-width: 430px) { .brc-panel-heading { margin-right: auto; } .brc-sort-label { order: 3; } .brc-panel-actions { order: 2; } #brc-panel-status { order: 4; } .brc-open { flex-basis: 100%; margin-left: 0; } } `); function init() { registerMenu(); syncCollectButton(); ensureDetector(false); document.addEventListener('turbo:load', syncCollectButton); } if (document.body) init(); else document.addEventListener('DOMContentLoaded', init, { once: true }); })();