Add F95_BRATR_Management_Ratings_Helper.user.js
This commit is contained in:
parent
a63f074e5f
commit
26eefa36ee
415
F95_BRATR_Management_Ratings_Helper.user.js
Normal file
415
F95_BRATR_Management_Ratings_Helper.user.js
Normal file
@ -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 = `
|
||||||
|
<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, '"')}"` : '';
|
||||||
|
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); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user