Sleazy Fork is available in English.

Rule34.xxx Proper Comments Filter

Completely remove filtered posts and comments from the global comments page + configurable comment highlighting

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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">&#10005;</button>
  </header>
  <div class="grid">
    <label class="pos">Positive &mdash; names
      <textarea id="f-pos-names" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
    <label class="pos">Positive &mdash; content
      <textarea id="f-pos-content" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
    <label class="neg">Negative &mdash; names
      <textarea id="f-neg-names" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
    <label class="neg">Negative &mdash; 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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  // 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;">&gt;&gt; #${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);
  }
})();