diff --git a/F95_BRATR_Management_Ratings_Helper.user.js b/F95_BRATR_Management_Ratings_Helper.user.js new file mode 100644 index 0000000..86e4279 --- /dev/null +++ b/F95_BRATR_Management_Ratings_Helper.user.js @@ -0,0 +1,415 @@ +// ==UserScript== +// @name F95 BRATR Management Ratings Helper +// @namespace Ryahn +// @version 1.4.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.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 with labels + const cliché = [ + { label: 'good game', rx: /\bgood game\b/i }, + { label: 'very good', rx: /\bit was very good\b/i }, + { label: 'amazing', rx: /\bamazing!?(\b|$)/i }, + { label: 'top N', rx: /\btop\s+\d+\b/i }, + { label: 'pretty sex scenes', rx: /\bpretty sex scenes\b/i }, + { label: 'downloads flex', rx: /\bdownload(ed)? hundreds of games\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 phrase: ${m[0]}` }); + }); + } + + 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 (+ clickable rows + pulse highlight) + const css = document.createElement('style'); + css.textContent = ` + #bratr-helper{position:fixed;right:12px;bottom:12px;width:780px;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{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'), + table: panel.querySelector('.bh-table'), + metrics: panel.querySelector('.bh-metrics') + }; + + panel.querySelector('.bh-collapse').addEventListener('click', () => { + const body = panel.querySelector('.bh-body'); + if (body.style.display === 'none') { body.style.display = ''; panel.querySelector('.bh-collapse').textContent = '–'; } + else { body.style.display = 'none'; panel.querySelector('.bh-collapse').textContent = '+'; } + }); + 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 = ''; + render(); + }); + ['input','change'].forEach(ev => { + [ui.min, ui.maxlen, ui.user, ui.lowonly, ui.from, ui.to].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 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; } + 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); + ui.metrics.textContent = `on page: ${reviews.length} • avg ★ ${avgRating} • low-effort flagged: ${lowCount}`; + + 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(''); + + // Bind row click delegation exactly once and keep it + 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; + + // Smooth scroll and pulse + r.node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + r.node.classList.remove('bh-target-pulse'); + void r.node.offsetWidth; // reflow to restart animation + 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); } + }); +})(); \ No newline at end of file