ejchan 1

Custom timed updater + deleted marker

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         ejchan 1
// @namespace    local.ejchan.deleted-marker
// @version      1.1.1
// @description  Custom timed updater + deleted marker
// @match        https://ejchan.net/*/res/*.html*
// @match        https://ejchan.site/*/res/*.html*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==
(function () {
  'use strict';
  const CFG = {
    minIntervalSec: 5,
    maxIntervalSec: 60,
    defaultIntervalSec: 10,
    // Network safety.
    fetchTimeoutMs: 25000,
    // Blocks custom.js Favorites JSON polling, but should not block cross-thread hover.
    blockCustomJsJsonPolling: true,
    // Native Tinyboard/Vichan updater blocker.
    nativeOffWatchdogMs: 1000,
    // Page init after custom insert.
    postInitDelayMs: 120,
    // Expensive site function patch:
    // custom.js -> updatePostLimits -> querySelectorAll can lag badly on form click.
    patchUpdatePostLimits: true,
    // Minimum time between real original updatePostLimits() runs.
    // Synchronous click calls are skipped/deferred instead of scanning immediately.
    updatePostLimitsMinIntervalMs: 5 * 60 * 1000,
    // If requestIdleCallback is unavailable/busy, run deferred update within this timeout.
    updatePostLimitsIdleTimeoutMs: 10000,
    debug: false
  };
  const log = (...args) => {
    if (CFG.debug) console.log('[ejchan-helper]', ...args);
  };
  if (unsafeWindow.__ejDeletedMarkerLoaded || window.__ejDeletedMarkerLoaded) return;
  unsafeWindow.__ejDeletedMarkerLoaded = true;
  window.__ejDeletedMarkerLoaded = true;
  forceNativeStorageOff();
  injectPageFetchBlocker();
  injectPageBridge();
  // Userscript-context fetch for our own requests.
  // This should avoid the page-context fetch patch.
  const scriptFetch = window.fetch.bind(window);
  let threadEl = null;
  let board = null;
  let threadId = null;
  let storagePrefix = null;
  let intervalKey = null;
  let enabledKey = null;
  let intervalSec = CFG.defaultIntervalSec;
  let enabled = true;
  let checkInFlight = false;
  let nativeOffWatchdog = null;
  let schedulerTimer = null;
  let nextDueAt = 0;
  let knownPostNos = new Set();
  let unreadCount = 0;
  let windowFocused = document.hasFocus();
  let baseTitle = '';
  let titleWriteLock = false;
  onReady(() => {
    startInitAttempts();
  });
  function onReady(fn) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', fn, { once: true });
    } else {
      fn();
    }
  }
  function startInitAttempts() {
    let attempts = 0;
    const maxAttempts = 80;
    const tryInit = () => {
      attempts++;
      threadEl = document.querySelector('.thread[id^="thread_"], .thread[data-threadid]');
      const threadLinks = document.querySelector('#thread-links');
      if (!threadEl || !threadLinks) {
        if (attempts < maxAttempts) {
          setTimeout(tryInit, 250);
        } else {
          console.warn('[ejchan-helper] Could not initialize: thread container or thread links not found.');
        }
        return;
      }
      init();
    };
    tryInit();
  }
  function init() {
    board =
      unsafeWindow.board_name ||
      window.board_name ||
      threadEl.dataset.board ||
      location.pathname.split('/')[1];
    threadId =
      unsafeWindow.thread_id ||
      window.thread_id ||
      threadEl.dataset.threadid ||
      (location.pathname.match(/\/res\/(\d+)\.html/) || [])[1];
    if (!board || !threadId) {
      console.warn('[ejchan-helper] Could not detect board/thread id.');
      return;
    }
    storagePrefix = `ejdm:${location.hostname}:${board}:${threadId}:`;
    intervalKey = storagePrefix + 'intervalSec';
    enabledKey = storagePrefix + 'enabled';
    intervalSec = clampInterval(Number(GM_getValue(intervalKey, CFG.defaultIntervalSec)));
    enabled = GM_getValue(enabledKey, true);
    baseTitle = stripNativeCounter(document.title);
    injectStyle();
    disableNativeAutoUpdate();
    startNativeOffWatchdog();
    buildControls();
    hideNativeUpdater();
    knownPostNos = new Set(getLocalPosts().map(p => p.no));
    setupFocusTracking();
    setupTitleManager();
    setupMutationObserver();
    resetUnreadCounter();
    // One normalization pass. This avoids repeatedly replacing unchanged backlink blocks later.
    rebuildMentionedBlocksFromLocal();
    if (enabled) {
      startLoop();
    } else {
      setStatus('выкл');
    }
  }
  // ------------------------------------------------------------
  // Native/custom updater blocking
  // ------------------------------------------------------------
  function forceNativeStorageOff() {
    try {
      localStorage.auto_thread_update = 'false';
    } catch (e) {}
  }
  function injectPageFetchBlocker() {
    if (!CFG.blockCustomJsJsonPolling) return;
    const code = `
      (function () {
        if (window.__ejdmFetchPatched) return;
        window.__ejdmFetchPatched = true;
        var originalFetch = window.fetch;
        if (!originalFetch) return;
        window.__ejdmOriginalFetch = originalFetch;
        window.fetch = function ejdmPatchedFetch(input, init) {
          var rawUrl = '';
          try {
            rawUrl = String(input && input.url ? input.url : input || '');
          } catch (e) {}
          var stack = '';
          try {
            stack = new Error().stack || '';
          } catch (e) {}
          var isThreadJson = /\\/[^\\/]+\\/res\\/\\d+\\.json(?:[?#].*)?$/.test(rawUrl);
          /*
            Do not block every custom.js JSON request.
            Cross-thread hover previews may also fetch thread JSON.
            Only block Favorites updater paths from custom.js:
              checkForNewPosts()
              fetchNewPosts()
          */
          var fromFavoritesFetchNewPosts =
            /fetchNewPosts/.test(stack) ||
            /checkForNewPosts/.test(stack);
          if (isThreadJson && fromFavoritesFetchNewPosts) {
            if (window.__ejdmDebug) {
              console.log('[ejdm page blocker] blocked Favorites updater fetch:', rawUrl, stack);
            }
            return Promise.resolve(new Response(
              JSON.stringify({ posts: [] }),
              {
                status: 200,
                statusText: 'OK',
                headers: {
                  'Content-Type': 'application/json; charset=utf-8',
                  'X-EJDM-Blocked': 'favorites-fetchNewPosts'
                }
              }
            ));
          }
          return originalFetch.apply(this, arguments);
        };
      })();
    `;
    try {
      const s = document.createElement('script');
      s.textContent = code;
      (document.documentElement || document.head || document).appendChild(s);
      s.remove();
    } catch (err) {
      console.warn('[ejchan-helper] failed to inject page fetch blocker:', err);
    }
  }
  function injectPageBridge() {
    const bridgeOptions = {
      patchUpdatePostLimits: CFG.patchUpdatePostLimits,
      updatePostLimitsMinIntervalMs: CFG.updatePostLimitsMinIntervalMs,
      updatePostLimitsIdleTimeoutMs: CFG.updatePostLimitsIdleTimeoutMs,
      debug: CFG.debug
    };
    const code = `
      (function () {
        var OPTS = ${JSON.stringify(bridgeOptions)};
        if (!window.__ejdmPageBridgeInstalled) {
          window.__ejdmPageBridgeInstalled = true;
          window.__ejdmPageInitPosts = function (postIdsJson) {
            var postIds = [];
            try {
              postIds = JSON.parse(postIdsJson || '[]');
            } catch (e) {
              postIds = [];
            }
            /*
              Native auto-reload effectively emits new_post and runs initPosts().
              We do this in page context for Firefox compatibility.
              Keep it minimal: trigger new_post for inserted posts, then initPosts once.
            */
            try {
              if (window.jQuery && Array.isArray(postIds) && postIds.length) {
                postIds.forEach(function (id) {
                  var node = document.getElementById(id);
                  if (node) {
                    window.jQuery(document).trigger('new_post', node);
                  }
                });
              }
            } catch (e) {
              console.warn('[ejdm page bridge] jQuery new_post failed:', e);
            }
            try {
              if (typeof window.initPosts === 'function') {
                window.initPosts();
              }
            } catch (e) {
              console.warn('[ejdm page bridge] initPosts failed:', e);
            }
            setTimeout(function () {
              try {
                if (typeof window.initPosts === 'function') {
                  window.initPosts();
                }
              } catch (e) {}
            }, 250);
          };
        }
        function installPostLimitsPatch() {
          if (!OPTS.patchUpdatePostLimits) return true;
          if (window.__ejdmPostLimitsPatched) return true;
          if (typeof window.updatePostLimits !== 'function') return false;
          var original = window.updatePostLimits;
          window.__oldUpdatePostLimits = original;
          window.__ejdmPostLimitsPatched = true;
          var stats = window.__ejdmPostLimitsStats = {
            calls: 0,
            skippedSync: 0,
            realRuns: 0,
            errors: 0,
            pending: false,
            lastRun: 0,
            lastResult: undefined
          };
          function runOriginal(ctx, args, reason) {
            stats.pending = false;
            var now = Date.now();
            stats.lastRun = now;
            stats.realRuns++;
            try {
              if (OPTS.debug) {
                console.log('[ejdm] running original updatePostLimits:', reason);
              }
              stats.lastResult = original.apply(ctx || window, args || []);
              return stats.lastResult;
            } catch (e) {
              stats.errors++;
              console.warn('[ejdm] original updatePostLimits failed:', e);
              return stats.lastResult;
            }
          }
          function scheduleIdleRun() {
            if (stats.pending) return;
            var now = Date.now();
            if (now - stats.lastRun < OPTS.updatePostLimitsMinIntervalMs) return;
            stats.pending = true;
            var runner = function () {
              var now2 = Date.now();
              if (now2 - stats.lastRun < OPTS.updatePostLimitsMinIntervalMs) {
                stats.pending = false;
                return;
              }
              runOriginal(window, [], 'idle');
            };
            if (typeof window.requestIdleCallback === 'function') {
              window.requestIdleCallback(runner, {
                timeout: OPTS.updatePostLimitsIdleTimeoutMs
              });
            } else {
              setTimeout(runner, OPTS.updatePostLimitsIdleTimeoutMs);
            }
          }
          window.__ejdmRunUpdatePostLimitsNow = function () {
            return runOriginal(window, [], 'forced');
          };
          window.updatePostLimits = function ejdmThrottledUpdatePostLimits() {
            stats.calls++;
            /*
              Critical behavior:
              Do NOT run expensive querySelectorAll synchronously during click/focus.
              The original function is deferred/throttled instead.
            */
            stats.skippedSync++;
            scheduleIdleRun();
            return stats.lastResult;
          };
          return true;
        }
        var tries = 0;
        function retryPatch() {
          if (installPostLimitsPatch()) return;
          tries++;
          if (tries < 120) {
            setTimeout(retryPatch, 250);
          }
        }
        retryPatch();
        if (document.readyState === 'loading') {
          document.addEventListener('DOMContentLoaded', retryPatch, { once: true });
        } else {
          retryPatch();
        }
      })();
    `;
    try {
      const s = document.createElement('script');
      s.textContent = code;
      (document.documentElement || document.head || document).appendChild(s);
      s.remove();
    } catch (err) {
      console.warn('[ejchan-helper] failed to inject page bridge:', err);
    }
  }
  function disableNativeAutoUpdate() {
    forceNativeStorageOff();
    const optBox = document.querySelector('#auto-thread-update > input');
    if (optBox && optBox.checked) {
      optBox.checked = false;
    }
    const box = document.querySelector('#auto_update_status');
    if (box) {
      // Clicking is important if native updater already started,
      // because its internal stop_auto_update() lives inside closure.
      if (box.checked) {
        log('disabling native auto-update');
        box.click();
      }
      box.checked = false;
      box.removeAttribute('checked');
    }
    const secs = document.querySelector('#update_secs');
    if (secs) secs.textContent = '';
  }
  function startNativeOffWatchdog() {
    if (nativeOffWatchdog) return;
    nativeOffWatchdog = setInterval(() => {
      const box = document.querySelector('#auto_update_status');
      const nativeEnabled =
        box?.checked ||
        localStorage.auto_thread_update === 'true';
      if (nativeEnabled) {
        disableNativeAutoUpdate();
        hideNativeUpdater();
      }
    }, CFG.nativeOffWatchdogMs);
  }
  function hideNativeUpdater() {
    const updater = document.querySelector('#updater');
    if (updater) updater.style.display = 'none';
  }
  // ------------------------------------------------------------
  // UI
  // ------------------------------------------------------------
  function clampInterval(n) {
    if (!Number.isFinite(n)) n = CFG.defaultIntervalSec;
    n = Math.round(n);
    return Math.max(CFG.minIntervalSec, Math.min(CFG.maxIntervalSec, n));
  }
  function injectStyle() {
    const probeLink =
      document.querySelector('#thread-links a') ||
      document.querySelector('.post a') ||
      document.querySelector('a');
    const probeText =
      document.querySelector('#thread-links') ||
      document.querySelector('.post.reply') ||
      document.body;
    const probePost =
      document.querySelector('.post.reply') ||
      document.querySelector('.post.op') ||
      document.body;
    const linkStyle = probeLink ? getComputedStyle(probeLink) : null;
    const textStyle = probeText ? getComputedStyle(probeText) : null;
    const postStyle = probePost ? getComputedStyle(probePost) : null;
    const uiColor =
      linkStyle?.color ||
      textStyle?.color ||
      'currentColor';
    const textColor =
      textStyle?.color ||
      'currentColor';
    const postBg =
      postStyle?.backgroundColor ||
      'transparent';
    const postBorder =
      postStyle?.borderTopColor ||
      uiColor;
    const css = `
      :root {
        --ejdm-ui-color: ${uiColor};
        --ejdm-text-color: ${textColor};
        --ejdm-post-bg: ${postBg};
        --ejdm-post-border: ${postBorder};
        --ejdm-delete-bg: #b00020;
        --ejdm-delete-fg: #ffffff;
      }
      #updater {
        display: none !important;
      }
      #ejdm-controls {
        margin-left: 8px;
        white-space: nowrap;
        color: var(--ejdm-ui-color) !important;
        font: inherit;
      }
      #ejdm-controls,
      #ejdm-controls label,
      #ejdm-controls span,
      #ejdm-controls input,
      #ejdm-check-now {
        color: var(--ejdm-ui-color) !important;
        font: inherit;
      }
      #ejdm-controls input[type="number"] {
        width: 3.5em;
        background: transparent !important;
        background-color: transparent !important;
        background-image: none !important;
        color: var(--ejdm-ui-color) !important;
        border: 1px solid var(--ejdm-ui-color);
        border-radius: 3px;
        padding: 1px 3px;
        box-shadow: none !important;
        outline: none;
        appearance: textfield;
        -moz-appearance: textfield;
      }
      #ejdm-controls input[type="number"]::-webkit-outer-spin-button,
      #ejdm-controls input[type="number"]::-webkit-inner-spin-button {
        opacity: 0.7;
      }
      #ejdm-controls input[type="number"]:focus {
        outline: 1px solid var(--ejdm-ui-color);
        outline-offset: 1px;
      }
      #ejdm-enabled {
        accent-color: var(--ejdm-ui-color);
      }
      #ejdm-check-now {
        cursor: pointer;
        background: transparent !important;
        background-color: transparent !important;
        background-image: none !important;
        color: var(--ejdm-ui-color) !important;
        border: 1px solid var(--ejdm-ui-color);
        border-radius: 3px;
        padding: 1px 5px;
        box-shadow: none !important;
        appearance: none;
        -webkit-appearance: none;
      }
      #ejdm-check-now:hover {
        filter: brightness(1.2);
      }
      #ejdm-check-now:active {
        filter: brightness(0.85);
      }
      #ejdm-status {
        margin-left: 4px;
        opacity: 0.85;
        color: var(--ejdm-ui-color) !important;
      }
      .ejdm-deleted-post {
        opacity: 0.82;
        outline: 1px dashed var(--ejdm-delete-bg);
        outline-offset: 2px;
      }
      .ejdm-deleted-tag {
        display: inline-block;
        margin-right: 6px;
        padding: 1px 5px;
        border-radius: 3px;
        background: var(--ejdm-delete-bg);
        color: var(--ejdm-delete-fg);
        font-weight: bold;
        font-size: 12px;
        line-height: 1.4;
      }
    `;
    const style = document.createElement('style');
    style.textContent = css;
    (document.head || document.documentElement).appendChild(style);
  }
  function buildControls() {
    if (document.querySelector('#ejdm-controls')) return;
    const threadLinks = document.querySelector('#thread-links');
    const updater = document.querySelector('#updater');
    if (!threadLinks && !updater) return;
    const controls = document.createElement('span');
    controls.id = 'ejdm-controls';
    controls.innerHTML = `
      <label title="Проверять новые и удалённые посты">
        <input id="ejdm-enabled" type="checkbox">
        Проверка
      </label>
      <input id="ejdm-interval" type="number" min="${CFG.minIntervalSec}" max="${CFG.maxIntervalSec}" step="1">
      сек
      <button id="ejdm-check-now" type="button">Проверить</button>
      <span id="ejdm-status"></span>
    `;
    if (updater) {
      updater.insertAdjacentElement('afterend', controls);
    } else {
      threadLinks.appendChild(controls);
    }
    const enabledBox = controls.querySelector('#ejdm-enabled');
    const intervalInput = controls.querySelector('#ejdm-interval');
    const checkBtn = controls.querySelector('#ejdm-check-now');
    enabledBox.checked = enabled;
    intervalInput.value = String(intervalSec);
    enabledBox.addEventListener('change', () => {
      enabled = enabledBox.checked;
      GM_setValue(enabledKey, enabled);
      if (enabled) {
        disableNativeAutoUpdate();
        hideNativeUpdater();
        startLoop();
      } else {
        stopLoop();
        setStatus('выкл');
      }
    });
    intervalInput.addEventListener('change', () => {
      intervalSec = clampInterval(Number(intervalInput.value));
      intervalInput.value = String(intervalSec);
      GM_setValue(intervalKey, intervalSec);
      if (enabled) {
        startLoop();
      }
    });
    checkBtn.addEventListener('click', async () => {
      if (!enabled) return;
      stopSchedulerOnly();
      await checkOnce('manual');
      if (enabled) scheduleNextCheck();
    });
    setStatus(enabled ? `след. ${intervalSec}с` : 'выкл');
  }
  function setStatus(text) {
    const status = document.querySelector('#ejdm-status');
    if (status) status.textContent = text ? `[${text}]` : '';
  }
  function formatTime24(date = new Date()) {
    return new Intl.DateTimeFormat('ru-RU', {
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
      hour12: false
    }).format(date);
  }
  function updateFinalStatus(newCount = 0) {
    const time = formatTime24();
    const parts = [time];
    if (newCount > 0) {
      parts.push(`новых ${newCount}`);
    } else {
      parts.push('ок');
    }
    setStatus(parts.join(', '));
  }
  // ------------------------------------------------------------
  // Custom recursive scheduler
  // ------------------------------------------------------------
  function startLoop() {
    stopLoop();
    disableNativeAutoUpdate();
    hideNativeUpdater();
    scheduleNextCheck();
  }
  function stopLoop() {
    stopSchedulerOnly();
  }
  function stopSchedulerOnly() {
    if (schedulerTimer) {
      clearTimeout(schedulerTimer);
      schedulerTimer = null;
    }
  }
  function scheduleNextCheck() {
    stopSchedulerOnly();
    nextDueAt = Date.now() + intervalSec * 1000;
    schedulerTick();
  }
  function schedulerTick() {
    if (!enabled) return;
    const leftMs = nextDueAt - Date.now();
    const leftSec = Math.max(0, Math.ceil(leftMs / 1000));
    if (leftSec > 0) {
      setStatus(`след. ${leftSec}с`);
      schedulerTimer = setTimeout(schedulerTick, Math.min(1000, leftMs));
      return;
    }
    schedulerTimer = null;
    checkOnce('timer').finally(() => {
      if (enabled) {
        scheduleNextCheck();
      }
    });
  }
  // ------------------------------------------------------------
  // Custom update
  // ------------------------------------------------------------
  async function checkOnce(reason = 'unknown') {
    if (checkInFlight) return 0;
    checkInFlight = true;
    try {
      disableNativeAutoUpdate();
      hideNativeUpdater();
      setStatus('проверка...');
      log('check:', reason);
      const serverSet = await fetchServerPostSet();
      const before = compareAndMark(serverSet);
      let insertedCount = 0;
      let insertedNodes = [];
      if (before.missingLocally.length > 0) {
        const freshDoc = await fetchFreshThreadDocument();
        const inserted = insertMissingPostsFromDocument(freshDoc, before.missingLocally);
        insertedCount = inserted.count;
        insertedNodes = inserted.nodes;
        const backlinksChanged = rebuildMentionedBlocksFromLocal();
        if (insertedNodes.length || backlinksChanged) {
          callPagePostInit(insertedNodes);
        }
      }
      const freshSetAfterInsert = await fetchServerPostSet();
      const after = compareAndMark(freshSetAfterInsert);
      if (after.changed) {
        const backlinksChanged = rebuildMentionedBlocksFromLocal();
        if (backlinksChanged) {
          callPagePostInit([]);
        }
      }
      updateFinalStatus(insertedCount);
      updateManagedTitle();
      return insertedCount;
    } catch (err) {
      console.error('[ejchan-helper] check failed:', err);
      setStatus('ошибка');
      return 0;
    } finally {
      checkInFlight = false;
    }
  }
  async function fetchWithTimeout(url, options = {}, timeoutMs = CFG.fetchTimeoutMs) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      return await scriptFetch(url, {
        ...options,
        signal: controller.signal
      });
    } finally {
      clearTimeout(timer);
    }
  }
  async function fetchServerPostSet() {
    try {
      return await fetchServerPostSetJson();
    } catch (err) {
      console.warn('[ejchan-helper] JSON check failed, falling back to HTML:', err);
      const doc = await fetchFreshThreadDocument();
      return extractPostSetFromDocument(doc);
    }
  }
  async function fetchServerPostSetJson() {
    const jsonUrl = location.pathname.replace(/\.html(?:$|\?.*)/, '.json') + '?_=' + Date.now();
    const res = await fetchWithTimeout(jsonUrl, {
      credentials: 'same-origin',
      cache: 'no-store',
      headers: {
        Accept: 'application/json,*/*;q=0.8'
      }
    });
    if (!res.ok) throw new Error(`JSON HTTP ${res.status}`);
    const data = await res.json();
    if (!data || !Array.isArray(data.posts)) {
      throw new Error('Invalid JSON shape: no posts[]');
    }
    return new Set(data.posts.map(p => Number(p.no)).filter(Boolean));
  }
  async function fetchFreshThreadDocument() {
    const htmlUrl = location.pathname + '?_=' + Date.now();
    const res = await fetchWithTimeout(htmlUrl, {
      credentials: 'same-origin',
      cache: 'no-store',
      headers: {
        Accept: 'text/html,*/*;q=0.8'
      }
    });
    if (!res.ok) throw new Error(`HTML HTTP ${res.status}`);
    const html = await res.text();
    return new DOMParser().parseFromString(html, 'text/html');
  }
  function extractPostSetFromDocument(doc) {
    const nums = [...doc.querySelectorAll(
      '.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
    )]
      .map(getPostNoFromEl)
      .filter(Boolean);
    return new Set(nums);
  }
  function insertMissingPostsFromDocument(doc, missingNos) {
    const missingSet = new Set(missingNos.map(Number));
    const freshPosts = [...doc.querySelectorAll(
      '.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
    )];
    const postsToInsert = freshPosts
      .map(el => ({ no: getPostNoFromEl(el), el }))
      .filter(p => p.no && missingSet.has(p.no));
    let inserted = 0;
    const insertedNodes = [];
    for (const { no, el } of postsToInsert) {
      if (document.querySelector(`#op_${CSS.escape(String(no))}, #reply_${CSS.escape(String(no))}`)) {
        continue;
      }
      const imported = document.importNode(el, true);
      imported.classList.add('new-post');
      // Fetched HTML contains server-rendered time text.
      // Since we insert manually, convert inserted post times to local time.
      localizeInsertedPostTimes(imported);
      // Prevent MutationObserver double-count.
      knownPostNos.add(no);
      threadEl.appendChild(imported);
      const br = document.createElement('br');
      br.className = 'clear';
      threadEl.appendChild(br);
      inserted++;
      insertedNodes.push(imported);
    }
    if (inserted > 0 && !isTabActive()) {
      unreadCount += inserted;
      updateManagedTitle();
    }
    return {
      count: inserted,
      nodes: insertedNodes
    };
  }
  // ------------------------------------------------------------
  // Page post init / hover init
  // ------------------------------------------------------------
  function callPagePostInit(insertedNodes = []) {
    const insertedIds = insertedNodes
      .map(node => node && node.id)
      .filter(Boolean);
    const idsJson = JSON.stringify(insertedIds);
    try {
      if (typeof unsafeWindow.__ejdmPageInitPosts === 'function') {
        unsafeWindow.__ejdmPageInitPosts(idsJson);
      } else {
        const s = document.createElement('script');
        s.textContent = `
          if (window.__ejdmPageInitPosts) {
            window.__ejdmPageInitPosts(${JSON.stringify(idsJson)});
          } else if (typeof window.initPosts === 'function') {
            window.initPosts();
          }
        `;
        (document.documentElement || document.head || document).appendChild(s);
        s.remove();
      }
    } catch (err) {
      console.warn('[ejchan-helper] page-context post init failed:', err);
      try {
        if (typeof unsafeWindow.initPosts === 'function') {
          unsafeWindow.initPosts();
        }
      } catch (err2) {
        console.warn('[ejchan-helper] unsafeWindow.initPosts fallback failed:', err2);
      }
    }
    // Keep manually inserted post times local.
    for (const post of document.querySelectorAll('.post.new-post')) {
      localizeInsertedPostTimes(post);
    }
    setTimeout(() => {
      for (const post of document.querySelectorAll('.post.new-post')) {
        localizeInsertedPostTimes(post);
      }
    }, 300);
  }
  // ------------------------------------------------------------
  // Local time fix for custom-inserted posts
  // ------------------------------------------------------------
  function localizeInsertedPostTimes(root) {
    if (!root) return;
    const times = root.matches?.('time[datetime]')
      ? [root]
      : [...root.querySelectorAll?.('time[datetime]') || []];
    for (const timeEl of times) {
      const dt = timeEl.getAttribute('datetime');
      if (!dt) continue;
      const date = new Date(dt);
      if (Number.isNaN(date.getTime())) continue;
      timeEl.textContent = formatEjchanLocalTime(date);
      timeEl.dataset.local = 'true';
      timeEl.dataset.ejdmLocalized = 'true';
      if (!timeEl.title) {
        timeEl.title = date.toLocaleString();
      }
    }
  }
  function formatEjchanLocalTime(date) {
    const pad = n => String(n).padStart(2, '0');
    const weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
    const mm = pad(date.getMonth() + 1);
    const dd = pad(date.getDate());
    const yy = String(date.getFullYear()).slice(-2);
    const wd = weekdays[date.getDay()];
    const hh = pad(date.getHours());
    const mi = pad(date.getMinutes());
    const ss = pad(date.getSeconds());
    return `${mm}/${dd}/${yy} (${wd}) ${hh}:${mi}:${ss}`;
  }
  // ------------------------------------------------------------
  // Backlink / "Replies:" rebuilding
  // ------------------------------------------------------------
  function rebuildMentionedBlocksFromLocal() {
    const posts = getLocalPosts();
    const postByNo = new Map(posts.map(p => [p.no, p.el]));
    const backlinks = new Map();
    for (const { no: sourceNo, el: sourceEl } of posts) {
      const targets = collectQuoteTargets(sourceEl);
      for (const targetNo of targets) {
        if (!targetNo) continue;
        if (targetNo === sourceNo) continue;
        // Only create backlink if quoted post exists locally.
        // This includes deleted-but-still-visible local posts.
        if (!postByNo.has(targetNo)) continue;
        if (!backlinks.has(targetNo)) {
          backlinks.set(targetNo, new Set());
        }
        backlinks.get(targetNo).add(sourceNo);
      }
    }
    let changed = false;
    for (const { no, el } of posts) {
      const oldMentioned = getDirectMentionedBlock(el);
      const sourceSet = backlinks.get(no);
      if (!sourceSet || sourceSet.size === 0) {
        // Do not remove native/site-created mentioned blocks unless they are ours.
        if (oldMentioned && oldMentioned.dataset.ejdmSignature) {
          oldMentioned.remove();
          changed = true;
        }
        continue;
      }
      const sourceNos = [...sourceSet].sort((a, b) => a - b);
      const newSignature = sourceNos.join(',');
      if (oldMentioned) {
        const oldSignature =
          oldMentioned.dataset.ejdmSignature ||
          getMentionedBlockSignature(oldMentioned);
        if (oldSignature === newSignature) {
          // Do not replace unchanged .mentioned blocks.
          oldMentioned.dataset.ejdmSignature = newSignature;
          continue;
        }
      }
      const mentioned = document.createElement('div');
      mentioned.className = 'mentioned';
      mentioned.dataset.ejdmSignature = newSignature;
      for (const sourceNo of sourceNos) {
        const a = document.createElement('a');
        a.className = `mentioned-${sourceNo}`;
        a.href = `#${sourceNo}`;
        a.textContent = `>>${sourceNo}`;
        a.setAttribute('onclick', `highlightReply('${sourceNo}');`);
        mentioned.appendChild(a);
      }
      if (oldMentioned) {
        oldMentioned.replaceWith(mentioned);
      } else {
        el.appendChild(mentioned);
      }
      changed = true;
    }
    return changed;
  }
  function getMentionedBlockSignature(mentionedEl) {
    const nums = [...mentionedEl.querySelectorAll('a')]
      .map(a => {
        const text = a.textContent || '';
        const href = a.getAttribute('href') || '';
        const cls = a.className || '';
        const m =
          text.match(/>>\s*(\d+)/) ||
          href.match(/#q?(\d+)\b/) ||
          cls.match(/mentioned-(\d+)/);
        return m ? Number(m[1]) : null;
      })
      .filter(Boolean)
      .sort((a, b) => a - b);
    return nums.join(',');
  }
  function collectQuoteTargets(postEl) {
    const result = new Set();
    const body = getDirectBodyBlock(postEl);
    if (!body) return result;
    const links = [...body.querySelectorAll('a')];
    for (const a of links) {
      const text = a.textContent || '';
      const href = a.getAttribute('href') || '';
      const onclick = a.getAttribute('onclick') || '';
      let m = null;
      m = text.match(/>>\s*(\d+)/);
      if (m) {
        result.add(Number(m[1]));
        continue;
      }
      m = href.match(/#q?(\d+)\b/);
      if (m) {
        result.add(Number(m[1]));
        continue;
      }
      m = onclick.match(/highlightReply\(['"]?(\d+)['"]?/);
      if (m) {
        result.add(Number(m[1]));
        continue;
      }
    }
    return result;
  }
  function getDirectBodyBlock(postEl) {
    return [...postEl.children].find(el => el.classList?.contains('body')) || null;
  }
  function getDirectMentionedBlock(postEl) {
    return [...postEl.children].find(el => el.classList?.contains('mentioned')) || null;
  }
  // ------------------------------------------------------------
  // Deleted marker
  // ------------------------------------------------------------
  function compareAndMark(serverSet) {
    const localPosts = getLocalPosts();
    const localSet = new Set(localPosts.map(p => p.no));
    const deletedLocallyPresent = [];
    const missingLocally = [];
    let changed = false;
    for (const { no, el } of localPosts) {
      if (!serverSet.has(no)) {
        const didMark = markDeleted(el, no);
        if (didMark) changed = true;
        deletedLocallyPresent.push(no);
      } else {
        const didUnmark = unmarkDeleted(el);
        if (didUnmark) changed = true;
      }
    }
    for (const no of serverSet) {
      if (!localSet.has(no)) {
        missingLocally.push(no);
      }
    }
    return {
      deletedLocallyPresent,
      missingLocally,
      changed
    };
  }
  function getLocalPosts() {
    return [...document.querySelectorAll(
      '.thread .post.op[id^="op_"], .thread .post.reply[id^="reply_"]'
    )]
      .map(el => {
        const no = getPostNoFromEl(el);
        return no ? { no, el } : null;
      })
      .filter(Boolean);
  }
  function getPostNoFromEl(el) {
    const m = String(el?.id || '').match(/(?:op|reply)_(\d+)/);
    return m ? Number(m[1]) : null;
  }
  function markDeleted(postEl, no) {
    if (!postEl || postEl.classList.contains('ejdm-deleted-post')) return false;
    postEl.classList.add('ejdm-deleted-post');
    postEl.dataset.ejdmDeleted = 'true';
    const introLeft =
      postEl.querySelector('.intro .post-left') ||
      postEl.querySelector('.intro') ||
      postEl;
    const tag = document.createElement('span');
    tag.className = 'ejdm-deleted-tag';
    tag.textContent = '(Удалено)';
    tag.title = `Пост ${no} отсутствует в свежем JSON/HTML с сервера`;
    introLeft.insertAdjacentElement('afterbegin', tag);
    return true;
  }
  function unmarkDeleted(postEl) {
    if (!postEl || !postEl.classList.contains('ejdm-deleted-post')) return false;
    postEl.classList.remove('ejdm-deleted-post');
    delete postEl.dataset.ejDeleted;
    postEl.querySelectorAll('.ejdm-deleted-tag').forEach(el => el.remove());
    return true;
  }
  // ------------------------------------------------------------
  // Correct unread title counter
  // ------------------------------------------------------------
  function stripNativeCounter(title) {
    return String(title || '').replace(/^\(\d+\)\s+/, '');
  }
  function isTabActive() {
    return !document.hidden && windowFocused && document.hasFocus();
  }
  function setupFocusTracking() {
    window.addEventListener('focus', () => {
      windowFocused = true;
      if (isTabActive()) resetUnreadCounter();
    });
    window.addEventListener('blur', () => {
      windowFocused = false;
    });
    document.addEventListener('visibilitychange', () => {
      if (!document.hidden && document.hasFocus()) {
        windowFocused = true;
        resetUnreadCounter();
      }
    });
    document.addEventListener('focusin', () => {
      windowFocused = true;
      if (isTabActive()) resetUnreadCounter();
    });
  }
  function resetUnreadCounter() {
    unreadCount = 0;
    updateManagedTitle();
  }
  function getManagedTitle() {
    return unreadCount > 0 ? `(${unreadCount}) ${baseTitle}` : baseTitle;
  }
  function updateManagedTitle() {
    const wanted = getManagedTitle();
    if (document.title === wanted) return;
    titleWriteLock = true;
    document.title = wanted;
    setTimeout(() => {
      titleWriteLock = false;
    }, 0);
  }
  function setupTitleManager() {
    updateManagedTitle();
    const titleEl = document.querySelector('title');
    if (!titleEl) return;
    const obs = new MutationObserver(() => {
      if (titleWriteLock) return;
      const wanted = getManagedTitle();
      if (document.title !== wanted) {
        setTimeout(updateManagedTitle, 0);
      }
    });
    obs.observe(titleEl, {
      childList: true,
      characterData: true,
      subtree: true
    });
  }
  function setupMutationObserver() {
    const obs = new MutationObserver(mutations => {
      const newlySeen = [];
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          if (!(node instanceof HTMLElement)) continue;
          const posts = [];
          if (node.matches?.('.post.op[id^="op_"], .post.reply[id^="reply_"]')) {
            posts.push(node);
          }
          posts.push(...node.querySelectorAll?.('.post.op[id^="op_"], .post.reply[id^="reply_"]') || []);
          for (const post of posts) {
            const no = getPostNoFromEl(post);
            if (!no) continue;
            if (!knownPostNos.has(no)) {
              knownPostNos.add(no);
              newlySeen.push(post);
            }
          }
        }
      }
      if (newlySeen.length > 0 && !isTabActive()) {
        unreadCount += newlySeen.length;
        updateManagedTitle();
      } else if (newlySeen.length > 0) {
        updateManagedTitle();
      }
    });
    obs.observe(threadEl, {
      childList: true,
      subtree: true
    });
  }
})();