F95Zone-Scripts/F95_BRATR_Management_Ratings_Helper.js
Ryahn 1796314db6 Update F95_BRATR_Management_Ratings_Helper.js
Minimize by default, remember it as well
2025-09-18 03:31:04 +00:00

460 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==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 <number>”', 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 = `
<div class="bh-head">
<strong>BRATR Helper</strong>
<span class="bh-metrics"></span>
<button class="bh-collapse" title="Collapse"></button>
</div>
<div class="bh-body">
<div class="bh-controls">
<label>Min ★ <input type="number" step="0.5" min="0" max="5" class="bh-minrating" value="0"></label>
<label>Low-effort length cutoff <input type="number" min="0" class="bh-maxlen" value="220"></label>
<label>User <input type="text" class="bh-user" placeholder="author contains"></label>
<label>Date from <input type="datetime-local" class="bh-from"></label>
<label>Date to <input type="datetime-local" class="bh-to"></label>
<label><input type="checkbox" class="bh-lowonly" checked> Low-effort only</label>
<label>Cliché <select class="bh-cliche"><option value="__ALL__">All clichés</option></select></label>
<label><input type="checkbox" class="bh-cliche-only"> Only reviews matching selected cliché</label>
<button class="bh-copy">Copy CSV</button>
<button class="bh-reset">Reset</button>
</div>
<div class="bh-table"></div>
</div>
`;
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 = [`<table><thead><tr>
<th>Author</th><th>★</th><th>Thread</th><th>When</th><th>Len</th><th>Flags (why)</th><th>Actions</th></tr></thead><tbody>`];
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, '&quot;')}"` : '';
return `<span class="${pill}"${tip}>${reason.text}</span>`;
}).join('');
html.push(`<tr class="${cls}" data-i="${r.i}">
<td>${r.author || ''}</td>
<td>${r.rating != null ? r.rating.toFixed(2) : ''}</td>
<td><a href="${r.threadUrl}" target="_blank" rel="noreferrer">${r.thread || ''}</a></td>
<td title="${r.timeIso || ''}">${r.timeTxt || ''}</td>
<td>${r.effLen} <span title="raw ${r.rawLen} chars">(raw ${r.rawLen})</span></td>
<td>${flagHtml || ''}${one ? '<span class="pill one" title="Rounded rating equals 1 star">1★</span>' : ''}</td>
<td class="links">
${r.links.report ? `<a href="${r.links.report}" target="_blank">Report</a>` : ''}
${r.links.edit ? `<a href="${r.links.edit}" target="_blank">Edit</a>` : ''}
${r.links.del ? `<a href="${r.links.del}" target="_blank">Delete</a>` : ''}
${r.links.warn ? `<a href="${r.links.warn}" target="_blank">Warn</a>` : ''}
</td>
</tr>`);
});
html.push(`</tbody></table>`);
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); }
});
})();