// ==UserScript==
// @name F95 BRATR Management Ratings Helper
// @namespace Ryahn
// @version 1.4.2
// @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 },
{ label: 'mentions 200 (char rule)', 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=200) {
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 = 200; 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 || '200', 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 = [`
| Author | ★ | Thread | When | Len | Flags (why) | Actions |
`];
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(`
| ${r.author || ''} |
${r.rating != null ? r.rating.toFixed(2) : ''} |
${r.thread || ''} |
${r.timeTxt || ''} |
${r.effLen} (raw ${r.rawLen}) |
${flagHtml || ''}${one ? '1★' : ''} |
${r.links.report ? `Report` : ''}
${r.links.edit ? `Edit` : ''}
${r.links.del ? `Delete` : ''}
${r.links.warn ? `Warn` : ''}
|
`);
});
html.push(`
`);
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 || '200', 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 || '200', 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); }
});
})();