F95Zone-Scripts/F95_BRATR_Management_Ratings_Helper.user.js

415 lines
17 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.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 = `
<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>
<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 (+ 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 = [`<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('');
// 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); }
});
})();