☰

Sankaku Tag Copy & Paste

Copy all tags from a Sankaku Complex post page and paste them into the upload page tag field. Tries to reuse the site's own tag objects so tags keep their references; falls back to raw text paste.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         Sankaku Tag Copy & Paste
// @namespace    sankaku-tagging-tool
// @version      1.9.2
// @description  Copy all tags from a Sankaku Complex post page and paste them into the upload page tag field. Tries to reuse the site's own tag objects so tags keep their references; falls back to raw text paste.
// @match        *://sankakucomplex.com/*
// @match        *://*.sankakucomplex.com/*
// @match        *://sankaku.app/*
// @match        *://*.sankaku.app/*
// @run-at       document-start
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_setClipboard
// @grant        unsafeWindow
// @license      MIT
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'skk_tag_bundle_v1';
  const BTN_ATTR = 'data-skk-btn';
  let copyBusy = false;

  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  const safeDecode = (value) => {
    try { return decodeURIComponent(value); } catch (e) { return value; }
  };

  // Match display spaces to slug underscores without merging punctuation or Unicode tags.
  const normKey = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, '_');

  const store = {
    read() {
      try {
        if (typeof GM_getValue === 'function') {
          const value = GM_getValue(STORAGE_KEY, null);
          if (value && typeof value === 'object' && Array.isArray(value.tags)) return value;
        }
      } catch (e) {}
      try {
        const raw = localStorage.getItem(STORAGE_KEY);
        return raw ? JSON.parse(raw) : null;
      } catch (e) { return null; }
    },
    write(value) {
      try {
        if (typeof GM_setValue === 'function') GM_setValue(STORAGE_KEY, value);
      } catch (e) {}
      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); } catch (e) {}
    }
  };

  async function copyToClipboard(text) {
    try {
      if (typeof GM_setClipboard === 'function') {
        GM_setClipboard(text, 'text');
        return true;
      }
    } catch (e) {}
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(text);
        return true;
      }
    } catch (e) {}
    try {
      const area = document.createElement('textarea');
      area.value = text;
      area.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
      (document.body || document.documentElement).appendChild(area);
      area.select();
      const ok = document.execCommand('copy');
      area.remove();
      return ok;
    } catch (e) { return false; }
  }

  async function readClipboardText() {
    try {
      if (navigator.clipboard && navigator.clipboard.readText) return await navigator.clipboard.readText();
    } catch (e) {}
    return '';
  }

  const capturedResponses = [];
  let captureVersion = 0;
  let pendingTagRequests = 0;
  let tagResponseVersion = 0;
  const unrelatedPostData = /related|similar|recommend|comments?|reactions?|votes?|ratings?|favorites?|users?/i;
  const unrelatedPostUrl = (url) => /related|similar|recommend|comments?/i.test(url) ||
    /\/(?:reactions?|votes?|ratings?|favorites?|users?)(?:[/?#]|$)/i.test(url);
  const isTagRequest = (url) => /\/tags?(?:[/?#]|$)|\/posts?(?:[/?#]|$)/i.test(String(url || '')) && !unrelatedPostUrl(String(url || ''));

  function rememberResponse(url, text) {
    if (!text || text.length > 5000000) return;
    const head = text.trimStart().charAt(0);
    if (head !== '{' && head !== '[') return;
    let json = null;
    try { json = JSON.parse(text); } catch (e) { return; }
    rememberJsonResponse(url, json);
  }

  function rememberJsonResponse(url, json) {
    if (!json || typeof json !== 'object') return;
    captureVersion += 1;
    if (isTagRequest(url)) tagResponseVersion += 1;
    const lists = [];
    try { scanForTagLists(json, lists); } catch (e) { return; }
    const entries = new WeakMap();
    const compact = [];
    for (const list of lists) {
      const tags = list.tags.map((item) => createTagEntry(item, entries)).filter(Boolean);
      if (tags.length) compact.push({
        id: list.id && typeof list.id === 'object' ? String(list.id) : list.id,
        contextId: list.contextId && typeof list.contextId === 'object' ? String(list.contextId) : list.contextId,
        declared: list.declared,
        fromArray: list.fromArray, tags,
      });
    }
    if (!compact.length) return;
    // Keep the matching metadata and complete tag objects, never the response envelope.
    capturedResponses.push({ url: String(url || ''), lists: compact,
      total: !Array.isArray(json) && Number.isInteger(json.total) ? json.total : 0 });
    if (capturedResponses.length > 40) capturedResponses.shift();
  }

  function installNetworkCapture() {
    try {
      const pageWindow = typeof unsafeWindow !== 'undefined' && unsafeWindow ? unsafeWindow : window;
      const origFetch = pageWindow.fetch;
      if (typeof origFetch === 'function') {
        pageWindow.fetch = function () {
          const args = arguments;
          const input = args[0];
          const url = typeof input === 'string' ? input : input && input.url ? input.url : '';
          const relevant = isTagRequest(url);
          if (relevant) pendingTagRequests += 1;
          let finished = false;
          const done = () => {
            if (finished) return;
            finished = true;
            if (relevant) pendingTagRequests -= 1;
          };
          let result;
          try { result = origFetch.apply(this, args); } catch (e) { done(); throw e; }
          if (result && typeof result.then === 'function') {
            result.then((response) => {
              try {
                const type = response && response.headers ? response.headers.get('content-type') || '' : '';
                if (response && response.ok && /json/i.test(type)) {
                  return response.clone().text().then((text) => rememberResponse(url, text)).catch(() => {}).then(done);
                }
              } catch (e) {}
              done();
            }, done);
          } else done();
          return result;
        };
      }

      const XHR = pageWindow.XMLHttpRequest;
      if (XHR && XHR.prototype) {
        const origOpen = XHR.prototype.open;
        const origSend = XHR.prototype.send;
        XHR.prototype.open = function (method, url) {
          try { this.__skkUrl = url; } catch (e) {}
          return origOpen.apply(this, arguments);
        };
        XHR.prototype.send = function () {
          const xhr = this;
          const relevant = isTagRequest(this.__skkUrl);
          if (relevant) pendingTagRequests += 1;
          let finished = false;
          const done = () => {
            if (finished) return;
            finished = true;
            if (relevant) pendingTagRequests -= 1;
            try {
              xhr.removeEventListener('load', onLoad);
              xhr.removeEventListener('loadend', done);
            } catch (e) {}
          };
          const onLoad = function () {
            try {
              if (this.responseType === '' || this.responseType === 'text') {
                rememberResponse(this.__skkUrl, this.responseText);
              } else if (this.responseType === 'json') {
                rememberJsonResponse(this.__skkUrl, this.response);
              }
            } catch (e) {}
          };
          try {
            this.addEventListener('loadend', done, { once: true });
            this.addEventListener('load', onLoad, { once: true });
          } catch (e) { done(); }
          try { return origSend.apply(this, arguments); } catch (e) { done(); throw e; }
        };
      }
    } catch (e) {}
  }

  installNetworkCapture();

  function getPostId() {
    const path = location.pathname || '';
    const match = path.match(/\/post\/show\/([^/?#]+)/i) || path.match(/\/posts?\/([^/?#]+)/i);
    if (match) return safeDecode(match[1]);
    const queryId = new URLSearchParams(location.search).get('id');
    return queryId || null;
  }

  function readTagFields(value) {
    if (!value || typeof value !== 'object') return { slug: '', name: '' };
    const pick = (keys) => {
      for (const key of keys) {
        const candidate = value[key];
        if (typeof candidate === 'string' && candidate) return candidate;
      }
      return '';
    };
    const slug = pick(['name', 'tag_name', 'tagName', 'slug', 'name_en']);
    const name = pick(['name_en', 'name', 'tag_name', 'tagName', 'slug']);
    return { slug, name };
  }

  function isTagRecord(value) {
    if (typeof value === 'string') return value.length > 0;
    if (!value || typeof value !== 'object') return false;
    const fields = readTagFields(value);
    return !!(fields.slug || fields.name);
  }

  function looksLikeTagItem(value) {
    if (typeof value === 'string') return value.length > 0 && value.length < 100;
    if (!value || typeof value !== 'object') return false;
    const fields = readTagFields(value);
    if (!fields.slug && !fields.name) return false;
    return 'id' in value || 'type' in value || 'tag_type' in value || 'name_en' in value;
  }

  function scanForTagLists(node, lists) {
    const queue = [{ value: node, depth: 0, parentId: null, parentDeclared: 0, collectionEnvelope: true }];
    const seen = new Set();
    let visited = 0;
    for (let cursor = 0; cursor < queue.length && visited < 20000 && lists.length < 12; cursor += 1) {
      const entry = queue[cursor];
      queue[cursor] = null;
      visited += 1;
      const value = entry.value;
      if (!value || typeof value !== 'object' || seen.has(value) || entry.depth > 8) continue;
      seen.add(value);
      if (Array.isArray(value)) {
        // A named object with an ID may be a reaction or user. Only collection
        // envelopes can be anonymous tag lists; other arrays require a `tags` key.
        if (entry.collectionEnvelope && value.length && value.length <= 500 && value.every(looksLikeTagItem)) {
          lists.push({
            tags: value,
            id: null,
            contextId: entry.parentId,
            declared: entry.parentDeclared || 0,
            fromArray: true,
          });
        }
        for (const child of value) queue.push({ value: child, depth: entry.depth + 1, parentId: entry.parentId, parentDeclared: entry.parentDeclared });
        continue;
      }
      const ownId = value.id !== undefined ? value.id : (value.post_id !== undefined ? value.post_id : (value.postId !== undefined ? value.postId : null));
      const contextId = ownId !== null ? ownId : entry.parentId;
      let ownDeclared = entry.parentDeclared || 0;
      // A generic `total` can count posts, search results, or a tag's uses.
      for (const field of ['tag_count', 'tags_count', 'total_tag_count', 'tagcount', 'totalTags']) {
        const candidate = value[field];
        if (typeof candidate === 'number' && candidate > ownDeclared) ownDeclared = candidate;
      }
      if (
        Array.isArray(value.tags) &&
        value.tags.length &&
        value.tags.every((t) => isTagRecord(t) || (t && typeof t === 'object' && isTagRecord(t.tag)))
      ) {
        lists.push({ tags: value.tags, id: ownId, contextId, declared: ownDeclared, fromArray: false });
      }
      for (const key of Object.keys(value)) {
        const child = value[key];
        if (unrelatedPostData.test(key)) continue;
        if (child && typeof child === 'object') queue.push({ value: child, depth: entry.depth + 1, parentId: contextId, parentDeclared: ownDeclared,
          collectionEnvelope: entry.collectionEnvelope && /^(data|results|tags)$/.test(key) });
      }
    }
  }

  function createTagEntry(item, entries) {
    const tagObj = extractTagFromItem(item);
    if (!tagObj) return null;
    if (entries && entries.has(tagObj)) return entries.get(tagObj);
    const fields = readTagFields(tagObj);
    const slug = fields.slug || fields.name;
    const name = fields.name || fields.slug;
    const key = normKey(slug) || normKey(name);
    if (!key) return null;
    const raw = sanitizeValue(tagObj, 0, new Set());
    const entry = { slug, name, raw: hasTagData(raw) ? raw : null };
    if (entries) entries.set(tagObj, entry);
    return entry;
  }

  function addTagRecordToMap(map, item, entries) {
    const entry = createTagEntry(item, entries);
    if (entry) mergeTagEntries([entry], map);
  }

  function findPostTagsFromApi(postId, wanted = new Set()) {
    if (!postId || !capturedResponses.length) return null;
    const merged = new Map();
    let declared = 0;
    for (const response of capturedResponses) {
      const url = typeof response.url === 'string' ? response.url : '';
      let urlHasPostId = false;
      try {
        const parsed = new URL(url, location.href);
        urlHasPostId = parsed.pathname.split('/').includes(postId) ||
          ['post_id', 'postId', 'post', 'id'].some((key) => parsed.searchParams.get(key) === postId);
      } catch (e) {}
      if (unrelatedPostUrl(url)) continue;
      const urlIsPostEndpoint = urlHasPostId && /\/posts?[/?]/i.test(url);
      const urlIsTagsEndpoint = /\/tags?(?:[/?#]|$)/i.test(url);
      for (const list of response.lists) {
        const idMatch = String(list.id) === postId || String(list.contextId) === postId;
        // Post responses authorize explicit tags fields, not arbitrary arrays.
        const associated = list.fromArray
          ? urlIsTagsEndpoint && urlHasPostId
          : idMatch || urlIsPostEndpoint || (urlIsTagsEndpoint && urlHasPostId);
        const accept = associated || urlIsTagsEndpoint;
        if (!accept) continue;
        if (associated && list.declared > declared) declared = list.declared;
        // `total` is authoritative only for a tag collection scoped to this post.
        if (urlIsTagsEndpoint && urlHasPostId) {
          const total = response.total;
          if (Number.isInteger(total) && total > declared) declared = total;
        }
        for (const tag of list.tags) {
          if (associated || wanted.has(normKey(tag.slug)) || wanted.has(normKey(tag.name))) {
            mergeTagEntries([tag], merged);
          }
        }
      }
    }
    if (!merged.size) return null;
    return { tags: Array.from(merged.values()), declared };
  }

  function getFiber(element) {
    if (!element) return null;
    for (const name of Object.keys(element)) {
      if (name.startsWith('__reactFiber$') || name.startsWith('__reactInternalInstance$')) {
        const fiber = element[name];
        let root = fiber;
        for (let level = 0; root && root.return && level < 64; level += 1) root = root.return;
        // React may leave the DOM pointing at the previous alternate after a commit.
        const current = root && root.stateNode && root.stateNode.current;
        return current && current !== root && fiber.alternate ? fiber.alternate : fiber;
      }
    }
    return null;
  }

  function looksLikeTagObject(value, wanted) {
    if (!value || typeof value !== 'object') return false;
    const fields = readTagFields(value);
    return (fields.slug && wanted.has(normKey(fields.slug))) || (fields.name && wanted.has(normKey(fields.name)));
  }

  function scoreTagObject(value) {
    let score = 0;
    if ('id' in value) score += 4;
    if (typeof value.name === 'string') score += 2;
    if (typeof value.name_en === 'string') score += 1;
    if (typeof value.type === 'string') score += 2;
    score += Math.min(Object.keys(value).length, 8) / 8;
    return score;
  }

  function findTagObjectIn(value, wanted, depth, seen, out) {
    if (!value || typeof value !== 'object' || depth > 4 || out.length >= 10) return;
    if (seen.has(value)) return;
    seen.add(value);
    if (Array.isArray(value)) {
      for (const item of value) findTagObjectIn(item, wanted, depth + 1, seen, out);
      return;
    }
    if (Object.prototype.toString.call(value) !== '[object Object]') return;
    if (looksLikeTagObject(value, wanted)) {
      out.push(value);
      return;
    }
    for (const key of Object.keys(value)) {
      const child = value[key];
      if (child && typeof child === 'object') findTagObjectIn(child, wanted, depth + 1, seen, out);
    }
  }

  function sanitizeValue(value, depth, seen) {
    if (value === null) return null;
    const type = typeof value;
    if (type === 'string' || type === 'number' || type === 'boolean') return value;
    if (type !== 'object' || depth > 5 || seen.has(value)) return undefined;
    seen.add(value);
    if (Array.isArray(value)) {
      const out = [];
      for (const item of value) {
        const clean = sanitizeValue(item, depth + 1, seen);
        if (clean !== undefined) out.push(clean);
      }
      return out;
    }
    if (Object.prototype.toString.call(value) !== '[object Object]') return undefined;
    const out = {};
    for (const key of Object.keys(value)) {
      const clean = sanitizeValue(value[key], depth + 1, seen);
      if (clean !== undefined) out[key] = clean;
    }
    return out;
  }

  function extractTagObject(anchor, slug, name) {
    try {
      const wanted = new Set([normKey(slug), normKey(name)].filter(Boolean));
      if (!wanted.size) return null;
      const candidates = [];
      let fiber = getFiber(anchor);
      for (let level = 0; fiber && level < 16 && candidates.length < 10; level += 1) {
        if (fiber.memoizedProps) findTagObjectIn(fiber.memoizedProps, wanted, 0, new Set(), candidates);
        fiber = fiber.return;
      }
      if (!candidates.length) return null;
      candidates.sort((a, b) => scoreTagObject(b) - scoreTagObject(a));
      const clean = sanitizeValue(candidates[0], 0, new Set());
      return clean && typeof clean === 'object' ? clean : null;
    } catch (e) {
      return null;
    }
  }

  function tagObjectLike(value) {
    if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
    const fields = readTagFields(value);
    if (!fields.slug && !fields.name) return false;
    return 'id' in value || 'type' in value || 'tag_type' in value || 'name_en' in value || 'name' in value || 'tag_name' in value;
  }

  function hasTagData(value) {
    return !!(value && typeof value === 'object' &&
      (value.id !== undefined && value.id !== null || value.tag_id !== undefined && value.tag_id !== null) &&
      isTagRecord(value));
  }

  function extractTagFromItem(item) {
    if (typeof item === 'string') return item ? { name: item, name_en: item } : null;
    if (!item || typeof item !== 'object') return null;
    if (tagObjectLike(item.tag)) return item.tag;
    if (tagObjectLike(item)) return item;
    for (const key of Object.keys(item)) {
      const child = item[key];
      if (child && typeof child === 'object' && !Array.isArray(child) && tagObjectLike(child)) return child;
    }
    return null;
  }

  function virtuosoState(scroller) {
    let countState = null;
    try {
      let fiber = getFiber(scroller);
      for (let level = 0; fiber && level < 16; level += 1) {
        const props = fiber.memoizedProps;
        if (
          props &&
          (props.itemContent || props.item) &&
          (Array.isArray(props.data) || typeof props.totalCount === 'number')
        ) {
          const state = {
            data: Array.isArray(props.data) ? props.data : null,
            totalCount: typeof props.totalCount === 'number' ? props.totalCount : null,
          };
          // Internal wrappers may expose only a count; the outer component has data.
          if (state.data) {
            if (state.totalCount === null && countState) state.totalCount = countState.totalCount;
            return state;
          }
          if (!countState) countState = state;
        }
        fiber = fiber.return;
      }
    } catch (e) {}
    return countState;
  }

  function countVirtuoso(state) {
    if (!state) return 0;
    if (state.data) return state.data.length;
    return state.totalCount || 0;
  }

  function mergeVirtuosoTags(scroller, found, state = virtuosoState(scroller), entries) {
    if (!state || !state.data) return countVirtuoso(state);
    for (const item of state.data) {
      addTagRecordToMap(found, item, entries);
    }
    return countVirtuoso(state);
  }

  function findPostTagsHeader() {
    const labels = document.querySelectorAll('p.MuiTypography-root, p');
    for (const label of labels) {
      if (label.textContent.trim() !== 'Tags') continue;
      const header = label.parentElement;
      if (!header) continue;
      if (!header.querySelector(':scope > .MuiInputBase-root')) continue;
      const wrapper = header.parentElement;
      if (!wrapper) continue;
      if (wrapper.querySelector('a[href^="/tags/"]') || wrapper.querySelector('[data-virtuoso-scroller]')) return header;
    }
    return null;
  }

  function collectTags(scope, found) {
    scope.querySelectorAll('a[href^="/tags/"]').forEach((anchor) => {
      const href = anchor.getAttribute('href') || '';
      const match = href.match(/^\/tags\/(.+)$/);
      if (!match) return;
      const slug = safeDecode(match[1].split('?')[0].split('#')[0].replace(/\/+$/, ''));
      const labelEl = anchor.querySelector('.MuiChip-label') || anchor;
      const name = labelEl.textContent.trim();
      const key = normKey(slug) || normKey(name);
      if (!key) return;
      const existing = found.get(key);
      if (existing && hasTagData(existing.raw)) return;
      const raw = extractTagObject(anchor, slug, name);
      mergeTagEntries([{ slug, name: name || slug, raw: hasTagData(raw) ? raw : null }], found);
    });
  }

  function collectScrollers(scope) {
    const scrollers = new Set();
    scope.querySelectorAll('a[href^="/tags/"]').forEach((anchor) => {
      const scroller = anchor.closest('[data-virtuoso-scroller]');
      if (scroller) scrollers.add(scroller);
    });
    scope.querySelectorAll('[data-virtuoso-scroller]').forEach((scroller) => scrollers.add(scroller));
    return Array.from(scrollers);
  }

  function mergeTagEntries(entries, found) {
    for (const tag of entries) {
      const key = normKey(tag.slug) || normKey(tag.name);
      if (!key) continue;
      const existing = found.get(key);
      if (existing) {
        if (hasTagData(tag.raw) && (!hasTagData(existing.raw) || scoreTagObject(tag.raw) > scoreTagObject(existing.raw))) existing.raw = tag.raw;
      } else {
        found.set(key, { slug: tag.slug, name: tag.name, raw: tag.raw });
      }
    }
  }

  let siteApi = null;
  let siteRequire = null;
  let siteApiCaptureQueued = false;

  function getSiteApi() {
    if (siteApi) return siteApi;
    try {
      if (!siteRequire && !siteApiCaptureQueued) {
        const pageWindow = typeof unsafeWindow !== 'undefined' && unsafeWindow ? unsafeWindow : window;
        const chunks = pageWindow.__LOADABLE_LOADED_CHUNKS__;
        if (!chunks || typeof chunks.push !== 'function') return null;
        siteApiCaptureQueued = true;
        chunks.push([[`skk_tag_reader_${Date.now()}`], {}, (require) => { siteRequire = require; }]);
      }
      if (!siteRequire) return null;
      // Retry module lookup through the same loader if the site's client arrives later.
      const module = siteRequire('./src/shared/api.js');
      const candidate = module.A || module.default;
      if (candidate && typeof candidate.get === 'function') siteApi = candidate;
    } catch (e) {}
    return siteApi;
  }

  function readPostTagPage(payload) {
    let items = null;
    const candidates = [payload, payload && payload.tags, payload && payload.data,
      payload && payload.data && payload.data.tags, payload && payload.results];
    for (const candidate of candidates) {
      if (Array.isArray(candidate) && candidate.every((item) => !!extractTagFromItem(item))) {
        items = candidate;
        break;
      }
    }
    if (!items) throw new Error('Unrecognized post tag response');
    let total = 0;
    let hasMore = null;
    let pageSize = 40;
    for (const meta of [payload, payload && payload.meta, payload && payload.pagination,
      payload && payload.pageInfo, payload && payload.data, payload && payload.data && payload.data.meta]) {
      if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue;
      for (const key of ['tag_count', 'tags_count', 'total_tag_count', 'total', 'totalCount']) {
        const value = Number(meta[key]);
        if (Number.isInteger(value) && value > total) total = value;
      }
      for (const key of ['has_more', 'hasMore', 'hasNextPage']) {
        if (typeof meta[key] === 'boolean') hasMore = meta[key];
      }
      for (const key of ['limit', 'per_page', 'perPage']) {
        if (Number.isInteger(meta[key]) && meta[key] > 0) pageSize = meta[key];
      }
    }
    return { items, total, hasMore, pageSize };
  }

  async function waitForTagResponse(result, deadline) {
    let settled = false;
    let value;
    let error;
    Promise.resolve(result).then((response) => {
      value = response;
      settled = true;
    }, (failure) => {
      error = failure;
      settled = true;
    });
    while (!settled && Date.now() < deadline) await sleep(100);
    if (!settled) throw new Error('Tag request timed out');
    if (error) throw error;
    return value;
  }

  async function harvestPostTags(scope) {
    const found = new Map();
    const stats = { postId: getPostId(), api: 0, declared: 0, list: 0, total: 0,
      sweeps: 0, requests: 0, loads: 0, found: 0, ms: 0, complete: false };
    const started = Date.now();
    const deadline = started + 15000;
    let completed = false;
    let apiFailed = false;
    let lastApiScanVersion = -1;
    let lastApiWantedSize = -1;
    let loader = null;

    const readCurrentTags = () => {
      loader = null;
      const entries = new WeakMap();
      for (const scroller of collectScrollers(scope)) {
        const state = virtuosoState(scroller);
        mergeVirtuosoTags(scroller, found, state, entries);
        if (state && state.data) stats.list = Math.max(stats.list, state.data.length);
        if (state && state.totalCount) stats.total = Math.max(stats.total, state.totalCount);
        // The containing tag section/post may hold more data than the rendered list.
        let fiber = getFiber(scroller);
        for (let level = 0; fiber && level < 32; level += 1, fiber = fiber.return) {
          const props = fiber.memoizedProps;
          if (!props) continue;
          const sources = [props, props.currentPost, props.post];
          for (const source of sources) {
            if (!source || !Array.isArray(source.tags)) continue;
            for (const item of source.tags) addTagRecordToMap(found, item, entries);
            for (const key of ['tag_count', 'tags_count', 'total_tag_count', 'totalTags']) {
              if (Number.isInteger(source[key])) stats.declared = Math.max(stats.declared, source[key]);
            }
          }
          if (!loader && typeof props.endReached === 'function' &&
              (props.itemContent || props.item) && (Array.isArray(props.data) || typeof props.totalCount === 'number')) {
            loader = { props, count: Array.isArray(props.data) ? props.data.length : props.totalCount };
          }
        }
      }
      collectTags(scope, found);
      if (stats.postId && (captureVersion !== lastApiScanVersion || found.size !== lastApiWantedSize)) {
        lastApiScanVersion = captureVersion;
        const wanted = new Set();
        for (const entry of found.values()) {
          wanted.add(normKey(entry.slug));
          wanted.add(normKey(entry.name));
        }
        const api = findPostTagsFromApi(stats.postId, wanted);
        if (api) {
          mergeTagEntries(api.tags, found);
          stats.api = Math.max(stats.api, api.tags.length);
          stats.declared = Math.max(stats.declared, api.declared);
        }
        lastApiWantedSize = found.size;
      }
      return stats.declared > 0 && found.size >= Math.max(stats.declared, stats.list, stats.total) &&
        Array.from(found.values()).every((entry) => hasTagData(entry.raw));
    };

    completed = readCurrentTags();
    const client = completed ? null : getSiteApi();
    if (!completed && client && stats.postId) {
      // Fetch the full collection independently of the virtual list's visible rows.
      // Count API records separately: existing UI data must not hide a repeated page.
      const apiTags = new Map();
      const endpoint = `posts/${encodeURIComponent(stats.postId)}/tags`;
      try {
        for (let page = 1; page <= 100 && Date.now() < deadline; page += 1) {
          stats.requests += 1;
          const response = await waitForTagResponse(client.get(endpoint, { params: { page, limit: 40 } }), deadline);
          const payload = response && Object.prototype.hasOwnProperty.call(response, 'data') ? response.data : response;
          const batch = readPostTagPage(payload);
          const before = apiTags.size;
          for (const item of batch.items) {
            const entry = createTagEntry(item);
            if (!entry) continue;
            mergeTagEntries([entry], apiTags);
            mergeTagEntries([entry], found);
          }
          stats.api = Math.max(stats.api, apiTags.size);
          stats.declared = Math.max(stats.declared, batch.total);
          const expected = Math.max(stats.declared, stats.list, stats.total);
          if (stats.declared > 0 && apiTags.size >= expected) { completed = true; break; }
          const exhausted = batch.hasMore === false || !batch.items.length ||
            (batch.hasMore !== true && batch.items.length < batch.pageSize);
          if (exhausted) {
            completed = !expected || apiTags.size >= expected;
            break;
          }
          if (apiTags.size === before) throw new Error('Tag API repeated a page without supplying missing tags');
        }
      } catch (error) {
        stats.reason = String(error && error.message || 'Tag API request failed');
      }
      apiFailed = !completed;
    }

    // A build without the exposed API client can still run the exact lazy-load
    // callback used by Virtuoso, without changing either scrollbar position.
    if (!completed) {
      let checkedSize = -1;
      let checkedResponse = -1;
      let checkedAt = started;
      let stalledLoads = 0;
      let previousSize = found.size;
      const sizeBeforeFallback = found.size;
      while (Date.now() < deadline) {
        if (readCurrentTags()) { completed = true; break; }
        if (found.size > previousSize) stalledLoads = 0;
        previousSize = found.size;
        const expected = Math.max(stats.declared, stats.list, stats.total);
        const unchanged = found.size === checkedSize && tagResponseVersion === checkedResponse;
        if (unchanged && pendingTagRequests === 0 && Date.now() - checkedAt >= 900) {
          if ((!expected || found.size >= expected) &&
              (!apiFailed || stats.declared > 0 || found.size > sizeBeforeFallback)) {
            completed = true;
            break;
          }
          if (stalledLoads >= 2) break;
        }
        if (loader && pendingTagRequests === 0 && (!unchanged || Date.now() - checkedAt >= 900)) {
          checkedSize = found.size;
          checkedResponse = tagResponseVersion;
          checkedAt = Date.now();
          stats.loads += 1;
          stalledLoads += 1;
          try {
            await waitForTagResponse(loader.props.endReached(Math.max(0, loader.count - 1)), deadline);
          } catch (error) {
            stats.reason = String(error && error.message || 'Tag loader failed');
            break;
          }
        } else if (!loader && pendingTagRequests === 0 && Date.now() - checkedAt >= 900) {
          stats.reason = stats.reason || 'No accessible tag API or tag loader';
          break;
        }
        await sleep(100);
      }
    }

    readCurrentTags();
    const expected = Math.max(stats.declared, stats.list, stats.total);
    completed = completed && (!expected || found.size >= expected);
    stats.found = found.size;
    stats.complete = completed;
    stats.ms = Date.now() - started;
    return { tags: Array.from(found.values()), stats, complete: completed };
  }

  async function handleCopy(button) {
    if (copyBusy || button.dataset.busy === '1') return;
    copyBusy = true;
    button.dataset.busy = '1';
    const original = button.textContent;
    button.textContent = 'Copying...';
    let holdMs = 2500;
    try {
      const header = findPostTagsHeader();
      const scope = header && header.parentElement ? header.parentElement : document;
      const harvested = await harvestPostTags(scope);
      const tags = harvested.tags;
      const stats = harvested.stats;
      console.info('[Sankaku Tags] copy stats', JSON.stringify(stats));
      const knownTotal = Math.max(stats.declared, stats.total, stats.list);
      button.title = JSON.stringify(stats);
      if (!tags.length) {
        button.textContent = 'No tags found';
      } else if (!harvested.complete || (knownTotal && tags.length < knownTotal)) {
        button.textContent = knownTotal && tags.length < knownTotal
          ? `Copy failed: only ${tags.length} of ${knownTotal} tags loaded`
          : 'Copy failed: could not confirm all tags';
        holdMs = 9000;
      } else {
        store.write({ source: location.href, copiedAt: Date.now(), complete: true, expectedCount: knownTotal || tags.length, tags });
        const withData = tags.filter((tag) => hasTagData(tag.raw)).length;
        const missing = tags.length - withData;
        const suffix = missing ? ` (${missing} without data)` : '';
        button.textContent = `Saved ${tags.length} tags${suffix}`;
      }
    } catch (error) {
      console.error('[Sankaku Tags] copy failed', error);
      button.textContent = 'Copy failed';
    }
    setTimeout(() => {
      button.textContent = original;
      delete button.dataset.busy;
      copyBusy = false;
    }, holdMs);
  }

  function getUploadRoot() {
    const root = document.querySelector('.MuiAutocomplete-root[name="tags"]');
    if (root) return root;
    const input = document.querySelector('input[name="tags"]');
    return input ? input.closest('.MuiAutocomplete-root') : null;
  }

  function getUploadHeader() {
    const root = getUploadRoot();
    if (!root) return null;
    const box = root.closest('.MuiBox-root');
    const header = box ? box.firstElementChild : null;
    return header && header.tagName === 'DIV' ? header : null;
  }

  function getTagInput(root) {
    return root.querySelector('input.MuiAutocomplete-input') || root.querySelector('input[type="text"]');
  }

  function chipCount(root) {
    return root.querySelectorAll('[data-testid="tag-chip"]').length;
  }

  function setInputValue(input, value) {
    const descriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
    if (descriptor && descriptor.set) descriptor.set.call(input, value);
    else input.value = value;
    input.dispatchEvent(new Event('input', { bubbles: true }));
  }

  function chipKeys(root) {
    return new Set(
      Array.prototype.map.call(
        root.querySelectorAll('[data-testid="tag-chip"]'),
        (chip) => normKey(chip.textContent)
      )
    );
  }

  function isTagPresent(tag, keys) {
    return [normKey(tag.slug), normKey(tag.name)].filter(Boolean).some((key) => keys.has(key));
  }

  function parseClipboardTags(text) {
    if (!text) return [];
    return text
      .split(/[\s,;]+/)
      .map((token) => token.trim())
      .filter((token) => token && token.length < 200)
      .map((token) => {
        const decoded = safeDecode(token);
        return { slug: decoded, name: decoded, raw: null };
      });
  }

  function tagKeyOf(value) {
    if (!value) return '';
    if (typeof value === 'string') return normKey(value);
    return normKey(value.name || value.name_en || '');
  }

  function autocompletePropsFromRoot(root) {
    try {
      let fiber = getFiber(root);
      for (let level = 0; fiber && level < 14; level += 1) {
        const props = fiber.memoizedProps;
        if (
          props &&
          typeof props.onChange === 'function' &&
          Array.isArray(props.value) &&
          (props.renderInput || props.getOptionLabel || Array.isArray(props.options))
        ) {
          return props;
        }
        fiber = fiber.return;
      }
    } catch (e) {}
    return null;
  }

  function fakeChangeEvent() {
    return {
      type: 'change',
      target: { value: '' },
      currentTarget: { value: '' },
      nativeEvent: { type: 'change' },
      preventDefault() {},
      stopPropagation() {},
      persist() {},
    };
  }

  function callAutocompleteChange(props, next, lastOption) {
    const details = { option: lastOption };
    try {
      props.onChange(fakeChangeEvent(), next, 'selectOption', details);
      return true;
    } catch (e) {
      try {
        props.onChange(null, next, 'selectOption', details);
        return true;
      } catch (e2) {
        console.warn('[Sankaku Tags] tag injection call failed', e2);
        return false;
      }
    }
  }

  function injectTags(rawList) {
    const root = getUploadRoot();
    if (!root) return false;
    const props = autocompletePropsFromRoot(root);
    if (!props) return false;
    const current = Array.isArray(props.value) ? props.value : [];
    const existing = new Set(current.map((item) => tagKeyOf(item)).filter(Boolean));
    const additions = [];
    for (const raw of rawList) {
      const key = tagKeyOf(raw);
      if (!key || existing.has(key)) continue;
      existing.add(key);
      additions.push(raw);
    }
    if (!additions.length) return true;
    const next = current.concat(additions);
    return callAutocompleteChange(props, next, additions[additions.length - 1]);
  }

  function injectOneTag(raw) {
    const root = getUploadRoot();
    if (!root) return false;
    const props = autocompletePropsFromRoot(root);
    if (!props) return false;
    const current = Array.isArray(props.value) ? props.value : [];
    const key = tagKeyOf(raw);
    if (!key) return false;
    if (current.some((item) => tagKeyOf(item) === key)) return true;
    const next = current.concat([raw]);
    return callAutocompleteChange(props, next, raw);
  }

  async function waitForChips(tags, timeout) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      const root = getUploadRoot();
      if (!root) return;
      const keys = chipKeys(root);
      if (tags.every((tag) => isTagPresent(tag, keys))) return;
      await sleep(100);
    }
  }

  async function injectTagsIntoField(tags, button) {
    const rawTags = tags.filter((tag) => hasTagData(tag.raw));
    if (!rawTags.length) return;
    const rootBefore = getUploadRoot();
    if (!rootBefore) return;
    const beforeKeys = chipKeys(rootBefore);
    const todo = rawTags.filter((tag) => !isTagPresent(tag, beforeKeys));
    if (!todo.length) return;

    button.textContent = 'Injecting tag data...';
    if (injectTags(todo.map((tag) => tag.raw))) {
      await waitForChips(todo, 1600);
    }

    const rootAfter = getUploadRoot();
    if (!rootAfter) return;
    const afterKeys = chipKeys(rootAfter);
    const missing = todo.filter((tag) => !isTagPresent(tag, afterKeys));
    if (missing.length) console.warn('[Sankaku Tags] bulk injection left', missing.length, 'tag(s), retrying one by one');
    for (const tag of missing) {
      const liveRoot = getUploadRoot();
      if (!liveRoot) break;
      if (isTagPresent(tag, chipKeys(liveRoot))) continue;
      if (!injectOneTag(tag.raw)) break;
      await waitForChips([tag], 900);
    }
  }

  async function rawTextPaste(text, timeout) {
    const root = getUploadRoot();
    if (!root) return 0;
    const before = chipCount(root);
    let dispatched = false;
    try {
      if (typeof DataTransfer === 'function' && typeof ClipboardEvent === 'function') {
        const root = getUploadRoot();
        const input = root && getTagInput(root);
        if (input) {
          const data = new DataTransfer();
          data.setData('text/plain', text);
          input.focus({ preventScroll: true });
          input.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }));
          dispatched = true;
        }
      }
    } catch (e) {}
    if (!dispatched) return 0;

    const start = Date.now();
    let last = before;
    let lastChange = Date.now();
    while (Date.now() - start < timeout) {
      await sleep(120);
      const liveRoot = getUploadRoot() || root;
      const count = chipCount(liveRoot);
      if (count !== last) {
        last = count;
        lastChange = Date.now();
      } else if (count > before && Date.now() - lastChange > 600) {
        return count - before;
      }
    }
    const liveRoot = getUploadRoot() || root;
    return Math.max(0, chipCount(liveRoot) - before);
  }

  function getUploadSelectedTags() {
    const root = getUploadRoot();
    if (!root) return [];
    const tags = [];
    const seen = new Set();
    const props = autocompletePropsFromRoot(root);
    if (props && Array.isArray(props.value)) {
      for (const item of props.value) {
        if (!item || typeof item !== 'object') continue;
        const slug = typeof item.name === 'string' && item.name ? item.name : String(item.name_en || '');
        const name = String(item.name_en || item.name || slug);
        const key = normKey(slug) || normKey(name);
        if (!key || seen.has(key)) continue;
        seen.add(key);
        const raw = sanitizeValue(item, 0, new Set());
        tags.push({ slug, name, raw: raw && typeof raw === 'object' ? raw : null });
      }
    }
    if (!tags.length) {
      root.querySelectorAll('[data-testid="tag-chip"]').forEach((chip) => {
        const text = String(chip.textContent || '').trim();
        if (!text) return;
        const key = normKey(text);
        if (!key || seen.has(key)) return;
        seen.add(key);
        tags.push({ slug: text, name: text, raw: null });
      });
    }
    return tags;
  }

  async function handleUploadCopy(button) {
    if (button.dataset.busy === '1') return;
    button.dataset.busy = '1';
    const original = button.textContent;
    try {
      const tags = getUploadSelectedTags();
      if (!tags.length) {
        button.textContent = 'No tags in field';
      } else {
        store.write({ source: location.href, copiedAt: Date.now(), complete: true, expectedCount: tags.length, tags });
        const withData = tags.filter((tag) => hasTagData(tag.raw)).length;
        const missing = tags.length - withData;
        const suffix = missing ? ` (${missing} without data)` : '';
        button.textContent = `Saved ${tags.length} tags${suffix}`;
      }
    } catch (error) {
      console.error('[Sankaku Tags] upload copy failed', error);
      button.textContent = 'Copy failed';
    }
    setTimeout(() => {
      button.textContent = original;
      delete button.dataset.busy;
    }, 2500);
  }

  async function handlePaste(button) {
    if (button.dataset.busy === '1') return;
    button.dataset.busy = '1';
    const original = button.textContent;
    try {
      const root = getUploadRoot();
      if (!root || !getTagInput(root)) {
        button.textContent = 'Tag field not found';
        setTimeout(() => { button.textContent = original; delete button.dataset.busy; }, 2500);
        return;
      }

      let tags = [];
      const bundle = store.read();
      if (bundle && Array.isArray(bundle.tags)) tags = bundle.tags.filter((tag) => tag && (tag.slug || tag.name || tag.raw));
      if (bundle && (bundle.complete === false || bundle.expectedCount > tags.length)) {
        button.textContent = 'Incomplete tag bundle - copy again';
        setTimeout(() => { button.textContent = original; delete button.dataset.busy; }, 3000);
        return;
      }
      if (!tags.length) tags = parseClipboardTags(await readClipboardText());

      if (!tags.length) {
        button.textContent = 'No tags copied yet';
        setTimeout(() => { button.textContent = original; delete button.dataset.busy; }, 2500);
        return;
      }

      const initialCount = chipCount(root);
      const initialKeys = chipKeys(root);
      const skipped = tags.filter((tag) => isTagPresent(tag, initialKeys)).length;

      await injectTagsIntoField(tags, button);

      let liveRoot = getUploadRoot() || root;
      const remainingKeys = chipKeys(liveRoot);
      const remaining = tags.filter((tag) => !isTagPresent(tag, remainingKeys));
      if (remaining.length) {
        button.textContent = 'Pasting text...';
        const text = remaining.map((tag) => tag.slug || tag.name).filter(Boolean).join(' ');
        await rawTextPaste(text, 3000);
        await sleep(200);
      }

      liveRoot = getUploadRoot() || root;
      const finalInput = getTagInput(liveRoot);
      if (finalInput) setInputValue(finalInput, '');

      const added = Math.max(0, chipCount(liveRoot) - initialCount);
      const finalKeys = chipKeys(liveRoot);
      const stillMissing = tags.filter((tag) => !isTagPresent(tag, finalKeys));
      if (!stillMissing.length) {
        button.textContent = `Pasted ${added}${skipped ? `, ${skipped} already there` : ''}`;
      } else {
        // Only a failed automatic paste needs the system clipboard for Ctrl+V.
        const text = stillMissing.map((tag) => tag.slug || tag.name).filter(Boolean).join(' ');
        const clipboardReady = !!text && await copyToClipboard(text);
        button.textContent = added > 0
          ? `Pasted ${added}, ${stillMissing.length} failed${clipboardReady ? ' - clipboard ready' : ''}`
          : clipboardReady ? 'Clipboard ready - press Ctrl+V' : 'Paste failed';
      }
    } catch (error) {
      console.error('[Sankaku Tags] paste failed', error);
      button.textContent = 'Paste failed';
    }
    setTimeout(() => {
      button.textContent = original;
      delete button.dataset.busy;
    }, 3000);
  }

  function makeButton(id, label, title) {
    const button = document.createElement('button');
    button.type = 'button';
    button.setAttribute(BTN_ATTR, id);
    button.textContent = label;
    button.title = title;
    button.style.cssText = 'margin-left:8px;padding:4px 12px;border:1px solid #ff8c00;border-radius:4px;background:rgba(255,140,0,0.12);color:#ff8c00;font-size:12px;font-family:Roboto,Helvetica,Arial,sans-serif;font-weight:500;line-height:1.6;white-space:nowrap;cursor:pointer;flex-shrink:0;';
    button.addEventListener('mouseenter', () => { button.style.background = 'rgba(255,140,0,0.25)'; });
    button.addEventListener('mouseleave', () => { button.style.background = 'rgba(255,140,0,0.12)'; });
    return button;
  }

  function insertButton(header, button) {
    const label = header.querySelector(':scope > p');
    if (label && label.nextElementSibling) header.insertBefore(button, label.nextElementSibling);
    else header.appendChild(button);
  }

  function ensurePostButton(header = findPostTagsHeader()) {
    if (!header) return;
    if (header.querySelector(`[${BTN_ATTR}="copy"]`)) return;
    if (document.querySelector(`[${BTN_ATTR}="copy"][data-busy="1"]`)) return;
    const button = makeButton('copy', 'Copy Tags', 'Copy all tags of this post to the tag clipboard');
    button.addEventListener('click', () => handleCopy(button));
    insertButton(header, button);
  }

  function ensureUploadButton(header = getUploadHeader()) {
    if (!header) return;
    const label = header.querySelector(':scope > p');
    let copyButton = header.querySelector(`[${BTN_ATTR}="upload-copy"]`);
    if (!copyButton) {
      copyButton = makeButton('upload-copy', 'Copy Tags', 'Copy the tags in this field to the tag clipboard');
      copyButton.addEventListener('click', () => handleUploadCopy(copyButton));
      if (label && label.nextElementSibling) header.insertBefore(copyButton, label.nextElementSibling);
      else header.appendChild(copyButton);
    }
    if (!header.querySelector(`[${BTN_ATTR}="paste"]`)) {
      const pasteButton = makeButton('paste', 'Paste Tags', 'Paste the copied tags into this field');
      pasteButton.addEventListener('click', () => handlePaste(pasteButton));
      header.insertBefore(pasteButton, copyButton.nextSibling);
    }
  }

  function pruneButtons(postHeader = findPostTagsHeader(), uploadHeader = getUploadHeader()) {
    document.querySelectorAll(`[${BTN_ATTR}="copy"]`).forEach((button) => {
      if (button.dataset.busy === '1') return;
      if (!postHeader || !postHeader.contains(button)) button.remove();
    });
    document.querySelectorAll(`[${BTN_ATTR}="paste"], [${BTN_ATTR}="upload-copy"]`).forEach((button) => {
      if (!uploadHeader || !uploadHeader.contains(button)) button.remove();
    });
  }

  const controls = { postHeader: null, uploadHeader: null, postButton: null, copyButton: null, pasteButton: null };
  let discoverControls = true;

  function controlsConnected() {
    const { postHeader, uploadHeader, postButton, copyButton, pasteButton } = controls;
    return (!postHeader || (postHeader.isConnected && postButton && postHeader.contains(postButton))) &&
      (!uploadHeader || (uploadHeader.isConnected && copyButton && pasteButton &&
        uploadHeader.contains(copyButton) && uploadHeader.contains(pasteButton)));
  }

  function refresh() {
    try {
      if (!discoverControls && controlsConnected()) return;
      const postHeader = findPostTagsHeader();
      const uploadHeader = getUploadHeader();
      ensurePostButton(postHeader);
      ensureUploadButton(uploadHeader);
      pruneButtons(postHeader, uploadHeader);
      controls.postHeader = postHeader;
      controls.uploadHeader = uploadHeader;
      controls.postButton = postHeader && postHeader.querySelector(`[${BTN_ATTR}="copy"]`);
      controls.copyButton = uploadHeader && uploadHeader.querySelector(`[${BTN_ATTR}="upload-copy"]`);
      controls.pasteButton = uploadHeader && uploadHeader.querySelector(`[${BTN_ATTR}="paste"]`);
      discoverControls = false;
    } catch (error) {
      console.error('[Sankaku Tags]', error);
    }
  }

  let refreshTimer = null;
  const CONTROL_MARKERS = '[data-virtuoso-scroller], a[href^="/tags/"], .MuiAutocomplete-root[name="tags"], input[name="tags"]';

  function affectsControls(record) {
    if (record.target && record.target.tagName === 'P' && record.target.textContent.trim() === 'Tags') return true;
    const inKnownSection = [controls.postHeader, controls.uploadHeader].some((header) =>
      header && header.parentElement && header.parentElement.contains(record.target));
    // An edit/upload field can appear inside a section that already has post controls.
    const missingMarkers = [
      !controls.uploadHeader && '.MuiAutocomplete-root[name="tags"], input[name="tags"]',
      !controls.postHeader && '[data-virtuoso-scroller], a[href^="/tags/"]',
    ].filter(Boolean).join(', ');
    if (inKnownSection && !missingMarkers) return false;
    const markers = inKnownSection ? missingMarkers : CONTROL_MARKERS;
    for (const node of record.addedNodes) {
      if (node.nodeType !== 1) continue;
      if (node.matches(markers) || node.querySelector(markers)) return true;
      if (inKnownSection) continue;
      if ((node.tagName === 'P' && node.textContent.trim() === 'Tags') ||
          Array.from(node.querySelectorAll('p')).some((label) => label.textContent.trim() === 'Tags')) return true;
    }
    return false;
  }

  function scheduleRefresh(records) {
    if (Array.isArray(records)) {
      const relevant = records.filter((record) => {
        const target = record.target && (record.target.nodeType === 1 ? record.target : record.target.parentElement);
        return !target || !target.closest(`[${BTN_ATTR}]`);
      });
      if (!relevant.length) return;
      if (!controlsConnected() || relevant.some(affectsControls)) discoverControls = true;
    } else {
      discoverControls = true; // Initial readiness and the window load event.
    }
    if (refreshTimer) return;
    refreshTimer = setTimeout(() => {
      refreshTimer = null;
      refresh();
    }, 400);
  }

  const observer = new MutationObserver(scheduleRefresh);
  if (document.documentElement) {
    observer.observe(document.documentElement, { childList: true, subtree: true });
  } else {
    const readyTimer = setInterval(() => {
      if (!document.documentElement) return;
      clearInterval(readyTimer);
      observer.observe(document.documentElement, { childList: true, subtree: true });
      scheduleRefresh();
    }, 50);
  }

  refresh();
  window.addEventListener('load', scheduleRefresh);
})();