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