Rule34.xxx Proper Comments Filter

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
  }
})();