Sleazy Fork is available in English.
Completely remove filtered posts and comments from the global comments page + configurable comment highlighting
// ==UserScript==
// @name Rule34.xxx Proper Comments Filter
// @namespace 861ddd094884eac5bea7a3b12e074f34
// @version 2.3.0
// @description Completely remove filtered posts and comments from the global comments page + configurable comment highlighting
// @author Anonymous
// @match https://rule34.xxx/index.php?page=comment&s=list
// @match https://rule34.xxx/index.php?page=post&s=view&id=*
// @match https://rule34.xxx/index.php?page=comment&s=user&user=*
// @icon https://external-content.duckduckgo.com/ip3/rule34.xxx.ico
// @connect api.rule34.xxx
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect api.rule34.xxx
// @license MIT-0
// ==/UserScript==
(function () {
'use strict';
// configuration
///////////////////
const CONFIG = {
// number of (non-blacklisted) posts to fill the page with
postsPerPage: 10,
// most-recent comments shown per post
commentsPerPost: 5,
// hard ceiling on API request rate; the API rate-limits at 60 req/s
maxRequestsPerSecond: 60,
// retries per request on 429/503/transport errors before giving up
maxRetries: 4,
// hard cap on candidate posts discovered from the feed, to bound API usage
maxScan: 120,
// localStorage cache TTL for post objects (post tags/score drift slowly)
cacheExpiryDays: 1,
// logging
debug: true,
};
const API_BASE = 'https://api.rule34.xxx/index.php?page=dapi&q=index';
const LOG = '[Rule34.xxx Proper Comments Filter] ';
// request pacing
const requestStats = {
minDelay: 200,
maxDelay: 1500,
currentDelay: 350,
successStreak: 0,
failureStreak: 0,
adjustDelay(success) {
if (success) {
this.failureStreak = 0;
if (++this.successStreak >= 3) {
this.currentDelay = Math.max(this.minDelay, this.currentDelay - 50);
this.successStreak = 2;
}
} else {
this.successStreak = 0;
this.currentDelay = Math.min(
this.maxDelay,
this.currentDelay * (1 + ++this.failureStreak * 0.5)
);
}
return this.currentDelay;
},
};
const log = (...a) => CONFIG.debug && console.log(LOG, ...a);
const warn = (...a) => console.warn(LOG, ...a);
// styling
/////////////
const COMMENT_CSS = `
span.spoiler {
cursor: auto;
color: #121212;
background: #121212; /* --bg-color */
}
span.spoiler:hover {
color: #c0c0c0; /* --c-text */
}
#comment-list .pcf-today {
background: rgba(240, 128, 0, 0.10);
border-left: 3px solid #f08000;
border-radius: 4px;
padding: 4px 0 4px 8px;
}
`;
const STYLE_ELEMENT = document.createElement('style');
STYLE_ELEMENT.textContent = COMMENT_CSS;
document.head.appendChild(STYLE_ELEMENT);
// A native <dialog> hosted inside a Shadow root: the shadow boundary keeps the
// site's stylesheet (stock or the dark-gallery reskin) from bleeding in, and
// showModal() gives backdrop dimming, focus trapping and Esc-to-close for free.
const HL_CSS = `
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, sans-serif; }
dialog.card {
border: none; border-radius: 10px; padding: 0; color: #e6e6e6;
background: #1f2023; width: min(700px, 42vw);
box-shadow: 0 10px 40px rgba(0,0,0,.5);
}
dialog.card::backdrop { background: rgba(0,0,0,.6); }
.wrap { padding: 18px 20px 16px; margin: 0; }
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
h1 { font-size: 16px; font-weight: 600; margin: 0; }
.x { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; line-height: 1; }
.x:hover { color: #fff; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #b8b8b8; }
label.pos { color: #7bd88f; }
label.neg { color: #e88; }
textarea {
resize: vertical; padding: 6px 8px; min-height: 52px;
border: 1px solid #3a3c40; border-radius: 6px;
background: #141517; color: #e6e6e6;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px;
}
textarea:focus { outline: none; border-color: #6ea8fe; }
.opts { display: flex; align-items: center; gap: 18px; margin: 14px 0 4px; flex-wrap: wrap; }
.colors { display: flex; align-items: center; gap: 18px; margin: 8px 0 4px; flex-wrap: wrap; }
.chk, .col { flex-direction: row; align-items: center; gap: 6px; color: #e6e6e6; }
.clabel { color: #b8b8b8; }
input.hex {
border: 1px solid #3a3c40; border-radius: 6px;
background: #141517; color: #e6e6e6; font-size: 12px; padding: 4px 6px;
width: 84px; font-family: ui-monospace, Menlo, Consolas, monospace;
text-transform: lowercase;
}
input.hex:focus { outline: none; border-color: #6ea8fe; }
.cpick { position: relative; }
.cpick .head {
display: flex; align-items: center; gap: 6px; cursor: pointer;
border: 1px solid #3a3c40; border-radius: 6px; min-width: 116px;
background: #141517; color: #e6e6e6; font-size: 12px; padding: 4px 8px;
}
.cpick .head .caret { margin-left: auto; color: #9a9a9a; font-size: 10px; }
.cpick.open .head { border-color: #6ea8fe; }
.cpick .menu {
position: fixed; z-index: 2147483647;
margin: 0; padding: 4px; list-style: none; display: none;
border: 1px solid #3a3c40; border-radius: 6px; background: #1f2023;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
max-height: 220px; overflow: auto; min-width: 140px;
}
.cpick.open .menu { display: block; }
.cpick .opt {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 4px 6px; border-radius: 4px; font-size: 12px; color: #e6e6e6;
white-space: nowrap;
}
.cpick .opt:hover { background: #2a2c30; }
.cpick .opt[aria-selected="true"] { background: #34373c; }
.swatch {
width: 12px; height: 12px; border-radius: 3px; flex: none;
border: 1px solid rgba(255,255,255,.25);
}
.swatch.none {
background: repeating-linear-gradient(45deg,#555,#555 3px,#222 3px,#222 6px);
}
.errors { color: #ffb4b4; font-size: 11px; white-space: pre-wrap; margin: 8px 0 0;
font-family: ui-monospace, monospace; max-height: 120px; overflow: auto; }
.errors:empty { display: none; }
footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.btn { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
border: 1px solid #3a3c40; background: #2a2c30; color: #e6e6e6; }
.btn:hover { background: #34373c; }
.btn.primary { background: #3b6ea5; border-color: #3b6ea5; }
.btn.primary:hover { background: #4279b8; }
`;
const HL_DIALOG_HTML = `
<form method="dialog" class="wrap">
<header>
<h1>Comment highlighting</h1>
<button type="button" id="f-close" class="x" title="Close">✕</button>
</header>
<div class="grid">
<label class="pos">Positive — names
<textarea id="f-pos-names" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
<label class="pos">Positive — content
<textarea id="f-pos-content" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
<label class="neg">Negative — names
<textarea id="f-neg-names" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
<label class="neg">Negative — content
<textarea id="f-neg-content" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
</div>
<div class="opts">
<label class="chk"><input type="checkbox" id="f-spoiler"> Spoiler negative bodies</label>
<label class="chk"><input type="checkbox" id="f-recent"> Highlight comments from today</label>
</div>
<div class="colors">
<div class="col"><span class="clabel">Positive</span>
<div class="cpick" id="f-color-pos-pick"></div>
<input type="text" id="f-color-pos" class="hex" spellcheck="false"
placeholder="#rrggbb" maxlength="7" autocomplete="off"></div>
<div class="col"><span class="clabel">Negative</span>
<div class="cpick" id="f-color-neg-pick"></div>
<input type="text" id="f-color-neg" class="hex" spellcheck="false"
placeholder="#rrggbb" maxlength="7" autocomplete="off"></div>
</div>
<pre id="f-errors" class="errors"></pre>
<footer>
<button type="button" id="f-cancel" class="btn">Cancel</button>
<button type="button" id="f-save" class="btn primary">Save</button>
</footer>
</form>
`;
// helpers
/////////////
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// hard request-rate ceiling: enforce a minimum gap between consecutive API
// calls so we never exceed the API's documented limit, independent of the
// adaptive backoff above. All requests are sequential (awaited), so a single
// shared timestamp is sufficient.
const rateGate = {
_last: 0,
async wait() {
const minGap = 1000 / CONFIG.maxRequestsPerSecond;
const elapsed = Date.now() - this._last;
if (elapsed < minGap) await sleep(minGap - elapsed);
this._last = Date.now();
},
};
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// tag link target uses '+' for spaces and url-encodes the rest, matching the
// stock markup closely enough for navigation
function tagSearchHref(tag) {
return 'index.php?page=post&s=list&tags=' + encodeURIComponent(tag);
}
// transport: api.rule34.xxx sends no CORS headers, so a same-origin fetch
// from rule34.xxx can never read its responses (the request still hits the
// server, doubling the effective rate). Use GM_xmlhttpRequest as the primary
// transport — it is cross-origin privileged — and only fall back to fetch
// when GM is unavailable.
function rawGet(url) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (res) => {
if (res.status >= 200 && res.status < 300) {
resolve(res.responseText);
return;
}
const err = new Error('HTTP ' + res.status);
err.status = res.status;
const m = /retry-after:\s*(\d+)/i.exec(res.responseHeaders || '');
if (m) err.retryAfter = parseInt(m[1], 10) * 1000;
reject(err);
},
onerror: () =>
reject(
Object.assign(new Error('GM_xmlhttpRequest error'), { status: 0 })
),
});
return;
}
fetch(url, { credentials: 'omit' })
.then((r) => {
if (!r.ok) {
const err = new Error('HTTP ' + r.status);
err.status = r.status;
throw err;
}
return r.text();
})
.then(resolve, reject);
});
}
// gated, self-throttling GET: retries on 429/503/transport errors with
// adaptive backoff (honouring Retry-After) so transient rate-limiting does
// not leave a post with an empty comment list.
async function httpGet(url, attempt = 0) {
await rateGate.wait();
try {
const text = await rawGet(url);
requestStats.adjustDelay(true);
return text;
} catch (err) {
const status = err.status || 0;
const retryable = status === 429 || status === 503 || status === 0;
const backoff = requestStats.adjustDelay(false);
if (retryable && attempt < CONFIG.maxRetries) {
const wait = Math.max(err.retryAfter || 0, backoff);
log(
`retry ${attempt + 1}/${CONFIG.maxRetries} after ${wait}ms (status ${status})`
);
await sleep(wait);
return httpGet(url, attempt + 1);
}
throw err;
}
}
// post-object cache
///////////////////////
function cacheGet(id) {
try {
const raw = localStorage.getItem(id);
if (!raw) return null;
const data = JSON.parse(raw);
if (Date.now() - data._ts < CONFIG.cacheExpiryDays * 864e5) return data.v;
localStorage.removeItem(id);
} catch (e) {
log('cache read error', e);
}
return null;
}
function cacheSet(id, v) {
try {
localStorage.setItem(id, JSON.stringify({ _ts: Date.now(), v }));
} catch (e) {
log('cache write error', e);
}
}
// auth
//////////
function resolveAuth() {
return new Promise((resolve) => {
const cached = GM_getValue('api_auth', null);
if (cached) {
resolve(cached);
return;
}
const xhr = new XMLHttpRequest();
xhr.responseType = 'document';
xhr.open('GET', '/index.php?page=account&s=options', true);
xhr.onload = function () {
if (xhr.status !== 200) {
resolve(null);
return;
}
try {
const areas = xhr.response.getElementsByTagName('TEXTAREA');
const apiString = areas[2] && areas[2].defaultValue;
if (!apiString || apiString === '&api_key=&user_id=2') {
resolve(null);
return;
}
GM_setValue('api_auth', apiString);
resolve(apiString);
} catch (e) {
warn('failed to scrape api auth:', e);
resolve(null);
}
};
xhr.onerror = () => resolve(null);
xhr.send();
});
}
// blacklist
///////////////
function readCookie(name) {
const m = document.cookie.match(
new RegExp('(?:^|;\\s*)' + name + '=([^;]*)')
);
return m ? decodeURIComponent(m[1]) : '';
}
function getBlacklist() {
// flat, lowercased token list (OR semantics), as the stock code treats it.
// The stored cookie is double-encoded, so the single decodeURIComponent in
// readCookie leaves token separators as literal "%20" rather than real
// spaces. Split on whitespace, commas, OR "%20" to match stock Cookie.get(),
// which double-decodes and then splits on /[, ]|%20+/.
return readCookie('tag_blacklist')
.toLowerCase()
.split(/(?:[\s,]|%20)+/)
.filter((t) => t && t !== ' ');
}
const postThreshold = () => parseInt(readCookie('post_threshold'), 10) || 0;
const commentThreshold = () =>
parseInt(readCookie('comment_threshold'), 10) || -5;
// The "filter_ai" account option is applied server-side, so the API does not
// honour it; replicate it here by dropping posts tagged ai_generated when the
// cookie is enabled.
const aiFilterEnabled = () => (parseInt(readCookie('filter_ai')) === 1);
function isAiHidden(post, aiFilter) {
if (!aiFilter) return false;
const tags = (post.tags || '').toLowerCase().split(/\s+/).filter(Boolean);
return tags.includes('ai_generated');
}
function isPostBlacklisted(post, blacklist) {
if (!blacklist.length) return false;
const tags = (post.tags || '').toLowerCase().split(/\s+/).filter(Boolean);
const rating = (post.rating || '').toLowerCase();
const score = parseInt(post.score, 10) || 0;
if (score < postThreshold()) return true;
if (blacklist.includes('rating:' + rating)) return true;
return tags.some((t) => blacklist.includes(t));
}
// TODO: parse from post page DOM
function isCommentHidden(comment, blacklist) {
const user = (comment.creator || '').toLowerCase();
if (blacklist.includes('user:' + user)) return true;
// comment score is not exposed by the comments API; honour the threshold
// only if a score field ever appears.
if (comment.score != null && parseInt(comment.score, 10) < commentThreshold())
return true;
return false;
}
// API
///////
// Global comment feed = recency seed. Returns ordered comment objects. The
// s=comment endpoint ignores limit/pid and always returns its default batch
// of the most recent comments, so it is a single, un-paginated request.
async function fetchCommentFeed(auth) {
const url = `${API_BASE}&s=comment${auth}`;
const xml = await httpGet(url);
return parseComments(xml);
}
async function fetchPost(auth, id) {
const cached = cacheGet(id);
if (cached) return cached;
const url = `${API_BASE}&s=post&json=1&id=${id}${auth}`;
const text = await httpGet(url);
let post = null;
try {
const data = JSON.parse(text);
const arr = Array.isArray(data) ? data : data.post || [];
post = arr[0] || null;
} catch (e) {
log('post parse error for', id, e);
}
if (post) cacheSet(id, post);
return post;
}
function parseComments(xml) {
const doc = new DOMParser().parseFromString(xml, 'text/xml');
if (doc.querySelector('parsererror')) return [];
return Array.from(doc.getElementsByTagName('comment')).map((c) => ({
id: c.getAttribute('id'),
post_id: c.getAttribute('post_id'),
creator: c.getAttribute('creator'),
creator_id: c.getAttribute('creator_id'),
body: c.getAttribute('body') || '',
created_at: c.getAttribute('created_at') || '',
score: c.getAttribute('score'),
}));
}
// rendering
///////////////
// Surviving native posts keep their original DOM (with genuine timestamps),
// so only back-filled comments are rendered from feed data here. Format the
// feed's created_at to match the native "YYYY-MM-DD HH:MM:SS" style; omit the
// line entirely if the feed gives no parseable timestamp.
function formatCommentDate(c) {
const raw = c.created_at || '';
if (!raw) return '';
const d = new Date(raw);
if (isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
);
}
function formatPostDate(post) {
const raw = post.created_at || '';
const d = new Date(raw);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
return escapeHtml(raw);
}
function buildCommentBlock(c) {
const cid = escapeHtml(c.id);
const name = escapeHtml(c.creator);
const body = escapeHtml(c.body).replace(/\r?\n/g, '<br />');
const date = formatCommentDate(c);
const dateLine = date
? `<span class="date"> ${escapeHtml(date)}</span><br />`
: '';
return (
`<div class="post" id="c${cid}" style="margin-bottom: 1em;">` +
`<div class="author"><h6>` +
`<a href="index.php?page=account&s=profile&uname=${encodeURIComponent(
c.creator
)}">${name}</a></h6>` +
dateLine +
`<span class="date" style="font-size: 11px;">>> #${cid}</span></div>` +
`<div class="content"><div class="body">${body}</div>` +
`<div class="footer"><a id="rc${cid}"></a> ` +
`<a href="#" id="rcl${cid}" onclick="Javascript:cflag('${cid}'); ` +
`$('rc${cid}').innerHTML='<b>Reported</b>'; $('rcl${cid}').innerHTML=''; return false;">` +
`Report comment</a></div></div></div>`
);
}
function buildTagsMarkup(post) {
const tags = (post.tags || '').split(/\s+/).filter(Boolean);
const spans = tags
.map((t) => `<span class="tag-type-general"><a href="${tagSearchHref(t)}">${escapeHtml(t)}</a></span>`)
.join(' ');
return `<div class="tags" style="width: 700px;"><strong>Tags:</strong> ${spans}</div>`;
}
function buildPostBlock(post, comments) {
const id = escapeHtml(post.id);
const thumb = post.preview_url || post.sample_url || post.file_url || '';
const owner = post.owner || '';
const rating = escapeHtml(post.rating || '');
const score = escapeHtml(post.score == null ? '' : post.score);
const header =
`<div class="header"><div>` +
`<span class="info"><strong>Date</strong> ${formatPostDate(post)}</span>` +
`<span class="info"><strong>User</strong> ` +
`<a href="index.php?page=post&s=list&tags=user:${encodeURIComponent(
owner
)}">${escapeHtml(owner)}</a></span>` +
`<span class="info"><strong>Rating</strong> ${rating}</span>` +
`<span class="info"><strong>Score</strong> <span id="psc${id}">${score}</span></span>` +
`(vote <a href="#" onclick="post_vote('${id}','up'); return false;">up</a>)</div>` +
buildTagsMarkup(post) +
`</div>`;
const commentsHtml = comments.map(buildCommentBlock).join('');
return (
`<div class="post" id="p${id}">` +
`<div class="col1 thumb"><a href="index.php?page=post&s=view&id=${id}">` +
`<img src="${escapeHtml(thumb)}" border="0" class="preview" ` +
`title="${escapeHtml(post.tags || '')}" alt="thumbnail"/></a></div>` +
`<div class="col2">${header}<div class="response-list">${commentsHtml}</div></div>` +
`</div>`
);
}
// comment feed
//////////////////
// Fetch the global comment feed and group every comment by post id while
// preserving recency order of first appearance. The per-post comment endpoint
// sporadically returns nothing for posts that do have comments, so the feed
// is our sole, reliable comment source.
async function gatherFeed(auth) {
const commentsByPost = new Map(); // post_id -> comment[]
const orderedPostIds = [];
const seenPost = new Set();
const seenComment = new Set();
let page;
try {
page = await fetchCommentFeed(auth);
} catch (e) {
warn('feed fetch failed', e.message);
return { commentsByPost, orderedPostIds };
}
for (const c of page) {
if (!c.post_id || !c.id || seenComment.has(c.id)) continue;
seenComment.add(c.id);
if (!commentsByPost.has(c.post_id)) commentsByPost.set(c.post_id, []);
commentsByPost.get(c.post_id).push(c);
if (!seenPost.has(c.post_id)) {
seenPost.add(c.post_id);
orderedPostIds.push(c.post_id);
if (seenPost.size >= CONFIG.maxScan) break;
}
}
return { commentsByPost, orderedPostIds };
}
// native DOM
////////////////
// The native page renders the most recent posts with comments but does NOT
// apply the account blacklist server-side, so blacklisted posts/comments are
// present in the markup. Comment scores are only exposed via the inline
// `posts.comments[CID] = {'score':N, ...}` scripts, so harvest them once.
function parseCommentScores(list) {
const map = new Map();
const re = /posts\.comments\[(\d+)\]\s*=\s*\{[^}]*?'score'\s*:\s*(-?\d+)/g;
list.querySelectorAll('script').forEach((s) => {
const text = s.textContent || '';
let m;
while ((m = re.exec(text)) !== null) map.set(m[1], parseInt(m[2], 10));
});
return map;
}
// Build the same {tags, rating, score} shape isPostBlacklisted/isAiHidden
// expect, sourced from the native markup: the thumbnail img title carries the
// full space-separated tag list, the header carries rating, and #psc<id> the
// score.
function readNativePost(el, id) {
const img = el.querySelector('.col1 img');
const tags = img ? img.getAttribute('title') || '' : '';
let rating = '';
el.querySelectorAll('.header .info').forEach((info) => {
const strong = info.querySelector('strong');
if (strong && /^rating$/i.test(strong.textContent.trim())) {
rating = info.textContent.replace(strong.textContent, '').trim();
}
});
const scoreEl =
el.querySelector('#psc' + id) || el.querySelector('[id^="psc"]');
const score = scoreEl ? scoreEl.textContent.trim() : '';
return { id, tags, rating, score };
}
// Remove a native comment node along with the inline script that registers
// its metadata, keeping the markup tidy (the script has already executed).
function removeCommentNode(cEl) {
const next = cEl.nextElementSibling;
if (next && next.tagName === 'SCRIPT') next.remove();
cEl.remove();
}
// Filter the native DOM in place: drop blacklisted/AI-filtered posts, hide
// blacklisted comments, trim to commentsPerPost, and drop posts left with no
// visible comments. Returns the set of every native post id seen (used to
// avoid duplicating them during back-fill) and how many survived.
function filterNativeDom(list, blacklist, aiFilter) {
const commentScores = parseCommentScores(list);
const allIds = new Set();
let survivingCount = 0;
list.querySelectorAll(':scope > div.post[id^="p"]').forEach((el) => {
const id = el.id.slice(1);
allIds.add(id);
const post = readNativePost(el, id);
if (isPostBlacklisted(post, blacklist)) {
log('remove blacklisted native post', id);
el.remove();
return;
}
if (isAiHidden(post, aiFilter)) {
log('remove ai-filtered native post', id);
el.remove();
return;
}
const responseList = el.querySelector('.response-list');
const commentEls = responseList
? Array.from(responseList.querySelectorAll(':scope > div.post[id^="c"]'))
: [];
let kept = 0;
for (const cEl of commentEls) {
const cid = cEl.id.slice(1);
const creator = cEl.querySelector('.author h6 a')
? cEl.querySelector('.author h6 a').textContent
: '';
const score = commentScores.has(cid) ? commentScores.get(cid) : null;
if (isCommentHidden({ creator, score }, blacklist)) {
removeCommentNode(cEl);
continue;
}
// native comments are newest-first; keep only the most recent few
if (kept >= CONFIG.commentsPerPost) {
removeCommentNode(cEl);
continue;
}
kept++;
}
if (kept === 0) {
log('remove native post with no visible comments', id);
el.remove();
return;
}
survivingCount++;
});
return { allIds, survivingCount };
}
// API backfill
//////////////////
// Build post blocks from the API feed to top the page back up to postsPerPage,
// skipping any post already decided on the native page (skipIds) so survivors
// are never duplicated.
async function gatherBackfill(auth, skipIds, needed, blacklist, aiFilter) {
const { commentsByPost, orderedPostIds } = await gatherFeed(auth);
const blocks = [];
for (const postId of orderedPostIds) {
if (blocks.length >= needed) break;
if (skipIds.has(postId)) continue;
let post;
try {
post = await fetchPost(auth, postId);
} catch (e) {
warn('post fetch failed after retries', postId, e.message);
continue;
}
if (!post) continue;
if (isPostBlacklisted(post, blacklist)) {
log('skip blacklisted post', postId);
continue;
}
if (isAiHidden(post, aiFilter)) {
log('skip ai-filtered post', postId);
continue;
}
let comments = (commentsByPost.get(postId) || []).filter(
(c) => !isCommentHidden(c, blacklist)
);
// newest first; comment id increases monotonically, so it is a reliable
// recency key (the API's created_at is not). Keep only the most recent.
comments.sort((a, b) => (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0));
comments = comments.slice(0, CONFIG.commentsPerPost);
if (!comments.length) {
log('skip post with no visible comments', postId);
continue;
}
blocks.push(buildPostBlock(post, comments));
}
return blocks;
}
function appendBlocks(list, blocks) {
const tmp = document.createElement('div');
tmp.innerHTML = blocks.join('');
while (tmp.firstChild) list.appendChild(tmp.firstChild);
}
// highlighting
//////////////////
// Author names (and, on a negative match, comment bodies) are restyled when a
// name or body matches a user-configured regex set. Filters are stored as
// regex SOURCE strings (JSON can't serialise RegExp) under a single GM key and
// compiled at apply time. The pass runs over the rebuilt DOM, so it covers
// both native-surviving and API-back-filled comments.
const HL_KEY = 'highlight';
const HL_MARK = 'pcfHl'; // dataset flag on author links we have coloured
const COLOR_CHOICES = {
// native
orange: '#f08000',
green: '#00a000',
darkRed: '#a00000',
darkPurple: '#a000a0',
darkBlue: '#000090',
// enhanced dark gallery
lightYellow: '#f0f0a0',
lightGreen: '#b0e0b0',
lightRed: '#f0a0a0',
lightBlue: '#90d9ed',
lightPurple: '#f0a0f0',
purple: '#9c64a6'
};
// reverse lookup hex(lowercase) -> choice name, for the settings dropdown
const COLOR_BY_HEX = new Map(
Object.entries(COLOR_CHOICES).map(([name, hex]) => [hex.toLowerCase(), name])
);
const HL_DEFAULTS = {
filters: {
positive: { names: [], content: [] },
negative: { names: [], content: [] },
},
colors: {
positive: COLOR_CHOICES.green,
negative: COLOR_CHOICES.darkRed
},
spoiler: true,
recent: false,
};
const strArr = (v) =>
Array.isArray(v) ? v.filter((s) => typeof s === 'string') : [];
const colorOr = (v, d) =>
typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) ? v : d;
// read config from GM storage, deep-merged against defaults
function loadConfig() {
let s = {};
try {
const raw = GM_getValue(HL_KEY, null);
if (raw) s = JSON.parse(raw);
} catch (e) {
warn('highlight config read error', e);
}
const f = (s && s.filters) || {};
const pick = (side) => ({
names: strArr(f[side] && f[side].names),
content: strArr(f[side] && f[side].content),
});
return {
filters: { positive: pick('positive'), negative: pick('negative') },
colors: {
positive: colorOr(s.colors && s.colors.positive, HL_DEFAULTS.colors.positive),
negative: colorOr(s.colors && s.colors.negative, HL_DEFAULTS.colors.negative),
},
spoiler: typeof s.spoiler === 'boolean' ? s.spoiler : HL_DEFAULTS.spoiler,
recent: typeof s.recent === 'boolean' ? s.recent : HL_DEFAULTS.recent,
};
}
function saveConfig(cfg) {
try {
GM_setValue(HL_KEY, JSON.stringify(cfg));
} catch (e) {
warn('highlight config write error', e);
}
}
// Compile a single stored line into a RegExp. Accepts both bare patterns
// (`exp`) and regex-literal syntax (`/exp/gi`); in the literal form the
// trailing flags are honoured. The `g`/`y` flags are stripped because the
// matcher uses `.test()`, whose lastIndex statefulness with those flags would
// make matches intermittent — they add nothing to a presence test anyway.
function compileOne(src) {
const m = /^\/(.*)\/([a-z]*)$/is.exec(src);
const pattern = m ? m[1] : src;
const flags = (m ? m[2] : '').replace(/[gy]/g, '');
return new RegExp(pattern, flags);
}
// Compile each stored source string into a RegExp; collect failures so the
// settings dialog can report them rather than silently dropping a pattern.
function compileFilters(cfg) {
const errors = [];
const list = (lines, label) =>
lines
.map((src) => {
try {
return compileOne(src);
} catch (e) {
errors.push({ label, src, message: e.message });
return null;
}
})
.filter(Boolean);
const compiled = {
positive: {
names: list(cfg.filters.positive.names, 'positive names'),
content: list(cfg.filters.positive.content, 'positive content'),
},
negative: {
names: list(cfg.filters.negative.names, 'negative names'),
content: list(cfg.filters.negative.content, 'negative content'),
},
};
return { compiled, errors };
}
const anyMatch = (regexes, text) => regexes.some((re) => re.test(text));
// Two comment layouts exist. The global comments page (and our back-filled
// blocks) render `div.post[id^="c"]` with `.author h6 a` / `.content .body`;
// post detail pages render bare `div[id^="c"]` with the author link and
// "Posted on ..." timestamp in `.col1` and the body in `.col2`. Normalise
// both into {node, author, body, dateText} records so the highlight and
// recency passes are layout-agnostic.
function collectComments(list) {
const records = [];
const seen = new Set();
list.querySelectorAll('div.post[id^="c"]').forEach((node) => {
const author = node.querySelector('.author h6 a');
const body = node.querySelector('.content .body');
if (!author || !body) return;
seen.add(node);
const dateEl = node.querySelector('.author > span.date');
records.push({
node,
author,
body,
dateText: dateEl ? dateEl.textContent : '',
});
});
list.querySelectorAll('div[id^="c"]').forEach((node) => {
if (seen.has(node)) return;
const col1 = node.querySelector(':scope > .col1');
const body = node.querySelector(':scope > .col2');
const author =
col1 && col1.querySelector(':scope > a[href*="s=profile"]');
if (!author || !body) return;
const dateEl = col1.querySelector('b');
records.push({
node,
author,
body,
dateText: dateEl ? dateEl.textContent : '',
});
});
return records;
}
// Wrap a comment body's contents in a site-styled spoiler span. Child nodes
// are moved (not stringified) so links/<br> survive and the op is reversible.
// The original highlighter cleared textContent before reading it, producing
// empty spoilers and losing the body — this captures content first.
function spoiler(body) {
if (body.querySelector(':scope > span[data-rgc-spoiler]')) return;
const span = document.createElement('span');
span.className = 'spoiler';
span.dataset.rgcSpoiler = '1';
while (body.firstChild) span.appendChild(body.firstChild);
body.appendChild(span);
}
function unspoiler(body) {
const span = body.querySelector(':scope > span[data-rgc-spoiler]');
if (!span) return;
while (span.firstChild) body.insertBefore(span.firstChild, span);
span.remove();
}
// Apply (or re-apply) highlighting over every comment node in `list`. Safe to
// run repeatedly: each pass first reverts the previous pass's effects on nodes
// we marked, so a Save never compounds colours or double-wraps a spoiler.
function highlightComments(list, cfg, compiled) {
collectComments(list).forEach(({ author, body }) => {
// revert any previous pass
if (author.dataset[HL_MARK]) {
author.style.removeProperty('color');
delete author.dataset[HL_MARK];
}
unspoiler(body);
const name = author.textContent || '';
const text = body.textContent || '';
const positive =
anyMatch(compiled.positive.names, name)
|| anyMatch(compiled.positive.content, text);
const negative =
anyMatch(compiled.negative.names, name)
|| anyMatch(compiled.negative.content, text);
// negative evaluated last so it wins over a positive match. Set the colour
// with 'important' priority: the enhanced dark gallery reskin paints author
// links with `color: var(--accent-color) !important`, which an ordinary
// inline style cannot override.
if (positive) {
author.style.setProperty('color', cfg.colors.positive, 'important');
author.dataset[HL_MARK] = '1';
}
if (negative) {
author.style.setProperty('color', cfg.colors.negative, 'important');
author.dataset[HL_MARK] = '1';
if (cfg.spoiler) spoiler(body);
}
});
}
// recency highlight
///////////////////////
// The site prints every comment timestamp in its server timezone (UTC+1).
// Read that wall-clock time, anchor it to its true UTC instant, and flag the
// comment when that instant falls on the viewer's current local day. Covers
// both native-surviving and API-back-filled comments, whose `.author`'s first
// `.date` span shares the same "YYYY-MM-DD HH:MM[:SS]" shape.
function parseSiteTimestamp(text) {
const re = /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/;
const m = re.exec(text || '');
if (!m) return null;
const [, y, mo, d, h, mi, s] = m;
const fmt = Date.UTC(+y, +mo - 1, +d, +h - 1, +mi, +(s || 0)); // UTC+1 -> UTC
return new Date(fmt);
}
function isToday(d) {
const now = new Date();
return (
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
);
}
// Idempotent: toggle drives the class from a fresh evaluation each pass, so a
// re-run (e.g. after Save) never strands the marker — disabling the feature
// clears every existing mark.
function markTodayComments(list, enabled) {
collectComments(list).forEach(({ node, dateText }) => {
const d = enabled && parseSiteTimestamp(dateText);
node.classList.toggle('pcf-today', !!(d && isToday(d)));
});
}
// settings UI
/////////////////
let _ui = null; // built lazily on first open
function buildSettingsUI() {
if (_ui) return _ui;
const host = document.createElement('div');
host.id = 'rgc-highlight-host';
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = HL_CSS;
const dialog = document.createElement('dialog');
dialog.className = 'card';
dialog.innerHTML = HL_DIALOG_HTML;
root.append(style, dialog);
const $ = (sel) => dialog.querySelector(sel);
const fields = {
posNames: $('#f-pos-names'),
posContent: $('#f-pos-content'),
negNames: $('#f-neg-names'),
negContent: $('#f-neg-content'),
spoiler: $('#f-spoiler'),
recent: $('#f-recent'),
colorPos: $('#f-color-pos'),
colorNeg: $('#f-color-neg'),
errors: $('#f-errors'),
};
// color controls: a custom dropdown of named choices (each with a real
// colour swatch — native <option> tinting is ignored by Chromium and
// unreliable elsewhere) backed by a free-form hex field, which is the
// value actually saved.
/////////////////////////////////////////////////////////////////////////
const HEX_RE = /^#[0-9a-f]{6}$/i;
const colorPickers = [];
// Build a swatch dropdown into `host`, driving (and driven by) `hex`. The
// hex field stays the source of truth: a named pick writes into it, the
// "Custom…" row just focuses it, and typing re-selects the matching row.
function attachColorPicker(host, hex) {
const head = document.createElement('div');
head.className = 'head';
const headSw = document.createElement('span');
headSw.className = 'swatch';
const headName = document.createElement('span');
headName.className = 'name';
const caret = document.createElement('span');
caret.className = 'caret';
caret.textContent = '▾';
head.append(headSw, headName, caret);
const menu = document.createElement('ul');
menu.className = 'menu';
const entries = Object.entries(COLOR_CHOICES)
.map(([name, h]) => ({ name, hex: h }))
.concat([{ name: 'Custom…', hex: '' }]);
const rows = entries.map((e) => {
const li = document.createElement('li');
li.className = 'opt';
li.dataset.hex = e.hex;
const sw = document.createElement('span');
sw.className = e.hex ? 'swatch' : 'swatch none';
if (e.hex) sw.style.background = e.hex;
const nm = document.createElement('span');
nm.textContent = e.name;
li.append(sw, nm);
li.addEventListener('click', () => {
close();
if (e.hex) hex.value = e.hex;
else hex.focus();
syncFromHex();
});
menu.appendChild(li);
return li;
});
host.append(head, menu);
const close = () => host.classList.remove('open');
// The menu is position:fixed so the dialog's bounds never clip it; anchor
// it to the head on open, flipping above when there's no room below.
function positionMenu() {
const r = head.getBoundingClientRect();
menu.style.left = r.left + 'px';
menu.style.minWidth = r.width + 'px';
const mh = menu.offsetHeight;
const below = window.innerHeight - r.bottom;
menu.style.top =
below < mh + 8 && r.top > below
? r.top - mh - 4 + 'px'
: r.bottom + 4 + 'px';
}
head.addEventListener('click', () => {
const opening = !host.classList.contains('open');
host.classList.toggle('open');
if (opening) positionMenu();
});
// reflect the hex field onto the head swatch/label and the selected row
function syncFromHex() {
const v = (hex.value || '').trim().toLowerCase();
const valid = HEX_RE.test(v);
const name = valid ? COLOR_BY_HEX.get(v) : undefined;
headSw.className = valid ? 'swatch' : 'swatch none';
headSw.style.background = valid ? v : '';
headName.textContent = name || (valid ? v : 'Custom…');
rows.forEach((li) => {
const sel = name
? li.dataset.hex.toLowerCase() === v
: li.dataset.hex === '';
li.setAttribute('aria-selected', sel ? 'true' : 'false');
});
}
hex.addEventListener('input', syncFromHex);
const ctl = { host, close, syncFromHex };
colorPickers.push(ctl);
return ctl;
}
const picks = {
pos: attachColorPicker($('#f-color-pos-pick'), fields.colorPos),
neg: attachColorPicker($('#f-color-neg-pick'), fields.colorNeg),
};
function setColorField(ctl, hex, value) {
hex.value = value;
ctl.syncFromHex();
}
// close any open picker when clicking elsewhere in the dialog
dialog.addEventListener('click', (e) => {
colorPickers.forEach((p) => {
if (!p.host.contains(e.target)) p.close();
});
});
const lines = (v) =>
v
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
function fill(cfg) {
fields.posNames.value = cfg.filters.positive.names.join('\n');
fields.posContent.value = cfg.filters.positive.content.join('\n');
fields.negNames.value = cfg.filters.negative.names.join('\n');
fields.negContent.value = cfg.filters.negative.content.join('\n');
fields.spoiler.checked = cfg.spoiler;
fields.recent.checked = cfg.recent;
setColorField(picks.pos, fields.colorPos, cfg.colors.positive);
setColorField(picks.neg, fields.colorNeg, cfg.colors.negative);
fields.errors.textContent = '';
}
function onSave() {
const hexErrors = [];
const readHex = (input, label) => {
const v = input.value.trim();
if (HEX_RE.test(v)) return v.toLowerCase();
hexErrors.push(`• [${label}] ${v || '(empty)'} — expected #rrggbb`);
return v;
};
const posColor = readHex(fields.colorPos, 'positive color');
const negColor = readHex(fields.colorNeg, 'negative color');
const cfg = {
filters: {
positive: {
names: lines(fields.posNames.value),
content: lines(fields.posContent.value),
},
negative: {
names: lines(fields.negNames.value),
content: lines(fields.negContent.value),
},
},
colors: {
positive: posColor,
negative: negColor,
},
spoiler: fields.spoiler.checked,
recent: fields.recent.checked,
};
const { compiled, errors } = compileFilters(cfg);
const allErrors = hexErrors.concat(
errors.map((e) => `• [${e.label}] ${e.src} — ${e.message}`)
);
if (allErrors.length) {
fields.errors.textContent =
'Invalid input — fix or remove:\n' + allErrors.join('\n');
return; // keep the dialog open
}
saveConfig(cfg);
const list = document.getElementById('comment-list');
if (list) {
highlightComments(list, cfg, compiled);
markTodayComments(list, cfg.recent);
}
dialog.close();
}
$('#f-close').addEventListener('click', () => dialog.close());
$('#f-cancel').addEventListener('click', () => dialog.close());
$('#f-save').addEventListener('click', onSave);
_ui = {
open() {
fill(loadConfig());
dialog.showModal();
},
};
return _ui;
}
function openSettings() {
buildSettingsUI().open();
}
// main
//////////
// Filtering and back-fill are exclusive to the global comments page; post
// detail pages share the #comment-list id but must keep their native posts.
function isGlobalCommentsPage() {
const p = new URLSearchParams(location.search);
return p.get('page') === 'comment' && p.get('s') === 'list';
}
async function main() {
if (!isGlobalCommentsPage()) return;
const list = document.getElementById('comment-list');
if (!list) return;
const blacklist = getBlacklist();
const aiFilter = aiFilterEnabled();
const { allIds, survivingCount } = filterNativeDom(list, blacklist, aiFilter);
log('native survivors', survivingCount, 'of', allIds.size);
const needed = CONFIG.postsPerPage - survivingCount;
if (needed <= 0) {
log('native set already full; no back-fill needed');
return;
}
const auth = await resolveAuth();
if (!auth) {
warn(
'No API key available (not logged in or no key set in account options); ' +
'cannot back-fill, leaving the filtered native list as-is.'
);
return;
}
try {
const blocks = await gatherBackfill(
auth,
allIds,
needed,
blacklist,
aiFilter
);
if (blocks.length) {
appendBlocks(list, blocks);
log('back-filled', blocks.length, 'posts');
} else {
log('no back-fill posts found');
}
} catch (e) {
warn('back-fill failed; leaving the filtered native list as-is:', e);
}
}
// Highlight once the list is final. Using .finally() (rather than a line
// inside main) guarantees the pass runs after every flow, including main's
// early returns (native set already full / no API key).
main().finally(() => {
const list = document.getElementById('comment-list');
if (!list) return;
const cfg = loadConfig();
const { compiled } = compileFilters(cfg);
highlightComments(list, cfg, compiled);
markTodayComments(list, cfg.recent);
});
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Comment highlighting settings…', openSettings);
}
})();