diff --git a/F95_BRATR_Management_Ratings_Helper.js b/F95_BRATR_Management_Ratings_Helper.js index c754e9e..9a3e452 100644 --- a/F95_BRATR_Management_Ratings_Helper.js +++ b/F95_BRATR_Management_Ratings_Helper.js @@ -1,7 +1,7 @@ // ==UserScript== // @name F95 BRATR Management Ratings Helper // @namespace Ryahn -// @version 1.5.1 +// @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* @@ -10,451 +10,719 @@ // @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 || '' + // 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 }; - // 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; + // Cached DOM utilities + const $ = (sel, root = document) => root.querySelector(sel); + const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); - return { i, node: msg, author, rating, thread, threadUrl, timeIso, timeTxt, bodyTxt, rawLen, effLen, deleted, links }; - }); + // 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; + }; - // 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 ”', 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 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; + }; - // Spam/bypass heuristics - function analyzeSpam(body) { - const reasons = []; - const s = body || ''; - if (!s) return reasons; + // 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 || '')); - // 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` }); + // 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; } - // 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)}%` }); + 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; } - // 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; + 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; + } - if (uniqRatio < 0.5 && totalTok >= 10) { - reasons.push({ code: 'SPAM', text: `LOW_UNIQUE ${uniq}/${totalTok}`, tip: `Unique/token ratio ${uniqRatio.toFixed(2)}` }); - } + // 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; - // 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` }); - } + // 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') + ]; - // 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); + 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; } - 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; - } - } - }); + }).filter(Boolean); // Remove null entries - // 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; - } + // 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 ”', 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 } + ]; - // Diagnose low-effort - function diagnoseLowEffort(r, cutoff=220) { - const reasons = []; - const body = r.bodyTxt || ''; + // Optimized spam analysis with caching and early returns + const spamAnalysisCache = new Map(); + function analyzeSpam(body) { + const s = body || ''; + if (!s) return []; - 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}` }); + // 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; } - 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 }); - }); + // 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; } - 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 - - -
-
-
- - - - - - - - - - + // Panel + const panel = document.createElement('div'); + panel.id = 'bratr-helper'; + panel.innerHTML = ` +
+ BRATR Helper + +
-
-
- `; - document.body.appendChild(panel); +
+
+ + + + + + + + + + +
+
+
+ `; + 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); + // 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') - }; + 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); + // 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'; + // 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 {} + 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 + collapseBtn.addEventListener('click', () => setCollapsed(bodyEl.style.display !== 'none')); 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)); - }); + // Optimized event handling with debouncing + let renderTimeout; + const debouncedRender = () => { + clearTimeout(renderTimeout); + renderTimeout = setTimeout(render, 100); // 100ms debounce + }; - 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; - } + // 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); + } + }); - 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 || ''); - } + // 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 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; + 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 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 = [` - `]; - - 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(` - - - - - - - - `); - }); - - html.push(`
AuthorThreadWhenLenFlags (why)Actions
${r.author || ''}${r.rating != null ? r.rating.toFixed(2) : ''}${r.thread || ''}${r.timeTxt || ''}${r.effLen} (raw ${r.rawLen})${flagHtml || ''}${one ? '1★' : ''}
`); - 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 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 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; + 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; - 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); - } + 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; - // Tint live page with reasons as tooltip - function retint() { - const maxlen = parseInt(ui.maxlen?.value || '220', 10); - reviews.forEach(r => { - if (r.deleted) { + 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 = [` + `]; + + // 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, '"')}"` : ''; + return `${reason.text}`; + }).join(''); + + html.push(` + + + + + + + + `); + } catch (error) { + console.warn('Error rendering row:', error); + } + } + + html.push(`
AuthorThreadWhenLenFlags (why)Actions
${r.author || ''}${r.rating != null ? r.rating.toFixed(2) : ''}${r.thread || ''}${r.timeTxt || ''}${r.effLen} (raw ${r.rawLen})${flagHtml || ''}${one ? '1★' : ''}
`); + + 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'); - 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); - } - }); - } + 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(); + 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(',')); + // 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); + } }); - const csv = lines.join('\n'); - try { GM_setClipboard(csv, { type: 'text', mimetype: 'text/csv' }); } - catch { navigator.clipboard?.writeText(csv); } - }); -})(); \ No newline at end of file + })(); \ No newline at end of file