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      16.0
// @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/*
// @exclude      *://kone.gg/s/*/write
// @exclude      *://kone.gg/s/*/write/*
// @exclude      *://*.kone.gg/s/*/write
// @exclude      *://*.kone.gg/s/*/write/*
// @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_CARD:       GM_getValue('linkCard',        true),
    LINK_CHECK:      GM_getValue('linkCheck',       true),
    DRAG_DECODE:     GM_getValue('dragDecode',      true),
    BRAILLE_DECODE:  GM_getValue('brailleDecode',   true),
    DLSITE_PREVIEW:  GM_getValue('dlsitePreview',   true),
    SCROLL_TO_FIRST: GM_getValue('scrollToFirst',   false),
    NAV_TARGET:      GM_getValue('navTarget',       'links'), // 'links' | 'products' | 'both'
    PW_AUTO:         GM_getValue('pwAuto',          true),
    LIVE_APPLY:      GM_getValue('liveApply',       true),

    CONTENT_SELECTORS: [
      '#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',
    ],
    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.io':      ['does not exist', 'content not found', 'not found', 'error'],
      'workupload.com': ['not found', 'expired'],
      '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"]',
        successSel:  '.ready-to-download-box, .file-manager-box',
        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',
        successSel:  '.btn.btn-prio',
        errorPat:    [/incorrect/i, /wrong/i],
        mode: 'generic',
        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,
      },
      'drive.google.com': {
        mode: 'gdrive',
      },
      'drive.usercontent.google.com': {
        dlBtnSel:    '.goog-inline-block.jfk-button.jfk-button-action, input[type="submit"]',
        mode: 'gdrive-dl',
      },
    },
  };

  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         = 8;

  // 조상 중 이미 처리된(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;
  }
  const MAX_DECODE  = 5;

  let HOTKEY = GM_getValue('hotkey', 'Alt+Shift+K');

  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 (buildCombo(e) === HOTKEY) { e.preventDefault(); openSettingsPanel(); }
  });

  /* ================================================================
     비번 저장소
  ================================================================ */
  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 = 'b64ds-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() {
    // kone.gg 자체 다크모드(Tailwind class 방식) 우선 확인
    if (document.documentElement.classList.contains('dark')) return 'dark';
    return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? '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;
  }
  window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', applyThemeToOverlays);
  // kone.gg가 <html class="dark"> 토글 방식을 사용하므로 class 변화 감지
  new MutationObserver(applyThemeToOverlays).observe(
    document.documentElement, { attributes: true, attributeFilter: ['class'] }
  );

  function closeSettingsPanel() {
    document.getElementById('b64d-settings')?.remove();
    document.getElementById('b64d-settings-backdrop')?.remove();
  }

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

    function mkToggle(key, stKey, label) {
      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>`;
      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' && CFG.LIVE_APPLY) applyLive();
      });
      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;
    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; // 수식키만 누름: 대기
        e.preventDefault();
        e.stopPropagation();
        if (e.key === 'Escape') {
          // 취소
        } else if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
          // 수식키+일반키 조합만 허용
          HOTKEY = buildCombo(e);
          GM_setValue('hotkey', HOTKEY);
          hotkeyKey.textContent = HOTKEY;
        } else {
          return; // 수식키 없는 단일 키는 무시하고 계속 대기
        }
        capturing = false;
        hotkeyBtn.classList.remove('capturing');
        hotkeyBtn.textContent = '변경';
        document.removeEventListener('keydown', captureListener, true);
      };
      document.addEventListener('keydown', captureListener, true);
    });
    hotkeyR.appendChild(hotkeyKey);
    hotkeyR.appendChild(hotkeyBtn);
    hotkeyRow.appendChild(hotkeyLabel);
    hotkeyRow.appendChild(hotkeyR);

    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;
        });
        btns.appendChild(b);
      });
      row.appendChild(lbl);
      row.appendChild(btns);
      return row;
    }

    [
      mkSep('적용'),
      mkToggle('LIVE_APPLY', 'liveApply', '설정 변경 즉시 적용'),
      mkNote('OFF 시 다음 페이지 로드에 반영 · 긴 글에서 렉 방지'),
      mkSep('본문'),
      mkToggle('CONTENT_DECODE', 'contentDecode', '본문 번역'),
      mkToggle('LIST_DECODE',    'listDecode',    '목록 페이지 제목 번역'),
      mkToggle('DRAG_DECODE',    'dragDecode',    '드래그 자동 변환'),
      mkToggle('BRAILLE_DECODE', 'brailleDecode', '점자 디코딩'),
      mkSep('링크'),
      mkToggle('LINK_CARD',       'linkCard',       '링크 카드'),
      mkToggle('LINK_CHECK',      'linkCheck',      '링크 생존 확인'),
      mkToggle('DLSITE_PREVIEW',  'dlsitePreview',  '카드 호버 미리보기'),
      mkToggle('SCROLL_TO_FIRST', 'scrollToFirst',  '첫 다운로드 링크로 자동 스크롤'),
      mkTriple('NAV_TARGET', 'navTarget', 'w/s 탐색 범위', [
        { value: 'links',    text: '다운로드' },
        { value: 'products', text: '작품' },
        { value: 'both',     text: '모두' },
      ]),
      mkSep('비번'),
      mkToggle('PW_AUTO',        'pwAuto',        '비번 자동입력'),
      mkAction('🔑 비번 목록 관리', openPwManager),
      mkSep('단축키'),
      hotkeyRow,
    ].forEach(el => body.appendChild(el));

    const panel = document.createElement('div');
    panel.id = 'b64d-settings';
    panel.dataset.theme = getSystemTheme();
    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);
  }

  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 8px;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;
  transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;}
@media(prefers-color-scheme:dark){.b64-link{background:#27272a;border-color:#3f3f46;color:#fafafa;}}
.b64-link:hover{background:#e4e4e7;border-color:#a1a1aa;}
@media(prefers-color-scheme:dark){.b64-link:hover{background:#3f3f46;border-color:#71717a;}}
.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;}
@media(prefers-color-scheme:dark){.b64-link.lk-alive{background:#14532d;border-color:#16a34a;color:#bbf7d0;}}
.b64-link.lk-dead{background:#fef2f2;border-color:#fca5a5;color:#7f1d1d;}
@media(prefers-color-scheme:dark){.b64-link.lk-dead{background:#7f1d1d;border-color:#dc2626;color:#fecaca;}}
.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 8px;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;
  transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;}
@media(prefers-color-scheme:dark){.b64-product-link{background:#2e1065;border-color:#7c3aed;color:#ddd6fe;}}
.b64-product-link:hover{background:#ede9fe;border-color:#a78bfa;}
@media(prefers-color-scheme:dark){.b64-product-link:hover{background:#4c1d95;border-color:#8b5cf6;}}
.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;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-steam{background:#082f49;border-color:#0369a1;color:#bae6fd;}}
.b64-product-link.pl-steam:hover{background:#e0f2fe;border-color:#38bdf8;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-steam:hover{background:#0c4a6e;border-color:#0284c7;}}

/* ── Patreon 카드 (오렌지) ── */
.b64-product-link.pl-patreon{background:#fff7ed;border-color:#fdba74;color:#7c2d12;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-patreon{background:#431407;border-color:#c2410c;color:#fed7aa;}}
.b64-product-link.pl-patreon:hover{background:#ffedd5;border-color:#fb923c;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-patreon:hover{background:#7c2d12;border-color:#ea580c;}}


/* ── 설정 패널 / 비번 모달 ── */
#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%);
  width:300px;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-pw-manager{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-body{padding:12px;display:flex;flex-direction:column;gap:5px;}
.b64ds-sep{font-size:12px;font-weight:700;color:var(--b64-sep-c);letter-spacing:.06em;text-transform:uppercase;padding:10px 4px 3px;}
.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-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;}
@media(prefers-color-scheme:dark){.b64-title-card-bar{border-top-color:#3f3f46;}}
.b64-title-card-label{font-size:11px;color:#71717a;font-weight:600;letter-spacing:.05em;margin-bottom:2px;}
#b64d-dlsite-preview{
  position:fixed;z-index:2147483646;background:rgba(0,0,0,.85);
  border:1px solid #333;max-width:640px;max-height:640px;overflow:hidden;
  display:flex;align-items:center;justify-content:center;border-radius:8px;pointer-events:auto;}
#b64d-dlsite-preview img{max-width:100%;max-height:100%;display:block;}
#b64d-dlsite-preview .dlp-hint{
  position:absolute;bottom:6px;right:8px;
  font-size:11px;color:rgba(255,255,255,.5);pointer-events:none;}

/* ── 키보드 링크 탐색 포커스 ── */
.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);}
@media(prefers-color-scheme:dark){.b64-link.kp-focused,.b64-product-link.kp-focused{outline-color:#60a5fa;box-shadow:0 0 0 4px rgba(96,165,250,.2);}}
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);}

/* ── kone.gg 자체 다크모드 (html.dark 클래스 방식) ── */
html.dark .b64-link{background:#27272a;border-color:#3f3f46;color:#fafafa;}
html.dark .b64-link:hover{background:#3f3f46;border-color:#71717a;}
html.dark .b64-link.lk-alive{background:#14532d;border-color:#16a34a;color:#bbf7d0;}
html.dark .b64-link.lk-dead{background:#7f1d1d;border-color:#dc2626;color:#fecaca;}
html.dark .b64-product-link{background:#2e1065;border-color:#7c3aed;color:#ddd6fe;}
html.dark .b64-product-link:hover{background:#4c1d95;border-color:#8b5cf6;}
html.dark .b64-product-link.pl-steam{background:#082f49;border-color:#0369a1;color:#bae6fd;}
html.dark .b64-product-link.pl-steam:hover{background:#0c4a6e;border-color:#0284c7;}
html.dark .b64-product-link.pl-patreon{background:#431407;border-color:#c2410c;color:#fed7aa;}
html.dark .b64-product-link.pl-patreon:hover{background:#7c2d12;border-color:#ea580c;}
html.dark .b64-title-card-bar{border-top-color:#3f3f46;}
`;
    (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"/>'),
  };

  /* ================================================================
     점자 디코딩
  ================================================================ */
  const BRAILLE_ALPHA = {
    '⠁':'a','⠃':'b','⠉':'c','⠙':'d','⠑':'e',
    '⠋':'f','⠛':'g','⠓':'h','⠊':'i','⠚':'j',
    '⠅':'k','⠇':'l','⠍':'m','⠝':'n','⠕':'o',
    '⠏':'p','⠟':'q','⠗':'r','⠎':'s','⠞':'t',
    '⠥':'u','⠧':'v','⠺':'w','⠭':'x','⠽':'y','⠵':'z',
    '⠂':',','⠆':';','⠒':':','⠲':'.','⠖':'!','⠦':'?',
    '⠄':"'",'⠤':'-','⠀':' ',
  };
  const BRAILLE_NUM = {
    '⠁':'1','⠃':'2','⠉':'3','⠙':'4','⠑':'5',
    '⠋':'6','⠛':'7','⠓':'8','⠊':'9','⠚':'0',
  };

  function decodeBraille(str) {
    let result = '', numMode = false, capNext = false;
    for (const ch of str) {
      if (ch === '⠼') { numMode = true; continue; }
      if (ch === '⠠') { capNext = true; numMode = false; continue; }
      if (ch === '⠀') { numMode = false; result += ' '; continue; }
      if (numMode && BRAILLE_NUM[ch]) { result += BRAILLE_NUM[ch]; continue; }
      numMode = false;
      const a = BRAILLE_ALPHA[ch];
      if (!a) { result += ch; continue; }
      result += capNext ? a.toUpperCase() : a;
      capNext = false;
    }
    return result;
  }

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

  function looksLikeB64(str) {
    return str.length >= MIN_B64 && str.length % 4 !== 1 &&
           /^[A-Za-z0-9+/]+={0,2}$/.test(str);
  }

  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;
  }

  function decodeB64Once(str) {
    if (!looksLikeB64(str)) return null;
    try {
      let s = str;
      const rem = s.length % 4;
      if (rem === 2) s += '==';
      else if (rem === 3) s += '=';
      const bytes = Uint8Array.from(atob(s), c => c.charCodeAt(0));
      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)\d{4,8})\b/gi;
  // DLsite 한글 IME 입력 – 거(rj) / 꺼(Rj, shift)
  const DLSITE_KR_RE    = /(꺼|거)(\d{4,8})/g;
  // Steam – 스팀, Steam, steam, st/St/ST (단어 경계), 구분자 공백·대시 허용
  const STEAM_CODE_RE   = /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*(\d{4,10})/g;

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

    // ── 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;
    }

    // ── 점자 ──
    if (CFG.BRAILLE_DECODE) {
      const bre = /[⠀-⣿]{3,}/g;
      while ((m = bre.exec(raw)) !== null) {
        const decoded = decodeBraille(m[0]);
        if (!decoded || decoded === m[0]) continue;
        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, type: 'braille' });
      }
    }

    // ── 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].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] });
    }

    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;
  }

  /* ================================================================
     링크 생존 확인
  ================================================================ */
  function setLinkState(cardEl, alive, msg) {
    cardEl.classList.remove('lk-checking');
    if (alive === true)  cardEl.classList.add('lk-alive');
    else if (alive === false) cardEl.classList.add('lk-dead');
    // alive === null → 회색 (검증 불가) 상태, 클래스 추가 없음
    const sub  = cardEl.querySelector('.bl-sub');
    const wrap = cardEl.querySelector('.bl-icon-wrap');
    const arr  = cardEl.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 beginCheck(cardEl) {
    cardEl.classList.add('lk-checking');
    const arr = cardEl.querySelector('.bl-arrow');
    if (arr) arr.innerHTML = ICO.spin;
  }

  function checkLinkByContent(url, cardEl, deadPatterns, 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 = deadPatterns.some(p => body.includes(p.toLowerCase()));
        setLinkState(cardEl, !dead, dead ? `${siteName} · 링크 만료` : `${siteName} · 링크 정상`);
      },
      onerror()   { setLinkState(cardEl, false, `${siteName} · 연결 실패`); },
      ontimeout() { setLinkState(cardEl, false, `${siteName} · 응답 없음`); },
    });
  }

  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: guest 토큰 발급 후 contents API 조회
  function checkGofileLink(url, cardEl) {
    const m = url.match(/gofile\.io\/d\/([^/?#]+)/);
    if (!m) { checkLinkByHead(url, cardEl); return; }
    const contentId = m[1];
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'POST',
      url: 'https://api.gofile.io/accounts',
      headers: { 'Content-Type': 'application/json' },
      data: '{}',
      timeout: 1500,
      onload(r1) {
        let token = '';
        try { token = JSON.parse(r1.responseText)?.data?.token || ''; } catch(e) {}
        if (!token) { checkLinkByHead(url, cardEl); return; }
        GM_xmlhttpRequest({
          method: 'GET',
          url: `https://api.gofile.io/contents/${contentId}?token=${token}&wt=4fd6sg89d7s6&cache=true`,
          timeout: 1500,
          onload(r2) {
            try {
              const d = JSON.parse(r2.responseText);
              const dead = d.status !== 'ok';
              setLinkState(cardEl, !dead, dead ? 'gofile.io · 링크 만료' : 'gofile.io · 링크 정상');
            } catch(e) { setLinkState(cardEl, true, 'gofile.io · 링크 정상'); }
          },
          onerror()   { setLinkState(cardEl, false, 'gofile.io · 연결 실패'); },
          ontimeout() { setLinkState(cardEl, false, 'gofile.io · 응답 없음'); },
        });
      },
      onerror()   { checkLinkByHead(url, cardEl); },
      ontimeout() { checkLinkByHead(url, cardEl); },
    });
  }

  // mypikpak.com: Drive API 우선, 판단 불가 시 DEAD_PATTERNS 콘텐츠 폴백
  function checkPikpakLink(url, cardEl) {
    const m = url.match(/mypikpak\.com\/s\/([^/?#]+)/);
    if (!m) { checkLinkByHead(url, cardEl); return; }
    const shareId = m[1];
    const patterns = CFG.DEAD_PATTERNS['mypikpak.com'] || [];
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://api-drive.mypikpak.com/drive/v1/share?share_id=${shareId}&thumbnail_size=SIZE_MEDIUM&pass_code=`,
      timeout: 3000,
      onload(res) {
        // 인증 오류(401/403)는 "만료"가 아니라 "접근 불가" → 폴백으로
        if (res.status >= 400 && res.status !== 401 && res.status !== 403) {
          setLinkState(cardEl, false, 'mypikpak · 링크 만료'); return;
        }
        let apiDead = null;  // null = API로 판단 불가
        try {
          const d = JSON.parse(res.responseText);
          if (typeof d.code === 'number' && d.code !== 0) {
            apiDead = true;
          } else if (d.share_status) {
            const okSet = new Set(['OK', 'ACTIVE', 'NORMAL']);
            apiDead = !okSet.has(d.share_status);
          } else {
            apiDead = false;  // code===0, share_status 없음 → 정상
          }
        } catch(e) { /* JSON 파싱 실패 → 폴백 */ }
        if (apiDead === true)  { setLinkState(cardEl, false, 'mypikpak · 링크 만료'); return; }
        if (apiDead === false) { setLinkState(cardEl, true,  'mypikpak · 링크 정상'); return; }
        // API 판단 불가 → 페이지 콘텐츠 패턴 + HTTP 상태 폴백
        checkLinkByContent(url, cardEl, patterns, 'mypikpak');
      },
      onerror()   { checkLinkByContent(url, cardEl, patterns, 'mypikpak'); },
      ontimeout() { setLinkState(cardEl, false, 'mypikpak · 응답 없음'); },
    });
  }

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

  function checkLink(url, cardEl) {
    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);
    const patterns = CFG.DEAD_PATTERNS[h] || CFG.DEAD_PATTERNS[h.replace(/^www\./, '')];
    if (patterns) checkLinkByContent(url, cardEl, patterns, h);
    else          checkLinkByHead(url, cardEl);
  }

  /* ================================================================
     유틸
  ================================================================ */
  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 a;
  }

  /* ================================================================
     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 a;
  }

  /* ================================================================
     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 a;
  }

  /* ================================================================
     다운로드 링크 카드
     - 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 → 기본 상품 카드
        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</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 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) {}
      }
    }

    // 일반 카드
    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), 0);
    return a;
  }

  /* ================================================================
     디코딩 텍스트 → DOM 노드
     URL / DLsite 코드 / Steam 코드를 별도 regex로 처리 (i 플래그 분리)
  ================================================================ */
  function buildNodes(text) {
    // 각 타입을 별도 regex로 수집 후 위치 기준 정렬
    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].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] });

    // 위치순 정렬 + 중첩 제거
    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') {
        nodes.push(CFG.LINK_CARD ? makeDlsiteCard(mt.code) : document.createTextNode(mt.code));
      } else if (mt.type === 'steam') {
        nodes.push(CFG.LINK_CARD ? makeSteamCard(mt.appId) : document.createTextNode(`Steam ${mt.appId}`));
      } else {
        // URL: 후행 구두점 제거
        let url = mt.raw;
        const trail = (url.match(/[.,;:!?)\]}'"]+$/) || [''])[0];
        if (trail) url = url.slice(0, -trail.length);
        nodes.push(makeLinkCard(url));
        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 재렌더링 후 카드 재생성 판단용)

  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();
      if (existingWrap && document.contains(existingWrap)) {
        // decoded wrap이 문서에 살아있음 → 이 위치는 숨김
        // (같은 부모 = Svelte/React 재삽입, 다른 부모 = kone.gg 중복 렌더링 - 모두 숨김)
        const hide = document.createElement('span');
        hide.setAttribute(DONE_ATTR, '');
        hide.style.cssText = 'display:none!important;';
        hide.textContent = raw;
        node.parentNode.replaceChild(hide, node);
        return;
      }
      // existingWrap이 문서에 없음(분리된 DOM, React 전체 교체 등) → 재디코딩
      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') {
        frag.appendChild(CFG.LINK_CARD ? makeDlsiteCard(hit.code) : document.createTextNode(hit.decoded));
      } else if (type === 'steam') {
        frag.appendChild(CFG.LINK_CARD ? makeSteamCard(hit.appId) : document.createTextNode(hit.decoded));
      } else {
        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();
    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);
  }

  /* ================================================================
     드래그 자동 변환
  ================================================================ */
  let dragTooltip = null;
  function removeDragTooltip() { if (dragTooltip) { 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;
    const hits = findAllMatches(text);
    if (!hits.length) return;

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

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

    // ── 본문 인-플레이스 교체 우선 시도 ──
    // 선택 범위가 단일 미처리 텍스트 노드 안에 있으면 바로 본문에 적용
    if (sel.rangeCount) {
      const r  = sel.getRangeAt(0);
      const cn = r.startContainer;
      if (cn.nodeType === Node.TEXT_NODE &&
          cn === r.endContainer &&
          document.body.contains(cn) &&
          !hasProcessedAncestor(cn)) {
        processContentNode(cn);
        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 또는 ← → 로 이미지 전환, 휠 스크롤 가능
  ================================================================ */
  const dlp = { el: null, images: [], idx: 0, showing: false, srcWatcher: null };

  function dlpUpdateImage() {
    if (!dlp.el || !dlp.images.length) return;
    dlp.el.innerHTML = '';
    const img = document.createElement('img');
    img.src = dlp.images[dlp.idx];
    img.referrerPolicy = 'no-referrer';
    const hint = document.createElement('div');
    hint.className = 'dlp-hint';
    hint.textContent = `${dlp.idx + 1} / ${dlp.images.length}  ←→ / a·d / 휠`;
    dlp.el.appendChild(img);
    dlp.el.appendChild(hint);
  }

  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() {
    if (dlp.srcWatcher) { dlp.srcWatcher.disconnect(); dlp.srcWatcher = null; }
    if (dlp.el) { dlp.el.remove(); dlp.el = null; }
    dlp.showing = false; dlp.images = []; dlp.idx = 0;
    document.removeEventListener('mousemove', dlpMove);
    document.removeEventListener('keydown', dlpKey);
  }

  function dlpKey(e) {
    if (!dlp.showing || dlp.images.length <= 1) return;
    if (e.key === 'ArrowLeft'  || e.key === 'a') { e.preventDefault(); dlp.idx = (dlp.idx - 1 + dlp.images.length) % dlp.images.length; dlpUpdateImage(); }
    if (e.key === 'ArrowRight' || e.key === 'd') { e.preventDefault(); dlp.idx = (dlp.idx + 1) % dlp.images.length; dlpUpdateImage(); }
  }

  function dlpShow(e) {
    if (!CFG.DLSITE_PREVIEW || dlp.showing) return;
    dlp.showing = true;
    dlp.el = document.createElement('div');
    dlp.el.id = 'b64d-dlsite-preview';
    dlp.el.textContent = '로딩 중...';
    dlp.el.style.color = '#fff';
    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 href = e.currentTarget.href;
    const steamMatch = href && href.match(/store\.steampowered\.com\/app\/(\d+)/);

    if (steamMatch) {
      // ── Steam: API로 스크린샷 가져오기 ──
      GM_xmlhttpRequest({
        method: 'GET',
        url: `https://store.steampowered.com/api/appdetails?appids=${steamMatch[1]}&filters=screenshots`,
        timeout: 3000,
        onload(res) {
          if (!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_full);
              dlp.idx = 0;
              dlpUpdateImage();
            } else {
              dlp.el.textContent = '스크린샷 없음';
            }
          } catch(err) {
            if (dlp.el) dlp.el.textContent = '미리보기 로드 실패';
          }
        },
        onerror()   { if (dlp.el) dlp.el.textContent = '연결 실패'; },
        ontimeout() { if (dlp.el) dlp.el.textContent = '응답 없음'; },
      });
    } else {
      // ── DLsite: 페이지 HTML 파싱 ──
      GM_xmlhttpRequest({
        method: 'GET', url: href,
        headers: { 'User-Agent': navigator.userAgent, 'Referer': 'https://www.dlsite.com/' },
        onload(res) {
          if (!dlp.el) return;
          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.com"]']) {
            doc.querySelectorAll(sel).forEach(el => {
              const src = el.dataset.src || el.src || '';
              if (src && !src.includes('data:') && !src.includes('/resize/') && !imgs.includes(src)) imgs.push(src);
            });
            if (imgs.length) break;
          }
          dlp.images = imgs; dlp.idx = 0;
          if (imgs.length) dlpUpdateImage();
          else if (dlp.el) { dlp.el.textContent = '이미지를 찾을 수 없습니다.'; dlp.el.style.color = '#fff'; }
        },
        onerror() { if (dlp.el) { dlp.el.textContent = '미리보기 로드 실패'; dlp.el.style.color = '#fff'; } },
      });
    }

    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; // CONTENT_SELECTORS 미매칭 = 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{4,8})\b/i);
          if (idMatch) { seenKey = 'dl:' + idMatch[1].toUpperCase(); card = seen.has(seenKey) ? null : makeDlsiteCard(idMatch[1].toUpperCase()); }
        } else if (href.includes('store.steampowered.com/app/')) {
          const appMatch = href.match(/\/app\/(\d+)/);
          if (appMatch) { seenKey = 'st:' + appMatch[1]; card = seen.has(seenKey) ? null : makeSteamCard(appMatch[1]); }
        } 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) {}
        }

        // ── 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);
          if (dlLatin)      { seenKey = 'dl:' + dlLatin[1].toUpperCase(); card = seen.has(seenKey) ? null : makeDlsiteCard(dlLatin[1].toUpperCase()); }
          else if (dlKr)    { seenKey = 'dl:RJ' + dlKr[2]; card = seen.has(seenKey) ? null : makeDlsiteCard('RJ' + dlKr[2]); }
          else if (st)      { seenKey = 'st:' + st[1]; card = seen.has(seenKey) ? null : makeSteamCard(st[1]); }
        }

        // ── 알려진 다운로드/파일 공유 사이트 → 일반 링크 카드 ──
        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) 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/"]';
      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));
          if (siteCfg.successSel && document.querySelector(siteCfg.successSel)) break;
          if (findPwError(input, siteCfg.errorPat)) continue;
        }
      }
      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 === 'transfer') {
      let transferDone = false;
      async function transferTryPw() {
        if (transferDone) return;
        const pws = getPwList() || [];
        for (const pw of pws) {
          if (!pw) continue;
          const input = getInputEl(siteCfg.inputSel);
          if (!input) break;
          setNativeValue(input, pw);
          await new Promise(r => setTimeout(r, 150));
          const btn = document.querySelector(siteCfg.btnSel);
          if (btn) btn.click();
          await new Promise(r => setTimeout(r, 1200));
          if (siteCfg.successSel && document.querySelector(siteCfg.successSel)) { transferDone = true; break; }
        }
      }
      const transferObs = new MutationObserver(() => {
        const input = getInputEl(siteCfg.inputSel);
        if (input && !input.dataset._tTried) { input.dataset._tTried = '1'; setTimeout(transferTryPw, 400); }
      });
      transferObs.observe(document.documentElement, { childList: true, subtree: true });
      if (siteCfg.dlBtnSel) {
        setTimeout(() => {
          const dlBtn = document.querySelector(siteCfg.dlBtnSel);
          if (dlBtn && isVisible(dlBtn)) dlBtn.click();
        }, siteCfg.triggerDelay || 1500);
      }
    }

    if (siteCfg.mode === 'gdrive') {
      const parts = location.pathname.split('/');
      if (parts[1] === 'file' && parts[2] === 'd' && parts[3]) {
        location.href = `https://drive.usercontent.google.com/download?id=${parts[3]}&export=download&authuser=0`;
      }
      if (parts[1] === 'drive' && parts[2] === 'folders') {
        setTimeout(() => {
          const btn = document.querySelector('[aria-label="모두 다운로드"], [data-tooltip="모두 다운로드"]');
          if (btn) btn.click();
        }, 2500);
      }
    }

    if (siteCfg.mode === 'gdrive-dl') {
      setTimeout(() => {
        const btn = document.querySelector(siteCfg.dlBtnSel);
        if (btn) btn.click();
      }, 500);
    }
  }

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

  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 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'],
    ];

    let candidates = new Set();
    for (const group of PRIORITY_GROUPS) {
      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;
          candidates.add(el);
        });
      }
      if (candidates.size > 0) break; // 이 그룹에서 매칭됐으면 낮은 우선순위 건너뜀
    }

    // 중첩 제거: 다른 루트의 자손인 요소는 제외
    const result = 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) result.add(el);
    }
    return result;
  }

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

    CFG.LIST_SELECTORS.forEach(sel => {
      document.querySelectorAll(sel).forEach(el => {
        let anc = el;
        while (anc) { if (contentRoots.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 (contentRoots.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) ||
          /[⠀-⣿]{3,}/.test(v) ||
          /\b(?:RJ|BJ|VJ|RE|BE|VE)\d{4,8}\b/i.test(v) ||
          /[꺼거]\d{4,8}/.test(v) ||
          /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*\d{4,10}/.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');
        for (const hit of hits) {
          const id = hit.type === 'dlsite' ? hit.code : hit.appId;
          if (seen.has(id)) continue;
          seen.add(id);
          cards.push(
            hit.type === 'dlsite' ? makeDlsiteCard(hit.code) : makeSteamCard(hit.appId)
          );
        }
      }

      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 (e.key === 's' || e.key === 'ArrowDown') { if (kpNavigate(1))  e.preventDefault(); }
    else if (e.key === 'w' || e.key === 'ArrowUp') { 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로 숨김
  // 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 flush() {
    pending = false;
    if (mo) mo.disconnect();
    if (!document.body) { observe(); return; }
    injectOGHideStyle();
    // 글 작성 페이지에서는 모든 처리 비활성화 (복호화 무한 증식 방지)
    if (isWritePage()) { _wasWritePage = true; return; }

    const contentRoots = 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;
    }
    if (CFG.CONTENT_DECODE) contentRoots.forEach(el => walkAndProcess(el, processContentNode));
    flushListTitles(contentRoots);
    injectTitleCards(contentRoots);
    convertProductLinks(contentRoots);
    observe();

    // 첫 번째 다운로드 링크 카드로 스크롤 (페이지당 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));

    processedRaws.clear();
    processedListRaws.clear();
    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('.b64-link.kp-focused, .b64-product-link.kp-focused').forEach(el => el.classList.remove('kp-focused'));
      dlpHide();
      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);

  flush();
  observe();

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