Update F95_BRATR_Management_Ratings_Helper.js
Optimized and better language checker
This commit is contained in:
parent
79a44079cc
commit
8821dad70d
@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name F95 BRATR Management Ratings Helper
|
// @name F95 BRATR Management Ratings Helper
|
||||||
// @namespace Ryahn
|
// @namespace Ryahn
|
||||||
// @version 1.5.1
|
// @version 1.6.0
|
||||||
// @description Triage panel for /bratr-ratings/management: highlight low-effort reviews, filter, export CSV.
|
// @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/bratr-ratings/*/management*
|
// @match https://f95zone.to/bratr-ratings/*/management*
|
||||||
@ -10,9 +10,37 @@
|
|||||||
// @grant GM_setClipboard
|
// @grant GM_setClipboard
|
||||||
// ==/UserScript==
|
// ==/UserScript==
|
||||||
(function () {
|
(function () {
|
||||||
const $ = (sel, root=document) => root.querySelector(sel);
|
// Constants
|
||||||
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
|
const CONFIG = {
|
||||||
const text = el => (el ? el.textContent.replace(/\s+/g,' ').trim() : '');
|
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 getRating = el => {
|
||||||
const rs = el.querySelector('.ratingStars.bratr-rating');
|
const rs = el.querySelector('.ratingStars.bratr-rating');
|
||||||
if (!rs) return null;
|
if (!rs) return null;
|
||||||
@ -21,163 +49,327 @@
|
|||||||
if (m) return parseFloat(m[1]);
|
if (m) return parseFloat(m[1]);
|
||||||
return rs.querySelectorAll('.ratingStars-star--full').length || null;
|
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
|
// 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) => {
|
const reviews = $$('.message--review').map((msg, i) => {
|
||||||
|
try {
|
||||||
const main = msg.querySelector('.contentRow-main');
|
const main = msg.querySelector('.contentRow-main');
|
||||||
const authorA = main?.querySelector('.contentRow-header a.username');
|
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 author = text(authorA);
|
||||||
const rating = getRating(main);
|
const rating = getRating(main);
|
||||||
const threadA = main?.querySelector('dl.pairs dd a');
|
|
||||||
const thread = text(threadA);
|
const thread = text(threadA);
|
||||||
const threadUrl = threadA?.href || '';
|
const threadUrl = threadA?.href || '';
|
||||||
const timeEl = main?.querySelector('time.u-dt');
|
|
||||||
const timeIso = timeEl?.getAttribute('datetime') || '';
|
const timeIso = timeEl?.getAttribute('datetime') || '';
|
||||||
const timeTxt = timeEl?.getAttribute('title') || text(timeEl);
|
const timeTxt = timeEl?.getAttribute('title') || text(timeEl);
|
||||||
|
|
||||||
// Body excluding spoiler contents
|
// Optimized body text extraction
|
||||||
const bodyEl = main?.querySelector('article .bbWrapper');
|
|
||||||
let bodyTxt = '';
|
let bodyTxt = '';
|
||||||
if (bodyEl) {
|
if (bodyEl) {
|
||||||
const clone = bodyEl.cloneNode(true);
|
const clone = bodyEl.cloneNode(true);
|
||||||
clone.querySelectorAll('.bbCodeSpoiler-content').forEach(s => s.remove());
|
// Use more efficient removal
|
||||||
|
const spoilers = clone.querySelectorAll('.bbCodeSpoiler-content');
|
||||||
|
spoilers.forEach(s => s.remove());
|
||||||
bodyTxt = text(clone);
|
bodyTxt = text(clone);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = msg.classList.contains('message--deleted');
|
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)
|
// Batch action link queries
|
||||||
const rawLen = (bodyTxt || '').length;
|
const actionLinks = main.querySelectorAll('a[class*="actionBar-action"]');
|
||||||
const effBody = (bodyTxt || '')
|
const links = {
|
||||||
.replace(/([.!?…,_\-])\1{2,}/g, '$1$1') // collapse carpets to 2
|
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, ' ')
|
.replace(/\s{2,}/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
const effLen = effBody.length;
|
const effLen = effBody.length;
|
||||||
|
|
||||||
return { i, node: msg, author, rating, thread, threadUrl, timeIso, timeTxt, bodyTxt, rawLen, effLen, deleted, links };
|
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 (includes fixed 200 detector)
|
// Cliché patterns
|
||||||
const cliché = [
|
const cliché = [
|
||||||
{ key:'good game', label:'“good game”', rx:/\bgood game\b/i },
|
{ 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: 'very good', label: '“it was very good”', rx: /\bit was very good\b/i },
|
||||||
{ key:'amazing', label:'“amazing”', rx:/\bamazing!?(\b|$)/i },
|
{ key: 'amazing', label: '“amazing”', rx: /\bamazing!?(\b|$)/i },
|
||||||
{ key:'top N', label:'“top <number>”', rx:/\btop\s+\d+\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: '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: '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 }
|
{ key: 'mentions 200', label: 'mentions “200” (char/limit/min)', rx: /\b200(?:\s*[- ]?(?:char(?:s|acters?)?|word(?:s)?|limit|minimum|min))?\b/i }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Spam/bypass heuristics
|
// Optimized spam analysis with caching and early returns
|
||||||
|
const spamAnalysisCache = new Map();
|
||||||
function analyzeSpam(body) {
|
function analyzeSpam(body) {
|
||||||
const reasons = [];
|
|
||||||
const s = body || '';
|
const s = body || '';
|
||||||
if (!s) return reasons;
|
if (!s) return [];
|
||||||
|
|
||||||
// Punctuation carpets: 6+ of the same mark
|
// Cache results for identical text
|
||||||
const punctRuns = s.match(/([.!?…,_\-])\1{5,}/g) || [];
|
if (spamAnalysisCache.has(s)) return spamAnalysisCache.get(s);
|
||||||
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 reasons = [];
|
||||||
const punctChars = (s.match(/[^\w\s]/g) || []).length;
|
const len = s.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
|
// Early return for very short text
|
||||||
const tokens = s.toLowerCase().match(/[a-z0-9']+/g) || [];
|
if (len < 50) {
|
||||||
const totalTok = tokens.length;
|
spamAnalysisCache.set(s, reasons);
|
||||||
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;
|
return reasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnose low-effort
|
// Optimized punctuation run detection
|
||||||
function diagnoseLowEffort(r, cutoff=220) {
|
const punctRuns = s.match(/([.!?…,_\-])\1{5,}/g);
|
||||||
const reasons = [];
|
if (punctRuns?.length) {
|
||||||
const body = r.bodyTxt || '';
|
const totalRunChars = punctRuns.reduce((a, b) => a + b.length, 0);
|
||||||
|
reasons.push({
|
||||||
if (!r.deleted && (r.effLen || 0) < cutoff) {
|
code: 'SPAM',
|
||||||
reasons.push({ code:'LEN', text:`LEN ${r.effLen}<${cutoff}`, tip:`Effective length ${r.effLen} is below cutoff ${cutoff}` });
|
text: `DOTCARPET ×${punctRuns.length}`,
|
||||||
}
|
tip: `Punctuation runs total ${totalRunChars} chars`
|
||||||
|
|
||||||
if (!r.deleted) {
|
|
||||||
cliché.forEach(c => {
|
|
||||||
const m = body.match(c.rx);
|
|
||||||
if (m) reasons.push({ code:'CLICHÉ', text:`CLICHÉ: "${truncate(m[0], 28)}"`, tip:`Matched: ${m[0]}`, key:c.key });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzeSpam(body).forEach(sr => reasons.push(sr));
|
// 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)}%`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (r.deleted) reasons.push({ code:'DELETED', text:'DELETED', tip:'Author deleted review' });
|
// 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 low = reasons.some(x => x.code === 'LEN' || x.code === 'CLICHÉ' || x.code === 'SPAM');
|
const totalTok = tokens.length;
|
||||||
return { low, reasons };
|
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
|
// Panel
|
||||||
@ -192,7 +384,7 @@
|
|||||||
<div class="bh-body">
|
<div class="bh-body">
|
||||||
<div class="bh-controls">
|
<div class="bh-controls">
|
||||||
<label>Min ★ <input type="number" step="0.5" min="0" max="5" class="bh-minrating" value="0"></label>
|
<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>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>User <input type="text" class="bh-user" placeholder="author contains"></label>
|
||||||
<label>Date from <input type="datetime-local" class="bh-from"></label>
|
<label>Date from <input type="datetime-local" class="bh-from"></label>
|
||||||
<label>Date to <input type="datetime-local" class="bh-to"></label>
|
<label>Date to <input type="datetime-local" class="bh-to"></label>
|
||||||
@ -233,6 +425,8 @@
|
|||||||
#bratr-helper .pill.one{border-color:#ff5b5b;color:#ff5b5b}
|
#bratr-helper .pill.one{border-color:#ff5b5b;color:#ff5b5b}
|
||||||
#bratr-helper .pill.delet{border-color:#8aa;color:#8aa}
|
#bratr-helper .pill.delet{border-color:#8aa;color:#8aa}
|
||||||
#bratr-helper .pill.spam{border-color:#ff9d00;color:#ff9d00}
|
#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}
|
#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);}}
|
@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; }
|
.bh-target-pulse { animation: bh-pulse 1s ease-out 0s 2; outline: 2px solid #f5b400 !important; }
|
||||||
@ -263,26 +457,31 @@
|
|||||||
});
|
});
|
||||||
ui.clicheSel.appendChild(frag);
|
ui.clicheSel.appendChild(frag);
|
||||||
|
|
||||||
|
// collapse default minimized with persistence
|
||||||
const COLLAPSE_KEY = 'bh-collapsed';
|
const COLLAPSE_KEY = 'bh-collapsed';
|
||||||
const bodyEl = panel.querySelector('.bh-body');
|
const bodyEl = panel.querySelector('.bh-body');
|
||||||
const collapseBtn = panel.querySelector('.bh-collapse');
|
const collapseBtn = panel.querySelector('.bh-collapse');
|
||||||
|
|
||||||
function setCollapsed(collapsed) {
|
function setCollapsed(collapsed) {
|
||||||
bodyEl.style.display = collapsed ? 'none' : '';
|
bodyEl.style.display = collapsed ? 'none' : '';
|
||||||
collapseBtn.textContent = collapsed ? '+' : '–';
|
collapseBtn.textContent = collapsed ? '+' : '–';
|
||||||
try { localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0'); } catch {}
|
try { localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0'); } catch {}
|
||||||
}
|
}
|
||||||
|
collapseBtn.addEventListener('click', () => setCollapsed(bodyEl.style.display !== 'none'));
|
||||||
collapseBtn.addEventListener('click', () => {
|
|
||||||
setCollapsed(bodyEl.style.display !== 'none');
|
|
||||||
});
|
|
||||||
|
|
||||||
// initialize from storage; default to collapsed if unset
|
|
||||||
const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem(COLLAPSE_KEY) : null;
|
const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem(COLLAPSE_KEY) : null;
|
||||||
setCollapsed(stored === null ? true : stored === '1');
|
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', () => {
|
panel.querySelector('.bh-reset').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
ui.min.value = 0;
|
ui.min.value = 0;
|
||||||
ui.maxlen.value = 220;
|
ui.maxlen.value = CONFIG.DEFAULT_CUTOFF;
|
||||||
ui.user.value = '';
|
ui.user.value = '';
|
||||||
ui.lowonly.checked = true;
|
ui.lowonly.checked = true;
|
||||||
ui.from.value = '';
|
ui.from.value = '';
|
||||||
@ -290,11 +489,19 @@
|
|||||||
ui.clicheSel.value = '__ALL__';
|
ui.clicheSel.value = '__ALL__';
|
||||||
ui.clicheOnly.checked = false;
|
ui.clicheOnly.checked = false;
|
||||||
render();
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting controls:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
['input','change'].forEach(ev => {
|
// Optimized event delegation
|
||||||
[ui.min, ui.maxlen, ui.user, ui.lowonly, ui.from, ui.to, ui.clicheSel, ui.clicheOnly]
|
const controlElements = [ui.min, ui.maxlen, ui.user, ui.lowonly, ui.from, ui.to, ui.clicheSel, ui.clicheOnly];
|
||||||
.forEach(el => el.addEventListener(ev, render));
|
['input', 'change'].forEach(eventType => {
|
||||||
|
controlElements.forEach(el => {
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener(eventType, debouncedRender);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function withinDates(iso, from, to) {
|
function withinDates(iso, from, to) {
|
||||||
@ -334,31 +541,53 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimized render function with better error handling
|
||||||
function render() {
|
function render() {
|
||||||
const maxlen = parseInt(ui.maxlen.value || '220', 10);
|
try {
|
||||||
|
const maxlen = parseInt(ui.maxlen?.value || CONFIG.DEFAULT_CUTOFF.toString(), 10);
|
||||||
const live = reviews.filter(r => !r.deleted);
|
const live = reviews.filter(r => !r.deleted);
|
||||||
const lowCount = live.filter(r => diagnoseLowEffort(r, maxlen).low).length;
|
|
||||||
|
// 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 avgRating = avg(live.map(r => r.rating || 0)).toFixed(2);
|
||||||
const clicheInfo = ui.clicheOnly.checked
|
const clicheInfo = ui.clicheOnly?.checked
|
||||||
? ` • cliché: ${ui.clicheSel.options[ui.clicheSel.selectedIndex]?.text || 'All'}`
|
? ` • 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}`;
|
ui.metrics.textContent = `on page: ${reviews.length} • avg ★ ${avgRating} • low-effort flagged: ${lowCount}${clicheInfo}`;
|
||||||
|
}
|
||||||
|
|
||||||
const rows = currentRows();
|
const rows = currentRows();
|
||||||
const html = [`<table><thead><tr>
|
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>`];
|
<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 => {
|
// Optimized row rendering
|
||||||
|
for (const r of rows) {
|
||||||
|
try {
|
||||||
const diag = diagnoseLowEffort(r, maxlen);
|
const diag = diagnoseLowEffort(r, maxlen);
|
||||||
const low = diag.low;
|
const low = diag.low;
|
||||||
const one = Math.round(r.rating||0) === 1;
|
const one = Math.round(r.rating || 0) === 1;
|
||||||
const cls = `${low ? 'low' : ''} ${r.deleted ? 'deleted': ''}`.trim();
|
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 => {
|
const flagHtml = diag.reasons.map(reason => {
|
||||||
let pill = 'pill';
|
let pill = 'pill';
|
||||||
if (reason.code === 'LEN' || reason.code === 'CLICHÉ') pill += ' low';
|
const extra = pillClassByCode[reason.code];
|
||||||
if (reason.code === 'SPAM') pill += ' spam';
|
if (extra) pill += ' ' + extra;
|
||||||
if (reason.code === 'DELETED') pill += ' delet';
|
|
||||||
const tip = reason.tip ? ` title="${reason.tip.replace(/"/g, '"')}"` : '';
|
const tip = reason.tip ? ` title="${reason.tip.replace(/"/g, '"')}"` : '';
|
||||||
return `<span class="${pill}"${tip}>${reason.text}</span>`;
|
return `<span class="${pill}"${tip}>${reason.text}</span>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -377,22 +606,30 @@
|
|||||||
${r.links.warn ? `<a href="${r.links.warn}" target="_blank">Warn</a>` : ''}
|
${r.links.warn ? `<a href="${r.links.warn}" target="_blank">Warn</a>` : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
});
|
} catch (error) {
|
||||||
|
console.warn('Error rendering row:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html.push(`</tbody></table>`);
|
html.push(`</tbody></table>`);
|
||||||
|
|
||||||
|
if (ui.table) {
|
||||||
ui.table.innerHTML = html.join('');
|
ui.table.innerHTML = html.join('');
|
||||||
|
|
||||||
// Keep single delegated click handler alive
|
|
||||||
if (!ui.table._bhBound) {
|
if (!ui.table._bhBound) {
|
||||||
ui.table.addEventListener('click', onRowClick);
|
ui.table.addEventListener('click', onRowClick);
|
||||||
ui.table._bhBound = true;
|
ui.table._bhBound = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
retint();
|
retint();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in render function:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowClick(e) {
|
function onRowClick(e) {
|
||||||
if (e.target.closest('a, button')) return; // let links/buttons behave
|
if (e.target.closest('a, button')) return;
|
||||||
const tr = e.target.closest('tr[data-i]');
|
const tr = e.target.closest('tr[data-i]');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
const idx = parseInt(tr.getAttribute('data-i'), 10);
|
const idx = parseInt(tr.getAttribute('data-i'), 10);
|
||||||
@ -421,7 +658,7 @@
|
|||||||
if (diag.low) {
|
if (diag.low) {
|
||||||
r.node.style.outline = '2px solid #f5b400';
|
r.node.style.outline = '2px solid #f5b400';
|
||||||
const tips = diag.reasons
|
const tips = diag.reasons
|
||||||
.filter(x => x.code === 'LEN' || x.code === 'CLICHÉ' || x.code === 'SPAM')
|
.filter(x => x.code === 'LEN' || x.code === 'CLICHÉ' || x.code === 'SPAM' || x.code === 'LANG' || x.code === 'STYLE')
|
||||||
.map(x => x.text).join(' | ');
|
.map(x => x.text).join(' | ');
|
||||||
if (tips) r.node.setAttribute('title', tips);
|
if (tips) r.node.setAttribute('title', tips);
|
||||||
}
|
}
|
||||||
@ -430,13 +667,17 @@
|
|||||||
|
|
||||||
render();
|
render();
|
||||||
|
|
||||||
// Copy CSV of current view, with reasons column
|
// Optimized CSV export with better error handling
|
||||||
panel.querySelector('.bh-copy').addEventListener('click', () => {
|
panel.querySelector('.bh-copy').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
const rows = currentRows();
|
const rows = currentRows();
|
||||||
const maxlen = parseInt(ui.maxlen?.value || '220', 10);
|
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 header = ['author', 'rating', 'thread', 'threadUrl', 'timeIso', 'effLen', 'rawLen', 'deleted', 'low', 'reasons', 'body'];
|
||||||
const lines = [header.join(',')];
|
const lines = [header.join(',')];
|
||||||
rows.forEach(r => {
|
|
||||||
|
// Optimized CSV generation
|
||||||
|
for (const r of rows) {
|
||||||
|
try {
|
||||||
const diag = diagnoseLowEffort(r, maxlen);
|
const diag = diagnoseLowEffort(r, maxlen);
|
||||||
const reasonFlat = diag.reasons.map(x => x.text).join(' | ');
|
const reasonFlat = diag.reasons.map(x => x.text).join(' | ');
|
||||||
lines.push([
|
lines.push([
|
||||||
@ -450,11 +691,38 @@
|
|||||||
r.deleted ? '1' : '0',
|
r.deleted ? '1' : '0',
|
||||||
diag.low ? '1' : '0',
|
diag.low ? '1' : '0',
|
||||||
csvEscape(reasonFlat),
|
csvEscape(reasonFlat),
|
||||||
csvEscape((r.bodyTxt || '').slice(0,3000))
|
csvEscape((r.bodyTxt || '').slice(0, CONFIG.CSV_BODY_MAX_LENGTH))
|
||||||
].join(','));
|
].join(','));
|
||||||
});
|
} catch (error) {
|
||||||
|
console.warn('Error processing row for CSV:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const csv = lines.join('\n');
|
const csv = lines.join('\n');
|
||||||
try { GM_setClipboard(csv, { type: 'text', mimetype: 'text/csv' }); }
|
|
||||||
catch { navigator.clipboard?.writeText(csv); }
|
// 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user