KONE +

Base64/braille decoder, DLsite/Steam product cards, link-health checker, and password auto-fill for kone.gg.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         KONE +
// @namespace    https://kone.gg/kone-plus-KRUI
// @version      17.5
// @description  Base64/braille decoder, DLsite/Steam product cards, link-health checker, and password auto-fill for kone.gg.
// @description:ko  base64/점자 디코딩 · DLsite/Steam 링크 카드 · 링크 활성화 체크 · 비번 자동입력. kone.gg 전용.
// @author       KRUI
// @match        *://kone.gg/*
// @match        *://*.kone.gg/*
// @match        *://kio.ac/*
// @match        *://kiosk.ac/*
// @match        *://mega.nz/*
// @match        *://transfer.it/*
// @match        *://gofile.io/*
// @match        *://workupload.com/*
// @match        *://mypikpak.com/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      dlsite.com
// @connect      store.steampowered.com
// @connect      kio.ac
// @connect      kiosk.ac
// @connect      *
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const host = location.hostname;

  /* ================================================================
     DOM 준비 후 실행
  ================================================================ */
  function domReady(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn, { once: true });
  }

  domReady(function () {

  /* ================================================================
     설정
  ================================================================ */
  const CFG = {
    CONTENT_DECODE:    GM_getValue('contentDecode',    true),
    LIST_DECODE:       GM_getValue('listDecode',       true),
    LINK_PANEL:        GM_getValue('linkPanel',        false),
    PANEL_TARGET:      GM_getValue('panelTarget',      'both'),
    LINK_CARD:         GM_getValue('linkCard',         true),
    LINK_CHECK:        GM_getValue('linkCheck',        true),
    DRAG_DECODE:       GM_getValue('dragDecode',       true),
    DLSITE_PREVIEW:    GM_getValue('dlsitePreview',    true),
    PREVIEW_META:      GM_getValue('previewMeta',      true),
    SCROLL_TO_FIRST:   GM_getValue('scrollToFirst',    false),
    NO_DUPLICATE_CARD: GM_getValue('noDuplicateCard',  false),
    NAV_TARGET:        GM_getValue('navTarget',        'links'),
    PW_AUTO:           GM_getValue('pwAuto',           true),
    LIVE_APPLY:        GM_getValue('liveApply',        true),
    COPY_CODE:         GM_getValue('copyCode',         false),
    PREVIEW_SCALE:     GM_getValue('previewScale',     100),
    SETTINGS_SCALE:    GM_getValue('settingsScale',    100),
    LINK_CHECK_DELAY:  GM_getValue('linkCheckDelay',   0),
    TXT_LIVE:          GM_getValue('txtLive',          false),

    LIST_SELECTORS: [
      '.title', '.subject', '.list-title', '.gallery-title', '.item-title',
      'td.title', 'td.subject', 'td.title a', 'td.subject a',
      '.board-list td a', '.list-body td a',
      'li.list-item .title', '.post-list .title',
    ],

    DEAD_PATTERNS: {
      'kio.ac':         ['컬렉션을 찾을 수 없음', '만료되었습니다'],
      'kiosk.ac':       ['컬렉션을 찾을 수 없음', '만료되었습니다'],
      // mega.nz: 순수 SPA, 초기 HTML에 에러 없음, HEAD도 항상 200 → 감지 불가하여 제거
      'transfer.it':    ["can't find this transfer", 'oops', 'not found', 'expired'],
      // gofile: 죽으면 "This content does not exist" 문구가 페이지에 표시됨
      'gofile.io':      ['this content does not exist'],
      // workupload: 죽으면 "File not found" 표시, 또는 홈으로 리다이렉트
      'workupload.com': ['file not found', 'not found', 'expired', 'has been deleted', 'does not exist', 'no longer available'],
      // mypikpak: 죽으면 '죄송합니다. 공유 파일을 찾을 수 없습니다' 표시
      'mypikpak.com':   ['죄송합니다', '공유 파일을 찾을 수 없습니다', 'share has expired', 'been deleted', 'been detected', 'link is invalid', 'does not exist'],
    },

    PW_SITES: {
      'kio.ac': {
        inputSel:    '.overflow-auto.max-w-full.grow.p-1 input:nth-of-type(1)',
        btnSel:      '.flex.flex-col-reverse button:nth-of-type(1)',
        successSel:  '.files-list, #download-section',
        errorPat:    [/비밀번호가 일치하지 않/, /incorrect password/i, /invalid password/i],
        mode: 'kio',
      },
      'kiosk.ac': {
        inputSel:    '.input.shadow-xl.flex-grow',
        btnSel:      '.btn.btn-ghost.w-full.mt-2.rounded-md',
        successSel:  '#vexplorer-body',
        errorPat:    [/비밀번호가 일치하지 않/, /incorrect/i],
        mode: 'kio',
      },
      'mega.nz': {
        inputSel:    'input[name="decrypt-link"], #password-decrypt-input',
        btnSel:      'button.decrypt-button, .mega-component.decrypt-button',
        dlBtnSel:    '.mega-button.positive.js-default-download.js-standard-download, .mega-button.large.positive.download.continue-download',
        successSel:  '.mega-button.positive.js-default-download, .fm-item, .file-folder-view:not(.no-content) .item-type-folder',
        errorPat:    [/invalid password/i, /incorrect/i, /wrong password/i],
        mode: 'generic',
        triggerDelay: 2800,
        btnDelay: 500,  // React가 input 이벤트 처리 후 버튼 활성화까지 대기
      },
      'transfer.it': {
        inputSel:    'input[name="msg-dialog-input"]',
        btnSel:      'button.it-button.xl-size.js-positive-btn',
        dlBtnSel:    'section.it-box.lg-shadow.modal.ready-to-download-box button.it-button.xl-size.js-download, .it-button.xl-size[class*="download"]',
        errorPat:    [/incorrect password/i, /wrong password/i, /invalid/i],
        mode: 'transfer',
        triggerDelay: 1500,
      },
      'gofile.io': {
        inputSel:    '#filesErrorPasswordInput',
        btnSel:      '#filesErrorPasswordButton',
        successSel:  '.row.align-items-center.contentRow',
        errorPat:    [/incorrect/i, /wrong/i, /invalid/i],
        mode: 'generic',
        triggerDelay: 1000,
      },
      'workupload.com': {
        inputSel:    '#passwordprotected_file_password',
        btnSel:      '#passwordprotected_file_submit',
        dlBtnSel:    '.btn.btn-prio:not(.fa-unlock)',  // 비번 해제 후 다운로드 버튼
        mode: 'formpost',  // 전통적 form POST: 제출 시 페이지 리로드 → GM_setValue로 인덱스 유지
        triggerDelay: 1000,
      },
      'mypikpak.com': {
        inputSel:    '.el-input__inner[placeholder*="Password"], .el-input__inner[placeholder*="password"]',
        btnSel:      '.el-button--primary, .el-dialog__footer button:last-of-type',
        successSel:  '.file-list, [class*="file-list"], .drive-main',
        errorPat:    [/incorrect/i, /wrong/i, /invalid/i],
        mode: 'generic',
        triggerDelay: 1500,
      },
    },
  };

  const DONE_ATTR       = 'data-b64d';
  const LTDONE_ATTR     = 'data-b64lt';
  const TITLE_CARD_ATTR = 'data-b64tc';
  const RAW_ATTR        = 'data-b64d-raw';  // processContentNode wrap에 원본 텍스트 저장 (즉시 적용 복원용)
  const ORIG_ATTR       = 'data-b64d-orig'; // convertProductLinks 교체 전 <a> outerHTML 저장 (즉시 적용 복원용)
  const MIN_B64         = 6;
  const KP_FOCUSED_SEL  = '.b64-link.kp-focused, .b64-product-link.kp-focused';

  // 조상 중 이미 처리된(DONE_ATTR/LTDONE_ATTR) 노드가 있는지 확인
  function hasProcessedAncestor(node) {
    let n = node.parentElement;
    while (n) {
      if (n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR)) return true;
      n = n.parentElement;
    }
    return false;
  }
  // 최대 반복 디코딩 횟수: 다중 인코딩(6겹 이상)을 처리하기 위해 10으로 설정
  const MAX_DECODE  = 10;

  let HOTKEY     = GM_getValue('hotkey',    'Shift+Q');
  let DUP_HOTKEY = GM_getValue('dupHotkey', 'Shift+D');

  function buildCombo(e) {
    const parts = [];
    if (e.ctrlKey)  parts.push('Ctrl');
    if (e.altKey)   parts.push('Alt');
    if (e.shiftKey) parts.push('Shift');
    if (e.metaKey)  parts.push('Meta');
    const k = e.key;
    if (!['Control', 'Alt', 'Shift', 'Meta'].includes(k)) {
      parts.push(k.length === 1 ? k.toUpperCase() : k);
    }
    return parts.join('+');
  }
  document.addEventListener('keydown', e => {
    const tag = (document.activeElement?.tagName || '').toUpperCase();
    if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
    if (e.key === 'Escape' && document.getElementById('b64d-settings')) {
      e.preventDefault(); closeSettingsPanel(); return;
    }
    // 단축키는 오버레이 상태와 무관하게 토글 (열었으면 같은 키로 닫을 수 있어야 함)
    if (buildCombo(e) === HOTKEY) { e.preventDefault(); openSettingsPanel(); return; }
    if (buildCombo(e) === DUP_HOTKEY) {
      e.preventDefault();
      const existing = document.getElementById('b64-dup-overlay');
      if (existing) existing.remove(); else showDupModal();
      return;
    }
    // 나머지 키(w/s/a/d 등)는 오버레이가 열린 경우 무시
    if (document.getElementById('b64d-settings') || document.getElementById('b64-dup-overlay')) return;
  });

  /* ================================================================
     비번 저장소
  ================================================================ */
  function getPwList() {
    try {
      const raw = GM_getValue('pwList', null);
      if (raw === null) return null;
      return JSON.parse(raw);
    } catch(e) { return []; }
  }
  function savePwList(arr) {
    GM_setValue('pwList', JSON.stringify(arr.filter(Boolean)));
  }

  /* ================================================================
     메뉴
  ================================================================ */
  let menuIds = {};
  function refreshMenu() {
    Object.values(menuIds).forEach(id => { try { GM_unregisterMenuCommand(id); } catch(e){} });
    menuIds = {};
    menuIds['settings'] = GM_registerMenuCommand('⚙ KONE + 설정', openSettingsPanel);
  }

  /* ================================================================
     비번 관리 UI (커스텀 모달)
  ================================================================ */
  function openPwManager() {
    if (document.getElementById('b64d-pw-manager')) return;

    function closePm() {
      document.getElementById('b64d-pw-manager')?.remove();
      document.getElementById('b64d-pw-backdrop')?.remove();
    }

    function renderList(listEl) {
      listEl.innerHTML = '';
      const pws = getPwList() || [];
      if (!pws.length) {
        const em = document.createElement('div');
        em.className = 'b64dpm-empty';
        em.textContent = '(등록된 비번 없음)';
        listEl.appendChild(em);
        return;
      }
      pws.forEach((pw, i) => {
        const item = document.createElement('div');
        item.className = 'b64dpm-item';
        const sp = document.createElement('span');
        sp.textContent = `${i + 1}. ${pw}`;
        const del = document.createElement('button');
        del.className = 'b64dpm-del';
        del.textContent = '✕';
        del.title = '삭제';
        del.addEventListener('click', () => {
          const cur = getPwList() || [];
          savePwList(cur.filter((_, j) => j !== i));
          renderList(listEl);
        });
        item.appendChild(sp);
        item.appendChild(del);
        listEl.appendChild(item);
      });
    }

    const hdr = document.createElement('div');
    hdr.className = 'b64ds-header';
    hdr.innerHTML = '<span>🔑 비번 목록</span>';
    const x = document.createElement('button');
    x.className = 'b64ds-close';
    x.textContent = '✕';
    x.addEventListener('click', closePm);
    hdr.appendChild(x);

    const listEl = document.createElement('div');
    listEl.className = 'b64dpm-list';

    const inp = document.createElement('input');
    inp.className = 'b64dpm-input';
    inp.type = 'text';
    inp.placeholder = '추가할 비번 (쉼표로 구분)';
    inp.setAttribute('autocomplete', 'off');

    const addBtn = document.createElement('button');
    addBtn.className = 'b64dpm-add';
    addBtn.textContent = '추가';
    addBtn.addEventListener('click', () => {
      const val = inp.value.trim();
      if (!val) return;
      const cur = getPwList() || [];
      const newPws = val.split(',').map(s => s.trim()).filter(Boolean);
      savePwList([...new Set([...cur, ...newPws])]);
      inp.value = '';
      renderList(listEl);
    });
    inp.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });

    const inputRow = document.createElement('div');
    inputRow.className = 'b64dpm-input-row';
    inputRow.appendChild(inp);
    inputRow.appendChild(addBtn);

    const clearBtn = document.createElement('button');
    clearBtn.className = 'b64dpm-clear';
    clearBtn.textContent = '전체 삭제';
    let clearConfirming = false;
    clearBtn.addEventListener('click', () => {
      if (!clearConfirming) {
        clearConfirming = true;
        clearBtn.textContent = '정말 삭제하시겠습니까? (다시 클릭)';
        clearBtn.style.cssText = 'background:#ef4444;color:#fff;';
        setTimeout(() => {
          if (!clearConfirming) return;
          clearConfirming = false;
          clearBtn.textContent = '전체 삭제';
          clearBtn.style.cssText = '';
        }, 3000);
        return;
      }
      savePwList([]);
      renderList(listEl);
      clearConfirming = false;
      clearBtn.textContent = '전체 삭제';
      clearBtn.style.cssText = '';
    });

    const body = document.createElement('div');
    body.className = 'b64dpm-body';
    body.appendChild(listEl);
    body.appendChild(inputRow);
    body.appendChild(clearBtn);

    const panel = document.createElement('div');
    panel.id = 'b64d-pw-manager';
    panel.dataset.theme = getSystemTheme();
    panel.addEventListener('click', e => e.stopPropagation());
    panel.appendChild(hdr);
    panel.appendChild(body);

    const bd = document.createElement('div');
    bd.id = 'b64d-pw-backdrop';
    bd.addEventListener('click', closePm);

    document.body.appendChild(bd);
    document.body.appendChild(panel);
    renderList(listEl);
    inp.focus();
  }

  /* ================================================================
     설정 패널 (버튼 식 UI)
  ================================================================ */
  function getSystemTheme() {
    return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
  }
  function applyThemeToOverlays() {
    const theme = getSystemTheme();
    const s = document.getElementById('b64d-settings');
    if (s) s.dataset.theme = theme;
    const p = document.getElementById('b64d-pw-manager');
    if (p) p.dataset.theme = theme;
  }
  // kone.gg가 <html class="dark"> 토글 방식을 사용하므로 class 변화 감지
  new MutationObserver(applyThemeToOverlays).observe(
    document.documentElement, { attributes: true, attributeFilter: ['class'] }
  );

  function closeSettingsPanel() {
    if (_settingsCaptureCleanup) { _settingsCaptureCleanup(); _settingsCaptureCleanup = null; }
    document.getElementById('b64d-settings')?.remove();
    document.getElementById('b64d-settings-backdrop')?.remove();
    document.getElementById('b64d-stip')?.remove();
  }

  function openSettingsPanel() {
    if (document.getElementById('b64d-settings')) { closeSettingsPanel(); return; }
    dlpHide();

    const tipEl = document.createElement('div');
    tipEl.id = 'b64d-stip'; tipEl.className = 'b64d-stip'; tipEl.style.display = 'none';
    document.body.appendChild(tipEl);

    function mkToggle(key, stKey, label, tooltip, onChange) {
      const btn = document.createElement('button');
      btn.className = `b64ds-toggle${CFG[key] ? ' active' : ''}`;
      btn.innerHTML = `<span>${label}</span><span class="b64ds-badge">${CFG[key] ? 'ON' : 'OFF'}</span>`;
      if (tooltip) {
        btn.addEventListener('mouseenter', () => {
          tipEl.textContent = tooltip; tipEl.style.display = 'block';
          const r = btn.getBoundingClientRect();
          tipEl.style.left = r.left + 'px'; tipEl.style.top = '0px';
          requestAnimationFrame(() => {
            let top = r.top - tipEl.offsetHeight - 6;
            if (top < 4) top = r.bottom + 6;
            let left = r.left;
            if (left + tipEl.offsetWidth > window.innerWidth - 4) left = window.innerWidth - tipEl.offsetWidth - 4;
            tipEl.style.top = top + 'px'; tipEl.style.left = left + 'px';
          });
        });
        btn.addEventListener('mouseleave', () => { tipEl.style.display = 'none'; });
      }
      btn.addEventListener('click', () => {
        CFG[key] = !CFG[key];
        GM_setValue(stKey, CFG[key]);
        btn.classList.toggle('active', CFG[key]);
        btn.querySelector('.b64ds-badge').textContent = CFG[key] ? 'ON' : 'OFF';
        if (key === 'LIVE_APPLY') return;
        if (onChange) onChange(CFG[key]);
        if (CFG.LIVE_APPLY) applyLive(); else schedule();
      });
      return btn;
    }
    function mkSep(t) { const d = document.createElement('div'); d.className = 'b64ds-sep'; d.textContent = t; return d; }
    function mkAction(t, fn) {
      const btn = document.createElement('button');
      btn.className = 'b64ds-action'; btn.textContent = t;
      btn.addEventListener('click', fn); return btn;
    }

    // 단축키 행
    const hotkeyRow = document.createElement('div');
    hotkeyRow.className = 'b64ds-hotkey-row';
    const hotkeyLabel = document.createElement('span');
    hotkeyLabel.className = 'b64ds-hotkey-label';
    hotkeyLabel.textContent = '설정 단축키';
    const hotkeyR = document.createElement('div');
    hotkeyR.className = 'b64ds-hotkey-r';
    const hotkeyKey = document.createElement('span');
    hotkeyKey.className = 'b64ds-hotkey-key';
    hotkeyKey.textContent = HOTKEY;
    const hotkeyBtn = document.createElement('button');
    hotkeyBtn.className = 'b64ds-hotkey-btn';
    hotkeyBtn.textContent = '변경';
    let capturing = false;
    let captureListener = null;
    const cancelCapture = () => {
      if (!capturing) return;
      capturing = false;
      hotkeyBtn.classList.remove('capturing');
      hotkeyBtn.textContent = '변경';
      document.removeEventListener('keydown', captureListener, true);
    };
    hotkeyBtn.addEventListener('click', () => {
      if (capturing) return;
      capturing = true;
      hotkeyBtn.classList.add('capturing');
      hotkeyBtn.textContent = '입력…';
      captureListener = e => {
        if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return; // 수식키만 누름: 대기
        if (e.key !== 'Escape' && !(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)) return; // 평범한 키: 통과
        e.preventDefault(); e.stopPropagation();
        if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
          HOTKEY = buildCombo(e);
          GM_setValue('hotkey', HOTKEY);
          hotkeyKey.textContent = HOTKEY;
        }
        cancelCapture();
      };
      document.addEventListener('keydown', captureListener, true);
    });
    hotkeyR.appendChild(hotkeyKey);
    hotkeyR.appendChild(hotkeyBtn);
    hotkeyRow.appendChild(hotkeyLabel);
    hotkeyRow.appendChild(hotkeyR);

    // TXT 검사 단축키 행
    const dupHotkeyRow = document.createElement('div');
    dupHotkeyRow.className = 'b64ds-hotkey-row';
    const dupHkLabel = document.createElement('span');
    dupHkLabel.className = 'b64ds-hotkey-label';
    dupHkLabel.textContent = 'TXT 검사 단축키';
    const dupHkR = document.createElement('div');
    dupHkR.className = 'b64ds-hotkey-r';
    const dupHkKey = document.createElement('span');
    dupHkKey.className = 'b64ds-hotkey-key';
    dupHkKey.textContent = DUP_HOTKEY;
    const dupHkBtn = document.createElement('button');
    dupHkBtn.className = 'b64ds-hotkey-btn';
    dupHkBtn.textContent = '변경';
    let dupCapturing = false;
    let dupCaptureListener = null;
    const cancelDupCapture = () => {
      if (!dupCapturing) return;
      dupCapturing = false;
      dupHkBtn.classList.remove('capturing');
      dupHkBtn.textContent = '변경';
      document.removeEventListener('keydown', dupCaptureListener, true);
    };
    dupHkBtn.addEventListener('click', () => {
      if (dupCapturing) return;
      dupCapturing = true;
      dupHkBtn.classList.add('capturing');
      dupHkBtn.textContent = '입력…';
      dupCaptureListener = e => {
        if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return;
        if (e.key !== 'Escape' && !(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)) return; // 평범한 키: 통과
        e.preventDefault(); e.stopPropagation();
        if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
          DUP_HOTKEY = buildCombo(e);
          GM_setValue('dupHotkey', DUP_HOTKEY);
          dupHkKey.textContent = DUP_HOTKEY;
        }
        cancelDupCapture();
      };
      document.addEventListener('keydown', dupCaptureListener, true);
    });
    dupHkR.append(dupHkKey, dupHkBtn);
    dupHotkeyRow.append(dupHkLabel, dupHkR);

    const hdr = document.createElement('div');
    hdr.className = 'b64ds-header';
    hdr.innerHTML = '<span>KONE + 설정</span>';
    const x = document.createElement('button');
    x.className = 'b64ds-close'; x.textContent = '✕';
    x.addEventListener('click', closeSettingsPanel);
    hdr.appendChild(x);

    const body = document.createElement('div');
    body.className = 'b64ds-body';

    function mkNote(t) {
      const d = document.createElement('div');
      d.style.cssText = 'font-size:11px;color:var(--b64-text2);padding:2px 4px 6px;line-height:1.4;';
      d.textContent = t;
      return d;
    }
    function mkTriple(key, stKey, label, opts) {
      const row = document.createElement('div');
      row.className = 'b64ds-triple-row';
      const lbl = document.createElement('span');
      lbl.className = 'b64ds-triple-label';
      lbl.textContent = label;
      const btns = document.createElement('div');
      btns.className = 'b64ds-triple-btns';
      opts.forEach(({ value, text }) => {
        const b = document.createElement('button');
        b.className = `b64ds-triple-btn${CFG[key] === value ? ' active' : ''}`;
        b.textContent = text;
        b.addEventListener('click', () => {
          CFG[key] = value;
          GM_setValue(stKey, value);
          btns.querySelectorAll('.b64ds-triple-btn').forEach(x => x.classList.toggle('active', x === b));
          document.querySelectorAll('.b64-link.kp-focused, .b64-product-link.kp-focused')
            .forEach(el => el.classList.remove('kp-focused'));
          _kpIdx = -1;
          // 클래스 변경은 MO childList를 트리거하지 않으므로 즉시 재적용
          if (CFG.LIVE_APPLY) applyLive(); else schedule();
        });
        btns.appendChild(b);
      });
      row.appendChild(lbl);
      row.appendChild(btns);
      return row;
    }

    function mkSection(...items) {
      const s = document.createElement('div'); s.className = 'b64ds-section';
      items.forEach(el => s.appendChild(el)); return s;
    }
    function mkSlider(key, stKey, label, min, max, step, unit, defaultVal, onInput, onChange) {
      const row = document.createElement('div');
      row.className = 'b64ds-slider-row';
      const lbl = document.createElement('span');
      lbl.className = 'b64ds-triple-label'; lbl.textContent = label;
      const right = document.createElement('div');
      right.className = 'b64ds-slider-r';
      const valEl = document.createElement('span');
      valEl.className = 'b64ds-slider-val'; valEl.textContent = CFG[key] + unit;
      const inp = document.createElement('input');
      inp.type = 'range'; inp.min = min; inp.max = max; inp.step = step; inp.value = CFG[key];
      inp.className = 'b64ds-slider';
      inp.tabIndex = -1;
      inp.addEventListener('input', () => {
        CFG[key] = +inp.value;
        GM_setValue(stKey, +inp.value);
        valEl.textContent = inp.value + unit;
        if (onInput) onInput(+inp.value);
      });
      inp.addEventListener('pointerup', () => { inp.blur(); if (onChange) onChange(+inp.value); });
      inp.addEventListener('change', () => { if (onChange) onChange(+inp.value); });
      if (defaultVal !== undefined) {
        const rst = document.createElement('button');
        rst.type = 'button'; rst.className = 'b64ds-slider-reset';
        rst.textContent = '↺'; rst.title = `기본값 (${defaultVal}${unit})`;
        rst.addEventListener('click', () => { inp.value = defaultVal; inp.dispatchEvent(new Event('input')); inp.blur(); });
        right.append(valEl, rst, inp);
      } else {
        right.append(valEl, inp);
      }
      row.append(lbl, right);
      return row;
    }
    // 링크 만료 OFF 시 미리보기 자동 ON을 위해 previewToggleBtn 참조 연결
    let previewToggleBtn = null;
    const linkCheckToggle = mkToggle('LINK_CHECK', 'linkCheck', '링크 생존 확인', null, (isOn) => {
      if (!isOn && !CFG.DLSITE_PREVIEW && previewToggleBtn) {
        CFG.DLSITE_PREVIEW = true;
        GM_setValue('dlsitePreview', true);
        previewToggleBtn.classList.add('active');
        previewToggleBtn.querySelector('.b64ds-badge').textContent = 'ON';
      }
    });
    const previewToggle = mkToggle('DLSITE_PREVIEW', 'dlsitePreview', '카드 미리보기');
    previewToggleBtn = previewToggle;

    // 탭 구조: 본문 / 링크 / 미리보기 / 시스템
    const tabDefs = ['본문', '링크', '미리보기', '시스템'];
    const tabPanels = tabDefs.map(() => {
      const p = document.createElement('div');
      p.className = 'b64ds-tabpanel';
      return p;
    });
    const [pBonmun, pLink, pPreview, pSystem] = tabPanels;

    // 본문 탭
    pBonmun.append(
      mkToggle('CONTENT_DECODE', 'contentDecode', '본문 번역'),
      mkToggle('LIST_DECODE',    'listDecode',    '목록 페이지 제목 번역'),
      mkToggle('DRAG_DECODE',    'dragDecode',    '드래그 자동 변환'),
      mkToggle('TXT_LIVE',       'txtLive',       'TXT 작품 보유 확인', '마지막으로 업로드한 TXT 기준으로 카드 색상 표시'),
    );

    // 링크 탭
    pLink.append(
      mkToggle('LINK_CARD',       'linkCard',       '링크 카드'),
      mkToggle('LINK_PANEL',      'linkPanel',      '링크 모아보기'),
      (() => {
        const pair = document.createElement('div');
        pair.className = 'b64ds-triple-pair';
        pair.append(
          mkTriple('PANEL_TARGET', 'panelTarget', '모아보기 표시', [
            { value: 'both', text: '모두' }, { value: 'products', text: '작품' }, { value: 'links', text: '다운로드' },
          ]),
          mkTriple('NAV_TARGET', 'navTarget', 'w/s 탐색 범위', [
            { value: 'both', text: '모두' }, { value: 'products', text: '작품' }, { value: 'links', text: '다운로드' },
          ]),
        );
        return pair;
      })(),
      mkToggle('COPY_CODE',       'copyCode',       '작품 카드 복사 시 코드 복사'),
      mkToggle('SCROLL_TO_FIRST', 'scrollToFirst',  '첫 다운로드 링크로 자동 스크롤'),
      mkToggle('NO_DUPLICATE_CARD', 'noDuplicateCard', '중복 카드 생성 방지', 'ON 시 같은 작품·링크 코드가 반복되어도 첫 번째만 카드 생성'),
      linkCheckToggle,
      mkSlider('LINK_CHECK_DELAY', 'linkCheckDelay', '링크 확인 딜레이', 0, 5000, 250, 'ms', 0),
    );

    // 미리보기 탭
    pPreview.append(
      previewToggle,
      mkToggle('PREVIEW_META', 'previewMeta', '미리보기 상세정보', '평점·판매수·출시일 표시 (DLsite/Steam)'),
      mkSlider('PREVIEW_SCALE', 'previewScale', '미리보기 크기', 50, 200, 10, '%', 100, (v) => {
        document.documentElement.style.setProperty('--b64-dlp-scale', v / 100);
      }),
    );

    // 시스템 탭
    pSystem.append(
      mkSlider('SETTINGS_SCALE', 'settingsScale', '설정 화면 크기', 70, 150, 5, '%', 100, null, (v) => {
        const p = document.getElementById('b64d-settings');
        if (p) p.style.zoom = v / 100;
        const d = document.getElementById('b64-dup-modal');
        if (d) d.style.zoom = v / 100;
      }),
      mkSep('단축키'),
      hotkeyRow,
      dupHotkeyRow,
      mkSep('적용'),
      mkToggle('LIVE_APPLY', 'liveApply', '설정 변경 즉시 적용', 'OFF 시 다음 페이지 로드에 반영 · 긴 글에서 렉 방지'),
      mkSep('비번'),
      mkToggle('PW_AUTO', 'pwAuto', '비번 자동입력'),
      mkAction('🔑 비번 목록 관리', openPwManager),
    );

    // 탭 바 생성
    const tabBar = document.createElement('div');
    tabBar.className = 'b64ds-tabs';
    let activeTabIdx = GM_getValue('settingsTab', 0);
    const tabBtns = tabDefs.map((label, i) => {
      const btn = document.createElement('button');
      btn.className = 'b64ds-tab' + (i === activeTabIdx ? ' active' : '');
      btn.textContent = label;
      btn.addEventListener('click', () => {
        tabBtns.forEach((b, j) => {
          b.classList.toggle('active', j === i);
          tabPanels[j].classList.toggle('active', j === i);
        });
        GM_setValue('settingsTab', i);
      });
      return btn;
    });
    tabBtns.forEach(b => tabBar.appendChild(b));
    tabPanels[activeTabIdx].classList.add('active');

    body.appendChild(tabBar);
    tabPanels.forEach(p => body.appendChild(p));

    const panel = document.createElement('div');
    panel.id = 'b64d-settings';
    panel.dataset.theme = getSystemTheme();
    panel.style.zoom = CFG.SETTINGS_SCALE / 100;
    panel.addEventListener('click', e => e.stopPropagation());
    panel.appendChild(hdr);
    panel.appendChild(body);

    const bd = document.createElement('div');
    bd.id = 'b64d-settings-backdrop';
    bd.addEventListener('click', closeSettingsPanel);

    document.body.appendChild(bd);
    document.body.appendChild(panel);

    // 설정창이 닫힐 때 캡처 모드 리스너 정리
    _settingsCaptureCleanup = () => { cancelCapture(); cancelDupCapture(); };

    // 가장 긴 탭에 맞춰 모든 패널 높이 고정 (탭 전환 시 창 크기 변동 방지)
    requestAnimationFrame(() => {
      let maxH = 0;
      tabPanels.forEach(tp => {
        const prev = tp.style.cssText;
        tp.style.cssText = 'display:flex!important;visibility:hidden;position:absolute;';
        maxH = Math.max(maxH, tp.scrollHeight);
        tp.style.cssText = prev;
      });
      if (maxH > 0) tabPanels.forEach(tp => { tp.style.minHeight = maxH + 'px'; });
    });
  }

  function maybeInitPwSetup() {
    if (getPwList() !== null) return;
    setTimeout(() => {
      const ok = window.confirm('🔑 KONE +\n\n비번 자동입력이 처음 실행됩니다.\n지금 비번을 등록하시겠습니까?');
      if (!ok) { savePwList([]); return; }
      const input = window.prompt('비번 입력 (쉼표로 구분):\n예: pass1, 2024국룰');
      if (input) {
        const pws = input.split(',').map(s => s.trim()).filter(Boolean);
        savePwList(pws);
        window.alert(`✅ ${pws.length}개 등록됨.`);
      } else { savePwList([]); }
    }, 2000);
  }

  refreshMenu();
  maybeInitPwSetup();

  // 최초 설치 시 설정 패널 자동 오픈 (kone.gg에서만)
  if (!GM_getValue('_b64d_installed', false)) {
    GM_setValue('_b64d_installed', true);
    if (/kone\.gg/.test(location.hostname)) {
      setTimeout(openSettingsPanel, 2000);
    }
  }

  /* ================================================================
     CSS
  ================================================================ */
  if (!document.getElementById('b64d-style')) {
    const s = document.createElement('style');
    s.id = 'b64d-style';
    s.textContent = `
/* ── 다운로드 링크 카드 ── */
.b64-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 0;margin:2px 0;
  background:#f4f4f5;border:0.5px solid #d4d4d8;border-radius:8px;text-decoration:none;color:#18181b;
  font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;overflow:hidden;
  transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;}
.b64-link:hover{background:#e4e4e7;border-color:#a1a1aa;}
.b64-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.55;}
.b64-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}
.b64-link.lk-checking .bl-arrow{animation:b64spin .8s linear infinite;display:inline-block;}
@keyframes b64spin{to{transform:rotate(360deg);}}
.b64-link.lk-alive{background:#f0fdf4;border-color:#86efac;color:#14532d;}
.b64-link.lk-dead{background:#fef2f2;border-color:#fca5a5;color:#7f1d1d;}
.b64-link.lk-alive .bl-icon,.b64-link.lk-dead .bl-icon,
.b64-link.lk-alive .bl-arrow,.b64-link.lk-dead .bl-arrow{opacity:.7;}

/* ── 상품 링크 카드 (DLsite) ── */
.b64-product-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 0;margin:2px 0;
  background:#f5f3ff;border:0.5px solid #c4b5fd;border-radius:8px;text-decoration:none;color:#4c1d95;
  font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;overflow:hidden;
  transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;position:relative;}
.b64-tl-owned::after,.b64-tl-new::after{content:'';position:absolute;right:0;top:0;bottom:0;width:5px;}
.b64-tl-owned::after{background:#ef4444;}
.b64-tl-new::after{background:#22c55e;}
.b64-product-link:hover{background:#ede9fe;border-color:#a78bfa;}
.b64-product-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.6;}
.b64-product-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-product-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}

/* ── Steam 카드 ── */
.b64-product-link.pl-steam{background:#f0f9ff;border-color:#7dd3fc;color:#075985;}
.b64-product-link.pl-steam:hover{background:#e0f2fe;border-color:#38bdf8;}

/* ── Patreon / Getcu / Fanza / Fanbox 카드 — 공통 오렌지 ── */
.b64-product-link.pl-patreon,
.b64-product-link.pl-getcu,
.b64-product-link.pl-fanza,
.b64-product-link.pl-fanbox{background:#fff7ed;border-color:#fdba74;color:#7c2d12;}
.b64-product-link.pl-patreon:hover,
.b64-product-link.pl-getcu:hover,
.b64-product-link.pl-fanza:hover,
.b64-product-link.pl-fanbox:hover{background:#ffedd5;border-color:#fb923c;}


/* ── 설정 패널 / 비번 모달 ── */
#b64d-settings,#b64d-pw-manager{
  --b64-bg:#18181b;--b64-bg2:#27272a;--b64-bg3:#3f3f46;
  --b64-text:#fafafa;--b64-text2:#a1a1aa;--b64-sep-c:#71717a;
  --b64-on-bg:#0c4a6e;--b64-on-hov:#1a6a8f;--b64-on-text:#e0f2fe;
  --b64-input-bg:#27272a;--b64-input-bd:#52525b;--b64-del-c:#ef4444;
  position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
  max-width:95vw;max-height:82vh;overflow-y:auto;border-radius:14px;
  background:var(--b64-bg);color:var(--b64-text);
  box-shadow:0 16px 48px rgba(0,0,0,.65);z-index:2147483641;
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;}
#b64d-settings[data-theme="light"],#b64d-pw-manager[data-theme="light"]{
  --b64-bg:#ffffff;--b64-bg2:#f4f4f5;--b64-bg3:#e4e4e7;
  --b64-text:#18181b;--b64-text2:#52525b;--b64-sep-c:#71717a;
  --b64-on-bg:#0369a1;--b64-on-hov:#0284c7;--b64-on-text:#ffffff;
  --b64-input-bg:#ffffff;--b64-input-bd:#d4d4d8;}
#b64d-settings-backdrop,#b64d-pw-backdrop{
  position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483640;backdrop-filter:blur(2px);}
#b64d-settings{width:480px;}
#b64d-pw-manager{width:300px;z-index:2147483642;}
#b64d-pw-backdrop{z-index:2147483641;}
.b64ds-header{
  display:flex;align-items:center;justify-content:space-between;
  padding:14px 16px;background:var(--b64-bg2);border-radius:14px 14px 0 0;
  font-weight:700;font-size:15px;letter-spacing:-.01em;position:sticky;top:0;z-index:1;}
.b64ds-close{background:none;border:none;color:var(--b64-sep-c);cursor:pointer;font-size:18px;padding:0 4px;line-height:1;border-radius:4px;transition:color .15s;}
.b64ds-close:hover{color:var(--b64-text);}
.b64ds-slider-row{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:5px 4px;}
.b64ds-slider-r{display:flex;align-items:center;gap:4px;}
.b64ds-slider-val{font-size:11px;color:var(--b64-text2);min-width:34px;text-align:right;flex-shrink:0;}
.b64ds-slider-reset{background:none;border:1px solid var(--b64-bg3,#d4d4d8);border-radius:4px;color:var(--b64-text2);cursor:pointer;font-size:12px;padding:0 4px;line-height:1.5;flex-shrink:0;}
.b64ds-slider-reset:hover{background:var(--b64-bg2);color:var(--b64-text);}
.b64ds-slider{flex:1;min-width:80px;max-width:120px;cursor:pointer;accent-color:var(--b64-on-bg);}
.b64d-stip{position:fixed;z-index:2147483649;background:#18181b;color:#fafafa;font-size:11px;line-height:1.5;padding:5px 9px;border-radius:6px;max-width:220px;white-space:normal;pointer-events:none;box-shadow:0 2px 10px rgba(0,0,0,.4);}
.b64ds-body{padding:0;}
.b64ds-tabs{display:flex;background:var(--b64-bg2);border-bottom:1px solid var(--b64-bg3);padding:0 12px;overflow:hidden;}
.b64ds-tab{flex:1;padding:10px 0;font-size:13px;font-weight:600;cursor:pointer;border:none;background:none;color:var(--b64-text2);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s,background .12s;font-family:inherit;}
.b64ds-tab:hover{color:var(--b64-text);background:rgba(127,127,127,.1);}
.b64ds-tab.active{color:var(--b64-text);border-bottom-color:#3b82f6;}
.b64ds-tabpanel{display:none;flex-direction:column;gap:7px;padding:16px 18px 26px;}
.b64ds-tabpanel.active{display:flex;}
.b64ds-sep{font-size:11px;font-weight:700;color:var(--b64-sep-c);letter-spacing:.06em;text-transform:uppercase;padding:16px 4px 4px;}
.b64ds-sep:first-child{padding-top:2px;}
.b64ds-toggle{
  display:flex;align-items:center;justify-content:space-between;
  width:100%;padding:11px 14px;border-radius:9px;cursor:pointer;
  border:none;background:var(--b64-bg2);color:var(--b64-text2);text-align:left;
  font-size:13px;font-family:inherit;transition:background .12s;}
.b64ds-toggle:hover{background:var(--b64-bg3);}
.b64ds-toggle.active{background:var(--b64-on-bg);color:var(--b64-on-text);}
.b64ds-toggle.active:hover{background:var(--b64-on-hov);}
.b64ds-badge{font-size:11px;font-weight:700;padding:2px 9px;border-radius:99px;letter-spacing:.03em;flex-shrink:0;background:rgba(128,128,128,.15);}
.b64ds-toggle.active .b64ds-badge{background:rgba(255,255,255,.2);}
.b64ds-action{
  display:flex;align-items:center;gap:8px;
  width:100%;padding:11px 14px;border-radius:9px;cursor:pointer;
  border:none;background:var(--b64-bg2);color:var(--b64-text2);font-size:13px;
  font-family:inherit;text-align:left;transition:background .12s;}
.b64ds-action:hover{background:var(--b64-bg3);}
.b64ds-hotkey-row{
  display:flex;align-items:center;justify-content:space-between;gap:8px;
  padding:9px 14px;border-radius:9px;background:var(--b64-bg2);}
.b64ds-hotkey-label{font-size:13px;color:var(--b64-text2);}
.b64ds-hotkey-r{display:flex;align-items:center;gap:6px;}
.b64ds-hotkey-key{
  font-size:11px;font-family:monospace;padding:3px 9px;border-radius:6px;
  background:var(--b64-bg3);color:var(--b64-text);border:1px solid var(--b64-input-bd);}
.b64ds-hotkey-btn{
  font-size:11px;padding:3px 9px;border-radius:6px;cursor:pointer;
  border:1px solid var(--b64-input-bd);background:var(--b64-bg2);color:var(--b64-text2);
  font-family:inherit;transition:background .12s,color .12s;}
.b64ds-hotkey-btn:hover{background:var(--b64-bg3);}
.b64ds-hotkey-btn.capturing{color:var(--b64-on-text);background:var(--b64-on-bg);border-color:transparent;}
.b64dpm-body{padding:12px;display:flex;flex-direction:column;gap:8px;}
.b64dpm-list{display:flex;flex-direction:column;gap:4px;max-height:200px;overflow-y:auto;padding:2px 0;}
.b64dpm-empty{font-size:12px;color:var(--b64-sep-c);padding:8px 2px;}
.b64dpm-item{
  display:flex;align-items:center;justify-content:space-between;
  padding:9px 12px;border-radius:8px;background:var(--b64-bg2);}
.b64dpm-item span{font-size:13px;font-family:monospace;color:var(--b64-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;margin-right:8px;}
.b64dpm-del{
  background:none;border:none;color:var(--b64-sep-c);cursor:pointer;
  font-size:16px;padding:0 2px;flex-shrink:0;line-height:1;transition:color .15s;}
.b64dpm-del:hover{color:var(--b64-del-c);}
.b64dpm-input-row{display:flex;gap:6px;margin-top:6px;}
.b64dpm-input{
  flex:1;padding:9px 12px;border-radius:8px;font-size:13px;
  border:1px solid var(--b64-input-bd);background:var(--b64-input-bg);
  color:var(--b64-text);font-family:inherit;outline:none;min-width:0;}
.b64dpm-input:focus{border-color:var(--b64-on-bg);}
.b64dpm-add{
  padding:9px 14px;border-radius:8px;cursor:pointer;font-size:13px;
  border:none;background:var(--b64-on-bg);color:var(--b64-on-text);
  font-family:inherit;transition:background .12s;white-space:nowrap;flex-shrink:0;}
.b64dpm-add:hover{background:var(--b64-on-hov);}
.b64dpm-clear{
  padding:9px 12px;border-radius:8px;cursor:pointer;font-size:12px;
  border:1px solid var(--b64-del-c);background:transparent;color:var(--b64-del-c);
  font-family:inherit;transition:background .12s,color .12s;width:100%;margin-top:4px;text-align:center;}
.b64dpm-clear:hover{background:var(--b64-del-c);color:#fff;}

/* ── 공통 유틸 ── */
#b64d-drag-tooltip{
  position:fixed;z-index:2147483647;background:#18181b;color:#fafafa;
  font-size:13px;line-height:1.5;padding:8px 12px;border-radius:8px;
  max-width:480px;word-break:break-all;pointer-events:auto;
  box-shadow:0 4px 16px rgba(0,0,0,.25);
  display:flex;flex-direction:column;gap:6px;}
#b64d-drag-tooltip .b64d-drag-text{white-space:pre-wrap;}
#b64d-drag-tooltip .b64d-drag-cards{display:flex;flex-direction:column;gap:4px;}
/* ── 제목 카드 바 ── */
.b64-title-card-bar{margin-top:14px;padding-top:10px;border-top:2px dashed #e4e4e7;display:flex;flex-direction:column;gap:6px;}
.b64-title-card-label{font-size:11px;color:#71717a;font-weight:600;letter-spacing:.05em;margin-bottom:2px;}
@keyframes dlp-spin{to{transform:rotate(360deg);}}
#b64d-dlsite-preview{
  position:fixed;z-index:2147483646;background:rgba(0,0,0,.88);
  border:1px solid rgba(255,255,255,.12);
  width:calc(480px * var(--b64-dlp-scale,1));height:calc(360px * var(--b64-dlp-scale,1));
  overflow:hidden;display:flex;align-items:center;justify-content:center;border-radius:10px;
  pointer-events:auto;box-shadow:0 8px 32px rgba(0,0,0,.6);}
#b64d-dlsite-preview img{width:100%;height:100%;object-fit:contain;display:block;}
#b64d-dlsite-preview .dlp-spinner{
  width:calc(38px * var(--b64-dlp-scale,1));height:calc(38px * var(--b64-dlp-scale,1));border-radius:50%;
  border:3px solid rgba(255,255,255,.18);border-top-color:rgba(255,255,255,.85);
  animation:dlp-spin .7s linear infinite;}
#b64d-dlsite-preview .dlp-err{
  font-size:calc(13px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.55);text-align:center;padding:12px;}
#b64d-dlsite-preview .dlp-panel{
  position:absolute;bottom:0;left:0;right:0;
  padding:0;background:transparent;
  display:flex;flex-direction:column;gap:0;pointer-events:none;}
/* 칩 또는 내비가 생긴 순간 JS가 has-content 클래스를 추가 → 그라디언트 활성화 */
#b64d-dlsite-preview .dlp-panel.has-content{
  padding:calc(44px * var(--b64-dlp-scale,1)) calc(11px * var(--b64-dlp-scale,1)) calc(10px * var(--b64-dlp-scale,1));
  background:linear-gradient(transparent,rgba(0,0,0,.9) 40%);
  gap:calc(5px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-chips{display:flex;flex-wrap:wrap;gap:calc(5px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-chip{
  font-size:calc(16px * var(--b64-dlp-scale,1));font-weight:700;line-height:1;
  color:rgba(255,255,255,.92);background:rgba(255,255,255,.13);
  border:1px solid rgba(255,255,255,.18);
  border-radius:calc(4px * var(--b64-dlp-scale,1));
  padding:calc(5px * var(--b64-dlp-scale,1)) calc(11px * var(--b64-dlp-scale,1));
  white-space:nowrap;}
#b64d-dlsite-preview .dlp-chip-star{color:#fde68a;background:rgba(253,230,138,.1);border-color:rgba(253,230,138,.28);}
#b64d-dlsite-preview .dlp-chip-sales{color:#93c5fd;background:rgba(147,197,253,.08);border-color:rgba(147,197,253,.25);}
#b64d-dlsite-preview .dlp-chip-date{color:rgba(255,255,255,.6);}
#b64d-dlsite-preview .dlp-chip-review{color:#86efac;background:rgba(134,239,172,.08);border-color:rgba(134,239,172,.22);}
#b64d-dlsite-preview .dlp-nav{
  font-size:calc(12px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.7);text-align:right;line-height:1.3;
  text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 8px rgba(0,0,0,.8);}

/* ── 키보드 링크 탐색 포커스 ── */
.b64-link.kp-focused,.b64-product-link.kp-focused{outline:2px solid #3b82f6;outline-offset:2px;box-shadow:0 0 0 4px rgba(59,130,246,.15);}
html.dark .b64-link.kp-focused,html.dark .b64-product-link.kp-focused{outline-color:#60a5fa;box-shadow:0 0 0 4px rgba(96,165,250,.2);}
/* ── 설정: 3-way 탐색 범위 선택 ── */
.b64ds-triple-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 14px;border-radius:9px;background:var(--b64-bg2);}
.b64ds-triple-label{font-size:13px;color:var(--b64-text2);}
.b64ds-triple-btns{display:flex;gap:4px;}
.b64ds-triple-btn{font-size:11px;padding:4px 10px;border-radius:6px;cursor:pointer;border:1px solid var(--b64-input-bd);background:var(--b64-bg3);color:var(--b64-text2);font-family:inherit;transition:background .12s,color .12s;}
.b64ds-triple-btn.active{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
.b64ds-triple-btn:hover:not(.active){background:var(--b64-bg);}
.b64ds-triple-pair{display:flex;gap:6px;}
.b64ds-triple-pair .b64ds-triple-row{flex:1;flex-direction:column;align-items:flex-start;gap:6px;}

/* ── 링크 복사 버튼 — 카드 왼쪽 전체 영역, 세로선 구분 ── */
.bl-copy{align-self:stretch;display:flex;align-items:center;justify-content:center;
  padding:0 9px 0 11px;flex-shrink:0;cursor:pointer;opacity:.28;
  border-right:1px solid rgba(0,0,0,.1);
  transition:opacity .15s,background .15s,color .15s;}
.bl-copy:hover{opacity:.8;background:rgba(0,0,0,.07);}
.bl-copy.bl-copy-ok{opacity:1 !important;color:#16a34a;}
.b64-link .bl-copy .bl-icon,.b64-product-link .bl-copy .bl-icon{width:14px;height:14px;}

/* ── 링크 모아보기 패널 ── */
#b64d-link-panel{
  margin:0 0 16px;padding:12px 14px;
  background:#f4f4f5;border:1px solid #e4e4e7;border-radius:12px;
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
  box-sizing:border-box;}
.b64lp-header{
  display:flex;align-items:center;justify-content:space-between;
  margin-bottom:10px;font-size:12px;font-weight:700;
  color:#71717a;letter-spacing:.04em;text-transform:uppercase;}
.b64lp-close{
  background:none;border:none;cursor:pointer;color:#a1a1aa;
  font-size:14px;padding:0 2px;line-height:1;border-radius:4px;
  transition:color .15s;flex-shrink:0;}
.b64lp-close:hover{color:#18181b;}
.b64lp-section{display:flex;flex-direction:column;gap:6px;}
.b64lp-section+.b64lp-section{margin-top:10px;padding-top:10px;border-top:1px solid #e4e4e7;}
.b64lp-label{font-size:11px;font-weight:700;color:#a1a1aa;letter-spacing:.06em;text-transform:uppercase;}
.b64lp-cards{display:flex;flex-wrap:wrap;gap:4px;}

/* ── kone.gg 자체 다크모드 (html.dark 클래스 방식) ── */
html.dark .bl-copy{border-right-color:rgba(255,255,255,.12);}
html.dark .bl-copy:hover{background:rgba(255,255,255,.09);}
html.dark #b64d-link-panel{background:#27272a;border-color:#3f3f46;}
html.dark .b64lp-section+.b64lp-section{border-top-color:#3f3f46;}
html.dark .b64lp-close:hover{color:#fafafa;}
html.dark .b64-link{background:rgba(255,255,255,.08);border-color:rgba(209,213,219,.42);color:#e5e7eb;}
html.dark .b64-link:hover{background:rgba(255,255,255,.13);border-color:rgba(209,213,219,.60);}
html.dark .b64-link.lk-alive{background:rgba(22,163,74,.14);border-color:rgba(74,222,128,.58);color:#86efac;}
html.dark .b64-link.lk-alive:hover{background:rgba(22,163,74,.22);border-color:rgba(74,222,128,.78);}
html.dark .b64-link.lk-dead{background:rgba(220,38,38,.14);border-color:rgba(248,113,113,.58);color:#fca5a5;}
html.dark .b64-link.lk-dead:hover{background:rgba(220,38,38,.22);border-color:rgba(248,113,113,.78);}
html.dark .b64-product-link{background:rgba(109,40,217,.18);border-color:rgba(167,139,250,.62);color:#c4b5fd;}
html.dark .b64-product-link:hover{background:rgba(109,40,217,.28);border-color:rgba(167,139,250,.82);}
html.dark .b64-product-link.pl-steam{background:rgba(30,58,138,.18);border-color:rgba(96,165,250,.58);color:#93c5fd;}
html.dark .b64-product-link.pl-steam:hover{background:rgba(30,58,138,.28);border-color:rgba(96,165,250,.78);}
html.dark .b64-product-link.pl-patreon,
html.dark .b64-product-link.pl-getcu,
html.dark .b64-product-link.pl-fanza,
html.dark .b64-product-link.pl-fanbox{background:rgba(120,53,15,.18);border-color:rgba(251,146,60,.58);color:#fdba74;}
html.dark .b64-product-link.pl-patreon:hover,
html.dark .b64-product-link.pl-getcu:hover,
html.dark .b64-product-link.pl-fanza:hover,
html.dark .b64-product-link.pl-fanbox:hover{background:rgba(120,53,15,.28);border-color:rgba(251,146,60,.78);}
html.dark .b64-title-card-bar{border-top-color:#3f3f46;}

/* ── 채널 대문 TXT 중복 검사 버튼 ── */
#b64-dup-btn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid #e4e4e7;background:transparent;cursor:pointer;color:#71717a;padding:0;}
#b64-dup-btn:hover{background:#f4f4f5;color:#18181b;}
#b64-dup-btn .bl-icon{width:16px;height:16px;}
html.dark #b64-dup-btn{border-color:#3f3f46;color:#a1a1aa;}
html.dark #b64-dup-btn:hover{background:#27272a;color:#f4f4f5;}
/* ── TXT 중복 검사 모달 ── */
#b64-dup-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:99990;display:flex;align-items:center;justify-content:center;}
#b64-dup-modal{background:#fff;border-radius:14px;width:700px;max-width:95vw;max-height:min(710px,90vh);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,.22);overflow:hidden;}
html.dark #b64-dup-modal{background:#1c1c1e;color:#f4f4f5;}
#b64-dup-modal .dup-hdr{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid #e4e4e7;font-weight:600;font-size:14px;flex-shrink:0;}
html.dark #b64-dup-modal .dup-hdr{border-bottom-color:#3f3f46;}
#b64-dup-modal .dup-x{background:none;border:none;cursor:pointer;font-size:16px;padding:2px 6px;color:inherit;opacity:.5;border-radius:6px;}
#b64-dup-modal .dup-x:hover{opacity:1;background:rgba(0,0,0,.06);}
html.dark #b64-dup-modal .dup-x:hover{background:rgba(255,255,255,.08);}
#b64-dup-modal .dup-layout{display:flex;flex:1;overflow:hidden;}
#b64-dup-modal .dup-sidebar{width:185px;flex-shrink:0;border-right:1px solid #e4e4e7;overflow:hidden;padding:10px 8px;display:flex;flex-direction:column;gap:4px;}
html.dark #b64-dup-modal .dup-sidebar{border-right-color:#3f3f46;}
#b64-dup-modal .dup-sidebar-title{font-size:11px;font-weight:700;color:#71717a;letter-spacing:.05em;padding:2px 4px 6px;}
#b64-dup-modal .dup-hist-search{width:100%;box-sizing:border-box;padding:5px 8px;border:1px solid #e4e4e7;border-radius:6px;font-size:11px;font-family:inherit;background:transparent;color:inherit;outline:none;margin-bottom:4px;}
#b64-dup-modal .dup-hist-search:focus{border-color:#a5b4fc;}
html.dark #b64-dup-modal .dup-hist-search{border-color:#3f3f46;}
html.dark #b64-dup-modal .dup-hist-search:focus{border-color:#818cf8;}
#b64-dup-modal .dup-hist-pager{display:flex;align-items:center;justify-content:center;gap:3px;padding:6px 4px 2px;border-top:1px solid #e4e4e7;flex-shrink:0;}
html.dark #b64-dup-modal .dup-hist-pager{border-top-color:#3f3f46;}
#b64-dup-modal .dup-hist-pager-pages{display:flex;gap:2px;}
#b64-dup-modal .dup-hist-pager-btn,#b64-dup-modal .dup-hist-pager-num{background:none;border:1px solid #d4d4d8;border-radius:4px;padding:1px 6px;font-size:11px;cursor:pointer;color:#71717a;font-family:inherit;line-height:18px;min-width:22px;}
#b64-dup-modal .dup-hist-pager-btn:hover,#b64-dup-modal .dup-hist-pager-num:hover{background:#f4f4f5;}
#b64-dup-modal .dup-hist-pager-num.active{background:#4f46e5;color:#fff;border-color:transparent;}
#b64-dup-modal .dup-hist-pager-btn:disabled{opacity:.35;cursor:default;}
html.dark #b64-dup-modal .dup-hist-pager-btn,html.dark #b64-dup-modal .dup-hist-pager-num{border-color:#3f3f46;color:#a1a1aa;}
html.dark #b64-dup-modal .dup-hist-pager-btn:hover,html.dark #b64-dup-modal .dup-hist-pager-num:hover{background:#27272a;}
#b64-dup-modal .dup-hist-date-group{font-size:10px;font-weight:700;color:#a1a1aa;letter-spacing:.06em;padding:8px 4px 3px;border-bottom:1px solid #e4e4e7;margin-bottom:2px;}
html.dark #b64-dup-modal .dup-hist-date-group{color:#71717a;border-bottom-color:#3f3f46;}
#b64-dup-modal .dup-body{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px;}
#b64-dup-modal .dup-pick{display:flex;align-items:center;gap:8px;}
#b64-dup-modal .dup-file-btn{padding:6px 14px;background:#4f46e5;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;white-space:nowrap;}
#b64-dup-modal .dup-file-btn:hover{background:#4338ca;}
#b64-dup-modal .dup-file-name{font-size:12px;color:#71717a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}
#b64-dup-modal .dup-result{font-size:13px;display:flex;flex-direction:column;gap:6px;}
#b64-dup-modal .dup-summary{font-size:12px;color:#71717a;}
#b64-dup-modal .dup-ok{color:#16a34a;font-weight:500;}
html.dark #b64-dup-modal .dup-ok{color:#4ade80;}
#b64-dup-modal .dup-label{font-size:12px;font-weight:600;color:#ef4444;margin-bottom:2px;}
html.dark #b64-dup-modal .dup-label{color:#f87171;}
#b64-dup-modal .dup-page-link{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border:1px solid #c7d2fe;border-radius:8px;font-size:12px;color:#4f46e5;text-decoration:none;background:#eef2ff;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#b64-dup-modal .dup-page-link:hover{background:#e0e7ff;border-color:#a5b4fc;}
html.dark #b64-dup-modal .dup-page-link{background:#1e1b4b;border-color:#4338ca;color:#a5b4fc;}
html.dark #b64-dup-modal .dup-page-link:hover{background:#312e81;border-color:#818cf8;}
#b64-dup-modal .dup-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:2px;}
#b64-dup-modal .dup-list li{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#f4f4f5;border-radius:6px;}
html.dark #b64-dup-modal .dup-list li{background:#27272a;}
#b64-dup-modal .dup-code{font-family:monospace;font-weight:600;font-size:13px;}
#b64-dup-modal .dup-badge{font-size:11px;background:#fde68a;color:#92400e;border-radius:999px;padding:1px 7px;font-weight:600;}
html.dark #b64-dup-modal .dup-badge{background:#451a03;color:#fbbf24;}
#b64-dup-modal .dup-empty{color:#71717a;font-size:13px;}
#b64-dup-modal .dup-section-title{font-size:11px;font-weight:700;letter-spacing:.04em;margin:8px 0 3px;color:#52525b;}
html.dark #b64-dup-modal .dup-section-title{color:#a1a1aa;}
#b64-dup-modal .dup-owned-title{color:#ef4444;}
html.dark #b64-dup-modal .dup-owned-title{color:#f87171;}
#b64-dup-modal .dup-new-title{color:#16a34a;}
html.dark #b64-dup-modal .dup-new-title{color:#4ade80;}
#b64-dup-modal .dup-badge-ok{background:#fee2e2;color:#dc2626;border-radius:999px;padding:1px 7px;font-size:11px;font-weight:600;}
html.dark #b64-dup-modal .dup-badge-ok{background:#7f1d1d;color:#fca5a5;}
#b64-dup-modal .dup-list li.dup-goto{cursor:pointer;}
#b64-dup-modal .dup-list li.dup-goto:hover{background:#e4e4e7;outline:1px solid #a1a1aa;}
html.dark #b64-dup-modal .dup-list li.dup-goto:hover{background:#3f3f46;outline-color:#71717a;}
@keyframes b64-card-flash{0%,100%{outline:2px solid transparent;outline-offset:3px}15%{outline:2px solid #f97316;outline-offset:3px}85%{outline:2px solid #f97316;outline-offset:3px}}
.b64-card-flash{animation:b64-card-flash 1.8s ease;}
#b64-dup-modal .dup-hist-list{display:flex;flex-direction:column;gap:4px;}
#b64-dup-modal .dup-hist-row{display:flex;flex-direction:column;gap:2px;padding:7px 8px;border-radius:6px;cursor:pointer;font-size:12px;font-family:inherit;color:inherit;border:1px solid #e4e4e7;background:#fafafa;width:100%;text-align:left;}
#b64-dup-modal .dup-hist-row:hover{background:#f4f4f5;border-color:#d4d4d8;}
html.dark #b64-dup-modal .dup-hist-row{background:#1c1c1e;border-color:#3f3f46;}
html.dark #b64-dup-modal .dup-hist-row:hover{background:#27272a;border-color:#52525b;}
#b64-dup-modal .dup-hist-file{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;}
#b64-dup-modal .dup-hist-meta{display:flex;justify-content:space-between;gap:4px;}
#b64-dup-modal .dup-hist-date{color:#71717a;font-size:11px;}
#b64-dup-modal .dup-hist-stat{color:#71717a;font-size:11px;}
#b64-dup-modal .dup-hist-row.dup-hist-sel{background:#eff6ff;border-color:#93c5fd;}
html.dark #b64-dup-modal .dup-hist-row.dup-hist-sel{background:#1e3a5f;border-color:#3b82f6;}
#b64-dup-modal .dup-detail-hint{font-size:10px;color:#71717a;margin-top:4px;text-align:center;}
#b64-dup-modal .dup-detail-section{margin-top:8px;padding-top:8px;border-top:1px solid #e4e4e7;}
html.dark #b64-dup-modal .dup-detail-section{border-top-color:#3f3f46;}
#b64-dup-modal .dup-detail-title{font-size:11px;font-weight:600;color:#52525b;margin-bottom:4px;}
html.dark #b64-dup-modal .dup-detail-title{color:#a1a1aa;}
`;
    (document.head || document.documentElement).appendChild(s);
  }

  /* ================================================================
     SVG 아이콘
  ================================================================ */
  function svg(path) {
    return `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`;
  }
  const ICO = {
    world:   svg('<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'),
    link:    svg('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'),
    linkoff: svg('<path d="m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="m5.17 11.75-1.72 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71"/><line x1="2" y1="2" x2="22" y2="22"/>'),
    check:   svg('<polyline points="20 6 9 17 4 12"/>'),
    alert:   svg('<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'),
    spin:    `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.22-8.56"/></svg>`,
    tag:     svg('<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>'),
    gamepad:  svg('<line x1="6" y1="11" x2="10" y2="11"/><line x1="8" y1="9" x2="8" y2="13"/><line x1="15" y1="12" x2="15.01" y2="12"/><line x1="18" y1="10" x2="18.01" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 16 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.544-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>'),
    patreon:  svg('<circle cx="15" cy="9" r="5.5"/><line x1="3" y1="2" x2="3" y2="22"/>'),
    copy:     svg('<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
    hash:     svg('<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>'),
  };

  /* ================================================================
     카드 유틸
  ================================================================ */
  // 카드 <a> 왼쪽에 복사 버튼 추가 — 모든 make*Card 함수에서 공통 사용
  // code를 넘기면 COPY_CODE 설정에 따라 링크/코드 중 선택 복사 (작품 카드 전용)
  function wrapCard(a, code) {
    if (code) a.dataset.cardCode = String(code);
    const btn = document.createElement('span');
    btn.className = 'bl-copy';
    btn.title = (CFG.COPY_CODE && code) ? `코드 복사 (${code})` : '링크 복사';
    btn.innerHTML = ICO.copy;
    a.insertBefore(btn, a.firstChild);
    return a;
  }

  /* ================================================================
     링크 모아보기 패널 — 페이지 상단에 상품/다운로드 링크 중복 없이 모아 표시
  ================================================================ */
  function buildLinkPanel(contentRoots) {
    // 기존 패널 제거 + 패널 맵 초기화 (재빌드 or off 전환)
    document.getElementById('b64d-link-panel')?.remove();
    _panelCardMap.clear();
    if (!CFG.LINK_PANEL || !CFG.LINK_CARD) return;
    if (!contentRoots || !contentRoots.size) return;

    const showProducts  = CFG.PANEL_TARGET !== 'links';
    const showDownloads = CFG.PANEL_TARGET !== 'products';

    const seenHrefs = new Set();
    const products  = [];
    const downloads = [];

    function cloneCard(a) {
      const href = a.href;
      if (!href || seenHrefs.has(href)) return null;
      seenHrefs.add(href);
      const clone = a.cloneNode(true);
      clone.removeAttribute(DONE_ATTR);
      clone.removeAttribute(ORIG_ATTR);
      clone.removeAttribute('data-lkch');
      // cloneNode은 이벤트 리스너를 복사하지 않으므로 미리보기 훅 재연결
      if (CFG.DLSITE_PREVIEW && clone.dataset._dlpHooked) {
        clone.addEventListener('mouseenter', dlpShow);
      }
      if (_linkCheckCache.has(href)) {
        const { alive, msg } = _linkCheckCache.get(href);
        _applyLinkState(clone, alive, msg);
      }
      _panelCardMap.set(href, clone);
      return clone;
    }

    contentRoots.forEach(root => {
      if (showProducts)  root.querySelectorAll('.b64-product-link[href]').forEach(a => {
        const c = cloneCard(a); if (c) products.push(c);
      });
      if (showDownloads) root.querySelectorAll('.b64-link[href]').forEach(a => {
        const c = cloneCard(a); if (c) downloads.push(c);
      });
    });

    if (!products.length && !downloads.length) return;

    const panel = document.createElement('div');
    panel.id = 'b64d-link-panel';
    // DONE_ATTR: MO 재트리거 방지 + 내부 링크를 convertProductLinks가 재처리하지 않도록
    panel.setAttribute(DONE_ATTR, '');

    const hdr = document.createElement('div');
    hdr.className = 'b64lp-header';
    const title = document.createElement('span');
    title.textContent = '링크 모아보기';
    const closeBtn = document.createElement('button');
    closeBtn.className = 'b64lp-close';
    closeBtn.textContent = '✕';
    closeBtn.title = '닫기';
    closeBtn.addEventListener('click', () => panel.remove());
    hdr.appendChild(title);
    hdr.appendChild(closeBtn);
    panel.appendChild(hdr);

    function makeSection(label, cards) {
      const sec = document.createElement('div');
      sec.className = 'b64lp-section';
      const lbl = document.createElement('div');
      lbl.className = 'b64lp-label';
      lbl.textContent = label;
      const wrap = document.createElement('div');
      wrap.className = 'b64lp-cards';
      cards.forEach(c => wrap.appendChild(c));
      sec.appendChild(lbl);
      sec.appendChild(wrap);
      return sec;
    }

    if (products.length)  panel.appendChild(makeSection('작품',     products));
    if (downloads.length) panel.appendChild(makeSection('다운로드', downloads));

    // #post-article 최상단에 삽입 (없으면 첫 contentRoot 앞)
    const articleEl = document.getElementById('post-article');
    if (articleEl) {
      articleEl.insertBefore(panel, articleEl.firstChild);
    } else {
      const first = [...contentRoots][0];
      first.parentNode?.insertBefore(panel, first);
    }
  }

  // 복사 버튼 클릭 이벤트 (캡처 단계 — <a> 기본 네비게이션 차단)
  document.addEventListener('click', e => {
    const btn = e.target.closest && e.target.closest('.bl-copy');
    if (!btn) return;
    e.preventDefault();
    e.stopPropagation();
    const card = btn.closest('a[href]');
    if (!card) return;
    const code = card.dataset.cardCode;
    const textToCopy = (CFG.COPY_CODE && code) ? code : card.href;
    const onOk = () => {
      btn.innerHTML = ICO.check;
      btn.classList.add('bl-copy-ok');
      setTimeout(() => { btn.innerHTML = ICO.copy; btn.classList.remove('bl-copy-ok'); }, 1500);
    };
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(textToCopy).then(onOk).catch(() => {});
    } else {
      try {
        const ta = document.createElement('textarea');
        ta.value = textToCopy; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0;pointer-events:none;';
        document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); onOk();
      } catch(_) {}
    }
  }, true);

  /* ================================================================
     base64 디코딩
  ================================================================ */
  const DEC_STRICT  = new TextDecoder('utf-8', { fatal: true  });
  const DEC_LENIENT = new TextDecoder('utf-8', { fatal: false });

  // URL-safe base64('-'→'+', '_'→'/')도 허용. 정규화 후 표준 base64로 체크.
  function looksLikeB64(str) {
    const norm = str.replace(/-/g, '+').replace(/_/g, '/');
    return norm.length >= MIN_B64 && norm.length % 4 !== 1 &&
           /^[A-Za-z0-9+/]+={0,2}$/.test(norm);
  }

  function isProbablyText(str) {
    if (!str) return false;
    let ctrl = 0, repl = 0;
    for (const ch of str) {
      const c = ch.codePointAt(0);
      // C0 제어문자 + DEL + C1 제어문자(0x80-0x9F) — 일반 텍스트에 없음
      if (c < 0x09 || (c > 0x0d && c < 0x20) || c === 0x7f || (c >= 0x80 && c <= 0x9f)) ctrl++;
      if (c === 0xfffd) repl++;
    }
    return ctrl / str.length < 0.1 && repl / str.length < 0.15;
  }

  // URL-safe base64('-', '_')를 표준으로 변환한 뒤 디코딩
  function decodeB64Once(str) {
    if (!looksLikeB64(str)) return null;
    try {
      // URL-safe → 표준 base64 정규화
      let s = str.replace(/-/g, '+').replace(/_/g, '/');
      const rem = s.length % 4;
      if (rem === 2) s += '==';
      else if (rem === 3) s += '=';
      const bytes = Uint8Array.from(atob(s), c => c.charCodeAt(0));
      // strict 먼저 시도, 실패 시 lenient(대체 문자 허용)로 폴백
      try       { return DEC_STRICT.decode(bytes); }
      catch (e) { return DEC_LENIENT.decode(bytes); }
    } catch (e) { return null; }
  }

  function fullyDecode(str) {
    let cur = str, best = null;
    for (let i = 0; i < MAX_DECODE; i++) {
      const d = decodeB64Once(cur);
      if (!d || !isProbablyText(d)) break;
      best = d; cur = d;
    }
    return best;
  }

  /* ================================================================
     매칭 패턴 정의

     DLsite:
       - 라틴: RJ/BJ/VJ/RE/BE/VE + 4~8자리, 대소문자 무관 (i 플래그)
       - 한글 IME: 한글 모드에서 rj 를 타이핑하면 r→ㄱ, j→ㅓ → '거'
                   Rj(shift+r)→ㄲ, j→ㅓ → '꺼'
                   두 글자 모두 RJ 코드로 정규화

     Steam:
       - 스팀 (한글)
       - Steam / steam (영문 대소문자)
       - st / St (약칭, 단어 경계 필요)
  ================================================================ */
  // DLsite 라틴 코드 – 접두어·숫자 사이 공백 허용, 대소문자 무관
  const DLSITE_LATIN_RE = /\b((?:RJ|BJ|VJ|RE|BE|VE))\s?(\d{6,8})\b/gi;
  // DLsite 한글 IME – 꺼(Rj)/거(rj) 또는 분리 자모 ㄲㅓ/ㄱㅓ + 선택적 공백
  const DLSITE_KR_RE    = /(꺼|거|ㄲㅓ|ㄱㅓ)\s?(\d{6,8})/g;
  // Steam – 스팀, Steam, steam, st/St/ST (단어 경계), 구분자 공백·대시 허용
  const STEAM_CODE_RE   = /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*(\d{4,10})/g;
  // 크리에이터 플랫폼 코드 – Getcu(GC) / Fanza(FZ) / Fanbox(FB)
  const CREATOR_CODE_RE = /\b((?:GC|FZ|FB))\s?(\d{4,8})\b/gi;

  /* ================================================================
     findAllMatches (base64 + 점자 + DLsite 코드 + Steam 코드)
  ================================================================ */
  function findAllMatches(raw) {
    const hits = [];

    // ── base64 (URL-safe '-_' 포함) ──
    // URL-safe base64는 '+' 대신 '-', '/' 대신 '_' 를 사용하므로 함께 매칭
    const b64re = new RegExp(`[A-Za-z0-9+/\\-_]{${MIN_B64},}={0,2}`, 'g');
    let last = 0, m;
    while ((m = b64re.exec(raw)) !== null) {
      if (m.index < last) continue;
      const decoded = fullyDecode(m[0]);
      if (!decoded) continue;
      hits.push({ index: m.index, length: m[0].length, decoded, type: 'base64' });
      last = m.index + m[0].length;
      b64re.lastIndex = last;
    }

    // ── DLsite 코드 (라틴, 대소문자 무관) ──
    DLSITE_LATIN_RE.lastIndex = 0;
    while ((m = DLSITE_LATIN_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) {
        const code = (m[1] + m[2]).toUpperCase();
        hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
      }
    }

    // ── DLsite 코드 (한글 IME: 거=rj, 꺼=Rj → RJ 정규화) ──
    DLSITE_KR_RE.lastIndex = 0;
    while ((m = DLSITE_KR_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) {
        const code = 'RJ' + m[2];
        hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
      }
    }

    // ── Steam 코드 ──
    STEAM_CODE_RE.lastIndex = 0;
    while ((m = STEAM_CODE_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) hits.push({ index: m.index, length: m[0].length, decoded: `Steam ${m[1]}`, type: 'steam', appId: m[1] });
    }

    // ── 크리에이터 플랫폼 코드 (GC/FZ/FB) ──
    CREATOR_CODE_RE.lastIndex = 0;
    while ((m = CREATOR_CODE_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) {
        const code = (m[1] + m[2]).toUpperCase();
        hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'creator', code });
      }
    }

    hits.sort((a, b) => a.index - b.index);
    const clean = []; let end = 0;
    for (const h of hits) {
      if (h.index >= end) { clean.push(h); end = h.index + h.length; }
    }
    return clean;
  }

  /* ================================================================
     링크 생존 확인
  ================================================================ */
  // url → {alive, msg}: 동일 URL 재확인 생략용 캐시 (세션 유지)
  const _linkCheckCache = new Map();
  // url → 패널 클론 카드: 원본 체크 결과를 패널에 동기화
  const _panelCardMap   = new Map();
  // url → Set<cardEl>: 진행 중인 URL의 대기 카드 목록 (중복 요청 제거)
  const _lcPending      = new Map();

  // 링크 체크 동시 실행 제한 (GM 풀 포화 방지 → 미리보기 GM 요청 우선 확보)
  const _lcQueue = [];
  let   _lcActive = 0;
  const LC_MAX = 2;
  function _lcRelease() {
    _lcActive--;
    if (_lcQueue.length) { _lcActive++; _lcQueue.shift()(); }
  }
  function _lcEnqueue(fn) {
    if (_lcActive < LC_MAX) { _lcActive++; fn(); }
    else _lcQueue.push(fn);
  }

  function _applyLinkState(el, alive, msg) {
    el.classList.remove('lk-checking', 'lk-alive', 'lk-dead');
    if (alive === true)       el.classList.add('lk-alive');
    else if (alive === false) el.classList.add('lk-dead');
    const sub  = el.querySelector('.bl-sub');
    const wrap = el.querySelector('.bl-icon-wrap');
    const arr  = el.querySelector('.bl-arrow');
    if (sub)  sub.textContent = msg;
    if (wrap) wrap.innerHTML  = alive === true ? ICO.link : alive === false ? ICO.linkoff : ICO.world;
    if (arr)  arr.innerHTML   = alive === true ? ICO.check : alive === false ? ICO.alert : '↗';
  }

  function setLinkState(cardEl, alive, msg) {
    _applyLinkState(cardEl, alive, msg);
    const url = cardEl.href;
    if (url) {
      _linkCheckCache.set(url, { alive, msg });
      // 같은 URL을 기다리던 중복 카드에 결과 일괄 적용
      const waiting = _lcPending.get(url);
      if (waiting) {
        waiting.forEach(el => { if (el !== cardEl) _applyLinkState(el, alive, msg); });
        _lcPending.delete(url);
      }
      const pc = _panelCardMap.get(url);
      if (pc && pc !== cardEl) _applyLinkState(pc, alive, msg);
    }
    _lcRelease();
  }

  function beginCheck(cardEl) {
    cardEl.classList.add('lk-checking');
    const arr = cardEl.querySelector('.bl-arrow');
    if (arr) arr.innerHTML = ICO.spin;
  }

  function checkLinkByHead(url, cardEl) {
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'HEAD', url, timeout: 3000,
      onload(res) {
        const alive = res.status >= 200 && res.status < 400;
        let h = '';
        try { h = new URL(url).hostname; } catch(e) {}
        setLinkState(cardEl, alive, alive ? `${h} · 링크 정상` : `${h} · 링크 없음 (${res.status})`);
      },
      onerror()   { setLinkState(cardEl, false, '연결 실패'); },
      ontimeout() { setLinkState(cardEl, false, '응답 없음'); },
    });
  }

  // kio.ac/kiosk.ac: SPA라서 dead 텍스트가 JS 렌더링됨 → HTTP 상태 + JSON 패턴 병행 체크
  function checkKioLink(url, cardEl, siteName) {
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'GET', url, timeout: 3000,
      onload(res) {
        if (res.status >= 400) {
          setLinkState(cardEl, false, `${siteName} · 링크 만료 (${res.status})`);
          return;
        }
        const body = (res.responseText || '').toLowerCase();
        const dead = [
          '컬렉션을 찾을 수 없음', '만료되었습니다', '찾을 수 없습니다', '존재하지 않',
          '"notfound"', '"not_found"', '"not found"', '"status":404',
          '"status":"error"', '"error":true', 'collection not found',
        ].some(p => body.includes(p));
        setLinkState(cardEl, !dead, dead ? `${siteName} · 링크 만료` : `${siteName} · 링크 정상`);
      },
      onerror()   { setLinkState(cardEl, false, `${siteName} · 연결 실패`); },
      ontimeout() { setLinkState(cardEl, false, `${siteName} · 응답 없음`); },
    });
  }

  // mega.nz: CS API — folder/file 링크 구분하여 각각 적절한 커맨드 사용
  function checkMegaLink(url, cardEl) {
    const folderM = url.match(/mega\.nz\/folder\/([A-Za-z0-9_-]+)/);
    const fileM   = url.match(/mega\.nz\/file\/([A-Za-z0-9_-]+)/);
    const m = folderM || fileM;
    if (!m) { checkLinkByHead(url, cardEl); return; }
    const handle   = m[1];
    const isFolder = !!folderM;
    // folder: ?n=HANDLE + {"a":"f","c":1} / file: body에 핸들 포함 {"a":"g","p":HANDLE}
    const apiUrl = isFolder
      ? `https://g.api.mega.co.nz/cs?id=0&app=webclient&n=${handle}`
      : 'https://g.api.mega.co.nz/cs?id=0&app=webclient';
    const data = isFolder
      ? JSON.stringify([{ a: 'f', c: 1 }])
      : JSON.stringify([{ a: 'g', p: handle, ssl: 0 }]);
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'POST', url: apiUrl, data,
      headers: {
        'Content-Type': 'application/json',
        'Origin': 'https://mega.nz',
        'Referer': 'https://mega.nz/',
      },
      timeout: 3000,
      onload(res) {
        try {
          const d = JSON.parse(res.responseText);
          // 에러: [-9], [-2] 등 음수 배열 또는 단독 음수, 또는 {e: -N}
          const code = Array.isArray(d) ? d[0] : (typeof d === 'number' ? d : d?.e);
          const dead = typeof code === 'number' && code < 0;
          setLinkState(cardEl, !dead, dead ? 'mega.nz · 링크 만료' : 'mega.nz · 링크 정상');
        } catch(e) { setLinkState(cardEl, true, 'mega.nz · 링크 정상'); }
      },
      onerror()   { setLinkState(cardEl, false, 'mega.nz · 연결 실패'); },
      ontimeout() { setLinkState(cardEl, false, 'mega.nz · 응답 없음'); },
    });
  }

  // gofile.io: SPA이므로 페이지 GET은 무의미 → REST API 방식
  // 1) POST /accounts → 게스트 토큰 취득
  // 2) GET /contents/{id}?token={t} → status='ok' 이면 정상, 그 외 만료 추정
  // 실패·불확실 시 null(회색) 처리 → 살아있는 링크를 실수로 만료 표시하지 않도록
  // gofile.io: /contents API 유료 전용, SPA라 페이지 HTML에도 생사 정보 없음 → 검증 불가
  function checkGofileLink(url, cardEl) {
    setLinkState(cardEl, null, 'gofile.io · 만료 검증 불가');
  }

  // workupload.com: Cloudflare 봇 감지로 인해 자동 검증 불가 → 즉시 안내
  function checkWorkuploadLink(url, cardEl) {
    setLinkState(cardEl, null, 'workupload.com · Cloudflare 봇 감지로 검증 불가');
  }

  // mypikpak.com: device_id + X-Client-Id 헤더 포함
  // 진단: 실제 API 응답을 콘솔에 출력해 어떤 필드가 오는지 확인
  function checkPikpakLink(url, cardEl) {
    const m = url.match(/mypikpak\.com\/s\/([^/?#]+)/);
    if (!m) { checkLinkByHead(url, cardEl); return; }
    const shareId = m[1];
    const deviceId = (crypto && crypto.randomUUID)
      ? crypto.randomUUID()
      : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
          const r = Math.random() * 16 | 0;
          return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
        });
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://api-drive.mypikpak.com/drive/v1/share?share_id=${shareId}&thumbnail_size=SIZE_MEDIUM&pass_code=&device_id=${deviceId}`,
      headers: {
        // mypikpak 웹 클라이언트 식별 헤더 (공개 공유 API 접근용)
        'X-Client-Id':      'YNxT9w7GMdWvEOKa',
        'X-Client-Version': '1.0.0',
        'X-Device-Name':    'Chrome Browser',
        'Accept':           'application/json, text/plain, */*',
      },
      timeout: 3000,
      onload(res) {
        if (res.status === 404) { setLinkState(cardEl, false, 'mypikpak · 링크 만료'); return; }
        if (res.status >= 500) { setLinkState(cardEl, null, `mypikpak · 확인 불가 (${res.status})`); return; }
        try {
          const d = JSON.parse(res.responseText);
          // share_status: "OK" / "EXPIRED" / "CANCELLED" 등
          if (d.share_status) {
            const alive = ['OK', 'ACTIVE', 'NORMAL'].includes(d.share_status);
            setLinkState(cardEl, alive, alive ? 'mypikpak · 링크 정상' : `mypikpak · 링크 만료 (${d.share_status})`); return;
          }
          // 에러 응답: {error:'INVALID', message:'...'} or {code:-N}
          if (d.error || (typeof d.code === 'number' && d.code !== 0)) {
            // error가 'NOT_FOUND' 계열이면 만료, 그 외(인증 에러 등)는 불명
            const errStr = String(d.error || d.message || '').toLowerCase();
            const dead = errStr.includes('not_found') || errStr.includes('not found') ||
                         errStr.includes('expired') || errStr.includes('invalid');
            setLinkState(cardEl, dead ? false : null,
              dead ? 'mypikpak · 링크 만료' : `mypikpak · 확인 불가 (${d.error || d.code})`); return;
          }
        } catch(e) {}
        setLinkState(cardEl, null, 'mypikpak · 확인 불가');
      },
      onerror()   { setLinkState(cardEl, null, 'mypikpak · 확인 불가'); },
      ontimeout() { setLinkState(cardEl, null, 'mypikpak · 응답 없음'); },
    });
  }

  // transfer.it: SPA + MEGA API 헤더 제약으로 만료 감지 불가 → 회색 표시
  function checkTransferLink(url, cardEl) {
    setLinkState(cardEl, null, 'transfer.it · 만료 검증 불가');
  }

  function checkLink(url, cardEl) {
    // 캐시 조회: 이미 체크한 URL이면 즉시 결과 적용, 네트워크 요청 생략
    if (_linkCheckCache.has(url)) {
      const { alive, msg } = _linkCheckCache.get(url);
      _applyLinkState(cardEl, alive, msg);
      return;
    }
    // 동일 URL이 이미 진행 중이면 대기 목록에만 추가 (네트워크 요청 중복 방지)
    if (_lcPending.has(url)) {
      _lcPending.get(url).add(cardEl);
      beginCheck(cardEl);
      return;
    }
    _lcPending.set(url, new Set([cardEl]));
    // 동시 실행 제한 큐: GM 풀 포화 방지 → 미리보기 요청에 슬롯 확보
    _lcEnqueue(() => {
      let h = '';
      try { h = new URL(url).hostname; } catch(e) { return checkLinkByHead(url, cardEl); }
      if (h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac')
        return checkKioLink(url, cardEl, h);
      if (h === 'mega.nz')         return checkMegaLink(url, cardEl);
      if (h === 'gofile.io')       return checkGofileLink(url, cardEl);
      if (h === 'mypikpak.com')    return checkPikpakLink(url, cardEl);
      if (h === 'transfer.it')     return checkTransferLink(url, cardEl);
      if (h === 'workupload.com')  return checkWorkuploadLink(url, cardEl);
      // kio·mega·mypikpak 외 사이트는 자동 검증 불가 → 즉시 안내
      setLinkState(cardEl, null, h + ' · 만료 검증 불가');
    });
  }

  /* ================================================================
     유틸
  ================================================================ */
  function esc(s) {
    return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  /* ================================================================
     DLsite 상품 카드
     - 직접 RJ 코드 또는 DLsite URL에서 추출된 코드 모두 사용
  ================================================================ */
  function makeDlsiteCard(code) {
    const prefix = code.slice(0, 2).toUpperCase();
    const sectionMap = { RJ: 'maniax', BJ: 'bl', VJ: 'soft', RE: 'maniax', BE: 'bl', VE: 'soft' };
    const section = sectionMap[prefix] || 'maniax';
    const url = `https://www.dlsite.com/${section}/work/=/product_id/${code}.html`;

    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = 'b64-product-link';
    a.innerHTML = `<span class="bl-icon-wrap">${ICO.tag}</span>
<span class="bl-text">
  <span class="bl-title">DLsite · ${esc(code)}</span>
  <span class="bl-sub">dlsite.com · 호버 시 미리보기</span>
</span><span class="bl-arrow">↗</span>`;

    if (CFG.DLSITE_PREVIEW) {
      a.dataset._dlpHooked = '1';
      a.addEventListener('mouseenter', dlpShow);
    }
    return wrapCard(a, code);
  }

  /* ================================================================
     Steam 상품 카드
  ================================================================ */
  function makeSteamCard(appId) {
    const url = `https://store.steampowered.com/app/${appId}/`;
    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = 'b64-product-link pl-steam';
    a.innerHTML = `<span class="bl-icon-wrap">${ICO.gamepad}</span>
<span class="bl-text">
  <span class="bl-title">Steam · ${esc(appId)}</span>
  <span class="bl-sub">store.steampowered.com · 호버 시 미리보기</span>
</span><span class="bl-arrow">↗</span>`;

    // 게임 이름 비동기 취득
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://store.steampowered.com/api/appdetails?appids=${appId}&filters=basic&l=korean`,
      timeout: 6000,
      onload(res) {
        try {
          const data = JSON.parse(res.responseText);
          const info = data[appId];
          if (info?.success && info.data?.name) {
            const titleEl = a.querySelector('.bl-title');
            const subEl   = a.querySelector('.bl-sub');
            if (titleEl) titleEl.textContent = `Steam · ${info.data.name}`;
            if (subEl)   subEl.textContent   = `App #${appId} · 호버 시 미리보기`;
          }
        } catch(e) {}
      },
    });

    if (CFG.DLSITE_PREVIEW) {
      a.dataset._dlpHooked = '1';
      a.addEventListener('mouseenter', dlpShow);
    }
    return wrapCard(a, 'ST' + appId);
  }

  /* ================================================================
     Patreon 상품 카드
  ================================================================ */
  function makePatreonCard(url) {
    const m = url.match(/patreon\.com\/(?:c\/)?([^/?#]+)/i);
    const creator = m ? m[1] : '';
    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = 'b64-product-link pl-patreon';
    a.innerHTML = `<span class="bl-icon-wrap">${ICO.patreon}</span>
<span class="bl-text">
  <span class="bl-title">Patreon${creator ? ' · ' + esc(creator) : ''}</span>
  <span class="bl-sub">patreon.com</span>
</span><span class="bl-arrow">↗</span>`;
    return wrapCard(a, creator || null);
  }

  /* ================================================================
     크리에이터 플랫폼 카드 (Getchu / Fanza / Fanbox)
  ================================================================ */
  // href: URL에서 진입할 때 원본 URL 보존용 (없으면 getchu.com/item/{digits} 생성)
  function makeGetcuCard(code, href) {
    const digits = code.replace(/^GC/i, '');
    const url = href || `https://getchu.com/item/${digits}`;
    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = 'b64-product-link pl-getcu';
    a.innerHTML = `<span class="bl-icon-wrap">${ICO.tag}</span>
<span class="bl-text">
  <span class="bl-title">Getchu · ${esc(digits)}</span>
  <span class="bl-sub">getchu.com</span>
</span><span class="bl-arrow">↗</span>`;
    if (CFG.DLSITE_PREVIEW) { a.dataset._dlpHooked = '1'; a.addEventListener('mouseenter', dlpShow); }
    return wrapCard(a, code);
  }

  function makeFanzaCard(code) {
    const digits = code.replace(/^FZ/i, '');
    const url = `https://www.dmm.co.jp/dc/doujin/-/detail/=/cid=d_${digits}/`;
    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = 'b64-product-link pl-fanza';
    a.innerHTML = `<span class="bl-icon-wrap">${ICO.tag}</span>
<span class="bl-text">
  <span class="bl-title">Fanza · ${esc(code)}</span>
  <span class="bl-sub">dmm.co.jp</span>
</span><span class="bl-arrow">↗</span>`;
    return wrapCard(a, code);
  }

  // href: URL에서 진입할 때 원본 URL 보존용 (없으면 www.fanbox.cc/posts/{digits} 생성)
  // subLabel: 카드 하단 보조 텍스트 (기본 'fanbox.cc')
  function makeFanboxCard(code, href, subLabel) {
    const digits = code.replace(/^FB/i, '');
    const url = href || `https://www.fanbox.cc/posts/${digits}`;
    // code가 순수 숫자가 아니면 작가명으로 전달된 것 → "FB" 접두어 없이 표시
    const displayCode = /^\d+$/.test(digits) ? ('FB' + digits) : code;
    const sub = subLabel || 'fanbox.cc';
    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = 'b64-product-link pl-fanbox';
    a.innerHTML = `<span class="bl-icon-wrap">${ICO.patreon}</span>
<span class="bl-text">
  <span class="bl-title">Fanbox · ${esc(displayCode)}</span>
  <span class="bl-sub">${esc(sub)}</span>
</span><span class="bl-arrow">↗</span>`;
    return wrapCard(a, displayCode);
  }

  // Fanbox 크리에이터 페이지 카드 (포스트 ID 없이 서브도메인만 있는 경우)
  // 예: https://mangamaterials.fanbox.cc/posts → 크리에이터명 'mangamaterials' 표시
  function makeFanboxCreatorCard(creator, url) {
    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = 'b64-product-link pl-fanbox';
    a.innerHTML = `<span class="bl-icon-wrap">${ICO.patreon}</span>
<span class="bl-text">
  <span class="bl-title">Fanbox · ${esc(creator)}</span>
  <span class="bl-sub">fanbox.cc · 크리에이터</span>
</span><span class="bl-arrow">↗</span>`;
    return wrapCard(a, creator);
  }

  function makeCreatorCard(code) {
    const prefix = code.slice(0, 2).toUpperCase();
    if (prefix === 'GC') return makeGetcuCard(code);
    if (prefix === 'FZ') return makeFanzaCard(code);
    if (prefix === 'FB') return makeFanboxCard(code);
    return document.createTextNode(code);
  }

  /* ================================================================
     다운로드 링크 카드
     - DLsite/Steam/Patreon URL은 LINK_CARD 설정 시 상품 카드로 라우팅
  ================================================================ */
  function makeLinkCard(rawUrl) {
    let url = /^https?:\/\//i.test(rawUrl) ? rawUrl : 'https://' + rawUrl;
    let h = '';
    try { h = new URL(url).hostname; } catch(e) { h = url; }

    // kone.gg 내부 링크 → 카드 없이 일반 밑줄 링크
    if (/kone\.gg$/.test(h)) {
      const a = document.createElement('a');
      a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
      a.setAttribute(DONE_ATTR, '');
      a.textContent = rawUrl;
      a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
      return a;
    }

    if (CFG.LINK_CARD) {
      // DLsite URL → 상품 카드
      if (h.includes('dlsite.com')) {
        const idMatch = url.match(/product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i);
        if (idMatch) return makeDlsiteCard(idMatch[1].toUpperCase());
        // product_id 없는 DLsite URL (ci-en.dlsite.com 등) → 기본 상품 카드
        const a = document.createElement('a');
        a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
        a.setAttribute(DONE_ATTR, '');
        a.className = 'b64-product-link';
        const dispHost = (h === 'dlsite.com' || h === 'www.dlsite.com') ? 'DLsite' : h;
        a.innerHTML = `<span class="bl-icon-wrap">${ICO.tag}</span>
<span class="bl-text"><span class="bl-title">${esc(dispHost)}</span><span class="bl-sub">dlsite.com</span></span>
<span class="bl-arrow">↗</span>`;
        if (CFG.DLSITE_PREVIEW) { a.dataset._dlpHooked = '1'; a.addEventListener('mouseenter', dlpShow); }
        return wrapCard(a);
      }

      // Steam URL → 상품 카드
      if (h === 'store.steampowered.com') {
        const appMatch = url.match(/\/app\/(\d+)/);
        if (appMatch) return makeSteamCard(appMatch[1]);
      }

      // Patreon URL → 상품 카드 (경로 있는 경우만)
      if (h === 'patreon.com' || h === 'www.patreon.com') {
        try { if (new URL(url).pathname.length > 1) return makePatreonCard(url); } catch(e) {}
      }

      // Getcu URL → 크리에이터 카드 (원본 URL 보존)
      if (h === 'getcu.com' || h === 'www.getcu.com') {
        const m = url.match(/\/item\/(\d+)/i);
        const code = m ? 'GC' + m[1] : 'Getcu';
        return makeGetcuCard(code, url);
      }

      // Fanza/DMM URL → 크리에이터 카드
      // dlsoft.dmm.co.jp/detail/코드/ 형식도 지원
      if (h.includes('dmm.co.jp') || h === 'fanza.jp' || h === 'www.fanza.jp') {
        // 쿼리 파라미터 cid= 방식 (www.dmm.co.jp)
        let m = url.match(/cid=d_?([a-z0-9_]+)/i);
        // 경로 방식 (dlsoft.dmm.co.jp/detail/코드/)
        if (!m) m = url.match(/\/detail\/([a-z0-9][a-z0-9_-]*[a-z0-9])\/?(?:[?#]|$)/i);
        const code = m ? 'FZ' + m[1] : 'Fanza';
        return makeFanzaCard(code);
      }

      // Fanbox URL → 크리에이터/포스트 카드 (원본 URL 보존)
      // 작가코드.fanbox.cc/posts          → 크리에이터 카드
      // 작가코드.fanbox.cc/posts/작품코드 → 포스트 카드 (작가코드 서브도메인 유지)
      if (h === 'www.fanbox.cc' || h.endsWith('.fanbox.cc')) {
        const creatorM = h.match(/^([^.]+)\.fanbox\.cc$/);
        const creator = (creatorM && creatorM[1] !== 'www') ? creatorM[1] : null;
        const postM = url.match(/\/posts\/(\d+)/i);
        if (postM) {
          // 작가 서브도메인이 있으면 작가명 표시 + 원본 URL 유지
          if (creator) return makeFanboxCard(creator, url, 'fanbox.cc · 포스트');
          return makeFanboxCard('FB' + postM[1]);
        }
        if (creator) return makeFanboxCreatorCard(creator, url);
        return makeFanboxCard('Fanbox');
      }

      // Getchu URL → 크리에이터 카드 (getchu.com/item/코드/, 원본 URL 보존)
      if (h === 'getchu.com' || h === 'www.getchu.com') {
        const m = url.match(/\/item\/([a-z0-9_-]+)\/?/i) || url.match(/[?&]id=(\d+)/i);
        const code = m ? 'GC' + m[1] : 'Getchu';
        return makeGetcuCard(code, url);
      }
    }

    // 일반 카드
    const isKio = h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac';

    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');

    if (CFG.LINK_CARD) {
      a.className = 'b64-link';
      a.innerHTML = `<span class="bl-icon-wrap">${isKio ? ICO.link : ICO.world}</span>
<span class="bl-text"><span class="bl-title">${esc(rawUrl)}</span><span class="bl-sub">${esc(h)}</span></span>
<span class="bl-arrow">↗</span>`;
    } else {
      a.textContent = rawUrl;
      a.style.cssText = 'color:#1a73e8;text-decoration:underline;word-break:break-all;';
    }

    if (CFG.LINK_CHECK) setTimeout(() => checkLink(url, a), CFG.LINK_CHECK_DELAY);
    return CFG.LINK_CARD ? wrapCard(a) : a;
  }

  /* ================================================================
     디코딩 텍스트 → DOM 노드
     URL / DLsite 코드 / Steam 코드를 별도 regex로 처리 (i 플래그 분리)
  ================================================================ */
  function buildNodes(text) {
    // 디코딩 결과 내 embedded base64 재처리 (복합 인코딩 지원)
    // base64만 필터 (DLsite/Steam/Creator 코드는 아래 기존 로직이 처리)
    const b64Hits = findAllMatches(text).filter(h => h.type === 'base64');
    if (b64Hits.length) {
      const out = [];
      let pos = 0;
      for (const h of b64Hits) {
        if (h.index > pos) out.push(...buildNodes(text.slice(pos, h.index)));
        out.push(...buildNodes(h.decoded)); // 재귀: 디코딩 결과도 동일하게 처리
        pos = h.index + h.length;
      }
      if (pos < text.length) out.push(...buildNodes(text.slice(pos)));
      return out;
    }
    // 기존 로직: URL / DLsite 코드 / Steam 코드 / 크리에이터 코드
    const matches = [];
    let m;

    // URL (대소문자 무관)
    const urlRe = /((?:https?:\/\/|www\.)[^\s<>"'()]+)/gi;
    urlRe.lastIndex = 0;
    while ((m = urlRe.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'url', raw: m[0] });

    // DLsite 코드 (라틴, 대소문자 무관)
    DLSITE_LATIN_RE.lastIndex = 0;
    while ((m = DLSITE_LATIN_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: (m[1] + m[2]).toUpperCase() });

    // DLsite 코드 (한글 IME)
    DLSITE_KR_RE.lastIndex = 0;
    while ((m = DLSITE_KR_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: 'RJ' + m[2] });

    // Steam 코드
    STEAM_CODE_RE.lastIndex = 0;
    while ((m = STEAM_CODE_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'steam', appId: m[1] });

    // 크리에이터 플랫폼 코드 (GC/FZ/FB)
    CREATOR_CODE_RE.lastIndex = 0;
    while ((m = CREATOR_CODE_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'creator', code: (m[1] + m[2]).toUpperCase() });

    // 위치순 정렬 + 중첩 제거
    matches.sort((a, b) => a.index - b.index);
    const clean = []; let end = 0;
    for (const mt of matches) {
      if (mt.index >= end) { clean.push(mt); end = mt.end; }
    }

    const nodes = []; let last = 0;
    for (const mt of clean) {
      if (mt.index > last) nodes.push(document.createTextNode(text.slice(last, mt.index)));

      if (mt.type === 'dlsite') {
        const dKey = 'dl:' + mt.code;
        const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(dKey);
        if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(dKey);
        nodes.push(CFG.LINK_CARD && !skip ? makeDlsiteCard(mt.code) : document.createTextNode(mt.code));
      } else if (mt.type === 'steam') {
        const sKey = 'st:' + mt.appId;
        const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(sKey);
        if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(sKey);
        nodes.push(CFG.LINK_CARD && !skip ? makeSteamCard(mt.appId) : document.createTextNode(`Steam ${mt.appId}`));
      } else if (mt.type === 'creator') {
        const cKey = 'cr:' + mt.code;
        const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(cKey);
        if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(cKey);
        const gcD = mt.code.startsWith('GC') ? mt.code.slice(2) : null;
        const gcSkip = gcD !== null && _gcSeenInFlush.has(gcD);
        const crEl = CFG.LINK_CARD && !skip && !gcSkip ? makeCreatorCard(mt.code) : null;
        if (crEl && gcD !== null) _gcSeenInFlush.add(gcD);
        nodes.push(crEl || document.createTextNode(mt.code));
      } else {
        // URL: 후행 구두점 제거
        let url = mt.raw;
        const trail = (url.match(/[.,;:!?)\]}'"]+$/) || [''])[0];
        if (trail) url = url.slice(0, -trail.length);
        const fullUrl = /^https?:\/\//i.test(url) ? url : 'https://' + url;
        let urlHost = '';
        try { urlHost = new URL(fullUrl).hostname; } catch(e) {}
        // 알려진 지원 사이트만 링크 카드 생성; 미지원 사이트는 일반 밑줄 링크
        // getchu.com 추가 (GC 카드), fanbox 크리에이터 서브도메인 포함
        const isKnownHost = urlHost.includes('dlsite.com') || urlHost === 'store.steampowered.com'
          || urlHost === 'patreon.com' || urlHost === 'www.patreon.com'
          || urlHost === 'getcu.com' || urlHost === 'www.getcu.com'
          || urlHost === 'getchu.com' || urlHost === 'www.getchu.com'
          || urlHost.includes('dmm.co.jp') || urlHost === 'fanza.jp' || urlHost === 'www.fanza.jp'
          || urlHost === 'www.fanbox.cc' || urlHost.endsWith('.fanbox.cc')
          || /kone\.gg$/.test(urlHost)
          || !!(CFG.PW_SITES[urlHost] || CFG.PW_SITES[urlHost.replace(/^www\./, '')])
          || !!(CFG.DEAD_PATTERNS[urlHost] || CFG.DEAD_PATTERNS[urlHost.replace(/^www\./, '')]);
        if (isKnownHost) {
          // 중복 카드 방지: 같은 flush 내 동일 URL은 두 번째부터 일반 링크로
          // Getchu URL: GC 코드 키(cr:GC...)로 정규화 + _gcSeenInFlush 교차 체크
          let lkKey = 'lk:' + fullUrl;
          let gcLkDigits = null;
          if (urlHost === 'getchu.com' || urlHost === 'www.getchu.com') {
            const gcM = fullUrl.match(/\/item\/([a-z0-9_-]+)/i) || fullUrl.match(/[?&]id=(\d+)/i);
            if (gcM) { lkKey = 'cr:GC' + gcM[1]; gcLkDigits = gcM[1]; }
          }
          const gcLkDup = gcLkDigits !== null && _gcSeenInFlush.has(gcLkDigits);
          const lkSkip = gcLkDup || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(lkKey));
          if (!lkSkip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(lkKey);
          if (!lkSkip) {
            nodes.push(makeLinkCard(url));
            if (gcLkDigits !== null) _gcSeenInFlush.add(gcLkDigits);
          } else {
            const a = document.createElement('a');
            a.href = fullUrl; a.target = '_blank'; a.rel = 'noopener noreferrer';
            a.setAttribute(DONE_ATTR, ''); a.textContent = url;
            a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
            nodes.push(a);
          }
        } else {
          const a = document.createElement('a');
          a.href = fullUrl; a.target = '_blank'; a.rel = 'noopener noreferrer';
          a.setAttribute(DONE_ATTR, '');
          a.textContent = url;
          a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
          nodes.push(a);
        }
        if (trail) nodes.push(document.createTextNode(trail));
      }

      last = mt.end;
    }
    if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
    return nodes;
  }

  /* ================================================================
     본문 텍스트 노드 처리

     중복 감지:
     - processedRaws: Map<key, WeakRef<wrap>>
     - 같은 내용이 또 들어올 때, 기존 wrap이 살아있고 같은 부모이면
       Svelte 재삽입으로 판단 → 숨김.
     - 기존 wrap이 없거나 다른 부모이면 정상 중복 → 재디코딩.
  ================================================================ */
  const processedRaws     = new Map(); // key → WeakRef<wrap>
  const processedListRaws = new Map(); // key → WeakRef<decodedSpan> (목록 노드)
  const _cardMap          = new WeakMap(); // 원본 <a> → 생성된 카드 (Svelte 재렌더링 후 카드 재생성 판단용)
  const _seenProductCodes = new Set(); // flush 내 상품 코드 중복 방지 (NO_DUPLICATE_CARD)
  const _gcSeenInFlush    = new Set(); // GC 코드·URL 교차 중복 방지 (설정 무관, 항상 적용)

  function processContentNode(node) {
    if (!node.parentNode) return;
    const raw = node.nodeValue;
    if (!raw || !raw.trim()) return;

    let anc = node.parentNode;
    while (anc) {
      if (anc.nodeType === Node.ELEMENT_NODE) {
        if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
        if (anc.nodeName === 'A') return;
        const tn = anc.nodeName;
        if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
      }
      anc = anc.parentNode;
    }

    const key = raw.trim();

    if (processedRaws.has(key)) {
      const existingWrap = processedRaws.get(key)?.deref();
      // 같은 부모 + wrap이 살아있음 = Svelte/React 재삽입 → 숨김
      // 다른 부모(다른 섹션/독립 단락) = 별도 위치 → 재디코딩 허용
      const isSvelte = existingWrap &&
                       document.contains(existingWrap) &&
                       existingWrap.parentNode === node.parentNode;
      if (isSvelte) {
        const hide = document.createElement('span');
        hide.setAttribute(DONE_ATTR, '');
        hide.style.cssText = 'display:none!important;';
        hide.textContent = raw;
        node.parentNode.replaceChild(hide, node);
        return;
      }
      // wrap이 없거나 다른 부모(다른 섹션) → 재디코딩
      processedRaws.delete(key);
    }

    const hits = findAllMatches(raw);
    if (!hits.length) return;

    const frag = document.createDocumentFragment();
    let last = 0;
    for (const hit of hits) {
      const { index, length, type } = hit;
      if (index > last) frag.appendChild(document.createTextNode(raw.slice(last, index)));

      if (type === 'dlsite') {
        // 중복 방지: 같은 flush 내에서 동일 코드가 두 번 이상 나오면 두 번째부터 텍스트로
        const dKey = 'dl:' + hit.code;
        const dSkip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(dKey);
        if (!dSkip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(dKey);
        frag.appendChild(CFG.LINK_CARD && !dSkip ? makeDlsiteCard(hit.code) : document.createTextNode(hit.decoded));
      } else if (type === 'steam') {
        const sKey = 'st:' + hit.appId;
        const sSkip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(sKey);
        if (!sSkip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(sKey);
        frag.appendChild(CFG.LINK_CARD && !sSkip ? makeSteamCard(hit.appId) : document.createTextNode(hit.decoded));
      } else if (type === 'creator') {
        const cKey = 'cr:' + hit.code;
        const cSkip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(cKey);
        if (!cSkip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(cKey);
        const gcD = hit.code.startsWith('GC') ? hit.code.slice(2) : null;
        const gcSkipC = gcD !== null && _gcSeenInFlush.has(gcD);
        const crElC = CFG.LINK_CARD && !cSkip && !gcSkipC ? makeCreatorCard(hit.code) : null;
        if (crElC && gcD !== null) _gcSeenInFlush.add(gcD);
        frag.appendChild(crElC || document.createTextNode(hit.decoded));
      } else {
        // base64 디코딩 결과 → URL/코드 탐색 후 카드 생성
        buildNodes(hit.decoded).forEach(n => frag.appendChild(n));
      }

      last = index + length;
    }
    if (last < raw.length) frag.appendChild(document.createTextNode(raw.slice(last)));

    const wrap = document.createElement('span');
    wrap.setAttribute(DONE_ATTR, '');
    wrap.setAttribute(RAW_ATTR, raw);
    wrap.style.cssText = 'all:unset;display:contents;';
    wrap.appendChild(frag);

    processedRaws.set(key, new WeakRef(wrap));
    node.parentNode.replaceChild(wrap, node);
  }

  /* ================================================================
     목록 텍스트 노드 처리
  ================================================================ */
  function processListNode(node) {
    const raw = node.nodeValue;
    if (!raw || !raw.trim()) return;
    const p = node.parentNode;
    if (!p) return;
    if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute(LTDONE_ATTR)) return;
    let anc = p;
    while (anc) {
      if (anc.nodeType === Node.ELEMENT_NODE) {
        if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
        const tn = anc.nodeName;
        if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
      }
      anc = anc.parentNode;
    }

    const key = raw.trim();

    // processContentNode가 같은 내용을 이미 본문에 디코딩했으면 목록 디코딩 스킵
    if (processedRaws.has(key)) {
      const contentWrap = processedRaws.get(key)?.deref();
      if (contentWrap && document.contains(contentWrap)) return;
    }

    if (processedListRaws.has(key)) {
      const existingSpan = processedListRaws.get(key)?.deref();
      const isSvelte = existingSpan &&
                       document.contains(existingSpan) &&
                       existingSpan.parentNode === p;
      if (isSvelte) {
        const hide = document.createElement('span');
        hide.setAttribute(LTDONE_ATTR, '');
        hide.style.cssText = 'display:none!important;';
        hide.textContent = raw;
        p.replaceChild(hide, node);
        return;
      }
      processedListRaws.delete(key);
    }

    const hits = findAllMatches(raw);
    if (!hits.length) return;

    // 목록 페이지: RJ/Steam 코드는 인라인 카드로 만들지 않음 (썸네일이 대신 표시)
    // 텍스트만 변환 (base64 / 점자)
    let result = '', last = 0;
    for (const { index, length, decoded } of hits) {
      result += raw.slice(last, index) + decoded;
      last = index + length;
    }
    result += raw.slice(last);
    if (result === raw) return;

    const hideWrap = document.createElement('span');
    hideWrap.setAttribute(LTDONE_ATTR, 'src');
    hideWrap.style.cssText = 'display:none!important;';
    hideWrap.textContent = raw;

    const decodedSpan = document.createElement('span');
    decodedSpan.setAttribute(LTDONE_ATTR, '');
    decodedSpan.style.cssText = 'all:unset;';
    decodedSpan.textContent = result;

    processedListRaws.set(key, new WeakRef(decodedSpan));
    p.insertBefore(hideWrap, node);
    p.insertBefore(decodedSpan, node);
    p.removeChild(node);
  }

  /* ================================================================
     공통 walker
  ================================================================ */
  function walkAndProcess(root, handler) {
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode(node) {
        const p = node.parentNode;
        if (!p) return NodeFilter.FILTER_REJECT;
        const tag = p.nodeName;
        if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA') return NodeFilter.FILTER_REJECT;
        if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        let anc = p;
        while (anc && anc !== root) {
          if (anc.nodeType === Node.ELEMENT_NODE) {
            // contenteditable="true" 영역(Froala 에디터 등) 내부는 절대 처리하지 않음
            if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT;
            if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
            const tn = anc.nodeName;
            if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
          }
          anc = anc.parentNode;
        }
        return NodeFilter.FILTER_ACCEPT;
      },
    });
    const nodes = []; let n;
    while ((n = walker.nextNode())) nodes.push(n);
    nodes.forEach(handler);
  }

  /* ================================================================
     인접 블록 합산 디코딩
     walkAndProcess 보다 먼저 실행.
     이유: walkAndProcess가 먼저 실행되면 개별 디코딩된 요소에 DONE_ATTR가 붙어
           원본 base64 조각 정보가 사라지므로, 합산 시도 자체가 불가능해짐.
     전략: 개별 디코딩이 불가능한(분할된) base64 줄만 그룹에 포함,
           개별 디코딩 가능한 줄은 제외해 walkAndProcess에 위임.
  ================================================================ */
  function trySiblingMerge(root) {
    const BLOCK = new Set(['P','DIV','LI','BLOCKQUOTE','DD','DT']);

    function isUnprocessed(el) {
      return !el.querySelector('[' + DONE_ATTR + '], [' + LTDONE_ATTR + ']');
    }

    // 텍스트가 60%+ base64 문자로 구성인지 확인 (순수 한국어 단락 오합산 방지)
    // 한국어 접두사 + base64 혼합이나 공백 포함 base64도 통과할 수 있도록 여유 있게 설정
    function looksB64ish(txt) {
      const c = txt.replace(/\s/g, '');
      if (c.length < MIN_B64) return false;
      let n = 0;
      for (let i = 0; i < c.length; i++) {
        const ch = c[i];
        if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
            || ch === '+' || ch === '/' || ch === '-' || ch === '_' || ch === '=') n++;
      }
      return n / c.length >= 0.6;
    }

    function applyMerge(els) {
      if (els.length < 2) return;
      const parts = els.map(e => (e.textContent || '').trim()).filter(Boolean);
      const combined = parts.join('\n'); // \n: 개별 라인 구분 유지 → 각 라인 독립 매칭 보장
      const stripped = combined.replace(/\s+/g, ''); // 줄바꿈 제거 → 분할 base64 합산
      let hits = findAllMatches(combined); let src = combined;
      if (!hits.some(h => h.type === 'base64') && stripped.length >= MIN_B64) {
        const h2 = findAllMatches(stripped);
        if (h2.some(h => h.type === 'base64')) { hits = h2; src = stripped; }
      }
      if (!hits.some(h => h.type === 'base64')) return;
      let res = '', p = 0;
      for (const { index, length, decoded } of hits) { res += src.slice(p, index) + decoded; p = index + length; }
      res += src.slice(p);
      const wrap = document.createElement('span');
      wrap.setAttribute(DONE_ATTR, ''); wrap.style.cssText = 'all:unset;display:contents;';
      buildNodes(res).forEach(nd => wrap.appendChild(nd));
      while (els[0].firstChild) els[0].removeChild(els[0].firstChild);
      els[0].appendChild(wrap);
      for (let i = 1; i < els.length; i++) els[i].remove();
    }

    function scan(parent) {
      let run = [];
      for (const ch of Array.from(parent.children)) {
        const txt = (ch.textContent || '').trim();
        // 그룹 포함 조건: 블록 요소 + 미처리 + base64ish + 개별 디코딩 불가
        // (개별 디코딩 가능한 건 walkAndProcess에 위임)
        const eligible = BLOCK.has(ch.nodeName) && isUnprocessed(ch) && looksB64ish(txt)
          && !findAllMatches(txt).some(h => h.type === 'base64');
        if (eligible) {
          run.push(ch);
          if (run.length >= 8) { applyMerge(run); run = []; } // 과도한 합산 방지
        } else {
          applyMerge(run); run = [];
          if (isUnprocessed(ch)) scan(ch);
        }
      }
      applyMerge(run);
    }

    scan(root);
  }

  /* ================================================================
     드래그 자동 변환
  ================================================================ */
  let dragTooltip = null;
  // 드래그 툴팁 제거 시 내부 카드에 열려 있던 미리보기도 같이 닫음
  function removeDragTooltip() {
    if (dragTooltip) {
      // 툴팁 안의 카드에서 미리보기가 열려 있으면 함께 닫기
      if (dlp.showing) dlpHide();
      dragTooltip.remove();
      dragTooltip = null;
    }
  }

  document.addEventListener('mouseup', (e) => {
    if (!CFG.DRAG_DECODE) return;
    if (isWritePage()) return;
    // 툴팁 안을 클릭한 경우 닫지 않음 (카드 링크 클릭 보호)
    if (dragTooltip && dragTooltip.contains(e.target)) return;
    removeDragTooltip();
    const sel = window.getSelection();
    if (!sel || sel.isCollapsed) return;
    const text = sel.toString().trim();
    if (!text) return;

    // ── 본문 인-플레이스 교체 (팝업과 동일한 디코딩, DOM 직접 교체) ──
    if (sel.rangeCount) {
      const r = sel.getRangeAt(0);
      // text = sel.toString().trim() 이므로 팝업과 완전히 동일한 소스 사용
      let ipHits = findAllMatches(text);
      let ipSrc = text;
      if (!ipHits.length) {
        const stripped = text.replace(/[\r\n\s]+/g, '');
        if (stripped.length >= MIN_B64 && stripped !== text) {
          const h2 = findAllMatches(stripped);
          if (h2.length) { ipHits = h2; ipSrc = stripped; }
        }
      }
      // base64 디코딩 히트가 있을 때만 인-플레이스 교체 (DLsite/Steam 텍스트 코드는 팝업으로)
      if (ipHits.some(h => h.type === 'base64')) {
        let resultText = '', pos = 0;
        for (const { index, length, decoded } of ipHits) {
          resultText += ipSrc.slice(pos, index) + decoded;
          pos = index + length;
        }
        resultText += ipSrc.slice(pos);
        const rNodes = buildNodes(resultText);
        const wrap = document.createElement('span');
        wrap.setAttribute(DONE_ATTR, '');
        wrap.style.cssText = 'all:unset;display:contents;';
        rNodes.forEach(nd => wrap.appendChild(nd));
        try { r.deleteContents(); r.insertNode(wrap); sel.removeAllRanges(); return; } catch(_) {}
      }
    }

    // ── 팝업 표시 (인-플레이스 불가 시) ──
    // 줄바꿈 제거 후 통합 base64 시도 (여러 줄로 분리된 경우 대응)
    let hits = findAllMatches(text);
    let effectiveText = text;
    if (!hits.length) {
      const stripped = text.replace(/[\r\n\s]+/g, '');
      if (stripped !== text && stripped.length >= MIN_B64) {
        const h2 = findAllMatches(stripped);
        if (h2.length) { hits = h2; effectiveText = stripped; }
      }
    }
    if (!hits.length) return;

    // 디코딩 결과 텍스트 재조립
    let result = '', last = 0;
    for (const { index, length, decoded } of hits) { result += effectiveText.slice(last, index) + decoded; last = index + length; }
    result += effectiveText.slice(last);

    // 상품 코드(DLsite/Steam) 추출
    const productHits = hits.filter(h => h.type === 'dlsite' || h.type === 'steam');
    const hasNewText  = result !== effectiveText;
    if (!hasNewText && !productHits.length) return;

    const range = sel.getRangeAt(0);
    const rect  = range.getBoundingClientRect();
    dragTooltip = document.createElement('div');
    dragTooltip.id = 'b64d-drag-tooltip';

    // 텍스트 변환 결과
    if (hasNewText) {
      const textEl = document.createElement('div');
      textEl.className = 'b64d-drag-text';
      textEl.textContent = result;
      dragTooltip.appendChild(textEl);
    }

    // 상품 카드
    if (productHits.length) {
      const seen = new Set();
      const cardsWrap = document.createElement('div');
      cardsWrap.className = 'b64d-drag-cards';
      for (const hit of productHits) {
        const id = hit.type === 'dlsite' ? hit.code : hit.appId;
        if (seen.has(id)) continue;
        seen.add(id);
        cardsWrap.appendChild(
          hit.type === 'dlsite' ? makeDlsiteCard(hit.code) : makeSteamCard(hit.appId)
        );
      }
      dragTooltip.appendChild(cardsWrap);
    }

    dragTooltip.style.top  = `${rect.bottom + window.scrollY + 6}px`;
    dragTooltip.style.left = `${rect.left + window.scrollX}px`;
    document.body.appendChild(dragTooltip);
  });
  document.addEventListener('mousedown', (e) => {
    if (dragTooltip && dragTooltip.contains(e.target)) return;
    removeDragTooltip();
  });
  document.addEventListener('keydown', removeDragTooltip);

  /* ================================================================
     미리보기 팝업 (DLsite 및 Steam 공통)
     키보드 a/d 또는 ← → 로 이미지 전환, 휠 스크롤 가능
  ================================================================ */
  let _dlpRestore = null, _dlpRestoreTimer = null;
  const dlp = { el: null, images: [], idx: 0, showing: false, srcWatcher: null, chipsEl: null, panelEl: null, navEl: null, site: null, blobUrls: null, _pending: null, href: null };

  function dlpUpdateImage() {
    if (!dlp.el || !dlp.images.length) return;
    const newSrc = dlp.images[dlp.idx];

    // 진행 중인 교체 프리로드 취소
    if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }

    function hookErr(el) {
      el.onerror = () => {
        if (!dlp.el) return;
        dlp.images.splice(dlp.idx, 1);
        if (dlp.idx >= dlp.images.length) dlp.idx = Math.max(0, dlp.images.length - 1);
        if (dlp.images.length) { dlpUpdateImage(); return; }
        dlp.el.innerHTML = '';
        const errDiv = document.createElement('div');
        errDiv.className = 'dlp-err'; errDiv.textContent = '이미지를 불러올 수 없습니다.';
        dlp.el.appendChild(errDiv);
        if (dlp.panelEl && dlp.chipsEl?.children.length) dlp.el.appendChild(dlp.panelEl);
      };
    }
    function updatePanel() {
      if (!dlp.panelEl) return;
      if (dlp.navEl) dlp.navEl.textContent = dlp.images.length > 1 ? `${dlp.idx+1}/${dlp.images.length}  ←→ a·d` : '';
      dlp.el.appendChild(dlp.panelEl);
    }

    let img = dlp.el.querySelector('img');
    if (!img) {
      // 최초 호출: 스피너 제거 후 img 생성
      dlp.el.innerHTML = '';
      img = document.createElement('img');
      if (dlp.site !== 'getchu') img.referrerPolicy = 'no-referrer';
      dlp.el.appendChild(img);
      hookErr(img);
      img.src = newSrc;
      updatePanel();
      return;
    }

    if (img.src === newSrc) { updatePanel(); return; }

    // URL이 다를 때: DOM 밖에서 미리 로드 후 원자적 교체 → 로딩 화면 없음
    const next = document.createElement('img');
    if (dlp.site !== 'getchu') next.referrerPolicy = 'no-referrer';
    dlp._pending = next;
    next.onload = () => {
      if (!dlp.el || dlp._pending !== next) return;
      dlp._pending = null;
      if (dlp.el.contains(img)) dlp.el.replaceChild(next, img); else dlp.el.appendChild(next);
      hookErr(next);
      updatePanel();
    };
    next.onerror = () => {
      if (!dlp.el || dlp._pending !== next) return;
      dlp._pending = null;
      dlp.images.splice(dlp.idx, 1);
      if (dlp.idx >= dlp.images.length) dlp.idx = Math.max(0, dlp.images.length - 1);
      if (dlp.images.length) { dlpUpdateImage(); return; }
      dlp.el.innerHTML = '';
      const errDiv = document.createElement('div');
      errDiv.className = 'dlp-err'; errDiv.textContent = '이미지를 불러올 수 없습니다.';
      dlp.el.appendChild(errDiv);
      if (dlp.panelEl && dlp.chipsEl?.children.length) dlp.el.appendChild(dlp.panelEl);
    };
    next.src = newSrc;
  }

  function dlpMove(e) {
    if (!dlp.el) return;
    const pw = dlp.el.offsetWidth  || 50;
    const ph = dlp.el.offsetHeight || 50;
    const x = (e.clientX + 16 + pw > window.innerWidth)  ? Math.max(0, e.clientX - pw - 16) : e.clientX + 16;
    const y = (e.clientY + 16 + ph > window.innerHeight) ? Math.max(0, e.clientY - ph - 16) : e.clientY + 16;
    dlp.el.style.left = `${x}px`;
    dlp.el.style.top  = `${y}px`;
  }

  function dlpHide() {
    // 카드 DOM 교체 시(Svelte 재렌더 등) 빠른 재표시를 위해 미리보기 상태 임시 보존 (600ms)
    if (dlp.showing && dlp.href && dlp.images.length > 0) {
      clearTimeout(_dlpRestoreTimer);
      // Getchu blob URL은 revoke하지 않고 _dlpRestore에 이관 (타이머 만료 시 revoke)
      const savedBlobs = (dlp.site === 'getchu' && dlp.blobUrls) ? dlp.blobUrls : null;
      if (savedBlobs) dlp.blobUrls = null;
      const chips = dlp.chipsEl ? [...dlp.chipsEl.children].map(c => ({ text: c.textContent, cls: c.className })) : [];
      _dlpRestore = { images: dlp.images.slice(), idx: dlp.idx, href: dlp.href, site: dlp.site, chips, blobUrls: savedBlobs };
      _dlpRestoreTimer = setTimeout(() => {
        if (_dlpRestore?.blobUrls) _dlpRestore.blobUrls.forEach(u => URL.revokeObjectURL(u));
        _dlpRestore = null;
      }, 600);
    }
    if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }
    if (dlp.srcWatcher) { dlp.srcWatcher.disconnect(); dlp.srcWatcher = null; }
    if (dlp.el) { dlp.el.remove(); dlp.el = null; }
    if (dlp.blobUrls) { dlp.blobUrls.forEach(u => URL.revokeObjectURL(u)); dlp.blobUrls = null; }
    dlp.showing = false; dlp.images = []; dlp.idx = 0; dlp.href = null;
    dlp.chipsEl = null; dlp.panelEl = null; dlp.navEl = null; dlp.site = null;
    document.removeEventListener('mousemove', dlpMove);
    document.removeEventListener('keydown', dlpKey);
  }

  function dlpKey(e) {
    if (!dlp.showing || dlp.images.length <= 1) return;
    if (document.getElementById('b64d-settings') || document.getElementById('b64-dup-overlay')) return;
    if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; // Shift+D 등 수식키 조합은 단축키로 처리
    if (e.key === 'ArrowLeft'  || e.code === 'KeyA') { e.preventDefault(); dlp.idx = (dlp.idx - 1 + dlp.images.length) % dlp.images.length; dlpUpdateImage(); }
    if (e.key === 'ArrowRight' || e.code === 'KeyD') { e.preventDefault(); dlp.idx = (dlp.idx + 1) % dlp.images.length; dlpUpdateImage(); }
  }

  function dlpShow(e) {
    if (!CFG.DLSITE_PREVIEW) return;
    if (dlp.showing) dlpHide();
    dlp.showing = true;
    dlp.el = document.createElement('div');
    dlp.el.id = 'b64d-dlsite-preview';
    dlp.el.setAttribute('data-b64d', '');  // MO schedule() 재트리거 방지
    dlp.el.innerHTML = '<div class="dlp-spinner"></div>';
    // 패널 구조 (persistent: innerHTML 초기화 후 재부착)
    dlp.panelEl = document.createElement('div'); dlp.panelEl.className = 'dlp-panel';
    dlp.chipsEl = document.createElement('div'); dlp.chipsEl.className = 'dlp-chips';
    dlp.navEl   = document.createElement('div'); dlp.navEl.className   = 'dlp-nav';
    dlp.panelEl.appendChild(dlp.chipsEl);
    dlp.panelEl.appendChild(dlp.navEl);
    document.body.appendChild(dlp.el);
    dlpMove(e);
    document.addEventListener('mousemove', dlpMove);
    document.addEventListener('keydown', dlpKey);
    dlp.el.addEventListener('wheel', we => {
      we.preventDefault();
      if (dlp.images.length <= 1) return;
      dlp.idx = we.deltaY > 0 ? (dlp.idx+1)%dlp.images.length : (dlp.idx-1+dlp.images.length)%dlp.images.length;
      dlpUpdateImage();
    }, { passive: false });

    const thisEl   = dlp.el;
    const href     = e.currentTarget.href;
    dlp.href = href; // dlpHide 시 상태 보존용
    const cardAnchor = e.currentTarget; // work→announce 폴백 시 href 업데이트용
    const steamMatch = href && href.match(/store\.steampowered\.com\/app\/(\d+)/);
    const getchuMatch = href && href.includes('getchu.com/');
    const cienMatch   = href && href.includes('ci-en.dlsite.com/');

    // 카드 DOM 교체 시 이전 위치·메타 즉시 복원 (Svelte 재렌더 등 600ms 이내 재호출 대비)
    let skipAsync = false;
    if (_dlpRestore) {
      if (_dlpRestore.href === href) {
        clearTimeout(_dlpRestoreTimer);
        dlp.images = _dlpRestore.images;
        dlp.idx    = _dlpRestore.idx;
        dlp.site   = _dlpRestore.site;
        if (_dlpRestore.blobUrls) dlp.blobUrls = _dlpRestore.blobUrls; // Getchu blob 재활용
        // 칩(별점·판매수·날짜) 즉시 복원 — 상세정보 깜빡임 방지
        for (const { text, cls } of _dlpRestore.chips) {
          const c = document.createElement('span'); c.className = cls; c.textContent = text;
          dlp.chipsEl.appendChild(c);
        }
        if (_dlpRestore.chips.length) dlp.panelEl?.classList.add('has-content');
        dlpUpdateImage(); // 캐시에서 즉시 이전 위치 표시
        skipAsync = true; // 비동기 재요청 불필요 (이미지·메타 모두 유효)
      } else {
        clearTimeout(_dlpRestoreTimer);
        if (_dlpRestore.blobUrls) _dlpRestore.blobUrls.forEach(u => URL.revokeObjectURL(u));
      }
      _dlpRestore = null;
    }

    // 공통 에러 표시 헬퍼
    const showErr = msg => {
      if (thisEl !== dlp.el) return;
      dlp.el.innerHTML = '';
      const d = document.createElement('div');
      d.className = 'dlp-err';
      d.textContent = msg;
      dlp.el.appendChild(d);
      if (dlp.panelEl && dlp.chipsEl?.children.length) dlp.el.appendChild(dlp.panelEl);
    };
    // 메타 칩 추가 헬퍼
    const addChip = (text, cls) => {
      if (!dlp.chipsEl || !text) return;
      const c = document.createElement('span');
      c.className = 'dlp-chip' + (cls ? ' ' + cls : '');
      c.textContent = text;
      dlp.chipsEl.appendChild(c);
      dlp.panelEl?.classList.add('has-content');
    };

    if (skipAsync) {
      // 복원 완료 — 비동기 재로딩 건너뜀 (Steam/DLsite/Getchu 공통)
    } else if (steamMatch) {
      // ── Steam: 스크린샷 + 출시일 + 리뷰 ──
      dlp.site = 'steam';
      const setMeta = (key, val) => {
        if (!dlp.chipsEl) return;
        dlp.chipsEl.dataset[key] = val;
        dlp.chipsEl.innerHTML = '';
        if (dlp.chipsEl.dataset.review) addChip(dlp.chipsEl.dataset.review, 'dlp-chip-review');
        if (dlp.chipsEl.dataset.date)   addChip(dlp.chipsEl.dataset.date, 'dlp-chip-date');
      };
      const steamFilters = CFG.PREVIEW_META ? 'screenshots,release_date' : 'screenshots';
      GM_xmlhttpRequest({
        method: 'GET',
        url: `https://store.steampowered.com/api/appdetails?appids=${steamMatch[1]}&filters=${steamFilters}`,
        timeout: 5000,
        onload(res) {
          if (thisEl !== dlp.el) return;
          try {
            const data = JSON.parse(res.responseText);
            const info = data[steamMatch[1]];
            if (info?.success && info.data?.screenshots?.length) {
              dlp.images = info.data.screenshots.map(s => s.path_thumbnail || s.path_full);
              dlp.idx = 0;
              dlpUpdateImage();
            } else {
              showErr('스크린샷 없음');
            }
            if (CFG.PREVIEW_META && info?.data?.release_date?.date) setMeta('date', info.data.release_date.date);
          } catch { showErr('미리보기 로드 실패'); }
        },
        onerror()   { showErr('연결 실패'); },
        ontimeout() { showErr('응답 없음'); },
      });
      if (CFG.PREVIEW_META) GM_xmlhttpRequest({
        method: 'GET',
        url: `https://store.steampowered.com/appreviews/${steamMatch[1]}?json=1&language=all`,
        timeout: 5000,
        onload(res) {
          if (thisEl !== dlp.el) return;
          try {
            const s = JSON.parse(res.responseText)?.query_summary;
            if (s?.review_score_desc) {
              setMeta('review', `${s.review_score_desc} (${(s.total_positive||0).toLocaleString()}/${(s.total_reviews||0).toLocaleString()})`);
            }
          } catch {}
        },
      });
    } else if (getchuMatch) {
      // ── Getchu: 페이지 파싱 → 이미지 GM_xmlhttpRequest 프록시(Referer 우회) ──
      dlp.site = 'getchu';
      dlp.blobUrls = [];
      const gcIdMatch = href.match(/\/item\/(\d+)|[?&]id=(\d+)/);
      const gcId = gcIdMatch?.[1] || gcIdMatch?.[2];
      const fetchUrl = gcId ? `https://www.getchu.com/soft.phtml?id=${gcId}` : href;
      GM_xmlhttpRequest({
        method: 'GET', url: fetchUrl,
        headers: { 'Referer': 'https://www.getchu.com/' },
        timeout: 10000,
        onload(res) {
          if (thisEl !== dlp.el) return;
          const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
          const pairs = []; // [{full, thumb}]
          doc.querySelectorAll('a.highslide').forEach(a => {
            const im = a.querySelector('img');
            let full = a.getAttribute('href') || '';
            if (!full || !/\.(jpe?g|png|gif|webp)/i.test(full)) {
              full = im ? (im.getAttribute('src') || '').replace(/_s(\.(jpe?g|png|gif|webp))$/i, '$1') : '';
            }
            if (!full) return;
            if (full.startsWith('/')) full = 'https://www.getchu.com' + full;
            if (!/^https?:/.test(full)) return;
            let thumb = im ? (im.getAttribute('src') || '') : full;
            if (thumb.startsWith('/')) thumb = 'https://www.getchu.com' + thumb;
            if (!pairs.some(p => p.full === full)) pairs.push({ full, thumb });
          });
          pairs.sort((a, b) => (b.full.includes('package') ? 1 : 0) - (a.full.includes('package') ? 1 : 0));
          if (!pairs.length) { showErr('이미지를 찾을 수 없습니다.'); return; }

          const rawUrls = pairs.map(p => p.full);
          const blobSlots = new Array(rawUrls.length).fill(null); // null=대기, false=실패, string=blob URL
          let doneCount = 0;
          let thumbShown = false;

          const maybeUpdate = () => {
            if (thisEl !== dlp.el) return;
            const avail = blobSlots.filter(u => typeof u === 'string');
            // 풀사이즈가 생겼거나, 썸네일만 표시 중이었다면 교체
            if (avail.length > 0 && (avail.length > dlp.images.length || thumbShown)) {
              dlp.images = avail; dlp.idx = Math.min(dlp.idx, Math.max(0, avail.length - 1));
              thumbShown = false;
              dlpUpdateImage();
            }
            if (doneCount === rawUrls.length && !dlp.images.length) showErr('이미지를 불러올 수 없습니다.');
          };

          // 첫 번째 이미지 썸네일 빠른 표시 (소용량, Referer 우회)
          const firstThumb = pairs[0].thumb;
          const firstFull  = pairs[0].full;
          if (firstThumb !== firstFull) {
            GM_xmlhttpRequest({
              method: 'GET', url: firstThumb,
              responseType: 'arraybuffer',
              headers: { 'Referer': 'https://www.getchu.com/' },
              timeout: 8000,
              onload(r) {
                // 풀사이즈가 이미 도착했으면 스킵
                if (thisEl !== dlp.el || blobSlots[0] !== null) return;
                const ct = (r.responseHeaders||'').match(/content-type:\s*([^\r\n;]+)/i)?.[1]?.trim() || 'image/jpeg';
                const burl = URL.createObjectURL(new Blob([r.response], { type: ct }));
                if (dlp.blobUrls) dlp.blobUrls.push(burl);
                if (!dlp.images.length) {
                  dlp.images = [burl]; dlp.idx = 0;
                  thumbShown = true;
                  dlpUpdateImage();
                }
              },
            });
          }

          // 풀사이즈 전체 동시 다운로드
          rawUrls.forEach((url, i) => {
            GM_xmlhttpRequest({
              method: 'GET', url,
              responseType: 'arraybuffer',
              headers: { 'Referer': 'https://www.getchu.com/' },
              timeout: 12000,
              onload(r) {
                if (thisEl !== dlp.el) { blobSlots[i] = false; doneCount++; return; }
                const ct = (r.responseHeaders||'').match(/content-type:\s*([^\r\n;]+)/i)?.[1]?.trim() || 'image/jpeg';
                const burl = URL.createObjectURL(new Blob([r.response], { type: ct }));
                blobSlots[i] = burl;
                if (dlp.blobUrls) dlp.blobUrls.push(burl);
                doneCount++; maybeUpdate();
              },
              onerror()  { blobSlots[i] = false; doneCount++; maybeUpdate(); },
              ontimeout(){ blobSlots[i] = false; doneCount++; maybeUpdate(); },
            });
          });
        },
        onerror()   { showErr('미리보기 로드 실패'); },
        ontimeout() { showErr('응답 없음'); },
      });
    } else if (cienMatch) {
      // ── Ci-en (ci-en.dlsite.com) ──
      dlp.site = 'cien';
      GM_xmlhttpRequest({
        method: 'GET', url: href,
        headers: { 'Referer': 'https://ci-en.dlsite.com/' },
        timeout: 10000,
        onload(res) {
          if (thisEl !== dlp.el) return;
          const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
          const seen = new Set();
          const imgs = [];
          const push = u => { if (u && u.startsWith('http') && !seen.has(u)) { seen.add(u); imgs.push(u); } };

          // 1. SSR JSON 스크립트 내 URL 추출 (Nuxt __NUXT_DATA__ 등)
          for (const s of doc.querySelectorAll('script')) {
            const txt = s.textContent || '';
            if (!txt.includes('media.ci-en.jp')) continue;
            for (const m of txt.matchAll(/https:\\?\/\\?\/media\.ci-en\.jp\\?\/[^"'\s\\]+\.(?:jpg|jpeg|png|gif|webp)[^"'\s\\]*/gi)) {
              push(m[0].replace(/\\\/|\\u002[Ff]/g, '/').split('"')[0]);
            }
          }

          // 2. img src / data-src
          doc.querySelectorAll('img[src*="media.ci-en.jp"], img[data-src*="media.ci-en.jp"], [data-src*="media.ci-en.jp"]').forEach(el => {
            push(el.getAttribute('src') || '');
            push(el.dataset?.src || '');
          });

          // private attachment 우선, 없으면 article_cover
          const attachments = imgs.filter(u => u.includes('/attachment/'));
          const covers      = imgs.filter(u => u.includes('/article_cover/'));
          const final = attachments.length ? attachments : covers;

          if (final.length) {
            dlp.images = final; dlp.idx = 0; dlpUpdateImage();
          } else {
            showErr('이미지 없음 (비공개 게시물이거나 로그인 필요)');
          }
        },
        onerror()   { showErr('미리보기 로드 실패'); },
        ontimeout() { showErr('응답 없음'); },
      });
    } else {
      // ── DLsite ──
      dlp.site = 'dlsite';
      let smpActive = true;
      let smpStarted = false;

      const dlsiteCodeMatch = href.match(/product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i);
      const sectionMatch    = href.match(/dlsite\.com\/(\w+)\//);
      const code    = dlsiteCodeMatch ? dlsiteCodeMatch[1].toUpperCase() : null;
      const section = sectionMatch    ? sectionMatch[1]                  : null;

      // smp probe — 호출측(guess 또는 info API)에 관계없이 최초 1회만 실행
      const startSmpProbes = (workImgUrl) => {
        if (smpStarted) return;
        smpStarted = true;
        if (!smpActive || !workImgUrl || thisEl !== dlp.el) return;
        const smpStem = workImgUrl.replace(/_img_main(\.\w+)$/, '_img_smp');
        const smpExt  = (workImgUrl.match(/_img_main(\.\w+)$/) || ['', '.jpg'])[1];
        if (smpStem === workImgUrl) return;
        const SMP_MAX = 12;
        const smpSlots = new Array(SMP_MAX).fill(null);
        const addSmpOrdered = () => {
          if (!smpActive || thisEl !== dlp.el) return;
          for (let i = 0; i < SMP_MAX; i++) {
            if (smpSlots[i] === null) break;
            if (smpSlots[i] === true) {
              const u = `${smpStem}${i + 1}${smpExt}`;
              if (!dlp.images.includes(u)) dlp.images.push(u);
            }
          }
          if (dlp.navEl)
            dlp.navEl.textContent = dlp.images.length > 1 ? `${dlp.idx+1}/${dlp.images.length}  ←→ a·d` : '';
        };
        for (let i = 0; i < SMP_MAX; i++) {
          const probe = new Image();
          probe.referrerPolicy = 'no-referrer';
          probe.onload  = () => { smpSlots[i] = true;  addSmpOrdered(); };
          probe.onerror = () => { smpSlots[i] = false; addSmpOrdered(); };
          probe.src = `${smpStem}${i + 1}${smpExt}`;
        }
      };

      // ── FAST PATH: CDN URL 즉시 예측 (API 응답 없이 ~0.5s) ──
      // img.dlsite.jp/modpub/images2/work/{cat}/{prefix}{floor/1000*1000}/{code}_img_main.jpg
      if (code) {
        const codeNum    = parseInt(code.replace(/^[A-Z]+/, ''), 10);
        const codePrefix = code.replace(/\d+$/, '');
        const codePad    = String(codeNum).padStart(8, '0');
        const dirPad     = codePrefix + String(Math.floor(codeNum / 1000) * 1000).padStart(8, '0');
        let guessShown   = false;
        for (const cat of ['doujin', 'professional', 'translation', 'books']) {
          const guessUrl = `https://img.dlsite.jp/modpub/images2/work/${cat}/${dirPad}/${codePrefix}${codePad}_img_main.jpg`;
          const p = new Image();
          p.referrerPolicy = 'no-referrer';
          p.onload = () => {
            if (guessShown || thisEl !== dlp.el) return;
            guessShown = true;
            if (!dlp.images.length) { dlp.images = [guessUrl]; dlp.idx = 0; dlpUpdateImage(); }
            startSmpProbes(guessUrl);
          };
          p.src = guessUrl;
        }
      }

      // ── HTML 페이지 (확정 이미지 리스트, work→announce 자동 폴백) ──
      const isWorkUrl = href.includes('/work/=/');
      const fetchDlsiteHtml = (url, isRetry) => {
        GM_xmlhttpRequest({
          method: 'GET', url,
          headers: { 'User-Agent': navigator.userAgent, 'Referer': 'https://www.dlsite.com/' },
          timeout: 15000,
          onload(res) {
            if (thisEl !== dlp.el) return;
            // work URL 4xx → announce 재시도 (smpActive 유지)
            if (!isRetry && isWorkUrl && res.status >= 400) {
              fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
              return;
            }
            smpActive = false;
            const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
            const imgs = [];
            for (const sel of ['.product-slider-data > div[data-src]', '.main-image-slider img', '.product-slider img', 'img[src*="img.dlsite.jp"]:not([src*="/resize/"])', 'img[src*="img.dlsite.com"]']) {
              doc.querySelectorAll(sel).forEach(el => {
                const rawSrc = el.dataset.src || el.getAttribute('src') || '';
                const src = rawSrc.startsWith('//') ? 'https:' + rawSrc : rawSrc;
                if (src && /^https?:\/\//.test(src) && !src.includes('/resize/') && !imgs.includes(src)) imgs.push(src);
              });
              if (imgs.length) break;
            }
            if (imgs.length) {
              // announce 폴백 성공 → 카드 링크도 announce URL로 갱신
              if (isRetry) try { cardAnchor.href = url; } catch(_) {}
              const prevSrc = dlp.images[dlp.idx] || '';
              const prevIdx = dlp.idx;
              // 파일명 기반 매칭: CDN 도메인(img.dlsite.jp vs .com)·쿼리스트링 차이 허용
              const fnOf = u => { const p = u.split('/').pop(); return p ? p.split('?')[0] : ''; };
              const prevFn = fnOf(prevSrc);
              const keepIdx = prevFn ? imgs.findIndex(u => fnOf(u) === prevFn) : -1;
              if (keepIdx !== -1) {
                // 파일명 매칭 성공 → HTML 목록으로 교체
                // 이미 로드된 URL은 그대로 유지해 불필요한 재로드 방지
                const fnMap = new Map(dlp.images.map(u => [fnOf(u), u]));
                dlp.images = imgs.map(u => fnMap.get(fnOf(u)) || u);
                dlp.idx = keepIdx;
                dlpUpdateImage();
              } else if (imgs.length < dlp.images.length) {
                // HTML이 SMP보다 적게 반환(JS 로딩 페이지 등) → 기존 목록 유지
                // 진짜 새 파일만 뒤에 추가
                let added = false;
                for (const u of imgs) {
                  if (!dlp.images.some(e => fnOf(e) === fnOf(u))) { dlp.images.push(u); added = true; }
                }
                if (added && dlp.navEl)
                  dlp.navEl.textContent = dlp.images.length > 1 ? `${dlp.idx+1}/${dlp.images.length}  ←→ a·d` : '';
              } else {
                // 완전히 다른 이미지 목록이고 HTML이 더 크거나 같음 → 교체 + 인덱스 클램프
                // (imgs.length >= dlp.images.length > prevIdx이므로 실제로 클램프 발생 안 함)
                dlp.images = imgs;
                dlp.idx = Math.min(prevIdx, imgs.length - 1);
                dlpUpdateImage();
              }
            } else if (!isRetry && isWorkUrl) {
              // 200이지만 이미지 없음(출시 전 work 페이지 등) → announce 재시도
              smpActive = true; // 재시도 동안 smp 유지
              fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
            } else if (!dlp.images.length) {
              showErr('이미지를 찾을 수 없습니다.');
            }
          },
          onerror() {
            if (!isRetry && isWorkUrl) {
              fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
            } else {
              smpActive = false;
              if (!dlp.images.length) showErr('미리보기 로드 실패');
            }
          },
          ontimeout() { smpActive = false; if (!dlp.images.length) showErr('응답 없음 (15s)'); },
        });
      };
      fetchDlsiteHtml(href, false);

      // ── Info API (메타 정보 + fast path 실패 시 fallback) ──
      if (code && section) {
        GM_xmlhttpRequest({
          method: 'GET',
          url: `https://www.dlsite.com/${section}/product/info/ajax?product_id=${code}`,
          timeout: 8000,
          onload(res) {
            if (thisEl !== dlp.el) return;
            try {
              const info = JSON.parse(res.responseText)?.[code];
              if (!info) return;
              if (info.work_image && !dlp.images.length) {
                const workImg = info.work_image.startsWith('//') ? 'https:' + info.work_image : info.work_image;
                dlp.images = [workImg]; dlp.idx = 0; dlpUpdateImage();
              }
              const wi = info.work_image ? (info.work_image.startsWith('//') ? 'https:' + info.work_image : info.work_image) : null;
              startSmpProbes(wi);
              if (CFG.PREVIEW_META && dlp.chipsEl) {
                if (info.rate_average_2dp != null) addChip(`★${Number(info.rate_average_2dp).toFixed(2)}${info.rate_count ? ` (${info.rate_count})` : ''}`, 'dlp-chip-star');
                if (info.dl_count        != null) addChip(`판매 ${Number(info.dl_count).toLocaleString()}건`, 'dlp-chip-sales');
                if (info.regist_date)              addChip(info.regist_date.slice(0, 10), 'dlp-chip-date');
              }
            } catch {}
          },
        });
      }
    }

    const srcEl = e.currentTarget;
    srcEl.addEventListener('mouseleave', dlpHide, { once: true });

    // srcEl이 DOM에서 제거될 경우(OG 프리뷰 → 카드 교체 등) 즉시 숨김
    const watchParent = srcEl.parentNode || document.body;
    dlp.srcWatcher = new MutationObserver(() => {
      if (!document.contains(srcEl)) dlpHide();
    });
    dlp.srcWatcher.observe(watchParent, { childList: true, subtree: true });
  }

  /* ================================================================
     하이퍼링크 → 상품 카드 변환
     href 또는 링크 텍스트에 DLsite/Steam 정보가 있는 <a>를 카드로 교체.
     교체 후 preview 훅도 자동으로 달린다(makeDlsiteCard/makeSteamCard 내부 처리).
  ================================================================ */
  function convertProductLinks(contentRoots) {
    if (!CFG.LINK_CARD) return;
    if (!contentRoots.size) return; // 본문 루트 미매칭 = kone.gg 본문 페이지 아님

    // contentRoots 내 링크만 처리 (OG 프리뷰 등 외부 중복 방지, 동일 ID 중복 카드 방지)
    const seen = new Set();
    contentRoots.forEach(root => {
      root.querySelectorAll('a').forEach(a => {
        if (a.hasAttribute(LTDONE_ATTR)) return;
        if (a.hasAttribute(DONE_ATTR)) {
          // 카드 원본으로 처리된 링크: 연결된 카드가 DOM에서 사라졌으면 재처리
          const prevCard = _cardMap.get(a);
          if (prevCard && !document.contains(prevCard)) {
            // Svelte 등이 카드를 제거했음 → DONE_ATTR 해제 후 재생성
            a.removeAttribute(DONE_ATTR);
            a.style.removeProperty('display');
            _cardMap.delete(a);
          } else {
            return;
          }
        }
        if (hasProcessedAncestor(a)) return;
        if (!a.parentNode) return;
        // 자식 요소가 있는 <a>: kone.gg OG 프리뷰 카드(not-prose + h-24)는 숨김, 그 외 스킵
        if (a.firstElementChild) {
          if (a.classList.contains('not-prose') && a.classList.contains('h-24')) {
            a.setAttribute(DONE_ATTR, '');
            a.style.cssText += ';display:none!important;';
          }
          return;
        }

        const href = a.href || '';
        // kone.gg 내부 링크 → 절대 카드로 변환하지 않음
        try { if (/kone\.gg$/.test(new URL(href).hostname)) return; } catch(e) {}
        let card = null;
        let seenKey = null;

        // ── href 기준 ──
        if (href.includes('dlsite.com')) {
          // www.dlsite.com: /product_id/RJxxx, ch.dlsite.com: /VJxxx 또는 /product/VJxxx
          const idMatch = href.match(/product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i)
                       || href.match(/[/=]((?:RJ|BJ|VJ|RE|BE|VE)\d{6,8})\b/i);
          if (idMatch) {
            seenKey = 'dl:' + idMatch[1].toUpperCase();
            card = (seen.has(seenKey) || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard(idMatch[1].toUpperCase());
            if (card && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
          } else if (CFG.LINK_CARD) {
            // ci-en.dlsite.com 등 작품 ID 없는 DLsite URL → 기본 링크 카드
            seenKey = 'dl:' + href.split(/[?#]/)[0];
            card = seen.has(seenKey) ? null : makeLinkCard(href);
          }
        } else if (href.includes('store.steampowered.com/app/')) {
          const appMatch = href.match(/\/app\/(\d+)/);
          if (appMatch) {
            seenKey = 'st:' + appMatch[1];
            card = (seen.has(seenKey) || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(seenKey))) ? null : makeSteamCard(appMatch[1]);
            if (card && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
          }
        } else if (href.includes('patreon.com')) {
          try {
            const u = new URL(href);
            if ((u.hostname === 'patreon.com' || u.hostname === 'www.patreon.com') && u.pathname.length > 1) {
              seenKey = 'pt:' + href.split(/[?#]/)[0];
              card = seen.has(seenKey) ? null : makePatreonCard(href);
            }
          } catch(e) {}
        } else if (href.includes('getchu.com') || href.includes('getcu.com')) {
          const gcIdM = href.match(/\/item\/(\d+)|[?&]id=(\d+)/);
          if (gcIdM) {
            const gcDigits = gcIdM[1] || gcIdM[2];
            seenKey = 'cr:GC' + gcDigits;
            if (_gcSeenInFlush.has(gcDigits)) {
              // 텍스트 GC 코드로 이미 카드 생성됨 → <a> 숨김만
              a.setAttribute(DONE_ATTR, '');
              a.style.cssText += ';display:none!important;';
              return;
            }
            card = (seen.has(seenKey) || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(seenKey))) ? null : makeGetcuCard('GC' + gcDigits, href);
            if (card) _gcSeenInFlush.add(gcDigits);
          } else {
            seenKey = 'lk:' + href.split(/[?#]/)[0];
            card = seen.has(seenKey) ? null : makeLinkCard(href);
          }
        } else if (href.includes('dmm.co.jp') || href.includes('fanza.jp') || href.includes('fanbox.cc')) {
          seenKey = 'cr:' + href.split(/[?#]/)[0];
          card = seen.has(seenKey) ? null : makeLinkCard(href);
        }

        // ── href 미매칭 시 링크 텍스트에서 코드 탐색 ──
        if (!card && !seenKey) {
          const t = a.textContent.trim();
          DLSITE_LATIN_RE.lastIndex = 0; const dlLatin = DLSITE_LATIN_RE.exec(t);
          DLSITE_KR_RE.lastIndex   = 0; const dlKr    = DLSITE_KR_RE.exec(t);
          STEAM_CODE_RE.lastIndex  = 0; const st      = STEAM_CODE_RE.exec(t);
          CREATOR_CODE_RE.lastIndex = 0; const cr     = CREATOR_CODE_RE.exec(t);
          if (dlLatin)   { seenKey = 'dl:' + (dlLatin[1]+dlLatin[2]).toUpperCase(); card = (seen.has(seenKey) || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard((dlLatin[1]+dlLatin[2]).toUpperCase()); }
          else if (dlKr) { seenKey = 'dl:RJ' + dlKr[2]; card = (seen.has(seenKey) || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard('RJ' + dlKr[2]); }
          else if (st)   { seenKey = 'st:' + st[1]; card = (seen.has(seenKey) || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(seenKey))) ? null : makeSteamCard(st[1]); }
          else if (cr) {
            const crCode = (cr[1]+cr[2]).toUpperCase();
            seenKey = 'cr:' + crCode;
            const crGcD = crCode.startsWith('GC') ? crCode.slice(2) : null;
            const crGcDup = crGcD !== null && _gcSeenInFlush.has(crGcD);
            if (crGcDup) {
              a.setAttribute(DONE_ATTR, ''); a.style.cssText += ';display:none!important;'; return;
            }
            card = (seen.has(seenKey) || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(seenKey))) ? null : makeCreatorCard(crCode);
            if (card && crGcD !== null) _gcSeenInFlush.add(crGcD);
          }
        }

        // ── 알려진 다운로드/파일 공유 사이트 → 일반 링크 카드 ──
        if (!card && !seenKey && href) {
          try {
            const u = new URL(href);
            const dlHost = u.hostname;
            const base = dlHost.replace(/^www\./, '');
            // 경로가 없는 사이트 루트 URL (예: https://transfer.it/) → 카드 생성 안 함
            if (u.pathname.length <= 1) return;
            if (CFG.PW_SITES[dlHost] || CFG.PW_SITES[base] ||
                CFG.DEAD_PATTERNS[dlHost] || CFG.DEAD_PATTERNS[base]) {
              seenKey = 'lk:' + href.split(/[?#]/)[0];
              card = seen.has(seenKey) ? null : makeLinkCard(href);
            }
          } catch(e) {}
        }

        if (card) {
          if (seenKey && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
          if (seenKey) seen.add(seenKey);
          card.setAttribute(ORIG_ATTR, a.outerHTML);
          a.setAttribute(DONE_ATTR, '');
          a.style.cssText += ';display:none!important;';
          a.parentNode.insertBefore(card, a);
          _cardMap.set(a, card); // 카드 제거 감지용 (Svelte 재렌더링 대응)
        } else if (seenKey && seen.has(seenKey)) {
          // 같은 flush 내 동일 URL 중복 → 숨김
          a.setAttribute(DONE_ATTR, '');
          a.style.cssText += ';display:none!important;';
        }
      });
    });

    // preview 훅: 카드로 교체되지 않은 DLsite/Steam 링크에도 hover 달기
    if (CFG.DLSITE_PREVIEW) {
      const previewSel = 'a[href*="dlsite.com"], a[href*="ch.dlsite.com"], a[href*="store.steampowered.com/app/"], a[href*="getchu.com/"]';
      contentRoots.forEach(root => {
        root.querySelectorAll(previewSel).forEach(a => {
          if (a.dataset._dlpHooked) return;
          if (a.firstElementChild) return; // OG 프리뷰 등 블록형 링크 제외
          a.dataset._dlpHooked = '1';
          a.addEventListener('mouseenter', dlpShow);
        });
      });
    }
  }

  /* ================================================================
     비번 자동입력 공통 유틸
  ================================================================ */
  function normText(t) { return String(t||'').replace(/\s+/g,' ').trim(); }

  function isVisible(el) {
    if (!el) return false;
    const s = window.getComputedStyle(el);
    const r = el.getBoundingClientRect();
    return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0;
  }

  function setNativeValue(el, value) {
    const proto = Object.getPrototypeOf(el);
    const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
                 Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
    if (desc?.set) desc.set.call(el, value); else el.value = value;
    if (el._valueTracker) el._valueTracker.setValue('');
    el.dispatchEvent(new Event('input',  { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
  }

  function getInputEl(sel) {
    return [...document.querySelectorAll(sel)].find(isVisible) || null;
  }

  function getScope(input) {
    if (!input) return document.body;
    return input.closest('[role="dialog"]') || input.closest('form') ||
           input.closest('[class*="modal"]') || input.parentElement || document.body;
  }

  function findPwError(input, patterns) {
    const scope = getScope(input);
    for (const el of scope.querySelectorAll('div,p,span,strong,em,small,li')) {
      if (!isVisible(el)) continue;
      const t = normText(el.textContent);
      if (t && t.length < 120 && patterns.some(p => p.test(t))) return t;
    }
    return '';
  }

  /* ================================================================
     비번 자동입력
  ================================================================ */
  const siteCfg = CFG.PW_SITES[host];

  if (siteCfg && CFG.PW_AUTO) {

    if (siteCfg.mode === 'kio') {
      const KIO = {
        phase: 'idle', lastInputEl: null,
        lastSubmitAt: 0, lastAttemptAt: 0,
        queue: [], idx: 0, tried: new Set(), active: null,
        lastAdvKey: '', lastErrText: '',
        GRACE: 3000, FALLBACK: 2000, RETRY_MS: 50, RETRY_MAX: 8,
      };

      function kioGetBtn(scope) {
        return [...(scope||document).querySelectorAll('button')]
          .find(b => isVisible(b) && normText(b.textContent) === '확인') || null;
      }

      function kioResetForNewModal(input) {
        if (KIO.lastInputEl === input) return;
        const lastAt = Math.max(KIO.lastAttemptAt, KIO.lastSubmitAt);
        if (KIO.active && ['attempting','submitted'].includes(KIO.phase) &&
            lastAt > 0 && Date.now() - lastAt <= KIO.GRACE) {
          KIO.lastInputEl = input; return;
        }
        KIO.lastInputEl = input; KIO.phase = 'idle';
        KIO.lastSubmitAt = 0; KIO.lastAttemptAt = 0;
        KIO.queue = (getPwList() || []).filter(Boolean).map(v => ({ value: v }));
        KIO.idx = 0; KIO.tried = new Set(); KIO.active = null;
        KIO.lastAdvKey = ''; KIO.lastErrText = '';
      }

      function kioClickWhenReady(expected, onSuccess) {
        let retries = 0;
        const attempt = () => {
          const input = getInputEl(siteCfg.inputSel); if (!input) return;
          const btn = kioGetBtn(getScope(input)); if (!btn) return;
          if (normText(input.value) !== normText(expected)) return;
          if (!btn.disabled) { btn.click(); KIO.lastSubmitAt = Date.now(); onSuccess(); return; }
          if (retries < KIO.RETRY_MAX) { retries++; setTimeout(attempt, KIO.RETRY_MS); }
          else { KIO.phase = 'submitted'; KIO.lastSubmitAt = Date.now(); }
        };
        queueMicrotask(attempt);
      }

      async function kioTryNext(input) {
        while (KIO.idx < KIO.queue.length) {
          const { value } = KIO.queue[KIO.idx];
          if (!value || KIO.tried.has(value)) { KIO.idx++; continue; }
          KIO.tried.add(value);
          KIO.active = { value };
          KIO.lastAdvKey = ''; KIO.lastErrText = '';
          KIO.lastAttemptAt = Date.now();
          KIO.phase = 'attempting';
          setNativeValue(input, value);
          kioClickWhenReady(value, () => { KIO.phase = 'submitted'; });
          return;
        }
        KIO.phase = 'done'; KIO.active = null;
      }

      async function kioTick() {
        const input = getInputEl(siteCfg.inputSel); if (!input) return;
        if (!kioGetBtn(getScope(input))) return;
        kioResetForNewModal(input);

        if (['attempting','submitted'].includes(KIO.phase) && KIO.active && !normText(input.value)) {
          setNativeValue(input, KIO.active.value);
          KIO.phase = 'attempting'; KIO.lastAttemptAt = Date.now();
          kioClickWhenReady(KIO.active.value, () => { KIO.phase = 'submitted'; KIO.lastSubmitAt = Date.now(); });
          return;
        }

        const err = findPwError(input, siteCfg.errorPat);
        const key = KIO.active ? `${KIO.idx}:${err}` : '';
        if (err && err !== KIO.lastErrText) KIO.lastErrText = err;

        if (err && KIO.phase === 'submitted' && KIO.active &&
            normText(input.value) === normText(KIO.active.value) &&
            key !== KIO.lastAdvKey) {
          KIO.lastAdvKey = key; KIO.idx++;
          await kioTryNext(input); return;
        }
        if (KIO.phase === 'idle') { await kioTryNext(input); return; }
        if (KIO.phase === 'attempting' && Date.now() - KIO.lastAttemptAt >= KIO.FALLBACK) { KIO.idx++; await kioTryNext(input); return; }
        if (KIO.phase === 'submitted' && Date.now() - KIO.lastSubmitAt >= KIO.FALLBACK)   { KIO.idx++; await kioTryNext(input); }
      }

      let kioTickBusy = false;
      window.setInterval(async () => {
        if (kioTickBusy) return;
        kioTickBusy = true;
        try { await kioTick(); } finally { kioTickBusy = false; }
      }, 100);
      window.addEventListener('pageshow', e => {
        if (!e.persisted) return;
        Object.assign(KIO, { phase:'idle', lastInputEl:null, lastSubmitAt:0, lastAttemptAt:0,
          queue:[], idx:0, tried:new Set(), active:null, lastAdvKey:'', lastErrText:'' });
        kioTickBusy = false;
      });
      document.addEventListener('visibilitychange', () => { if (!document.hidden) kioTick().catch(()=>{}); });
      setTimeout(() => kioTick().catch(()=>{}), 0);
    }

    if (siteCfg.mode === 'generic') {
      async function genericTryPw() {
        const pws = getPwList() || [];
        for (const pw of pws) {
          if (!pw) continue;
          const input = getInputEl(siteCfg.inputSel);
          if (!input) break;
          setNativeValue(input, pw);
          // React가 input 이벤트를 처리해 버튼을 활성화할 때까지 대기 (최대 btnDelay ms)
          const btnWait = siteCfg.btnDelay || 200;
          const btnDeadline = Date.now() + btnWait;
          let btn = null;
          while (Date.now() < btnDeadline) {
            btn = document.querySelector(siteCfg.btnSel) ||
                  [...document.querySelectorAll('button')].find(
                    b => isVisible(b) && (normText(b.textContent) === '확인' || normText(b.textContent) === 'OK')
                  );
            if (btn && !btn.disabled) break;
            await new Promise(r => setTimeout(r, 50));
          }
          if (btn && !btn.disabled) {
            btn.click();
          } else {
            // 버튼이 disabled이거나 없으면 Enter 키로 폼 제출 시도
            input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }));
            input.dispatchEvent(new KeyboardEvent('keyup',   { key: 'Enter', keyCode: 13, bubbles: true }));
          }
          await new Promise(r => setTimeout(r, 1000));
          const postInput = getInputEl(siteCfg.inputSel);
          // 비번 입력창이 사라졌으면 성공 (페이지 이동, AJAX 폼 제거 등)
          if (!postInput) break;
          // 에러 패턴 먼저 확인 (successSel보다 우선 — premature match 방지)
          if (findPwError(postInput, siteCfg.errorPat)) continue;
          // 성공 요소 체크: 입력창이 남아있지만 성공 콘텐츠가 나타난 경우 (AJAX)
          if (siteCfg.successSel && document.querySelector(siteCfg.successSel)) break;
        }
      }
      const gObs = new MutationObserver(() => {
        const input = getInputEl(siteCfg.inputSel);
        if (input && !input.dataset._gTried) { input.dataset._gTried = '1'; setTimeout(genericTryPw, 300); }
      });
      gObs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => {
        const i = getInputEl(siteCfg.inputSel);
        if (i && !i.dataset._gTried) { i.dataset._gTried = '1'; genericTryPw(); }
      }, siteCfg.triggerDelay || 1500);
    }

    if (siteCfg.mode === 'formpost') {
      // 전통적 form POST: 비번 제출 시 페이지 전체 리로드됨
      // → DOM 플래그 방식으로는 인덱스 유지 불가 → GM_setValue로 영속화
      // PERSIST_KEY는 파일 URL별로 분리 (다른 파일과 인덱스 혼용 방지)
      const PERSIST_KEY = `_fpIdx_${location.hostname}${location.pathname}`;
      const pws = getPwList() || [];

      const input = getInputEl(siteCfg.inputSel);
      if (!input) {
        // 비번 폼 없음 → 성공 페이지(다운로드 버튼 있음)인지 확인
        if (siteCfg.dlBtnSel) {
          const dlBtn = document.querySelector(siteCfg.dlBtnSel);
          if (dlBtn && isVisible(dlBtn)) {
            GM_setValue(PERSIST_KEY, 0); // 성공: 인덱스 초기화
            setTimeout(() => {
              const b = document.querySelector(siteCfg.dlBtnSel);
              if (b && isVisible(b)) b.click();
            }, siteCfg.triggerDelay || 1000);
          }
        }
        return; // 관련 없는 페이지 → 아무 동작 안 함
      }

      // 비번 폼 존재 → 저장된 인덱스로 다음 비번 시도
      const idx = GM_getValue(PERSIST_KEY, 0);
      if (!pws.length || idx >= pws.length) {
        GM_setValue(PERSIST_KEY, 0); return; // 비번 목록 없음 or 전부 실패 → 초기화 후 중단
      }

      setTimeout(() => {
        const inp = getInputEl(siteCfg.inputSel);
        if (!inp) return;
        const pw = pws[idx];
        if (!pw) { GM_setValue(PERSIST_KEY, 0); return; }

        // 제출 전에 다음 인덱스 저장: 페이지 리로드 후 이 값을 읽어 다음 비번으로 진행
        GM_setValue(PERSIST_KEY, idx + 1);
        setNativeValue(inp, pw);

        setTimeout(() => {
          const btn = document.querySelector(siteCfg.btnSel);
          if (btn && !btn.disabled) {
            btn.click();
          } else {
            inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }));
            inp.dispatchEvent(new KeyboardEvent('keyup',   { key: 'Enter', keyCode: 13, bubbles: true }));
          }
        }, siteCfg.btnDelay || 300);
      }, siteCfg.triggerDelay || 1500);
    }

    if (siteCfg.mode === 'transfer') {
      let transferDone = false;
      let _tIdx = 0;      // 다음 시도할 비번 인덱스 (재진입 시 이어서)
      let _tRunning = false;
      let _tObsLocked = false; // observer 중복 호출 방지

      async function transferTryPw() {
        // 이미 실행 중이거나 완료된 경우 스킵
        if (transferDone || _tRunning) return;
        _tRunning = true;
        try {
          const pws = getPwList() || [];
          while (_tIdx < pws.length) {
            const pw = pws[_tIdx];
            if (!pw) { _tIdx++; continue; }

            const input = getInputEl(siteCfg.inputSel);
            // 다이얼로그가 사라진 경우 → observer가 다시 열어줄 때 재진입
            if (!input) { _tRunning = false; return; }

            // 비번 입력 후 React/Vue 이벤트 처리 대기
            setNativeValue(input, pw);
            await new Promise(r => setTimeout(r, 200));

            // 확인 버튼 클릭 (disabled이면 Enter 키로 폼 제출)
            const btn = document.querySelector(siteCfg.btnSel);
            if (btn && !btn.disabled) {
              btn.click();
            } else {
              input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }));
              input.dispatchEvent(new KeyboardEvent('keyup',   { key: 'Enter', keyCode: 13, bubbles: true }));
            }
            // 서버 응답 대기
            await new Promise(r => setTimeout(r, 1000));

            // 성공 여부 확인
            // transfer.it: .file-manager-box가 비번 입력 전에도 DOM에 존재
            // → successSel 대신 "비번 다이얼로그(입력창)가 사라졌는가"를 기준으로 판단
            if (!getInputEl(siteCfg.inputSel)) {
              transferDone = true; return;
            }
            // 에러 패턴 감지 (다이얼로그가 아직 있는 경우)
            const errInput = getInputEl(siteCfg.inputSel);
            if (errInput) {
              const errTxt = findPwError(errInput, siteCfg.errorPat);
              if (errTxt) { _tIdx++; continue; } // 오류 확인 → 다음 비번
            }
            _tIdx++; // 결과 불명확 → 다음 비번으로 진행
          }
        } catch(e) {
          // 예외 발생 시 _tRunning 해제해서 재진입 가능하게
        } finally {
          _tRunning = false;
          // 비번이 남아있고 다이얼로그가 닫혔으면 dlBtnSel 재클릭으로 다이얼로그 재열기
          if (!transferDone) {
            const pws = getPwList() || [];
            if (_tIdx < pws.length && siteCfg.dlBtnSel && !getInputEl(siteCfg.inputSel)) {
              setTimeout(() => {
                if (getInputEl(siteCfg.inputSel)) return; // 이미 열림
                const dlBtn = document.querySelector(siteCfg.dlBtnSel);
                if (dlBtn && isVisible(dlBtn)) dlBtn.click();
              }, 700);
            }
          }
        }
      }

      // 다이얼로그 등장 감지 → 자동 시작
      const transferObs = new MutationObserver(() => {
        if (transferDone || _tRunning || _tObsLocked) return;
        const input = getInputEl(siteCfg.inputSel);
        if (!input) return;
        _tObsLocked = true;
        setTimeout(() => {
          _tObsLocked = false;
          if (!_tRunning && !transferDone) transferTryPw();
        }, 400);
      });
      transferObs.observe(document.documentElement, { childList: true, subtree: true });

      // 페이지 로드 시 dlBtnSel(다운로드 버튼)이 있으면 클릭해서 다이얼로그 열기
      // 단, 비번 다이얼로그가 이미 열려 있으면 클릭 불필요 (전달받은 링크가 pw 보호된 경우)
      if (siteCfg.dlBtnSel) {
        setTimeout(() => {
          if (getInputEl(siteCfg.inputSel)) return; // 이미 비번 다이얼로그 표시 중
          const dlBtn = document.querySelector(siteCfg.dlBtnSel);
          if (dlBtn && isVisible(dlBtn)) dlBtn.click();
        }, siteCfg.triggerDelay || 1500);
      }
    }

  }

  /* ================================================================
     flush / observe
  ================================================================ */
  let pending = false, mo = null, _scrolledOnce = false, _kpIdx = -1, _wasWritePage = false, _urlFlushTimer = null;
  let _settingsCaptureCleanup = null;
  let _hoveredCard = null;

  // 카드 호버 추적 (이벤트 위임)
  document.addEventListener('mouseover', e => {
    const card = e.target.closest && e.target.closest('.b64-link, .b64-product-link');
    _hoveredCard = card || null;
  }, { passive: true, capture: false });

  function isOurNode(node) {
    let n = node;
    while (n) {
      if (n.nodeType === Node.ELEMENT_NODE &&
          (n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR))) return true;
      n = n.parentNode;
    }
    return false;
  }

  function getContentRoots() {
    // 글 작성 페이지면 아무것도 처리 안 함
    if (isWritePage()) return { roots: new Set(), allRoots: new Set() };

    // 우선순위 그룹: 상위 그룹에서 1개라도 매칭되면 하위 그룹은 건너뜀.
    // kone.gg 가 #post-article 과 .article-body 를 별개 요소로 렌더링할 때
    // 글 내용이 2배로 보이는 현상을 방지한다.
    const PRIORITY_GROUPS = [
      ['#post-article', '#post-comment'],
      ['.article-body', 'div.fr-view.article-content', 'body div.article-body > div.fr-view'],
      ['p.text-sm.whitespace-pre-wrap', 'div.text-sm.whitespace-pre-wrap'],
    ];

    // allRoots: 우선순위 불문 매칭된 모든 요소 → flushListTitles 전역 스캔 배제에 사용
    const allRoots = new Set();
    let candidates = new Set();
    let activeFound = false;

    for (const group of PRIORITY_GROUPS) {
      const groupSet = new Set();
      for (const sel of group) {
        document.querySelectorAll(sel).forEach(el => {
          // contenteditable="true" 조상 → 에디터 내부 → 제외
          let anc = el;
          while (anc) {
            if (anc.contentEditable === 'true') return;
            anc = anc.parentElement;
          }
          // 요소 내부에 Froala 에디터 잔존 → write→read 전환 중 → 제외
          if (el.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) return;
          groupSet.add(el);
          allRoots.add(el); // 우선순위 무관하게 전체 수집
        });
      }
      if (!activeFound && groupSet.size > 0) {
        candidates = groupSet;
        activeFound = true;
        // break 제거: 낮은 우선순위 그룹도 allRoots 에 수집해야 하므로 계속 순회
      }
    }

    // 중첩 제거: 다른 루트의 자손인 요소는 제외
    const roots = new Set();
    for (const el of candidates) {
      let dominated = false;
      for (const other of candidates) {
        if (other !== el && other.contains(el)) { dominated = true; break; }
      }
      if (!dominated) roots.add(el);
    }
    return { roots, allRoots };
  }

  function flushListTitles(contentRoots, allContentRoots = contentRoots) {
    if (!CFG.LIST_DECODE || !document.body) return;

    CFG.LIST_SELECTORS.forEach(sel => {
      document.querySelectorAll(sel).forEach(el => {
        let anc = el;
        while (anc) { if (allContentRoots.has(anc)) return; anc = anc.parentElement; }
        walkAndProcess(el, processListNode);
      });
    });

    const gw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
      acceptNode(node) {
        const p = node.parentNode;
        if (!p) return NodeFilter.FILTER_REJECT;
        const tag = p.nodeName;
        if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
        if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        let anc = p;
        while (anc) {
          if (anc.nodeType === Node.ELEMENT_NODE) {
            if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT; // Froala 에디터 내부 제외
            if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
            if (allContentRoots.has(anc)) return NodeFilter.FILTER_REJECT;
            const tn = anc.nodeName;
            if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
          }
          anc = anc.parentNode;
        }
        return NodeFilter.FILTER_ACCEPT;
      },
    });

    const b64TestRe = new RegExp(`[A-Za-z0-9+/]{${MIN_B64},}={0,2}`);
    const listNodes = []; let ln;
    while ((ln = gw.nextNode())) {
      const v = ln.nodeValue;
      if (b64TestRe.test(v) ||
          /\b(?:RJ|BJ|VJ|RE|BE|VE)\s?\d{6,8}\b/i.test(v) ||
          /(꺼|거|ㄲㅓ|ㄱㅓ)\s?\d{6,8}/.test(v) ||
          /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*\d{4,10}/.test(v) ||
          /\b(?:GC|FZ|FB)\s?\d{4,8}\b/i.test(v))
        listNodes.push(ln);
    }
    listNodes.forEach(processListNode);
  }

  /* ================================================================
     제목 카드 삽입
     CONTENT_SELECTORS 본문 영역이 감지될 때, 같은 컨테이너 내
     h1/h2/h3 에서 RJ·Steam 코드를 찾아 본문 하단에 카드 바 추가
  ================================================================ */
  function injectTitleCards(contentRoots) {
    if (!CFG.LINK_CARD) return;

    // CONTENT_SELECTORS가 매칭된 경우에만 실행 (목록 페이지 제외)
    const roots = [...contentRoots].filter(r => !r.hasAttribute(TITLE_CARD_ATTR));
    if (!roots.length) return;

    // content root 밖의 heading 요소 수집
    const allHeadings = [...document.querySelectorAll('h1, h2, h3')].filter(h => {
      for (const r of contentRoots) if (r.contains(h)) return false;
      return true;
    });

    for (const root of roots) {
      root.setAttribute(TITLE_CARD_ATTR, '1');
      if (!allHeadings.length) continue;

      // root의 조상을 거슬러 올라가며 heading을 포함하는 가장 가까운 컨테이너 탐색
      let scoped = allHeadings;
      let anc = root.parentElement;
      while (anc && anc !== document.body) {
        const found = allHeadings.filter(h => anc.contains(h));
        if (found.length) { scoped = found; break; }
        anc = anc.parentElement;
      }

      // heading 텍스트에서 DLsite/Steam/크리에이터 코드 추출
      const seen = new Set();
      const cards = [];
      for (const h of scoped) {
        const hits = findAllMatches(h.textContent)
          .filter(hit => hit.type === 'dlsite' || hit.type === 'steam' || hit.type === 'creator');
        for (const hit of hits) {
          const id = hit.type === 'steam' ? hit.appId : hit.code;
          if (seen.has(id)) continue;
          seen.add(id);
          if (hit.type === 'dlsite') cards.push(makeDlsiteCard(hit.code));
          else if (hit.type === 'steam') cards.push(makeSteamCard(hit.appId));
          else cards.push(makeCreatorCard(hit.code));
        }
      }

      if (!cards.length) continue;

      // 카드 바 생성 후 본문 하단에 삽입
      const bar = document.createElement('div');
      bar.setAttribute(DONE_ATTR, '');
      bar.className = 'b64-title-card-bar';
      const label = document.createElement('div');
      label.className = 'b64-title-card-label';
      label.textContent = '📌 제목에서 발견된 작품';
      bar.appendChild(label);
      cards.forEach(c => bar.appendChild(c));
      root.appendChild(bar);
    }
  }

  /* ── 카드 키보드 탐색 (w/s, ↑/↓) ── */
  function kpGetLinks() {
    if (CFG.NAV_TARGET === 'products') return Array.from(document.querySelectorAll('.b64-product-link'));
    if (CFG.NAV_TARGET === 'both')     return Array.from(document.querySelectorAll('.b64-link, .b64-product-link'));
    return Array.from(document.querySelectorAll('.b64-link'));
  }

  function kpNavigate(dir) {
    const links = kpGetLinks();
    if (!links.length) return false;
    let next;
    if (_kpIdx < 0) {
      next = dir > 0 ? 0 : links.length - 1;
    } else {
      next = _kpIdx + dir;
      if (next < 0 || next >= links.length) return false; // 경계에서 정지 (순환 없음)
    }
    links.forEach(l => l.classList.remove('kp-focused'));
    _kpIdx = next;
    links[_kpIdx].classList.add('kp-focused');
    links[_kpIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
    return true;
  }
  // capture:true — 버블링 단계에서 사이트가 이벤트를 막아도 동작하도록
  window.addEventListener('keydown', e => {
    const tg = e.target;
    if (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA' || tg.isContentEditable) return;
    if (e.ctrlKey || e.altKey || e.metaKey) return;
    if (document.getElementById('b64d-settings') || document.getElementById('b64-dup-overlay')) return;
    const isDown = e.code === 'KeyS' || e.key === 'ArrowDown';
    const isUp   = e.code === 'KeyW' || e.key === 'ArrowUp';
    if (isDown || isUp) {
      // 마우스가 카드 위에 있으면 그 카드 기준으로 바로 위/아래 작품으로 이동
      if (_hoveredCard && document.contains(_hoveredCard)) {
        const allCards = kpGetLinks();
        const hovIdx = allCards.indexOf(_hoveredCard);
        if (hovIdx >= 0) {
          allCards.forEach(l => l.classList.remove('kp-focused'));
          if (_kpIdx !== hovIdx) {
            // 첫 번째 키: 포커스가 다른 곳에 있으면 먼저 호버 카드로 이동만
            _kpIdx = hovIdx;
          } else {
            // 두 번째 키: 이미 호버 카드에 포커스 → 방향대로 한 칸 이동
            _kpIdx = isDown
              ? Math.min(hovIdx + 1, allCards.length - 1)
              : Math.max(hovIdx - 1, 0);
          }
          allCards[_kpIdx].classList.add('kp-focused');
          allCards[_kpIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
          e.preventDefault();
        }
        return;
      }
      if (isDown) { if (kpNavigate(1))  e.preventDefault(); }
      else        { if (kpNavigate(-1)) e.preventDefault(); }
    } else if (e.key === 'Enter' && _kpIdx >= 0) {
      const links = kpGetLinks();
      if (links[_kpIdx]) { links[_kpIdx].click(); e.preventDefault(); }
    }
  }, { capture: true, passive: false });

  const WRITE_PAGE_RE = /kone\.gg\/s\/[^/]+(?:\/[^/]+)?\/write(?:\/|[?#]|$)/;
  function isWritePage(href = location.href) {
    return WRITE_PAGE_RE.test(href);
  }

  // kone.gg OG 프리뷰 카드(not-prose h-24)를 CSS로 숨김
  /* ================================================================
     채널 대문 TXT 중복 검사
  ================================================================ */
  const DUP_HIST_KEY     = 'b64d_dup_hist';
  const DUP_HIST_MAX     = 20;

  function getPageCardCodes() {
    const codes = new Set();
    document.querySelectorAll('a.b64-product-link').forEach(a => {
      const code = a.dataset.cardCode;
      if (code) codes.add(code.toUpperCase());
    });
    return codes;
  }

  function getDupHist() {
    try { return JSON.parse(GM_getValue(DUP_HIST_KEY, '[]')); } catch { return []; }
  }
  function saveDupHist(entry) {
    const h = getDupHist();
    h.unshift(entry);
    if (h.length > DUP_HIST_MAX) h.length = DUP_HIST_MAX;
    GM_setValue(DUP_HIST_KEY, JSON.stringify(h));
  }

  function extractCodesFromTxt(text) {
    // 쉼표·줄바꿈으로 항목 분리 후 각각에서 정규 식별자 추출
    const items = text.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
    const codes = [];
    for (const raw of items) {
      let m, id = null;
      if ((m = raw.match(/\b(RJ|BJ|VJ|RE|BE|VE)\s?(\d{6,8})\b/i)))
        id = m[1].toUpperCase() + m[2];
      else if ((m = raw.match(/store\.steampowered\.com\/app\/(\d{4,10})/)))
        id = 'ST' + m[1];
      else if ((m = raw.match(/\bST(\d{4,10})\b/i)))
        id = 'ST' + m[1];
      else if ((m = raw.match(/\bGC(\d+)\b/i)))
        id = 'GC' + m[1];
      else if ((m = raw.match(/getchu\.com\/(?:soft\.phtml.*?id=|item\/)(\d+)/)))
        id = 'GC' + m[1];
      else if ((m = raw.match(/\bFZ(\w+)\b/i)))
        id = 'FZ' + m[1].toUpperCase();
      else if ((m = raw.match(/dmm\.co\.jp.*?\/cid=(?:d_)?(\w+)/)))
        id = 'FZ' + m[1].toUpperCase();
      else if ((m = raw.match(/\bFB(\d+)\b/i)))
        id = 'FB' + m[1];
      else if ((m = raw.match(/fanbox\.cc\/posts\/(\d+)/)))
        id = 'FB' + m[1];
      else if ((m = raw.match(/([a-z0-9_-]+)\.fanbox\.cc/i)) && m[1].toLowerCase() !== 'www')
        id = 'FANBOX:' + m[1].toLowerCase();
      else if ((m = raw.match(/patreon\.com\/(?:c\/)?([^/?#\s]+)/i)))
        id = 'PATREON:' + m[1].toLowerCase();
      else if (/^https?:\/\//.test(raw)) {
        try {
          const u = new URL(raw.trim());
          id = 'URL:' + u.hostname.replace(/^www\./, '') + u.pathname.replace(/\/$/, '');
        } catch { id = null; }
      } else if (raw.length >= 4 && !/\s/.test(raw))
        id = raw.toUpperCase();
      if (id) codes.push(id);
    }
    return codes;
  }

  function findDupCodes(codes) {
    const cnt = {};
    for (const c of codes) cnt[c] = (cnt[c] || 0) + 1;
    return Object.entries(cnt).filter(([, n]) => n > 1).map(([code, count]) => ({ code, count }));
  }

  function showDupModal() {
    if (document.getElementById('b64-dup-overlay')) return;
    dlpHide();

    const overlay = document.createElement('div');
    overlay.id = 'b64-dup-overlay';

    const modal = document.createElement('div');
    modal.id = 'b64-dup-modal';
    if (CFG.SETTINGS_SCALE !== 100) modal.style.zoom = CFG.SETTINGS_SCALE / 100;

    // Header
    const hdr = document.createElement('div');
    hdr.className = 'dup-hdr';
    const hdrTitle = document.createElement('span');
    hdrTitle.textContent = '📋 TXT 중복 검사';
    const closeBtn = document.createElement('button');
    closeBtn.className = 'dup-x'; closeBtn.type = 'button'; closeBtn.textContent = '✕';
    closeBtn.addEventListener('click', () => overlay.remove());
    hdr.append(hdrTitle, closeBtn);

    // Body
    const body = document.createElement('div');
    body.className = 'dup-body';

    // File picker
    const pickRow = document.createElement('div');
    pickRow.className = 'dup-pick';
    const fileInput = document.createElement('input');
    fileInput.type = 'file'; fileInput.accept = '.txt,text/plain'; fileInput.style.display = 'none';
    const pickBtn = document.createElement('button');
    pickBtn.className = 'dup-file-btn'; pickBtn.type = 'button'; pickBtn.textContent = 'TXT 파일 선택';
    const fileLabel = document.createElement('span');
    fileLabel.className = 'dup-file-name'; fileLabel.textContent = '파일 미선택';
    pickRow.append(fileInput, pickBtn, fileLabel);
    pickBtn.addEventListener('click', () => fileInput.click());

    // Result area
    const resultEl = document.createElement('div');
    resultEl.className = 'dup-result';
    resultEl.innerHTML = '<span class="dup-empty">파일을 선택하면 결과가 표시됩니다.</span>';

    function scrollToCard(code) {
      const sel = `a.b64-product-link[data-card-code="${code.replace(/"/g, '\\"')}"]`;
      const el = document.querySelector(sel);
      if (!el) return;
      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
      el.classList.remove('b64-card-flash');
      void el.offsetWidth;  // reflow to restart animation
      el.classList.add('b64-card-flash');
      el.addEventListener('animationend', () => el.classList.remove('b64-card-flash'), { once: true });
    }

    function makeSection(titleText, titleClass, items, badgeFn, clickFn) {
      const wrap = document.createElement('div');
      const t = document.createElement('div');
      t.className = 'dup-section-title' + (titleClass ? ' ' + titleClass : '');
      t.textContent = titleText;
      wrap.appendChild(t);
      if (!items.length) {
        const e = document.createElement('span');
        e.className = 'dup-empty'; e.textContent = '없음';
        wrap.appendChild(e);
      } else {
        const ul = document.createElement('ul');
        ul.className = 'dup-list';
        items.forEach(item => {
          const li = document.createElement('li');
          li.innerHTML = badgeFn(item);
          if (clickFn) {
            li.classList.add('dup-goto');
            li.title = '클릭하면 해당 카드로 이동';
            li.addEventListener('click', () => clickFn(item));
          }
          ul.appendChild(li);
        });
        wrap.appendChild(ul);
      }
      return wrap;
    }

    function renderEntry(entry) {
      resultEl.innerHTML = '';
      if (!entry) { resultEl.innerHTML = '<span class="dup-empty">파일을 선택하면 결과가 표시됩니다.</span>'; return; }

      const hasPage = Array.isArray(entry.pageCodes) && entry.pageCodes.length > 0;
      const sumEl = document.createElement('div');
      sumEl.className = 'dup-summary';
      sumEl.textContent = hasPage
        ? `TXT ${entry.total}개 · 페이지 카드 ${entry.pageCodes.length}개`
        : `TXT ${entry.total}개 (${entry.unique}개 고유)`;
      resultEl.appendChild(sumEl);

      if (hasPage) {
        const owned    = entry.owned    || [];
        const notOwned = entry.notOwned || [];
        if (entry.url) {
          let path = '';
          try { path = new URL(entry.url).pathname.replace(/\/$/, '') || '/'; } catch {}
          const linkBtn = document.createElement('a');
          linkBtn.className = 'dup-page-link';
          linkBtn.href = entry.url;
          linkBtn.target = '_blank';
          linkBtn.rel = 'noopener noreferrer';
          linkBtn.textContent = '↗ ' + (path || entry.url);
          resultEl.appendChild(linkBtn);
        }
        resultEl.appendChild(makeSection(
          `미보유 / 새 항목 (${notOwned.length}개)`, 'dup-new-title', notOwned,
          c => `<span class="dup-code">${esc(c)}</span>`,
          c => scrollToCard(c)
        ));
        resultEl.appendChild(makeSection(
          `이미 보유 (${owned.length}개)`, 'dup-owned-title', owned,
          c => `<span class="dup-code">${esc(c)}</span><span class="dup-badge-ok">✓</span>`,
          c => scrollToCard(c)
        ));
        if (entry.duplicates && entry.duplicates.length) {
          resultEl.appendChild(makeSection(
            `TXT 내 중복 (${entry.duplicates.length}건)`, '', entry.duplicates,
            ({ code, count }) => `<span class="dup-code">${esc(code)}</span><span class="dup-badge">×${count}</span>`
          ));
        }
      } else {
        const noteEl = document.createElement('span');
        noteEl.className = 'dup-empty';
        noteEl.textContent = '현재 페이지에 작품 카드 없음 — TXT 내 중복만 검사';
        resultEl.appendChild(noteEl);
        if (!entry.duplicates.length) {
          const okEl = document.createElement('div');
          okEl.className = 'dup-ok'; okEl.textContent = '✅ 중복 없음';
          resultEl.appendChild(okEl);
        } else {
          resultEl.appendChild(makeSection(
            `중복 ${entry.duplicates.length}건`, 'dup-label', entry.duplicates,
            ({ code, count }) => `<span class="dup-code">${esc(code)}</span><span class="dup-badge">×${count}</span>`
          ));
        }
      }
    }

    fileInput.addEventListener('change', () => {
      const file = fileInput.files[0];
      if (!file) return;
      fileLabel.textContent = file.name;
      const reader = new FileReader();
      reader.onload = ev => {
        const codes   = extractCodesFromTxt(ev.target.result);
        const dups    = findDupCodes(codes);
        const txtSet  = new Set(codes.map(c => c.toUpperCase()));
        const pageSet = getPageCardCodes();
        const owned    = [...pageSet].filter(c => txtSet.has(c));
        const notOwned = [...pageSet].filter(c => !txtSet.has(c));
        const entry = {
          file: file.name,
          date: new Date().toISOString(),
          url: location.href,
          total: codes.length,
          unique: new Set(codes).size,
          duplicates: dups,
          allCodes: codes,
          pageCodes: [...pageSet],
          owned,
          notOwned,
        };
        GM_setValue('libCodes', JSON.stringify([...new Set(codes.map(c => c.toUpperCase()))]));
        saveDupHist(entry);
        renderEntry(entry);
        renderHist();
        if (CFG.TXT_LIVE) applyTxtLiveColors();
      };
      reader.readAsText(file, 'utf-8');
    });

    // History sidebar
    const histList = document.createElement('div');
    histList.className = 'dup-hist-list';
    function renderDetail(entry) {
      let sec = resultEl.querySelector('.dup-detail-section');
      if (sec) { sec.remove(); return; }
      sec = document.createElement('div');
      sec.className = 'dup-detail-section';
      const title = document.createElement('div');
      title.className = 'dup-detail-title';
      title.textContent = `전체 항목 빈도 (${(entry.allCodes || []).length}개)`;
      sec.appendChild(title);
      if (!entry.allCodes || !entry.allCodes.length) {
        const empty = document.createElement('span');
        empty.className = 'dup-empty';
        empty.textContent = '상세 데이터 없음 (이전 버전 기록)';
        sec.appendChild(empty);
      } else {
        const cnt = {};
        for (const c of entry.allCodes) cnt[c] = (cnt[c] || 0) + 1;
        const sorted = Object.entries(cnt).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
        const ul = document.createElement('ul');
        ul.className = 'dup-list';
        sorted.forEach(([code, count]) => {
          const li = document.createElement('li');
          if (count < 2) li.style.opacity = '0.5';
          const badge = count > 1 ? `<span class="dup-badge">${count}</span>` : '';
          li.innerHTML = `<span class="dup-code">${esc(code)}</span>${badge}`;
          ul.appendChild(li);
        });
        sec.appendChild(ul);
      }
      resultEl.appendChild(sec);
    }

    let _selRow = null;
    const HIST_PAGE_SIZE = 12;
    const pagerEl = document.createElement('div');
    pagerEl.className = 'dup-hist-pager';

    function renderHist(q, page) {
      page = page ?? 0;
      histList.innerHTML = ''; _selRow = null;
      const allEntries = getDupHist();
      if (!allEntries.length) { histList.innerHTML = '<span class="dup-empty">기록 없음</span>'; pagerEl.innerHTML = ''; return; }

      const now = new Date();
      const todayKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
      const yest = new Date(now); yest.setDate(now.getDate() - 1);
      const yestKey = `${yest.getFullYear()}-${yest.getMonth()}-${yest.getDate()}`;

      function makeGroup(dt) {
        const yy = String(dt.getFullYear()).slice(-2);
        const mm = String(dt.getMonth()+1).padStart(2,'0');
        const dd = String(dt.getDate()).padStart(2,'0');
        const m  = dt.getMonth()+1, d = dt.getDate();
        const dateCode = `${yy}.${mm}.${dd}`;
        const k = `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
        let label;
        if (k === todayKey) label = `오늘 · ${dateCode}`;
        else if (k === yestKey) label = `어제 · ${dateCode}`;
        else label = dateCode;
        const searchKey = `${label} ${dateCode} ${m}월 ${d}일 ${dt.getFullYear()}년 ${m}월 ${d}일`.toLowerCase();
        return { k, label, searchKey };
      }

      const groupMap = new Map();
      allEntries.forEach(entry => {
        const dt = new Date(entry.date);
        const g  = makeGroup(dt);
        if (!groupMap.has(g.k)) groupMap.set(g.k, { label: g.label, searchKey: g.searchKey, rows: [] });
        groupMap.get(g.k).rows.push({ entry, dt });
      });

      const filter = (q || '').trim().toLowerCase();
      const flatItems = [];
      groupMap.forEach(({ label, searchKey, rows }) => {
        if (filter && !searchKey.includes(filter)) return;
        flatItems.push({ type: 'header', label });
        rows.forEach(({ entry, dt }) => flatItems.push({ type: 'entry', entry, dt }));
      });

      if (!flatItems.length) { histList.innerHTML = '<span class="dup-empty">검색 결과 없음</span>'; pagerEl.innerHTML = ''; return; }

      const totalPages = Math.ceil(flatItems.length / HIST_PAGE_SIZE);
      const cur = Math.min(page, totalPages - 1);

      flatItems.slice(cur * HIST_PAGE_SIZE, (cur + 1) * HIST_PAGE_SIZE).forEach(item => {
        if (item.type === 'header') {
          const g = document.createElement('div');
          g.className = 'dup-hist-date-group'; g.textContent = item.label;
          histList.appendChild(g);
        } else {
          const { entry, dt } = item;
          const row = document.createElement('button');
          row.type = 'button'; row.className = 'dup-hist-row';
          const timeStr = `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`;
          const statText = (entry.pageCodes && entry.pageCodes.length)
            ? `보유 ${(entry.owned||[]).length}·신규 ${(entry.notOwned||[]).length}`
            : `중복 ${(entry.duplicates||[]).length}건`;
          if (entry.url) row.title = entry.url;
          row.innerHTML = `<span class="dup-hist-file">${esc(entry.file)}</span><span class="dup-hist-meta"><span class="dup-hist-date">${esc(timeStr)}</span><span class="dup-hist-stat">${esc(statText)}</span></span>`;
          row.addEventListener('click', () => {
            if (_selRow === row) { renderDetail(entry); }
            else {
              if (_selRow) _selRow.classList.remove('dup-hist-sel');
              _selRow = row; row.classList.add('dup-hist-sel');
              renderEntry(entry); fileLabel.textContent = entry.file;
            }
          });
          histList.appendChild(row);
        }
      });

      pagerEl.innerHTML = '';
      if (totalPages <= 1) return;
      const prev = document.createElement('button');
      prev.type = 'button'; prev.className = 'dup-hist-pager-btn'; prev.textContent = '←';
      prev.disabled = cur === 0;
      prev.addEventListener('click', () => renderHist(q, cur - 1));
      const pageNums = document.createElement('div');
      pageNums.className = 'dup-hist-pager-pages';
      let lo = Math.max(0, cur - 2), hi = Math.min(totalPages - 1, lo + 4);
      lo = Math.max(0, hi - 4);
      for (let p = lo; p <= hi; p++) {
        const nb = document.createElement('button');
        nb.type = 'button'; nb.className = `dup-hist-pager-num${p === cur ? ' active' : ''}`;
        nb.textContent = p + 1;
        const _p = p; nb.addEventListener('click', () => renderHist(q, _p));
        pageNums.appendChild(nb);
      }
      const next = document.createElement('button');
      next.type = 'button'; next.className = 'dup-hist-pager-btn'; next.textContent = '→';
      next.disabled = cur === totalPages - 1;
      next.addEventListener('click', () => renderHist(q, cur + 1));
      pagerEl.append(prev, pageNums, next);
    }

    renderHist();

    const sidebar = document.createElement('div');
    sidebar.className = 'dup-sidebar';
    const sidebarTitle = document.createElement('div');
    sidebarTitle.className = 'dup-sidebar-title';
    sidebarTitle.textContent = '이전 기록';
    const histSearch = document.createElement('input');
    histSearch.type = 'text';
    histSearch.className = 'dup-hist-search';
    histSearch.placeholder = '날짜 검색 (26.05, 오늘…)';
    histSearch.setAttribute('autocomplete', 'off');
    histSearch.addEventListener('input', () => renderHist(histSearch.value));
    sidebar.append(sidebarTitle, histSearch, histList, pagerEl);

    const layout = document.createElement('div');
    layout.className = 'dup-layout';
    body.append(pickRow, resultEl);
    layout.append(sidebar, body);
    modal.append(hdr, layout);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
    overlay.addEventListener('click', ev => { if (ev.target === overlay) overlay.remove(); });
    document.addEventListener('keydown', function escFn(ev) {
      if (ev.key === 'Escape' && document.getElementById('b64-dup-overlay')) {
        overlay.remove(); document.removeEventListener('keydown', escFn);
      }
    });
  }

  function injectDupCheckBtn() {
    if (document.getElementById('b64-dup-btn')) return;
    // 구독·서브 정보 버튼 그룹 우선, fallback: h1 기준 탐색
    const anchor = document.querySelector(
      'button[aria-label="서브 정보"], button[aria-label*="구독"]'
    );
    let btnArea = anchor?.parentElement;
    if (!btnArea) {
      const h1 = document.querySelector('main h1');
      btnArea = h1?.parentElement?.parentElement?.children?.[1];
    }
    if (!btnArea) return;
    const btn = document.createElement('button');
    btn.id = 'b64-dup-btn'; btn.type = 'button'; btn.title = 'TXT 중복 검사';
    btn.innerHTML = svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>');
    btn.addEventListener('click', showDupModal);
    btnArea.prepend(btn);
  }

  // inline style은 Svelte 재렌더링에 사라지지만 <head> CSS 규칙은 살아남음
  function injectOGHideStyle() {
    if (!document.head || document.getElementById('b64d-og-hide')) return;
    const s = document.createElement('style');
    s.id = 'b64d-og-hide';
    s.textContent = 'a.not-prose.h-24.overflow-hidden{display:none!important}';
    document.head.appendChild(s);
  }

  function applyTxtLiveColors() {
    const cards = document.querySelectorAll('a.b64-product-link');
    if (!CFG.TXT_LIVE) {
      cards.forEach(a => a.classList.remove('b64-tl-owned', 'b64-tl-new'));
      return;
    }
    let lib;
    try { lib = new Set(JSON.parse(GM_getValue('libCodes', '[]'))); } catch { lib = new Set(); }
    cards.forEach(a => {
      const code = a.dataset.cardCode?.toUpperCase();
      a.classList.remove('b64-tl-owned', 'b64-tl-new');
      if (code) a.classList.add(lib.has(code) ? 'b64-tl-owned' : 'b64-tl-new');
    });
  }

  function flush() {
    pending = false;
    if (mo) mo.disconnect();
    if (!document.body) { observe(); return; }
    injectOGHideStyle();
    // 글 작성 페이지에서는 모든 처리 비활성화 (복호화 무한 증식 방지)
    if (isWritePage()) { _wasWritePage = true; return; }

    const { roots: contentRoots, allRoots: allContentRoots } = getContentRoots();
    // write 페이지에서 돌아온 직후: Froala 에디터 DOM이 아직 남아있으면 재대기
    // contenteditable 속성이 먼저 제거되는 경우 대비 → Froala 전용 클래스로 체크
    if (_wasWritePage) {
      if (document.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) {
        pending = true;
        setTimeout(flush, 300);
        return;
      }
      _wasWritePage = false;
    }
    _seenProductCodes.clear();
    _gcSeenInFlush.clear();
    if (CFG.CONTENT_DECODE) {
      // 1) 분할 base64 먼저: walkAndProcess 전에 실행해야 원본 조각 정보 보존
      contentRoots.forEach(el => trySiblingMerge(el));
      // 2) 개별 노드 처리: 합산에서 제외된 요소들 (개별 디코딩 가능한 것들)
      contentRoots.forEach(el => walkAndProcess(el, processContentNode));
    }
    flushListTitles(contentRoots, allContentRoots);
    injectTitleCards(contentRoots);
    convertProductLinks(contentRoots);
    buildLinkPanel(contentRoots);
    injectDupCheckBtn();
    observe();
    applyTxtLiveColors();

    // 첫 번째 링크 카드로 스크롤 (페이지당 1회, 최초 복호화 시)
    if (CFG.SCROLL_TO_FIRST && !_scrolledOnce) {
      const firstLink = document.querySelector('.b64-link');
      if (firstLink) {
        _scrolledOnce = true;
        setTimeout(() => firstLink.scrollIntoView({ behavior: 'smooth', block: 'center' }), 150);
      }
    }
  }

  function applyLive() {
    // 1. convertProductLinks 교체 카드(a[data-b64d-orig]) → 원본 <a> 복원
    document.querySelectorAll(`[${ORIG_ATTR}]`).forEach(card => {
      const origHtml = card.getAttribute(ORIG_ATTR);
      if (!card.parentNode) return;
      const tmp = document.createElement('div');
      tmp.innerHTML = origHtml;
      const origEl = tmp.firstChild;
      if (origEl) card.parentNode.replaceChild(origEl, card);
    });

    // 2. processContentNode wrap(span[data-b64d-raw]) → 원본 텍스트 복원
    document.querySelectorAll(`span[${RAW_ATTR}]`).forEach(el => {
      const raw = el.getAttribute(RAW_ATTR);
      if (el.parentNode) el.parentNode.replaceChild(document.createTextNode(raw), el);
    });

    // 3. processListNode 결과 복원: span[data-b64lt="src"] + 바로 뒤 decoded span 제거
    document.querySelectorAll(`span[${LTDONE_ATTR}="src"]`).forEach(hideWrap => {
      const parent = hideWrap.parentNode;
      if (!parent) return;
      const next = hideWrap.nextSibling;
      parent.insertBefore(document.createTextNode(hideWrap.textContent), hideWrap);
      parent.removeChild(hideWrap);
      if (next && next.nodeType === Node.ELEMENT_NODE &&
          next.hasAttribute(LTDONE_ATTR) && next.getAttribute(LTDONE_ATTR) !== 'src') {
        parent.removeChild(next);
      }
    });

    // 4. 제목 카드 바 제거 + TITLE_CARD_ATTR 초기화
    document.querySelectorAll('.b64-title-card-bar').forEach(el => el.remove());
    document.querySelectorAll(`[${TITLE_CARD_ATTR}]`).forEach(el => el.removeAttribute(TITLE_CARD_ATTR));

    // 중복 코드 Set도 초기화 → NO_DUPLICATE_CARD 켜고/끌 때 즉시 반영
    _seenProductCodes.clear();
    _gcSeenInFlush.clear();
    processedRaws.clear();
    processedListRaws.clear();
    _kpIdx = -1;
    schedule();
  }

  function schedule(delayMs) {
    if (pending) return;
    pending = true;
    if (delayMs > 0) setTimeout(flush, delayMs);
    else queueMicrotask(flush); // 일반 mutation: 페인트 전 즉시 실행 → 깜빡임 제거
  }

  function observe() {
    if (!mo) {
      mo = new MutationObserver(mutations => {
        for (const mu of mutations)
          for (const added of mu.addedNodes) {
            if (isOurNode(added)) continue;
            if (added.nodeType === Node.ELEMENT_NODE &&
                (added.hasAttribute(DONE_ATTR) || added.hasAttribute(LTDONE_ATTR))) continue;
            schedule(); return;
          }
      });
    }
    const root = document.body || document.documentElement;
    if (root) mo.observe(root, { childList: true, subtree: true });
  }

  window.addEventListener('pageshow', e => {
    if (e.persisted) { processedRaws.clear(); processedListRaws.clear(); schedule(); }
  });

  let _lastUrl = location.href;
  function onUrlChange() {
    const cur = location.href;
    if (cur !== _lastUrl) {
      const prev = _lastUrl;
      _lastUrl = cur;
      // write 페이지에서 떠나는 경우 표시 (flush() 실행 여부와 무관하게 URL로 직접 판단)
      if (isWritePage(prev)) _wasWritePage = true;
      // 이전 URL 변경 타이머 취소 후 반드시 재시작 (pending=true 상태에서도 동작해야 함)
      if (_urlFlushTimer) { clearTimeout(_urlFlushTimer); _urlFlushTimer = null; }
      pending = false;
      if (mo) mo.disconnect();
      processedRaws.clear();
      processedListRaws.clear();
      _scrolledOnce = false;
      _kpIdx = -1;
      document.querySelectorAll(KP_FOCUSED_SEL).forEach(el => el.classList.remove('kp-focused'));
      dlpHide();
      // SPA로 write 페이지 진입 시 즉시 DOM 복원 (에디터 간섭 방지)
      if (!isWritePage(prev) && isWritePage(cur)) {
        document.getElementById('b64d-link-panel')?.remove();
        _panelCardMap.clear();
        applyLive(); // DOM 복원 → schedule() → flush()에서 isWritePage()로 조기 종료
        return;
      }
      // write 페이지 이탈 시: flush() early-return이 observe()를 생략해 MO가 끊겨있음.
      // pending=false 상태 그대로 MO 즉시 재연결 → Svelte가 읽기 콘텐츠 추가 시 schedule() 즉시 동작.
      // (pending=true를 먼저 세우면 MO → schedule()이 바로 return해버려 효과 없음)
      if (isWritePage(prev)) {
        observe(); // pending=false 유지 → MO 발화 시 schedule() 즉시 처리
        _urlFlushTimer = setTimeout(() => { _urlFlushTimer = null; flush(); }, 500); // 폴백
        return;
      }
      pending = true;
      _urlFlushTimer = setTimeout(() => { _urlFlushTimer = null; pending = false; flush(); }, 500);
    }
  }
  window.addEventListener('popstate', onUrlChange);
  ['pushState', 'replaceState'].forEach(method => {
    const orig = history[method];
    history[method] = function (...args) { const r = orig.apply(this, args); onUrlChange(); return r; };
  });

  // 탭 포커스 이탈(다른 탭 클릭·새 탭 열기) 또는 새로고침 시 미리보기 강제 숨김
  window.addEventListener('blur', dlpHide);
  window.addEventListener('beforeunload', dlpHide);

  document.documentElement.style.setProperty('--b64-dlp-scale', CFG.PREVIEW_SCALE / 100);
  flush();
  observe();

  }); // domReady end
})();