Compare commits
13 Commits
gameil-pat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bc4e35e28e | |||
| 8821dad70d | |||
| 79a44079cc | |||
| 1796314db6 | |||
| b3fac15c8f | |||
| aaa36886c9 | |||
| 26eefa36ee | |||
| a63f074e5f | |||
| 2a267bcc05 | |||
| 2733e86c8f | |||
| 71c588ee01 | |||
| 9c0fda2a44 | |||
| 71ae52daca |
1315
F95Zone_TemplateMaker.userscript.js
Normal file
1315
F95Zone_TemplateMaker.userscript.js
Normal file
File diff suppressed because it is too large
Load Diff
801
F95_BRATR_Management_Ratings_Helper.js
Normal file
801
F95_BRATR_Management_Ratings_Helper.js
Normal file
@ -0,0 +1,801 @@
|
|||||||
|
// ==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>
|
||||||
|
<button class="bh-prev-page">← Prev</button>
|
||||||
|
<button class="bh-next-page">Next →</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,#bratr-helper button.bh-prev-page,#bratr-helper button.bh-next-page{background:#1e1e1e;border:1px solid #444;color:#ddd;border-radius:8px;padding:4px 10px;cursor:pointer}
|
||||||
|
#bratr-helper button.bh-prev-page,#bratr-helper button.bh-next-page{background:#2a4a2a;border-color:#4a6a4a;font-size:11px;padding:3px 8px}
|
||||||
|
#bratr-helper button.bh-prev-page:hover,#bratr-helper button.bh-next-page:hover{background:#3a5a3a}
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation helper function
|
||||||
|
function navigateToPage(pageNumber) {
|
||||||
|
try {
|
||||||
|
const currentUrl = window.location.href;
|
||||||
|
const urlMatch = currentUrl.match(/\/bratr-ratings\/(?:page-(\d+)\/)?management/);
|
||||||
|
|
||||||
|
if (urlMatch) {
|
||||||
|
let targetUrl;
|
||||||
|
if (pageNumber === 1) {
|
||||||
|
// For page 1, remove the page number from URL
|
||||||
|
targetUrl = currentUrl.replace(
|
||||||
|
/\/bratr-ratings\/page-\d+\/management/,
|
||||||
|
'/bratr-ratings/management'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// For other pages, add or update the page number
|
||||||
|
targetUrl = currentUrl.replace(
|
||||||
|
/\/bratr-ratings\/(?:page-\d+\/)?management/,
|
||||||
|
`/bratr-ratings/page-${pageNumber}/management`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Navigating to page ${pageNumber}: ${targetUrl}`);
|
||||||
|
window.location.href = targetUrl;
|
||||||
|
} else {
|
||||||
|
console.warn('Could not determine current page number from URL:', currentUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error navigating to page:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous page handler
|
||||||
|
panel.querySelector('.bh-prev-page').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const currentUrl = window.location.href;
|
||||||
|
const urlMatch = currentUrl.match(/\/bratr-ratings\/(?:page-(\d+)\/)?management/);
|
||||||
|
|
||||||
|
if (urlMatch) {
|
||||||
|
const currentPage = urlMatch[1] ? parseInt(urlMatch[1], 10) : 1;
|
||||||
|
const prevPage = currentPage - 1;
|
||||||
|
|
||||||
|
if (prevPage >= 1) {
|
||||||
|
navigateToPage(prevPage);
|
||||||
|
} else {
|
||||||
|
console.log('Already on first page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error navigating to previous page:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Next page handler
|
||||||
|
panel.querySelector('.bh-next-page').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const currentUrl = window.location.href;
|
||||||
|
const urlMatch = currentUrl.match(/\/bratr-ratings\/(?:page-(\d+)\/)?management/);
|
||||||
|
|
||||||
|
if (urlMatch) {
|
||||||
|
const currentPage = urlMatch[1] ? parseInt(urlMatch[1], 10) : 1;
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
navigateToPage(nextPage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error navigating to next page:', 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, '"')}"` : '';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
78
auto-select-deleted-threads.js
Normal file
78
auto-select-deleted-threads.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name Auto Select Deleted Threads
|
||||||
|
// @namespace http://tampermonkey.net/
|
||||||
|
// @version 1.0.0
|
||||||
|
// @description Auto select deleted threads based on user preference.
|
||||||
|
// @author Ryahn
|
||||||
|
// @match https://f95zone.to/forums/*
|
||||||
|
// @grant GM.setValue
|
||||||
|
// @grant GM.getValue
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function createSettingsBar() {
|
||||||
|
const settingsBar = document.createElement('div');
|
||||||
|
settingsBar.style.float = 'right';
|
||||||
|
settingsBar.style.marginRight = '10px';
|
||||||
|
|
||||||
|
const selectLabel = document.createElement('label');
|
||||||
|
selectLabel.textContent = 'Auto Select Deleted: ';
|
||||||
|
|
||||||
|
const select = document.createElement('select');
|
||||||
|
const optionYes = document.createElement('option');
|
||||||
|
optionYes.value = 'yes';
|
||||||
|
optionYes.textContent = 'Yes';
|
||||||
|
const optionNo = document.createElement('option');
|
||||||
|
optionNo.value = 'no';
|
||||||
|
optionNo.textContent = 'No';
|
||||||
|
|
||||||
|
select.appendChild(optionYes);
|
||||||
|
select.appendChild(optionNo);
|
||||||
|
|
||||||
|
settingsBar.appendChild(selectLabel);
|
||||||
|
settingsBar.appendChild(select);
|
||||||
|
|
||||||
|
const targetDiv = document.querySelector('#top > div.uix_headerContainer > div.breadcrumb.block > div');
|
||||||
|
if (targetDiv) {
|
||||||
|
targetDiv.appendChild(settingsBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
GM.getValue('autoSelectDeleted', 'no').then(value => {
|
||||||
|
select.value = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
select.addEventListener('change', function() {
|
||||||
|
GM.setValue('autoSelectDeleted', this.value);
|
||||||
|
applyAutoSelect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerEvent(element, eventType) {
|
||||||
|
const event = new Event(eventType, { bubbles: true });
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutoSelect() {
|
||||||
|
GM.getValue('autoSelectDeleted', 'no').then(value => {
|
||||||
|
const deletedThreads = document.querySelectorAll('.is-deleted input[type="checkbox"]');
|
||||||
|
deletedThreads.forEach(checkbox => {
|
||||||
|
if (value === 'yes') {
|
||||||
|
checkbox.checked = true;
|
||||||
|
triggerEvent(checkbox, 'click');
|
||||||
|
triggerEvent(checkbox, 'change');
|
||||||
|
} else {
|
||||||
|
checkbox.checked = false;
|
||||||
|
triggerEvent(checkbox, 'click');
|
||||||
|
triggerEvent(checkbox, 'change');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
createSettingsBar();
|
||||||
|
applyAutoSelect();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Promote Current Time
|
// @name Promote Current Time
|
||||||
// @namespace http://tampermonkey.net/
|
// @namespace http://tampermonkey.net/
|
||||||
// @version 0.6.2
|
// @version 0.6.3
|
||||||
// @description Enter the current time for promotion.
|
// @description Enter the current time for promotion.
|
||||||
// @author Gameil
|
// @author Gameil
|
||||||
// @match https://f95zone.to/threads/*/
|
// @match https://f95zone.to/threads/*/
|
||||||
|
|||||||
@ -1,93 +1,204 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Sort Reports
|
// @name Sort Reports (no-freeze, resilient UI) — fixed WeakMap
|
||||||
// @namespace http://tampermonkey.net/
|
// @namespace http://tampermonkey.net/
|
||||||
// @version 2024-09-18
|
// @version 4.1
|
||||||
// @description Sort Reports
|
// @description Fast, chunked sorting with robust UI injection
|
||||||
// @author You
|
|
||||||
// @match https://f95zone.to/reports/queue/*
|
// @match https://f95zone.to/reports/queue/*
|
||||||
// @icon https://www.google.com/s2/favicons?sz=64&domain=f95zone.to
|
// @icon https://www.google.com/s2/favicons?sz=64&domain=f95zone.to
|
||||||
// @grant none
|
// @grant none
|
||||||
// @updateURL https://git.zonies.xyz/Ryahn/F95Zone-Scripts/raw/branch/main/report-organizer.js
|
// @run-at document-idle
|
||||||
// @downloadURL https://git.zonies.xyz/Ryahn/F95Zone-Scripts/raw/branch/main/report-organizer.js
|
|
||||||
// ==/UserScript==
|
// ==/UserScript==
|
||||||
|
|
||||||
(function() {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
window.onload = function() {
|
// Wait for a selector with MutationObserver fallback
|
||||||
const reportTable = document.querySelector('.structItemContainer');
|
function waitForElement(selector, root = document, timeoutMs = 15000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const first = root.querySelector(selector);
|
||||||
|
if (first) return resolve(first);
|
||||||
|
|
||||||
if (!reportTable) return;
|
const obs = new MutationObserver(() => {
|
||||||
|
const hit = root.querySelector(selector);
|
||||||
|
if (hit) { obs.disconnect(); resolve(hit); }
|
||||||
|
});
|
||||||
|
obs.observe(root === document ? document.documentElement : root, { childList: true, subtree: true });
|
||||||
|
|
||||||
const sortContainer = document.createElement('div');
|
const t = setTimeout(() => { obs.disconnect(); reject(new Error(`Timeout waiting for ${selector}`)); }, timeoutMs);
|
||||||
sortContainer.style.textAlign = 'right';
|
// ensure timer cleared on resolve
|
||||||
sortContainer.innerHTML = `
|
const originalResolve = resolve;
|
||||||
<label for="sortType">Sort By:</label>
|
resolve = (v) => { clearTimeout(t); originalResolve(v); };
|
||||||
<select id="sortType">
|
});
|
||||||
<option value="dateAsc">Date ASC</option>
|
}
|
||||||
<option value="dateDesc">Date DESC</option>
|
|
||||||
<option value="sectionAsc">Sub-section ASC</option>
|
|
||||||
<option value="sectionDesc">Sub-section DESC</option>
|
|
||||||
<option value="prefixAsc">Prefix ASC</option>
|
|
||||||
<option value="prefixDesc">Prefix DESC</option>
|
|
||||||
</select>
|
|
||||||
`;
|
|
||||||
|
|
||||||
reportTable.parentNode.insertBefore(sortContainer, reportTable);
|
const rIC = window.requestIdleCallback || function (cb) { return setTimeout(cb, 17); };
|
||||||
|
|
||||||
document.getElementById('sortType').addEventListener('change', function() {
|
function debounce(fn, ms) {
|
||||||
const sortValue = this.value;
|
let t;
|
||||||
const rows = Array.from(reportTable.querySelectorAll('.structItem'));
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||||
|
}
|
||||||
|
|
||||||
switch (sortValue) {
|
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
||||||
case 'dateAsc':
|
|
||||||
sortRows(rows, '.structItem-latestDate', true, 'date');
|
|
||||||
break;
|
|
||||||
case 'dateDesc':
|
|
||||||
sortRows(rows, '.structItem-latestDate', false, 'date');
|
|
||||||
break;
|
|
||||||
case 'sectionAsc':
|
|
||||||
sortRows(rows, '.structItem-forum a', true);
|
|
||||||
break;
|
|
||||||
case 'sectionDesc':
|
|
||||||
sortRows(rows, '.structItem-forum a', false);
|
|
||||||
break;
|
|
||||||
case 'prefixAsc':
|
|
||||||
sortRows(rows, '.structItem-title', true, 'prefix');
|
|
||||||
break;
|
|
||||||
case 'prefixDesc':
|
|
||||||
sortRows(rows, '.structItem-title', false, 'prefix');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function sortRows(rows, selector, asc = true, type = 'text') {
|
// Caches
|
||||||
const sortedRows = rows.sort((a, b) => {
|
let rowIndex = []; // [{rowEl, i, dateMs, section, prefix}]
|
||||||
let valA = a.querySelector(selector);
|
let rowMap = new WeakMap(); // rowEl -> cached keys
|
||||||
let valB = b.querySelector(selector);
|
|
||||||
|
|
||||||
if (!valA || !valB) {
|
function computeKeys(rowEl, i) {
|
||||||
return asc ? (valA ? -1 : 1) : (valB ? -1 : 1);
|
const dateEl = rowEl.querySelector('.structItem-latestDate');
|
||||||
}
|
const forumEl = rowEl.querySelector('.structItem-forum a');
|
||||||
|
const titleEl = rowEl.querySelector('.structItem-title');
|
||||||
|
|
||||||
if (type === 'date') {
|
const dateMs = dateEl ? (parseInt(dateEl.getAttribute('data-time') || '0', 10) * 1000) : 0;
|
||||||
valA = parseInt(valA.getAttribute('data-time')) * 1000;
|
const section = forumEl ? forumEl.textContent.trim() : '';
|
||||||
valB = parseInt(valB.getAttribute('data-time')) * 1000;
|
|
||||||
return asc ? valA - valB : valB - valA;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'prefix') {
|
let prefix = '';
|
||||||
valA = valA.innerText.trim().split(' - ')[0];
|
if (titleEl) {
|
||||||
valB = valB.innerText.trim().split(' - ')[0];
|
const t = titleEl.textContent.trim();
|
||||||
return asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
const mBracket = t.match(/^\s*\[([^\]]+)\]/);
|
||||||
}
|
if (mBracket && mBracket[1]) prefix = mBracket[1].trim();
|
||||||
|
else {
|
||||||
|
const parts = t.split(' - ');
|
||||||
|
prefix = (parts.length > 1 ? parts[0] : t).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
valA = valA.innerText.trim();
|
const obj = { rowEl, i, dateMs, section, prefix };
|
||||||
valB = valB.innerText.trim();
|
rowMap.set(rowEl, obj);
|
||||||
return asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
return obj;
|
||||||
});
|
}
|
||||||
|
|
||||||
sortedRows.forEach(row => reportTable.appendChild(row));
|
function rebuildIndex(reportTable) {
|
||||||
|
rowIndex = [];
|
||||||
|
rowMap = new WeakMap(); // recreate instead of .clear()
|
||||||
|
const rows = reportTable.querySelectorAll('.structItem');
|
||||||
|
let i = 0;
|
||||||
|
for (const r of rows) rowIndex.push(computeKeys(r, i++));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBusy(where) {
|
||||||
|
let badge = document.getElementById('sr-busy');
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.id = 'sr-busy';
|
||||||
|
badge.style.cssText = 'margin-left:8px;font-size:12px;opacity:.8;';
|
||||||
|
where.appendChild(badge);
|
||||||
|
}
|
||||||
|
badge.textContent = 'Sorting…';
|
||||||
|
}
|
||||||
|
function hideBusy() {
|
||||||
|
const badge = document.getElementById('sr-busy');
|
||||||
|
if (badge) badge.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream DOM writes across frames
|
||||||
|
function renderChunked(reportTable, orderedRows) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const total = orderedRows.length;
|
||||||
|
const chunk = Math.max(50, Math.floor(total / 20)); // ~20 frames worst case
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
reportTable.textContent = '';
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let n = 0; n < chunk && idx < total; n++, idx++) {
|
||||||
|
frag.appendChild(orderedRows[idx]);
|
||||||
}
|
}
|
||||||
};
|
reportTable.appendChild(frag);
|
||||||
|
if (idx < total) requestAnimationFrame(step);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelable sort
|
||||||
|
let sortAbort = { aborted: false };
|
||||||
|
async function sortAndRender(reportTable, kind, asc) {
|
||||||
|
sortAbort.aborted = true;
|
||||||
|
sortAbort = { aborted: false };
|
||||||
|
const token = sortAbort;
|
||||||
|
|
||||||
|
showBusy(document.getElementById('sortType')?.parentElement || reportTable);
|
||||||
|
await new Promise(res => rIC(res, { timeout: 100 }));
|
||||||
|
|
||||||
|
let cmp;
|
||||||
|
if (kind === 'date') {
|
||||||
|
cmp = (a, b) => (asc ? a.dateMs - b.dateMs : b.dateMs - a.dateMs) || (a.i - b.i);
|
||||||
|
} else if (kind === 'section') {
|
||||||
|
cmp = (a, b) => {
|
||||||
|
const c = asc ? collator.compare(a.section, b.section) : collator.compare(b.section, a.section);
|
||||||
|
return c || (a.i - b.i);
|
||||||
|
};
|
||||||
|
} else if (kind === 'prefix') {
|
||||||
|
cmp = (a, b) => {
|
||||||
|
const c = asc ? collator.compare(a.prefix, b.prefix) : collator.compare(b.prefix, a.prefix);
|
||||||
|
return c || (a.i - b.i);
|
||||||
|
};
|
||||||
|
} else return;
|
||||||
|
|
||||||
|
const items = rowIndex.slice();
|
||||||
|
items.sort(cmp);
|
||||||
|
if (token.aborted) return;
|
||||||
|
|
||||||
|
const nodes = items.map(it => it.rowEl);
|
||||||
|
await renderChunked(reportTable, nodes);
|
||||||
|
hideBusy();
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function boot() {
|
||||||
|
const reportTable = await waitForElement('.structItemContainer').catch(() => null);
|
||||||
|
if (!reportTable) return;
|
||||||
|
|
||||||
|
let host = document.querySelector('.block-outer .block-outer-opposite .buttonGroup')
|
||||||
|
|| document.querySelector('.block-outer .block-outer-opposite')
|
||||||
|
|| document.querySelector('.block-outer')
|
||||||
|
|| document.body;
|
||||||
|
|
||||||
|
const sortWrapper = document.createElement('span');
|
||||||
|
sortWrapper.style.display = 'inline-flex';
|
||||||
|
sortWrapper.style.alignItems = 'center';
|
||||||
|
sortWrapper.style.gap = '6px';
|
||||||
|
sortWrapper.style.marginLeft = '8px';
|
||||||
|
sortWrapper.innerHTML = `
|
||||||
|
<label for="sortType" style="font-weight:500;">Sort By:</label>
|
||||||
|
<select id="sortType"
|
||||||
|
style="background-color:#2f2d34; color:#fff; border-radius:6px; padding:4px 6px; border:1px solid #555;">
|
||||||
|
<option value="dateDesc" selected>Date DESC</option>
|
||||||
|
<option value="dateAsc">Date ASC</option>
|
||||||
|
<option value="sectionDesc">Sub-section DESC</option>
|
||||||
|
<option value="sectionAsc">Sub-section ASC</option>
|
||||||
|
<option value="prefixDesc">Prefix DESC</option>
|
||||||
|
<option value="prefixAsc">Prefix ASC</option>
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
host.insertBefore(sortWrapper, host.firstChild || null);
|
||||||
|
|
||||||
|
rebuildIndex(reportTable);
|
||||||
|
|
||||||
|
const scheduleReindex = debounce(() => rebuildIndex(reportTable), 150);
|
||||||
|
const mo = new MutationObserver(muts => {
|
||||||
|
for (const m of muts) {
|
||||||
|
if (m.addedNodes.length || m.removedNodes.length) {
|
||||||
|
scheduleReindex();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mo.observe(reportTable, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
const select = document.getElementById('sortType');
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
const v = select.value;
|
||||||
|
if (v === 'dateAsc') sortAndRender(reportTable, 'date', true);
|
||||||
|
else if (v === 'dateDesc') sortAndRender(reportTable, 'date', false);
|
||||||
|
else if (v === 'sectionAsc') sortAndRender(reportTable, 'section', true);
|
||||||
|
else if (v === 'sectionDesc') sortAndRender(reportTable, 'section', false);
|
||||||
|
else if (v === 'prefixAsc') sortAndRender(reportTable, 'prefix', true);
|
||||||
|
else if (v === 'prefixDesc') sortAndRender(reportTable, 'prefix', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
rIC(() => sortAndRender(reportTable, 'date', false), { timeout: 200 });
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
554
shared-ip-manager.userscript.js
Normal file
554
shared-ip-manager.userscript.js
Normal file
@ -0,0 +1,554 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name Shared IP Manager
|
||||||
|
// @namespace http://tampermonkey.net/
|
||||||
|
// @version 1.1.2
|
||||||
|
// @description Transform shared IP overlay into searchable table format
|
||||||
|
// @author Ryahn
|
||||||
|
// @match *://*/*
|
||||||
|
// @grant none
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Function to extract country code from flag class
|
||||||
|
function getCountryCode(flagElement) {
|
||||||
|
const classes = flagElement.className;
|
||||||
|
const match = classes.match(/tck-provider-country-flag\s+(\w+)/);
|
||||||
|
return match ? match[1].toUpperCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get country name from flag element
|
||||||
|
function getCountryName(flagElement) {
|
||||||
|
return flagElement.getAttribute('data-original-title') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get provider info
|
||||||
|
function getProviderInfo(providerElement) {
|
||||||
|
return {
|
||||||
|
name: providerElement.textContent.trim(),
|
||||||
|
asn: providerElement.getAttribute('data-original-title') || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get type info
|
||||||
|
function getTypeInfo(typeElement) {
|
||||||
|
return typeElement.getAttribute('data-original-title') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to extract usage count
|
||||||
|
function getUsageCount(usageText) {
|
||||||
|
const match = usageText.match(/(\d+)\s+time/);
|
||||||
|
return match ? parseInt(match[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create flag emoji from country code
|
||||||
|
function getFlagEmoji(countryCode) {
|
||||||
|
if (!countryCode) return '';
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toLowerCase()
|
||||||
|
.split('')
|
||||||
|
.map(char => 127397 + char.charCodeAt());
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create the table
|
||||||
|
function createTable() {
|
||||||
|
const overlay = document.querySelector('.overlay-content');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
// Find all user entries
|
||||||
|
const userEntries = overlay.querySelectorAll('.block-row.block-row--separated');
|
||||||
|
|
||||||
|
if (userEntries.length === 0) return;
|
||||||
|
|
||||||
|
// Create table container
|
||||||
|
const tableContainer = document.createElement('div');
|
||||||
|
tableContainer.style.cssText = `
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create search and filter container
|
||||||
|
const searchFilterContainer = document.createElement('div');
|
||||||
|
searchFilterContainer.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create search input
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'text';
|
||||||
|
searchInput.placeholder = 'Search users, countries, providers...';
|
||||||
|
searchInput.style.cssText = `
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #101113;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #101113;
|
||||||
|
color: #374151;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create banned filter button
|
||||||
|
const bannedFilterButton = document.createElement('button');
|
||||||
|
bannedFilterButton.textContent = 'Show Banned Only';
|
||||||
|
bannedFilterButton.style.cssText = `
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #dc262620;
|
||||||
|
color: #dc2626;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add hover effect for button
|
||||||
|
bannedFilterButton.addEventListener('mouseenter', function() {
|
||||||
|
this.style.backgroundColor = '#dc2626';
|
||||||
|
this.style.color = 'white';
|
||||||
|
});
|
||||||
|
bannedFilterButton.addEventListener('mouseleave', function() {
|
||||||
|
if (!this.classList.contains('active')) {
|
||||||
|
this.style.backgroundColor = '#dc262620';
|
||||||
|
this.style.color = '#dc2626';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create count display
|
||||||
|
const countDisplay = document.createElement('div');
|
||||||
|
countDisplay.style.cssText = `
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #17191b;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add button to container
|
||||||
|
searchFilterContainer.appendChild(searchInput);
|
||||||
|
searchFilterContainer.appendChild(bannedFilterButton);
|
||||||
|
searchFilterContainer.appendChild(countDisplay);
|
||||||
|
|
||||||
|
// Create table wrapper for scrolling
|
||||||
|
const tableWrapper = document.createElement('div');
|
||||||
|
tableWrapper.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: calc(100vh - 150px);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create table
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
min-width: 800px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create table header
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
thead.style.cssText = `position: sticky; top: 0; z-index: 10;`;
|
||||||
|
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
headerRow.style.cssText = `background: #101113; border-bottom: 1px solid #101113; color: #959595;`;
|
||||||
|
|
||||||
|
// Username header (sortable)
|
||||||
|
const usernameHeader = document.createElement('th');
|
||||||
|
usernameHeader.id = 'sort-username';
|
||||||
|
usernameHeader.style.cssText = `padding: 10px 12px; text-align: left; border-right: 1px solid #17191b; font-weight: 600; color: #959595; white-space: nowrap; cursor: pointer; user-select: none;`;
|
||||||
|
usernameHeader.innerHTML = `Username <span id="username-sort-icon" style="margin-left: 5px;">↕</span>`;
|
||||||
|
|
||||||
|
// Join Date header
|
||||||
|
const joinDateHeader = document.createElement('th');
|
||||||
|
joinDateHeader.style.cssText = `padding: 10px 12px; text-align: left; border-right: 1px solid #17191b; font-weight: 600; color: #959595; white-space: nowrap;`;
|
||||||
|
joinDateHeader.textContent = 'Join Date';
|
||||||
|
|
||||||
|
// Messages header
|
||||||
|
const messagesHeader = document.createElement('th');
|
||||||
|
messagesHeader.style.cssText = `padding: 10px 12px; text-align: left; border-right: 1px solid #17191b; font-weight: 600; color: #959595; white-space: nowrap;`;
|
||||||
|
messagesHeader.textContent = 'Messages';
|
||||||
|
|
||||||
|
// Country header
|
||||||
|
const countryHeader = document.createElement('th');
|
||||||
|
countryHeader.style.cssText = `padding: 10px 12px; text-align: left; border-right: 1px solid #17191b; font-weight: 600; color: #959595; white-space: nowrap;`;
|
||||||
|
countryHeader.textContent = 'Country';
|
||||||
|
|
||||||
|
// Provider header
|
||||||
|
const providerHeader = document.createElement('th');
|
||||||
|
providerHeader.style.cssText = `padding: 10px 12px; text-align: left; border-right: 1px solid #17191b; font-weight: 600; color: #959595; white-space: nowrap;`;
|
||||||
|
providerHeader.textContent = 'Provider';
|
||||||
|
|
||||||
|
// Type header (sortable)
|
||||||
|
const typeHeader = document.createElement('th');
|
||||||
|
typeHeader.id = 'sort-type';
|
||||||
|
typeHeader.style.cssText = `padding: 10px 12px; text-align: left; border-right: 1px solid #17191b; font-weight: 600; color: #959595; white-space: nowrap; cursor: pointer; user-select: none;`;
|
||||||
|
typeHeader.innerHTML = `Type <span id="type-sort-icon" style="margin-left: 5px;">↕</span>`;
|
||||||
|
|
||||||
|
// Usage header (sortable)
|
||||||
|
const usageHeader = document.createElement('th');
|
||||||
|
usageHeader.id = 'sort-usage';
|
||||||
|
usageHeader.style.cssText = `padding: 10px 12px; text-align: left; font-weight: 600; color: #959595; white-space: nowrap; cursor: pointer; user-select: none;`;
|
||||||
|
usageHeader.innerHTML = `Usage <span id="usage-sort-icon" style="margin-left: 5px;">↕</span>`;
|
||||||
|
|
||||||
|
// Append all headers to row
|
||||||
|
headerRow.appendChild(usernameHeader);
|
||||||
|
headerRow.appendChild(joinDateHeader);
|
||||||
|
headerRow.appendChild(messagesHeader);
|
||||||
|
headerRow.appendChild(countryHeader);
|
||||||
|
headerRow.appendChild(providerHeader);
|
||||||
|
headerRow.appendChild(typeHeader);
|
||||||
|
headerRow.appendChild(usageHeader);
|
||||||
|
|
||||||
|
thead.appendChild(headerRow);
|
||||||
|
|
||||||
|
// Create table body
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
|
||||||
|
// Process each user entry
|
||||||
|
userEntries.forEach((entry, index) => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.style.cssText = `
|
||||||
|
border-bottom: 1px solid #17191b;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
`;
|
||||||
|
row.style.backgroundColor = index % 2 === 0 ? '#131313' : '#131313';
|
||||||
|
|
||||||
|
// Username
|
||||||
|
const usernameLink = entry.querySelector('h3.contentRow-header a.username');
|
||||||
|
const username = usernameLink ? usernameLink.textContent.trim() : 'N/A';
|
||||||
|
const profileUrl = usernameLink ? usernameLink.href : '#';
|
||||||
|
|
||||||
|
// Check if user is banned
|
||||||
|
const usernameSpan = entry.querySelector('h3.contentRow-header a.username span');
|
||||||
|
const isBanned = usernameSpan && usernameSpan.classList.contains('username--banned');
|
||||||
|
|
||||||
|
// Join date
|
||||||
|
const joinDateElement = entry.querySelector('time.u-dt');
|
||||||
|
const joinDate = joinDateElement ? joinDateElement.textContent.trim() : 'N/A';
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
const dtElements = entry.querySelectorAll('dt');
|
||||||
|
let messagesElement = null;
|
||||||
|
for (const dt of dtElements) {
|
||||||
|
if (dt.textContent.includes('Messages')) {
|
||||||
|
messagesElement = dt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const messages = messagesElement ?
|
||||||
|
(messagesElement.nextElementSibling ? messagesElement.nextElementSibling.textContent.trim() : '0') : '0';
|
||||||
|
|
||||||
|
// Country flag and name
|
||||||
|
const flagElement = entry.querySelector('.tck-provider-country-flag');
|
||||||
|
const countryCode = flagElement ? getCountryCode(flagElement) : '';
|
||||||
|
const countryName = flagElement ? getCountryName(flagElement) : '';
|
||||||
|
const flagEmoji = getFlagEmoji(countryCode);
|
||||||
|
|
||||||
|
// Provider info
|
||||||
|
const providerElement = entry.querySelector('.tck-provider-txt');
|
||||||
|
const providerInfo = providerElement ? getProviderInfo(providerElement) : { name: 'N/A', asn: '' };
|
||||||
|
|
||||||
|
// Type info
|
||||||
|
const typeElement = entry.querySelector('.tck-provider-type');
|
||||||
|
const typeInfo = typeElement ? getTypeInfo(typeElement) : 'N/A';
|
||||||
|
|
||||||
|
// Usage count
|
||||||
|
const liElements = entry.querySelectorAll('li');
|
||||||
|
let usageElement = null;
|
||||||
|
for (const li of liElements) {
|
||||||
|
if (li.textContent.includes('time')) {
|
||||||
|
usageElement = li;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const usageText = usageElement ? usageElement.textContent.trim() : '0 time';
|
||||||
|
const usageCount = getUsageCount(usageText);
|
||||||
|
|
||||||
|
|
||||||
|
// Build row HTML
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="padding: 10px 12px; border-right: 1px solid #e5e7eb; white-space: nowrap;">
|
||||||
|
<a href="${profileUrl}" target="_blank" style="color: ${isBanned ? '#dc2626' : '#2563eb'}; text-decoration: ${isBanned ? 'line-through' : 'none'}; font-weight: 500;">
|
||||||
|
${isBanned ? '🚫 ' : ''}${username}
|
||||||
|
</a>
|
||||||
|
${isBanned ? '<span style="color: #dc2626; margin-left: 8px; font-size: 11px; font-weight: 600; background: #dc262620; padding: 2px 6px; border-radius: 3px;">BANNED</span>' : ''}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px 12px; border-right: 1px solid #e5e7eb; white-space: nowrap; color: #6b7280;">${joinDate}</td>
|
||||||
|
<td style="padding: 10px 12px; border-right: 1px solid #e5e7eb; white-space: nowrap; color: #6b7280;">${messages}</td>
|
||||||
|
<td style="padding: 10px 12px; border-right: 1px solid #e5e7eb; white-space: nowrap;">
|
||||||
|
<span title="${countryName}" style="color: #374151;">${flagEmoji} ${countryCode}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px 12px; border-right: 1px solid #e5e7eb; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${providerInfo.asn}">
|
||||||
|
<span style="color: #374151;">${providerInfo.name}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px 12px; border-right: 1px solid #e5e7eb; white-space: nowrap;" title="${typeInfo}">
|
||||||
|
<span style="color: #6b7280;">${typeInfo.replace('Type: ', '')}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px 12px; text-align: center; white-space: nowrap;">
|
||||||
|
<span style="color: #374151; font-weight: 500;">${usageCount}</span>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add hover effect
|
||||||
|
row.addEventListener('mouseenter', () => {
|
||||||
|
row.style.backgroundColor = '#131313';
|
||||||
|
});
|
||||||
|
row.addEventListener('mouseleave', () => {
|
||||||
|
row.style.backgroundColor = index % 2 === 0 ? '#131313' : '#131313';
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
let showBannedOnly = false;
|
||||||
|
|
||||||
|
// Function to apply both search and filter
|
||||||
|
function applyFilters() {
|
||||||
|
const searchTerm = searchInput.value.toLowerCase();
|
||||||
|
const rows = tbody.querySelectorAll('tr');
|
||||||
|
let visibleCount = 0;
|
||||||
|
let bannedCount = 0;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const text = row.textContent.toLowerCase();
|
||||||
|
const isBanned = row.querySelector('a[href*="/members/"]') &&
|
||||||
|
row.querySelector('a[href*="/members/"]').textContent.includes('🚫');
|
||||||
|
|
||||||
|
const matchesSearch = text.includes(searchTerm);
|
||||||
|
const matchesFilter = !showBannedOnly || isBanned;
|
||||||
|
|
||||||
|
const shouldShow = matchesSearch && matchesFilter;
|
||||||
|
row.style.display = shouldShow ? '' : 'none';
|
||||||
|
|
||||||
|
if (shouldShow) {
|
||||||
|
visibleCount++;
|
||||||
|
if (isBanned) bannedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update count display
|
||||||
|
if (showBannedOnly) {
|
||||||
|
countDisplay.textContent = `Showing ${bannedCount} banned users`;
|
||||||
|
} else {
|
||||||
|
countDisplay.textContent = `${visibleCount} users (${bannedCount} banned)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search functionality
|
||||||
|
searchInput.addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
// Add banned filter functionality
|
||||||
|
bannedFilterButton.addEventListener('click', () => {
|
||||||
|
showBannedOnly = !showBannedOnly;
|
||||||
|
|
||||||
|
if (showBannedOnly) {
|
||||||
|
bannedFilterButton.textContent = 'Show All Users';
|
||||||
|
bannedFilterButton.classList.add('active');
|
||||||
|
bannedFilterButton.style.backgroundColor = '#dc2626';
|
||||||
|
bannedFilterButton.style.color = 'white';
|
||||||
|
} else {
|
||||||
|
bannedFilterButton.textContent = 'Show Banned Only';
|
||||||
|
bannedFilterButton.classList.remove('active');
|
||||||
|
bannedFilterButton.style.backgroundColor = '#dc262620';
|
||||||
|
bannedFilterButton.style.color = '#dc2626';
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize filters to set up count display
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
// Assemble table
|
||||||
|
table.appendChild(thead);
|
||||||
|
table.appendChild(tbody);
|
||||||
|
|
||||||
|
// Add table to wrapper
|
||||||
|
tableWrapper.appendChild(table);
|
||||||
|
|
||||||
|
// Add elements to container
|
||||||
|
tableContainer.appendChild(searchFilterContainer);
|
||||||
|
tableContainer.appendChild(tableWrapper);
|
||||||
|
|
||||||
|
// Sorting functionality
|
||||||
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
|
|
||||||
|
function sortTable(column, direction) {
|
||||||
|
console.log('Sorting by:', column, direction); // Debug log
|
||||||
|
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
console.log('Found rows:', rows.length); // Debug log
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('No rows to sort');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let aValue, bValue;
|
||||||
|
|
||||||
|
if (column === 'username') {
|
||||||
|
const aLink = a.querySelector('a');
|
||||||
|
const bLink = b.querySelector('a');
|
||||||
|
aValue = aLink ? aLink.textContent.trim().toLowerCase() : '';
|
||||||
|
bValue = bLink ? bLink.textContent.trim().toLowerCase() : '';
|
||||||
|
} else if (column === 'type') {
|
||||||
|
aValue = a.cells[5] ? a.cells[5].textContent.trim().toLowerCase() : '';
|
||||||
|
bValue = b.cells[5] ? b.cells[5].textContent.trim().toLowerCase() : '';
|
||||||
|
} else if (column === 'usage') {
|
||||||
|
// Parse usage as numbers for proper numerical sorting
|
||||||
|
aValue = parseFloat(a.cells[6] ? a.cells[6].textContent.trim() : '0') || 0;
|
||||||
|
bValue = parseFloat(b.cells[6] ? b.cells[6].textContent.trim() : '0') || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Comparing:', aValue, 'vs', bValue); // Debug log
|
||||||
|
|
||||||
|
if (column === 'usage') {
|
||||||
|
// Numerical comparison for usage
|
||||||
|
if (direction === 'asc') {
|
||||||
|
return aValue - bValue;
|
||||||
|
} else {
|
||||||
|
return bValue - aValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// String comparison for username and type
|
||||||
|
if (direction === 'asc') {
|
||||||
|
return aValue.localeCompare(bValue);
|
||||||
|
} else {
|
||||||
|
return bValue.localeCompare(aValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear tbody and re-append sorted rows
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
|
|
||||||
|
// Update sort icons
|
||||||
|
document.querySelectorAll('[id$="-sort-icon"]').forEach(icon => {
|
||||||
|
icon.textContent = '↕';
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortIcon = document.getElementById(`${column}-sort-icon`);
|
||||||
|
if (sortIcon) {
|
||||||
|
sortIcon.textContent = direction === 'asc' ? '↑' : '↓';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sorting completed'); // Debug log
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click event listeners for sortable columns
|
||||||
|
console.log('Adding click listener to username header');
|
||||||
|
usernameHeader.addEventListener('click', (e) => {
|
||||||
|
console.log('Username header clicked');
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentSort.column === 'username') {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = 'username';
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
console.log('Current sort:', currentSort);
|
||||||
|
sortTable(currentSort.column, currentSort.direction);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hover effects for username header
|
||||||
|
usernameHeader.addEventListener('mouseenter', function() {
|
||||||
|
this.style.backgroundColor = '#1a1a1a';
|
||||||
|
});
|
||||||
|
usernameHeader.addEventListener('mouseleave', function() {
|
||||||
|
this.style.backgroundColor = '#101113';
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Adding click listener to type header');
|
||||||
|
typeHeader.addEventListener('click', (e) => {
|
||||||
|
console.log('Type header clicked');
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentSort.column === 'type') {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = 'type';
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
console.log('Current sort:', currentSort);
|
||||||
|
sortTable(currentSort.column, currentSort.direction);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hover effects for type header
|
||||||
|
typeHeader.addEventListener('mouseenter', function() {
|
||||||
|
this.style.backgroundColor = '#1a1a1a';
|
||||||
|
});
|
||||||
|
typeHeader.addEventListener('mouseleave', function() {
|
||||||
|
this.style.backgroundColor = '#101113';
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Adding click listener to usage header');
|
||||||
|
usageHeader.addEventListener('click', (e) => {
|
||||||
|
console.log('Usage header clicked');
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentSort.column === 'usage') {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = 'usage';
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
console.log('Current sort:', currentSort);
|
||||||
|
sortTable(currentSort.column, currentSort.direction);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hover effects for usage header
|
||||||
|
usageHeader.addEventListener('mouseenter', function() {
|
||||||
|
this.style.backgroundColor = '#1a1a1a';
|
||||||
|
});
|
||||||
|
usageHeader.addEventListener('mouseleave', function() {
|
||||||
|
this.style.backgroundColor = '#101113';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace overlay content
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
overlay.appendChild(tableContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for overlay to load and then transform it
|
||||||
|
function waitForOverlay() {
|
||||||
|
const overlay = document.querySelector('.overlay-content');
|
||||||
|
if (overlay) {
|
||||||
|
createTable();
|
||||||
|
} else {
|
||||||
|
// Check again in 100ms
|
||||||
|
setTimeout(waitForOverlay, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start monitoring for overlay
|
||||||
|
waitForOverlay();
|
||||||
|
|
||||||
|
// Also listen for overlay events in case it loads dynamically
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('js-overlayClose') ||
|
||||||
|
e.target.closest('.js-overlayClose')) {
|
||||||
|
// Overlay is closing, wait for it to reopen
|
||||||
|
setTimeout(waitForOverlay, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user