F95Zone-Scripts/F95_BRATR_Management_Ratings_Helper.js
Ryahn 8821dad70d Update F95_BRATR_Management_Ratings_Helper.js
Optimized and better language checker
2025-09-22 01:37:06 +00:00

728 lines
30 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.6.0
// @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 () {
// Constants
const CONFIG = {
DEFAULT_CUTOFF: 200,
MIN_TOKENS_FOR_ANALYSIS: 10,
PUNCTUATION_RATIO_THRESHOLD: 0.35,
UNIQUE_RATIO_THRESHOLD: 0.5,
MAX_WORD_FREQUENCY_THRESHOLD: 0.4,
NGRAM_REPEAT_THRESHOLD: 3,
SHORT_SENTENCE_MAX_LENGTH: 4,
RUNON_WALL_MIN_LENGTH: 260,
ENGLISH_ASCII_THRESHOLD: 0.7,
ENGLISH_NONASCII_THRESHOLD: 0.25,
PUNCTUATION_RUN_MIN_LENGTH: 5,
CSV_BODY_MAX_LENGTH: 3000
};
// Cached DOM utilities
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
// Optimized text extraction with caching
const textCache = new WeakMap();
const text = el => {
if (!el) return '';
if (textCache.has(el)) return textCache.get(el);
const result = el.textContent.replace(/\s+/g, ' ').trim();
textCache.set(el, result);
return result;
};
// Optimized rating extraction
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;
};
// Utility functions
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 || ''));
// Optimized Unicode-aware letter counters with caching
const letterCountCache = new Map();
function countLetters(str) {
if (!str) return { all: 0, ascii: 0, nonAscii: 0 };
if (letterCountCache.has(str)) return letterCountCache.get(str);
const all = (str.match(/\p{L}/gu) || []).length;
const ascii = (str.match(/[A-Za-z]/g) || []).length;
const result = { all, ascii, nonAscii: Math.max(0, all - ascii) };
// Cache only reasonable length strings to avoid memory bloat
if (str.length < 1000) letterCountCache.set(str, result);
return result;
}
function isLikelyEnglish(str) {
const { all, ascii, nonAscii } = countLetters(str);
if (all === 0) return true;
const asciiShare = ascii / all;
const nonAsciiShare = nonAscii / Math.max(1, all);
return asciiShare >= CONFIG.ENGLISH_ASCII_THRESHOLD && nonAsciiShare <= CONFIG.ENGLISH_NONASCII_THRESHOLD;
}
function isRunOnWall(str) {
const len = (str || '').length;
if (len < CONFIG.RUNON_WALL_MIN_LENGTH) return false;
const enders = (str.match(/[.!?…]/g) || []).length;
return enders < 1;
}
// Optimized review scraping with better error handling
const reviews = $$('.message--review').map((msg, i) => {
try {
const main = msg.querySelector('.contentRow-main');
if (!main) return null;
// Batch DOM queries for better performance
const [authorA, threadA, timeEl, bodyEl] = [
main.querySelector('.contentRow-header a.username'),
main.querySelector('dl.pairs dd a'),
main.querySelector('time.u-dt'),
main.querySelector('article .bbWrapper')
];
const author = text(authorA);
const rating = getRating(main);
const thread = text(threadA);
const threadUrl = threadA?.href || '';
const timeIso = timeEl?.getAttribute('datetime') || '';
const timeTxt = timeEl?.getAttribute('title') || text(timeEl);
// Optimized body text extraction
let bodyTxt = '';
if (bodyEl) {
const clone = bodyEl.cloneNode(true);
// Use more efficient removal
const spoilers = clone.querySelectorAll('.bbCodeSpoiler-content');
spoilers.forEach(s => s.remove());
bodyTxt = text(clone);
}
const deleted = msg.classList.contains('message--deleted');
// Batch action link queries
const actionLinks = main.querySelectorAll('a[class*="actionBar-action"]');
const links = {
report: '',
edit: '',
del: '',
warn: ''
};
actionLinks.forEach(link => {
const className = link.className;
if (className.includes('report')) links.report = link.href;
else if (className.includes('edit')) links.edit = link.href;
else if (className.includes('delete')) links.del = link.href;
else if (className.includes('warn')) links.warn = link.href;
});
const rawLen = bodyTxt.length;
// Optimized text processing
const effBody = bodyTxt
.replace(/([.!?…,_\-])\1{2,}/g, '$1$1')
.replace(/\s{2,}/g, ' ')
.trim();
const effLen = effBody.length;
return { i, node: msg, author, rating, thread, threadUrl, timeIso, timeTxt, bodyTxt, rawLen, effLen, deleted, links };
} catch (error) {
console.warn('Error processing review:', error);
return null;
}
}).filter(Boolean); // Remove null entries
// Cliché patterns
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 }
];
// Optimized spam analysis with caching and early returns
const spamAnalysisCache = new Map();
function analyzeSpam(body) {
const s = body || '';
if (!s) return [];
// Cache results for identical text
if (spamAnalysisCache.has(s)) return spamAnalysisCache.get(s);
const reasons = [];
const len = s.length;
// Early return for very short text
if (len < 50) {
spamAnalysisCache.set(s, reasons);
return reasons;
}
// Optimized punctuation run detection
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`
});
}
// Optimized punctuation ratio calculation
const punctChars = (s.match(/[^\w\s]/g) || []).length;
const pRatio = punctChars / len;
if (pRatio > CONFIG.PUNCTUATION_RATIO_THRESHOLD && len >= 120) {
reasons.push({
code: 'SPAM',
text: `PUNC ${Math.round(pRatio * 100)}%`,
tip: `Non-alphanumeric ratio ${Math.round(pRatio * 100)}%`
});
}
// Tokenize once and reuse
const tokens = s.toLowerCase().match(/[a-z0-9']+/g);
if (!tokens || tokens.length < CONFIG.MIN_TOKENS_FOR_ANALYSIS) {
spamAnalysisCache.set(s, reasons);
return reasons;
}
const totalTok = tokens.length;
const freq = new Map();
// Optimized frequency counting
for (const token of tokens) {
freq.set(token, (freq.get(token) || 0) + 1);
}
const uniq = freq.size;
const uniqRatio = uniq / totalTok;
if (uniqRatio < CONFIG.UNIQUE_RATIO_THRESHOLD) {
reasons.push({
code: 'SPAM',
text: `LOW_UNIQUE ${uniq}/${totalTok}`,
tip: `Unique/token ratio ${uniqRatio.toFixed(2)}`
});
}
// Find most frequent word efficiently
let maxWord = '', maxCount = 0;
for (const [word, count] of freq) {
if (count > maxCount) {
maxCount = count;
maxWord = word;
}
}
if (maxCount / totalTok > CONFIG.MAX_WORD_FREQUENCY_THRESHOLD && totalTok >= 8) {
reasons.push({
code: 'SPAM',
text: `REP "${truncate(maxWord, 12)}" ×${maxCount}`,
tip: `Word appears ${(maxCount / totalTok * 100).toFixed(0)}% of tokens`
});
}
// Optimized n-gram analysis
function analyzeNgrams(n) {
const ngramMap = new Map();
const limit = tokens.length - n;
for (let i = 0; i <= limit; i++) {
const ngram = tokens.slice(i, i + n).join(' ');
ngramMap.set(ngram, (ngramMap.get(ngram) || 0) + 1);
}
for (const [ngram, count] of ngramMap) {
if (count >= CONFIG.NGRAM_REPEAT_THRESHOLD) {
reasons.push({
code: 'SPAM',
text: `REP${n} "${truncate(ngram, 20)}" ×${count}`,
tip: `Repeated ${n}-gram ${count} times`
});
return true; // Found one, stop looking
}
}
return false;
}
// Check 2-grams and 3-grams
[2, 3].forEach(n => {
if (analyzeNgrams(n)) return; // Early exit if found
});
// Optimized short sentence analysis
const sentences = s.split(/(?<=[.!?])\s+/);
const shortFreq = new Map();
for (const sentence of sentences) {
const toks = sentence.toLowerCase().match(/[a-z0-9']+/g);
if (toks && toks.length <= CONFIG.SHORT_SENTENCE_MAX_LENGTH) {
const key = toks.join(' ');
const count = (shortFreq.get(key) || 0) + 1;
shortFreq.set(key, count);
if (count >= CONFIG.NGRAM_REPEAT_THRESHOLD) {
reasons.push({
code: 'SPAM',
text: `SHORT_SENT "${truncate(key, 18)}" ×${count}`,
tip: `Short sentence repeated ${count} times`
});
break; // Found one, stop looking
}
}
}
// Cache result (limit cache size to prevent memory issues)
if (spamAnalysisCache.size < 1000) {
spamAnalysisCache.set(s, reasons);
}
return reasons;
}
// Optimized low-effort diagnosis with caching
const diagnosisCache = new Map();
function diagnoseLowEffort(r, cutoff = CONFIG.DEFAULT_CUTOFF) {
const cacheKey = `${r.i}-${cutoff}-${r.effLen}-${r.deleted}`;
if (diagnosisCache.has(cacheKey)) {
return diagnosisCache.get(cacheKey);
}
const reasons = [];
const body = r.bodyTxt || '';
// Length check
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) {
// Optimized cliché checking with early exit
for (const c of cliché) {
const match = body.match(c.rx);
if (match) {
reasons.push({
code: 'CLICHÉ',
text: `CLICHÉ: "${truncate(match[0], 28)}"`,
tip: `Matched: ${match[0]}`,
key: c.key
});
break; // Found one cliché, no need to check others
}
}
// Language detection
if (!isLikelyEnglish(body)) {
const { all, ascii, nonAscii } = countLetters(body);
const tip = `letters: ascii=${ascii}, nonAscii=${nonAscii}, share=${(nonAscii / Math.max(1, all)).toFixed(2)}`;
reasons.push({ code: 'LANG', text: 'NON-EN', tip });
}
// Style check
if (isRunOnWall(body)) {
reasons.push({ code: 'STYLE', text: 'RUN-ON', tip: 'Long block with no sentence enders' });
}
}
// Spam analysis
const spamReasons = analyzeSpam(body);
reasons.push(...spamReasons);
if (r.deleted) {
reasons.push({ code: 'DELETED', text: 'DELETED', tip: 'Author deleted review' });
}
// Check if low-effort
const low = reasons.some(x =>
x.code === 'LEN' || x.code === 'CLICHÉ' || x.code === 'SPAM' || x.code === 'LANG' || x.code === 'STYLE'
);
const result = { low, reasons };
// Cache result (limit cache size)
if (diagnosisCache.size < 500) {
diagnosisCache.set(cacheKey, result);
}
return result;
}
// 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="${CONFIG.DEFAULT_CUTOFF}"></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 .pill.lang { border-color:#6fb3ff; color:#6fb3ff }
#bratr-helper .pill.style { border-color:#b07cff; color:#b07cff }
#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);
// collapse default minimized with persistence
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'));
const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem(COLLAPSE_KEY) : null;
setCollapsed(stored === null ? true : stored === '1');
// Optimized event handling with debouncing
let renderTimeout;
const debouncedRender = () => {
clearTimeout(renderTimeout);
renderTimeout = setTimeout(render, 100); // 100ms debounce
};
// Reset handler
panel.querySelector('.bh-reset').addEventListener('click', () => {
try {
ui.min.value = 0;
ui.maxlen.value = CONFIG.DEFAULT_CUTOFF;
ui.user.value = '';
ui.lowonly.checked = true;
ui.from.value = '';
ui.to.value = '';
ui.clicheSel.value = '__ALL__';
ui.clicheOnly.checked = false;
render();
} catch (error) {
console.error('Error resetting controls:', error);
}
});
// Optimized event delegation
const controlElements = [ui.min, ui.maxlen, ui.user, ui.lowonly, ui.from, ui.to, ui.clicheSel, ui.clicheOnly];
['input', 'change'].forEach(eventType => {
controlElements.forEach(el => {
if (el) {
el.addEventListener(eventType, debouncedRender);
}
});
});
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;
});
}
// Optimized render function with better error handling
function render() {
try {
const maxlen = parseInt(ui.maxlen?.value || CONFIG.DEFAULT_CUTOFF.toString(), 10);
const live = reviews.filter(r => !r.deleted);
// Optimized metrics calculation
const lowCount = live.filter(r => {
try {
return diagnoseLowEffort(r, maxlen).low;
} catch (error) {
console.warn('Error diagnosing review:', error);
return false;
}
}).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'}`
: '';
if (ui.metrics) {
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>`];
// Optimized row rendering
for (const r of rows) {
try {
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();
// Map codes to pill classes
const pillClassByCode = {
'LEN': 'low', 'CLICHÉ': 'low', 'SPAM': 'spam',
'DELETED': 'delet', 'LANG': 'lang', 'STYLE': 'style'
};
const flagHtml = diag.reasons.map(reason => {
let pill = 'pill';
const extra = pillClassByCode[reason.code];
if (extra) pill += ' ' + extra;
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>`);
} catch (error) {
console.warn('Error rendering row:', error);
}
}
html.push(`</tbody></table>`);
if (ui.table) {
ui.table.innerHTML = html.join('');
if (!ui.table._bhBound) {
ui.table.addEventListener('click', onRowClick);
ui.table._bhBound = true;
}
}
retint();
} catch (error) {
console.error('Error in render function:', error);
}
}
function onRowClick(e) {
if (e.target.closest('a, button')) return;
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' || x.code === 'LANG' || x.code === 'STYLE')
.map(x => x.text).join(' | ');
if (tips) r.node.setAttribute('title', tips);
}
});
}
render();
// Optimized CSV export with better error handling
panel.querySelector('.bh-copy').addEventListener('click', async () => {
try {
const rows = currentRows();
const maxlen = parseInt(ui.maxlen?.value || CONFIG.DEFAULT_CUTOFF.toString(), 10);
const header = ['author', 'rating', 'thread', 'threadUrl', 'timeIso', 'effLen', 'rawLen', 'deleted', 'low', 'reasons', 'body'];
const lines = [header.join(',')];
// Optimized CSV generation
for (const r of rows) {
try {
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, CONFIG.CSV_BODY_MAX_LENGTH))
].join(','));
} catch (error) {
console.warn('Error processing row for CSV:', error);
}
}
const csv = lines.join('\n');
// Try multiple clipboard methods
try {
if (typeof GM_setClipboard !== 'undefined') {
GM_setClipboard(csv, { type: 'text', mimetype: 'text/csv' });
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(csv);
} else {
// Fallback: create temporary textarea
const textarea = document.createElement('textarea');
textarea.value = csv;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
console.log(`CSV exported: ${rows.length} rows`);
} catch (clipboardError) {
console.error('Clipboard error:', clipboardError);
// Show CSV in console as fallback
console.log('CSV content:', csv);
}
} catch (error) {
console.error('Error exporting CSV:', error);
}
});
})();