// ==UserScript== // @name F95 BRATR Management Ratings Helper // @namespace Ryahn // @version 1.5.1 // @description Triage panel for /bratr-ratings/management: highlight low-effort reviews, filter, export CSV. // @match https://f95zone.to/bratr-ratings/management* // @match https://f95zone.to/bratr-ratings/*/management* // @match https://f95zone.to/threads/*/br-reviews/* // @run-at document-idle // @grant GM_setClipboard // ==/UserScript== (function () { const $ = (sel, root=document) => root.querySelector(sel); const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel)); const text = el => (el ? el.textContent.replace(/\s+/g,' ').trim() : ''); const getRating = el => { const rs = el.querySelector('.ratingStars.bratr-rating'); if (!rs) return null; const t = rs.getAttribute('title') || ''; const m = t.match(/([\d.]+)\s*star/i); if (m) return parseFloat(m[1]); return rs.querySelectorAll('.ratingStars-star--full').length || null; }; const csvEscape = s => `"${(s||'').replace(/"/g,'""')}"`; const avg = arr => arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : 0; const truncate = (s, n=80) => (s && s.length > n ? s.slice(0, n-1) + '…' : (s||'')); // Scrape reviews on page; stash stable index i for row->DOM mapping const reviews = $$('.message--review').map((msg, i) => { const main = msg.querySelector('.contentRow-main'); const authorA = main?.querySelector('.contentRow-header a.username'); const author = text(authorA); const rating = getRating(main); const threadA = main?.querySelector('dl.pairs dd a'); const thread = text(threadA); const threadUrl = threadA?.href || ''; const timeEl = main?.querySelector('time.u-dt'); const timeIso = timeEl?.getAttribute('datetime') || ''; const timeTxt = timeEl?.getAttribute('title') || text(timeEl); // Body excluding spoiler contents const bodyEl = main?.querySelector('article .bbWrapper'); let bodyTxt = ''; if (bodyEl) { const clone = bodyEl.cloneNode(true); clone.querySelectorAll('.bbCodeSpoiler-content').forEach(s => s.remove()); bodyTxt = text(clone); } const deleted = msg.classList.contains('message--deleted'); const links = { report: main?.querySelector('a.actionBar-action--report')?.href || '', edit: main?.querySelector('a.actionBar-action--edit')?.href || '', del: main?.querySelector('a.actionBar-action--delete')?.href || '', warn: main?.querySelector('a.actionBar-action--warn')?.href || '' }; // Raw vs effective length (collapse punctuation carpets) const rawLen = (bodyTxt || '').length; const effBody = (bodyTxt || '') .replace(/([.!?…,_\-])\1{2,}/g, '$1$1') // collapse carpets to 2 .replace(/\s{2,}/g, ' ') .trim(); const effLen = effBody.length; return { i, node: msg, author, rating, thread, threadUrl, timeIso, timeTxt, bodyTxt, rawLen, effLen, deleted, links }; }); // Cliché patterns (includes fixed 200 detector) const cliché = [ { key:'good game', label:'“good game”', rx:/\bgood game\b/i }, { key:'very good', label:'“it was very good”', rx:/\bit was very good\b/i }, { key:'amazing', label:'“amazing”', rx:/\bamazing!?(\b|$)/i }, { key:'top N', label:'“top ”', rx:/\btop\s+\d+\b/i }, { key:'pretty sex scenes', label:'“pretty sex scenes”', rx:/\bpretty sex scenes\b/i }, { key:'downloads flex', label:'“downloaded hundreds of games”', rx:/\bdownload(ed)? hundreds of games\b/i }, { key:'mentions 200', label:'mentions “200” (char/limit/min)', rx:/\b200(?:\s*[- ]?(?:char(?:s|acters?)?|word(?:s)?|limit|minimum|min))?\b/i } ]; // Spam/bypass heuristics function analyzeSpam(body) { const reasons = []; const s = body || ''; if (!s) return reasons; // Punctuation carpets: 6+ of the same mark const punctRuns = s.match(/([.!?…,_\-])\1{5,}/g) || []; if (punctRuns.length) { const totalRunChars = punctRuns.reduce((a,b)=>a+b.length,0); reasons.push({ code: 'SPAM', text: `DOTCARPET ×${punctRuns.length}`, tip: `Punctuation runs total ${totalRunChars} chars` }); } // Punctuation ratio const punctChars = (s.match(/[^\w\s]/g) || []).length; const pRatio = s.length ? punctChars / s.length : 0; if (pRatio > 0.35 && s.length >= 120) { reasons.push({ code: 'SPAM', text: `PUNC ${Math.round(pRatio*100)}%`, tip: `Non-alphanumeric ratio ${Math.round(pRatio*100)}%` }); } // Tokenization const tokens = s.toLowerCase().match(/[a-z0-9']+/g) || []; const totalTok = tokens.length; if (totalTok) { const freq = new Map(); tokens.forEach(t => freq.set(t, (freq.get(t)||0)+1)); const uniq = freq.size; const uniqRatio = uniq / totalTok; if (uniqRatio < 0.5 && totalTok >= 10) { reasons.push({ code: 'SPAM', text: `LOW_UNIQUE ${uniq}/${totalTok}`, tip: `Unique/token ratio ${uniqRatio.toFixed(2)}` }); } // Single-word dominance let maxWord = '', maxCount = 0; for (const [w,c] of freq.entries()) if (c > maxCount) { maxCount = c; maxWord = w; } if (maxCount / totalTok > 0.4 && totalTok >= 8) { reasons.push({ code: 'SPAM', text: `REP "${truncate(maxWord,12)}" ×${maxCount}`, tip: `Word appears ${(maxCount/totalTok*100).toFixed(0)}% of tokens` }); } // Repeated n-grams (bi/tri) function ngrams(n) { const m = new Map(); for (let i=0;i<=tokens.length-n;i++){ const g = tokens.slice(i,i+n).join(' '); m.set(g,(m.get(g)||0)+1); } return m; } [2,3].forEach(n => { const m = ngrams(n); for (const [g,c] of m.entries()) { if (c >= 3) { reasons.push({ code: 'SPAM', text: `REP${n} "${truncate(g,20)}" ×${c}`, tip: `Repeated ${n}-gram ${c} times` }); break; } } }); // Repeated very short sentences (<=4 words) 3+ times const sentences = s.split(/(?<=[.!?])\s+/); const shortFreq = new Map(); sentences.forEach(x => { const toks = (x.toLowerCase().match(/[a-z0-9']+/g) || []); if (toks.length && toks.length <= 4) { const key = toks.join(' '); shortFreq.set(key, (shortFreq.get(key)||0)+1); } }); for (const [key,c] of shortFreq.entries()) { if (c >= 3) { reasons.push({ code: 'SPAM', text: `SHORT_SENT "${truncate(key,18)}" ×${c}`, tip: `Short sentence repeated ${c} times` }); break; } } } return reasons; } // Diagnose low-effort function diagnoseLowEffort(r, cutoff=220) { const reasons = []; const body = r.bodyTxt || ''; if (!r.deleted && (r.effLen || 0) < cutoff) { reasons.push({ code:'LEN', text:`LEN ${r.effLen}<${cutoff}`, tip:`Effective length ${r.effLen} is below cutoff ${cutoff}` }); } if (!r.deleted) { cliché.forEach(c => { const m = body.match(c.rx); if (m) reasons.push({ code:'CLICHÉ', text:`CLICHÉ: "${truncate(m[0], 28)}"`, tip:`Matched: ${m[0]}`, key:c.key }); }); } analyzeSpam(body).forEach(sr => reasons.push(sr)); if (r.deleted) reasons.push({ code:'DELETED', text:'DELETED', tip:'Author deleted review' }); const low = reasons.some(x => x.code === 'LEN' || x.code === 'CLICHÉ' || x.code === 'SPAM'); return { low, reasons }; } // Panel const panel = document.createElement('div'); panel.id = 'bratr-helper'; panel.innerHTML = `
BRATR Helper
`; document.body.appendChild(panel); // Styles const css = document.createElement('style'); css.textContent = ` #bratr-helper{position:fixed;right:12px;bottom:12px;width:820px;max-height:70vh;z-index:99999; background:#111;border:1px solid #333;border-radius:12px;color:#ddd;font:12px/1.4 system-ui;box-shadow:0 8px 24px rgba(0,0,0,.4)} #bratr-helper .bh-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-bottom:1px solid #2a2a2a} #bratr-helper .bh-head strong{font-size:13px} #bratr-helper .bh-metrics{opacity:.8} #bratr-helper .bh-collapse{background:#222;border:1px solid #444;border-radius:8px;color:#ddd;padding:2px 8px;cursor:pointer} #bratr-helper .bh-body{padding:8px} #bratr-helper .bh-controls{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px} #bratr-helper .bh-controls label{display:flex;gap:6px;align-items:center;background:#161616;border:1px solid #2a2a2a;border-radius:8px;padding:4px 8px} #bratr-helper input, #bratr-helper select{background:#0f0f0f;border:1px solid #333;color:#ddd;border-radius:6px;padding:3px 6px} #bratr-helper button.bh-copy,#bratr-helper button.bh-reset{background:#1e1e1e;border:1px solid #444;color:#ddd;border-radius:8px;padding:4px 10px;cursor:pointer} #bratr-helper .bh-table{overflow:auto;max-height:48vh;border:1px solid #2a2a2a;border-radius:8px} #bratr-helper table{width:100%;border-collapse:collapse} #bratr-helper th,#bratr-helper td{padding:6px 8px;border-bottom:1px solid #232323;vertical-align:top} #bratr-helper tr[data-i]{cursor:pointer} #bratr-helper tr[data-i]:hover td{background:#18130a} #bratr-helper tr.low td{background:rgba(255,180,0,.08)} #bratr-helper tr.deleted td{opacity:.65} #bratr-helper .pill{font-size:11px;display:inline-block;padding:1px 6px;border-radius:999px;border:1px solid #444;margin-right:6px;white-space:nowrap} #bratr-helper .pill.low{border-color:#f5b400;color:#f5b400} #bratr-helper .pill.one{border-color:#ff5b5b;color:#ff5b5b} #bratr-helper .pill.delet{border-color:#8aa;color:#8aa} #bratr-helper .pill.spam{border-color:#ff9d00;color:#ff9d00} #bratr-helper .links a{margin-right:8px} @keyframes bh-pulse {0%{box-shadow:0 0 0 0 rgba(245,180,0,.6);}100%{box-shadow:0 0 0 12px rgba(245,180,0,0);}} .bh-target-pulse { animation: bh-pulse 1s ease-out 0s 2; outline: 2px solid #f5b400 !important; } @media (max-width: 880px){#bratr-helper{left:8px;right:8px;width:auto}} `; document.head.appendChild(css); const ui = { min: panel.querySelector('.bh-minrating'), maxlen: panel.querySelector('.bh-maxlen'), user: panel.querySelector('.bh-user'), lowonly: panel.querySelector('.bh-lowonly'), from: panel.querySelector('.bh-from'), to: panel.querySelector('.bh-to'), clicheSel: panel.querySelector('.bh-cliche'), clicheOnly: panel.querySelector('.bh-cliche-only'), table: panel.querySelector('.bh-table'), metrics: panel.querySelector('.bh-metrics') }; // Populate cliché dropdown const frag = document.createDocumentFragment(); cliché.forEach(c => { const opt = document.createElement('option'); opt.value = c.key; opt.textContent = c.label; frag.appendChild(opt); }); ui.clicheSel.appendChild(frag); const COLLAPSE_KEY = 'bh-collapsed'; const bodyEl = panel.querySelector('.bh-body'); const collapseBtn = panel.querySelector('.bh-collapse'); function setCollapsed(collapsed) { bodyEl.style.display = collapsed ? 'none' : ''; collapseBtn.textContent = collapsed ? '+' : '–'; try { localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0'); } catch {} } collapseBtn.addEventListener('click', () => { setCollapsed(bodyEl.style.display !== 'none'); }); // initialize from storage; default to collapsed if unset const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem(COLLAPSE_KEY) : null; setCollapsed(stored === null ? true : stored === '1'); panel.querySelector('.bh-reset').addEventListener('click', () => { ui.min.value = 0; ui.maxlen.value = 220; ui.user.value = ''; ui.lowonly.checked = true; ui.from.value = ''; ui.to.value = ''; ui.clicheSel.value = '__ALL__'; ui.clicheOnly.checked = false; render(); }); ['input','change'].forEach(ev => { [ui.min, ui.maxlen, ui.user, ui.lowonly, ui.from, ui.to, ui.clicheSel, ui.clicheOnly] .forEach(el => el.addEventListener(ev, render)); }); function withinDates(iso, from, to) { if (!iso) return true; const t = new Date(iso).getTime(); if (from && t < new Date(from).getTime()) return false; if (to && t > new Date(to).getTime()) return false; return true; } function reviewMatchesSelectedCliche(r) { if (!ui.clicheOnly.checked) return true; const selected = ui.clicheSel.value; if (!selected || selected === '__ALL__') return true; const def = cliché.find(c => c.key === selected); if (!def) return true; return def.rx.test(r.bodyTxt || ''); } function currentRows() { const min = parseFloat(ui.min.value || '0'); const maxlen = parseInt(ui.maxlen.value || '0', 10); const user = (ui.user.value || '').toLowerCase(); const lowOnly = ui.lowonly.checked; const from = ui.from.value; const to = ui.to.value; return reviews.filter(r => { if (Number.isFinite(min) && r.rating != null && r.rating < min) return false; if (user && !r.author.toLowerCase().includes(user)) return false; if (from || to) { if (!withinDates(r.timeIso, from, to)) return false; } if (!reviewMatchesSelectedCliche(r)) return false; const diag = diagnoseLowEffort(r, maxlen); if (lowOnly && !diag.low) return false; return true; }); } function render() { const maxlen = parseInt(ui.maxlen.value || '220', 10); const live = reviews.filter(r => !r.deleted); const lowCount = live.filter(r => diagnoseLowEffort(r, maxlen).low).length; const avgRating = avg(live.map(r => r.rating || 0)).toFixed(2); const clicheInfo = ui.clicheOnly.checked ? ` • cliché: ${ui.clicheSel.options[ui.clicheSel.selectedIndex]?.text || 'All'}` : ''; ui.metrics.textContent = `on page: ${reviews.length} • avg ★ ${avgRating} • low-effort flagged: ${lowCount}${clicheInfo}`; const rows = currentRows(); const html = [``]; rows.forEach(r => { const diag = diagnoseLowEffort(r, maxlen); const low = diag.low; const one = Math.round(r.rating||0) === 1; const cls = `${low ? 'low' : ''} ${r.deleted ? 'deleted': ''}`.trim(); const flagHtml = diag.reasons.map(reason => { let pill = 'pill'; if (reason.code === 'LEN' || reason.code === 'CLICHÉ') pill += ' low'; if (reason.code === 'SPAM') pill += ' spam'; if (reason.code === 'DELETED') pill += ' delet'; const tip = reason.tip ? ` title="${reason.tip.replace(/"/g, '"')}"` : ''; return `${reason.text}`; }).join(''); html.push(``); }); html.push(`
AuthorThreadWhenLenFlags (why)Actions
${r.author || ''} ${r.rating != null ? r.rating.toFixed(2) : ''} ${r.thread || ''} ${r.timeTxt || ''} ${r.effLen} (raw ${r.rawLen}) ${flagHtml || ''}${one ? '1★' : ''}
`); ui.table.innerHTML = html.join(''); // Keep single delegated click handler alive if (!ui.table._bhBound) { ui.table.addEventListener('click', onRowClick); ui.table._bhBound = true; } retint(); } function onRowClick(e) { if (e.target.closest('a, button')) return; // let links/buttons behave const tr = e.target.closest('tr[data-i]'); if (!tr) return; const idx = parseInt(tr.getAttribute('data-i'), 10); const r = reviews[idx]; if (!r || !r.node) return; r.node.scrollIntoView({ behavior: 'smooth', block: 'center' }); r.node.classList.remove('bh-target-pulse'); void r.node.offsetWidth; r.node.classList.add('bh-target-pulse'); setTimeout(() => r.node.classList.remove('bh-target-pulse'), 1800); } // Tint live page with reasons as tooltip function retint() { const maxlen = parseInt(ui.maxlen?.value || '220', 10); reviews.forEach(r => { if (r.deleted) { r.node.style.outline = ''; r.node.removeAttribute('title'); return; } const diag = diagnoseLowEffort(r, maxlen); r.node.style.outline = ''; r.node.removeAttribute('title'); if (diag.low) { r.node.style.outline = '2px solid #f5b400'; const tips = diag.reasons .filter(x => x.code === 'LEN' || x.code === 'CLICHÉ' || x.code === 'SPAM') .map(x => x.text).join(' | '); if (tips) r.node.setAttribute('title', tips); } }); } render(); // Copy CSV of current view, with reasons column panel.querySelector('.bh-copy').addEventListener('click', () => { const rows = currentRows(); const maxlen = parseInt(ui.maxlen?.value || '220', 10); const header = ['author','rating','thread','threadUrl','timeIso','effLen','rawLen','deleted','low','reasons','body']; const lines = [header.join(',')]; rows.forEach(r => { const diag = diagnoseLowEffort(r, maxlen); const reasonFlat = diag.reasons.map(x => x.text).join(' | '); lines.push([ csvEscape(r.author), r.rating ?? '', csvEscape(r.thread), csvEscape(r.threadUrl), csvEscape(r.timeIso), r.effLen ?? '', r.rawLen ?? '', r.deleted ? '1' : '0', diag.low ? '1' : '0', csvEscape(reasonFlat), csvEscape((r.bodyTxt || '').slice(0,3000)) ].join(',')); }); const csv = lines.join('\n'); try { GM_setClipboard(csv, { type: 'text', mimetype: 'text/csv' }); } catch { navigator.clipboard?.writeText(csv); } }); })();