Greasy Fork is available in English.
Base64/braille decoder, DLsite/Steam product cards, link-health checker, and password auto-fill for kone.gg.
// ==UserScript==
// @name KONE +
// @namespace https://kone.gg/kone-plus-KRUI
// @version 20.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/*
// @match *://jstrieb.github.io/*
// @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 greasyfork.org
// @connect update.greasyfork.org
// @connect *
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const host = location.hostname.replace(/^www\./, '');
/* ================================================================
DOM 준비 후 실행
================================================================ */
function domReady(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn, { once: true });
}
domReady(function () {
/* ================================================================
설정
================================================================ */
const CFG = {
CONTENT_DECODE: GM_getValue('contentDecode', true),
LIST_DECODE: GM_getValue('listDecode', true),
LINK_PANEL: GM_getValue('linkPanel', false),
PANEL_TARGET: GM_getValue('panelTarget', 'both'),
LINK_CARD: GM_getValue('linkCard', true),
GENRE_BAR: GM_getValue('genreBar', true), // DLsite 장르/태그 링크를 본문 하단 '별도 바'로 모음 (요구1: 인라인 칩 증식 방지)
LINK_CHECK: GM_getValue('linkCheck', true),
DRAG_DECODE: GM_getValue('dragDecode', true),
DLSITE_PREVIEW: GM_getValue('dlsitePreview', true),
PREVIEW_META: GM_getValue('previewMeta', true),
PREVIEW_TAGS: GM_getValue('previewTags', true),
PREVIEW_TAGS_EXPANDED: GM_getValue('previewTagsExpanded', false),
SCROLL_TO_FIRST: GM_getValue('scrollToFirst', false),
NO_DUPLICATE_CARD: GM_getValue('noDuplicateCard', false),
NAV_TARGET: GM_getValue('navTarget', 'links'),
PW_AUTO: GM_getValue('pwAuto', true),
LIVE_APPLY: GM_getValue('liveApply', true),
COPY_CODE: GM_getValue('copyCode', false),
PREVIEW_SCALE: GM_getValue('previewScale', 100),
SETTINGS_SCALE: GM_getValue('settingsScale', 100),
LINK_CHECK_DELAY: GM_getValue('linkCheckDelay', 0),
TXT_LIVE: GM_getValue('txtLive', false),
EXPAND_REPLIES: GM_getValue('expandReplies', true),
EXPAND_MORE: GM_getValue('expandMore', true),
NAV_TOAST: GM_getValue('navToast', true),
COMMENT_TOOLS: GM_getValue('commentTools', true), // 댓글 작성창에 점자/base64 변환 툴바 (요구1)
BRAILLE_PANEL: GM_getValue('braillePanel', true), // 작성 페이지 변환 패널
BR_MODE: GM_getValue('brMode', 'braille'), // 'braille' | 'base64'
BR_COUNT: GM_getValue('brCount', 1), // base64 반복 횟수
BR_LIVE: GM_getValue('brLive', true), // 라이브 변환
BR_PINNED: GM_getValue('brPinned', false), // 변환 패널 항상 열어두기
BRAILLE_DECODE: GM_getValue('brailleDecode', true), // 읽기 페이지 점자→한글 자동 변환
STEG_DECODE: GM_getValue('stegDecode', true), // 읽기 페이지: 숨은 코드(StegCloak) 자동 복원, 실패 시 배지 (요구2)
// 받침↔문장부호(ㅌ/?·ㅋ/!·ㅍ/.) 동형 보정용 빈출 종결음절. 받침으로 읽은 음절이 이 목록에
// 있으면 어절 끝에서 받침으로 디코딩(없으면 문장부호). 공백 구분. 설정에서 편집.
BRAILLE_FINAL: GM_getValue('brailleFinal', '끝 밑 밭 팥 솥 곁 볕 뭍 샅 녘 앞 옆 숲 잎 짚 늪 릎'),
// 외부 변환기(dencode 등)의 UEB(영어) 점자도 디코딩. ON 시 URL/영어로 감지된 토큰만 UEB로 해석.
BRAILLE_UEB: GM_getValue('brailleUeb', false),
// GreasyFork 자동 업데이트 확인: 접속 시 최신 @version 조회 후 새 버전이면 알림(6시간 간격).
UPDATE_CHECK: GM_getValue('updateCheck', true),
// W/S(↑/↓)로 카드 이동 시 그 카드의 미리보기도 자동으로 띄움 (미리보기 ON 필요).
KP_PREVIEW: GM_getValue('kpPreview', false),
// W/S 미리보기 위치: 'card'(기존=카드 옆) | 'center'(정가운데) | 'left'(왼쪽) | 'right'(오른쪽 고정).
KP_PREVIEW_POS: GM_getValue('kpPreviewPos', 'card'),
LIST_SELECTORS: [
'.title', '.subject', '.list-title', '.gallery-title', '.item-title',
'td.title', 'td.subject', 'td.title a', 'td.subject a',
'.board-list td a', '.list-body td a',
'li.list-item .title', '.post-list .title',
],
DEAD_PATTERNS: {
'kio.ac': ['컬렉션을 찾을 수 없음', '만료되었습니다'],
'kiosk.ac': ['컬렉션을 찾을 수 없음', '만료되었습니다'],
// mega.nz: 순수 SPA, 초기 HTML에 에러 없음, HEAD도 항상 200 → 감지 불가하여 제거
'transfer.it': ["can't find this transfer", 'oops', 'not found', 'expired'],
// gofile: 죽으면 "This content does not exist" 문구가 페이지에 표시됨
'gofile.io': ['this content does not exist'],
// workupload: 죽으면 "File not found" 표시, 또는 홈으로 리다이렉트
'workupload.com': ['file not found', 'not found', 'expired', 'has been deleted', 'does not exist', 'no longer available'],
// mypikpak: 죽으면 '죄송합니다. 공유 파일을 찾을 수 없습니다' 표시
'mypikpak.com': ['죄송합니다', '공유 파일을 찾을 수 없습니다', 'share has expired', 'been deleted', 'been detected', 'link is invalid', 'does not exist'],
},
PW_SITES: {
'kio.ac': {
inputSel: '.overflow-auto.max-w-full.grow.p-1 input:nth-of-type(1)',
btnSel: '.flex.flex-col-reverse button:nth-of-type(1)',
successSel: '.files-list, #download-section',
errorPat: [/비밀번호가 일치하지 않/, /incorrect password/i, /invalid password/i],
mode: 'kio',
},
'kiosk.ac': {
inputSel: '.input.shadow-xl.flex-grow',
btnSel: '.btn.btn-ghost.w-full.mt-2.rounded-md',
successSel: '#vexplorer-body',
errorPat: [/비밀번호가 일치하지 않/, /incorrect/i],
mode: 'kio',
},
'mega.nz': {
inputSel: 'input[name="decrypt-link"], #password-decrypt-input',
btnSel: 'button.decrypt-button, .mega-component.decrypt-button',
dlBtnSel: '.mega-button.positive.js-default-download.js-standard-download, .mega-button.large.positive.download.continue-download',
successSel: '.mega-button.positive.js-default-download, .fm-item, .file-folder-view:not(.no-content) .item-type-folder',
errorPat: [/invalid password/i, /incorrect/i, /wrong password/i],
mode: 'generic',
triggerDelay: 2800,
btnDelay: 500, // React가 input 이벤트 처리 후 버튼 활성화까지 대기
},
'transfer.it': {
inputSel: 'input[name="msg-dialog-input"]',
btnSel: 'button.it-button.xl-size.js-positive-btn',
dlBtnSel: 'section.it-box.lg-shadow.modal.ready-to-download-box button.it-button.xl-size.js-download, .it-button.xl-size[class*="download"]',
errorPat: [/incorrect password/i, /wrong password/i, /invalid/i],
mode: 'transfer',
triggerDelay: 1500,
},
'gofile.io': {
inputSel: '#filesErrorPasswordInput',
btnSel: '#filesErrorPasswordButton',
successSel: '.row.align-items-center.contentRow',
errorPat: [/incorrect/i, /wrong/i, /invalid/i],
mode: 'generic',
triggerDelay: 1000,
},
'workupload.com': {
inputSel: '#passwordprotected_file_password',
btnSel: '#passwordprotected_file_submit',
dlBtnSel: '.btn.btn-prio:not(.fa-unlock)', // 비번 해제 후 다운로드 버튼
mode: 'formpost', // 전통적 form POST: 제출 시 페이지 리로드 → GM_setValue로 인덱스 유지
triggerDelay: 1000,
},
'mypikpak.com': {
inputSel: '.el-input__inner[placeholder*="비밀번호"], .el-input__inner[placeholder*="password"], .el-input__inner[placeholder*="Password"], .el-input__inner',
btnSel: '.pp-primary-button, .el-button--primary.pp-primary-button',
successSel: '.file-list, [class*="file-list"], .drive-main',
errorPat: [/incorrect/i, /wrong/i, /invalid/i, /일치하지 않/, /틀렸/],
mode: 'generic',
triggerDelay: 1500,
btnDelay: 500, // 비번 입력 후 Element UI가 버튼 활성화(is-disabled 해제)할 때까지 대기
},
// link-lock (jstrieb.github.io/link-lock/#<암호화 데이터>): 브라우저 AES로 URL을 잠근 링크.
// 오답 시 .error 박스 표시(+'Try again'=main() 재실행), 정답 시 숨긴 URL로 리다이렉트.
'jstrieb.github.io': {
inputSel: '#password',
btnSel: '#unlockbutton',
errorSel: '.error.red-border, .error', // 표시되면(visible) 오답
retryText: /try again|다시\s*시도/i, // 폼 복원 버튼(main())
mode: 'linklock',
triggerDelay: 800,
},
},
};
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;
const KP_FOCUSED_SEL = '.b64-link.kp-focused, .b64-product-link.kp-focused';
const IS_MOBILE = navigator.maxTouchPoints > 0;
// 조상 중 이미 처리된(DONE_ATTR/LTDONE_ATTR) 노드가 있는지 확인
// 단, processContentNode·trySiblingMerge·드래그가 만든 '투명 디코딩 래퍼'(display:contents)는
// 조상으로 쳐서 막지 않는다(통과). 이 래퍼는 시각적으로 투명하고, 코네(SvelteKit)
// 하이드레이션이 래퍼 안의 우리 카드를 자기 평문 <a>(DONE 없음)로 갈아끼우는 일이 있는데,
// 그 <a>는 카드화 대상이지만 래퍼의 data-b64d 때문에 convertProductLinks가 통째로 건너뛰어
// 'NO_DUP ON 상태로 페이지 진입 시 다운로드 카드 미생성'이 발생했다(진단 확증).
//
// ★투과 판정은 '계산값(computed) display'로 한다. 과거엔 인라인 프로퍼티 n.style.display로
// 읽었는데, 래퍼가 'all:unset;display:contents;' cssText로 만들어진 데다 SvelteKit
// 하이드레이션이 style 속성을 재직렬화하면 n.style.display가 'contents'로 안 읽히는
// 경우가 있었다(인라인 읽기 불안정 → 페이지마다 카드화 성공/실패가 갈리던 간헐 버그의 원인).
// getComputedStyle은 실제 렌더링 display라 진단에서 입증된 'contents'를 안정적으로 반환한다.
// display:contents는 우리 투명 래퍼만 쓰므로(숨김블록=none, 카드=block/flex) 오판 없음.
// getComputedStyle 호출은 DONE 조상(=우리 래퍼/카드뿐, 드묾)일 때만 일어나 비용도 작다.
function hasProcessedAncestor(node) {
let n = node.parentElement;
while (n) {
if (n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR)) {
// 인라인이 이미 'contents'면 즉시 통과(빠른 경로), 아니면 계산값으로 재확인.
let transparent = n.style && n.style.display === 'contents';
if (!transparent) {
try { transparent = getComputedStyle(n).display === 'contents'; } catch (e) {}
}
if (!transparent) return true;
}
n = n.parentElement;
}
return false;
}
// 최대 반복 디코딩 횟수: 다중 인코딩(6겹 이상)을 처리하기 위해 10으로 설정
const MAX_DECODE = 10;
let HOTKEY = GM_getValue('hotkey', 'Shift+Q');
let DUP_HOTKEY = GM_getValue('dupHotkey', 'Shift+D');
function buildCombo(e) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Meta');
const k = e.key;
if (!['Control', 'Alt', 'Shift', 'Meta'].includes(k)) {
parts.push(k.length === 1 ? k.toUpperCase() : k);
}
return parts.join('+');
}
document.addEventListener('keydown', e => {
const tag = (document.activeElement?.tagName || '').toUpperCase();
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
if (e.key === 'Escape' && document.getElementById('b64d-settings')) {
e.preventDefault(); closeSettingsPanel(); return;
}
// 단축키는 오버레이 상태와 무관하게 토글 (열었으면 같은 키로 닫을 수 있어야 함)
if (buildCombo(e) === HOTKEY) { e.preventDefault(); openSettingsPanel(); return; }
if (buildCombo(e) === DUP_HOTKEY) {
e.preventDefault();
const existing = document.getElementById('b64-dup-overlay');
if (existing) existing.remove(); else showDupModal();
return;
}
// (w/s/a/d 등 나머지 키는 각자의 핸들러가 오버레이 열림 여부를 자체 확인한다)
});
/* ================================================================
비번 저장소
================================================================ */
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)));
}
/* ── 채널별 비번 (요구3) ──────────────────────────────────────────
카드 생성 시 '다운로드 URL → 채널코드' 매핑을 GM 저장(채널코드는 카드의 '보이지 않는 값').
다운로드 사이트(kio.ac 등)의 비번 자동입력이 자기 URL로 채널을 조회해, 그 채널에 정한
비번만 시도한다(없으면 기존 전체 pwList 폴백). URL은 건드리지 않아 mega #키 등도 무손상. */
function getChannelCode(href = location.href) {
try { const m = new URL(href).pathname.match(/^\/s\/([^/?#]+)/); return m ? m[1] : null; } catch (e) { return null; }
}
function _kchKey(u) {
try { const x = new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u); return x.origin + x.pathname; }
catch (e) { return String(u).split(/[?#]/)[0]; }
}
function getChannelUrlMap() {
try { const r = GM_getValue('channelUrlMap', null); return r ? (JSON.parse(r) || {}) : {}; } catch (e) { return {}; }
}
function setChannelForUrl(url, ch) {
if (!ch || !url) return;
const map = getChannelUrlMap();
map[_kchKey(url)] = { ch, ts: Date.now() };
const keys = Object.keys(map);
if (keys.length > 300) keys.sort((a, b) => (map[a].ts || 0) - (map[b].ts || 0)).slice(0, keys.length - 300).forEach(k => delete map[k]);
try { GM_setValue('channelUrlMap', JSON.stringify(map)); } catch (e) {}
}
function getChannelForCurrentSite() {
const rec = getChannelUrlMap()[_kchKey(location.href)];
return rec ? rec.ch : null;
}
function getChannelPwMap() {
try { const r = GM_getValue('channelPw', null); return r ? (JSON.parse(r) || {}) : {}; } catch (e) { return {}; }
}
function saveChannelPwMap(map) { try { GM_setValue('channelPw', JSON.stringify(map)); } catch (e) {} }
/* ── 채널명(표시 이름) 매핑 ───────────────────────────────────────────
비번 매칭·저장의 '내부 키'는 항상 채널 코드(/s/<코드>, URL에서만 얻음). 그 위에 코드→사람이 읽는
채널명 매핑(GM 'channelNames')을 얹어, 팝업·비번 안내·채널 목록 등 '표시'에는 채널명을 쓴다.
채널명은 채널 페이지 방문 시 자동 추출(captureChannelName) + 비번 관리에서 수동 보정. */
function getChannelNameMap() {
try { const r = GM_getValue('channelNames', null); return r ? (JSON.parse(r) || {}) : {}; } catch (e) { return {}; }
}
function setChannelName(code, name) {
if (!code) return;
name = String(name == null ? '' : name).replace(/\s+/g, ' ').trim();
const m = getChannelNameMap();
if (!name || name === code) { if (m[code] === undefined) return; delete m[code]; } // 빈 이름 = 매핑 해제
else { if (m[code] === name) return; m[code] = name; }
const keys = Object.keys(m);
if (keys.length > 500) delete m[keys[0]];
try { GM_setValue('channelNames', JSON.stringify(m)); } catch (e) {}
}
// 코드 → 표시명(이름 없으면 코드 그대로). 팝업·라벨·목록 표시 전용.
function channelDisplayName(code) { return code ? (getChannelNameMap()[code] || code) : ''; }
// 사용자 입력(이름 또는 코드) → 실제 채널 코드로 해석. 이름이 매핑에 있으면 그 코드, 아니면 입력을 코드로 간주.
function resolveChannelInput(input) {
const s = String(input || '').replace(/^#\s*/, '').trim();
if (!s) return '';
const m = getChannelNameMap();
for (const code in m) if (m[code] === s) return code; // 채널명 정확 일치 → 그 코드
return s; // 매핑에 없으면 코드로 간주
}
// 현재 채널 페이지에서 '채널 홈 링크(<a href="/s/<코드>">채널명</a>)' 텍스트를 채널명으로 자동 추출.
// 경로가 정확히 /s/<코드>인 링크만(하위 경로 제외) → 브레드크럼/헤더의 채널 이름을 안정적으로 집는다.
function captureChannelName() {
const code = getChannelCode();
if (!code || getChannelNameMap()[code]) return; // 없거나 이미 매핑됨
for (const a of document.querySelectorAll('a[href*="/s/' + code + '"]')) {
let p = ''; try { p = new URL(a.href).pathname.replace(/\/$/, ''); } catch (e) { continue; }
if (p !== '/s/' + code) continue;
const t = (a.textContent || '').replace(/\s+/g, ' ').trim();
if (t && t.length <= 60 && t !== code) { setChannelName(code, t); return; }
}
}
// 이 URL이 심어둔 채널의 비번을 '먼저' 시도하고, 실패하면 공동(전역) pwList로 폴백(채널 먼저, 중복 제거).
// (과거엔 채널 비번만 반환해, 채널 비번이 다 틀리면 공동 비번은 아예 안 넣었다 — StegCloak과 동작 통일.)
function getEffectivePwList() {
const out = [];
const ch = getChannelForCurrentSite();
if (ch) { const list = getChannelPwMap()[ch]; if (Array.isArray(list)) out.push(...list); }
const g = getPwList(); if (Array.isArray(g)) out.push(...g);
return [...new Set(out.filter(Boolean))];
}
/* ================================================================
메뉴
================================================================ */
let menuIds = {};
function refreshMenu() {
Object.values(menuIds).forEach(id => { try { GM_unregisterMenuCommand(id); } catch(e){} });
menuIds = {};
menuIds['settings'] = GM_registerMenuCommand('⚙ KONE + 설정', openSettingsPanel);
menuIds['update'] = GM_registerMenuCommand('🔄 업데이트 확인', () => runUpdateCheck(true));
}
/* ================================================================
비번 관리 UI (커스텀 모달)
================================================================ */
function openPwManager() {
if (document.getElementById('b64d-pw-manager')) return;
function closePm() {
document.getElementById('b64d-pw-manager')?.remove();
document.getElementById('b64d-pw-backdrop')?.remove();
}
// 2중 확인 삭제 버튼(3초 내 재클릭 시 실행) — 전역/채널 비번 공용(optimized+ 통합, 과거 2벌 복붙)
function mkConfirmClearBtn(label, onConfirm) {
const btn = document.createElement('button');
btn.className = 'b64dpm-clear';
btn.textContent = label;
let confirming = false;
const reset = () => { confirming = false; btn.textContent = label; btn.style.cssText = ''; };
btn.addEventListener('click', () => {
if (!confirming) {
confirming = true;
btn.textContent = '정말 삭제하시겠습니까? (다시 클릭)';
btn.style.cssText = 'background:#ef4444;color:#fff;';
setTimeout(() => { if (confirming) reset(); }, 3000);
return;
}
reset();
onConfirm();
});
return btn;
}
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 num = document.createElement('span');
num.className = 'b64dpm-num';
num.textContent = i + 1;
const val = document.createElement('span');
val.className = 'b64dpm-pw';
val.textContent = 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(num);
item.appendChild(val);
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 = mkConfirmClearBtn('전체 삭제', () => { savePwList([]); renderList(listEl); });
// ── 채널별 비번 (요구2/요구3, 버전 A 마스터-디테일) — 왼쪽 채널 목록 + 오른쪽 그 채널의 비번 편집. ──
const chSide = document.createElement('div'); chSide.className = 'b64dpm-chside';
const chDetail = document.createElement('div'); chDetail.className = 'b64dpm-chdetail';
const chGrid = document.createElement('div'); chGrid.className = 'b64dpm-chgrid';
chGrid.appendChild(chSide); chGrid.appendChild(chDetail);
let _selCh = null; // 선택 채널명, '__new__'=새 채널 등록 모드
let _chFocus = null; // 재렌더 후 포커스할 입력칸
const mkInput = (ph) => { const i = document.createElement('input'); i.className = 'b64dpm-input'; i.type = 'text'; i.setAttribute('autocomplete', 'off'); i.placeholder = ph; return i; };
function renderChannel() {
const map = getChannelPwMap();
const chans = Object.keys(map).filter(ch => (map[ch] || []).length);
if (_selCh !== '__new__' && !chans.includes(_selCh)) _selCh = chans[0] || '__new__';
_chFocus = null;
// 사이드바
chSide.innerHTML = '';
chans.forEach(ch => {
const it = document.createElement('button');
it.className = 'b64dpm-chitem' + (ch === _selCh ? ' on' : '');
const nm = document.createElement('span'); nm.className = 'b64dpm-chname'; nm.textContent = '# ' + channelDisplayName(ch);
const ct = document.createElement('span'); ct.className = 'b64dpm-chcount'; ct.textContent = (map[ch] || []).length;
it.appendChild(nm); it.appendChild(ct);
it.addEventListener('click', () => { _selCh = ch; renderChannel(); });
chSide.appendChild(it);
});
const addCh = document.createElement('button');
addCh.className = 'b64dpm-chitem b64dpm-chadd' + (_selCh === '__new__' ? ' on' : '');
const an = document.createElement('span'); an.className = 'b64dpm-chname'; an.textContent = '+ 새 채널';
addCh.appendChild(an);
addCh.addEventListener('click', () => { _selCh = '__new__'; renderChannel(); });
chSide.appendChild(addCh);
// 채널 목록 하단(전체폭)에 '채널명 관리' 버튼 — 표시 이름은 별도 모달에서 편집(비번창은 표시만).
const cnOpen = document.createElement('button');
cnOpen.className = 'b64dpm-chname-open'; cnOpen.type = 'button'; cnOpen.textContent = '채널명 관리';
cnOpen.addEventListener('click', openChannelNameManager);
chSide.appendChild(cnOpen);
// 디테일
chDetail.innerHTML = '';
if (_selCh === '__new__') {
const t = document.createElement('div'); t.className = 'b64dpm-chtitle'; t.textContent = '새 채널 등록';
const nameI = mkInput(getChannelCode() ? `채널명 또는 코드 (비우면 현재: ${channelDisplayName(getChannelCode())})` : '채널명 또는 코드');
const pwI = mkInput('비번 (쉼표로 구분)');
const btn = document.createElement('button'); btn.className = 'b64dpm-add'; btn.textContent = '등록';
const doAdd = () => {
// 입력이 알려진 채널명이면 그 코드로, 아니면 코드로 간주. 비우면 현재 채널.
const ch = (resolveChannelInput(nameI.value) || getChannelCode() || '');
const pws = pwI.value.split(',').map(s => s.trim()).filter(Boolean); // 쉼표 구분 + 각 항목 trim
if (!ch) { nameI.focus(); return; }
if (!pws.length) { pwI.focus(); return; }
const m = getChannelPwMap(); m[ch] = [...new Set([...(m[ch] || []), ...pws])]; saveChannelPwMap(m);
_selCh = ch; renderChannel();
};
btn.addEventListener('click', doAdd);
nameI.addEventListener('keydown', e => { if (e.key === 'Enter') pwI.focus(); });
pwI.addEventListener('keydown', e => { if (e.key === 'Enter') doAdd(); });
const nameRow = document.createElement('div'); nameRow.className = 'b64dpm-input-row'; nameRow.appendChild(nameI);
const pwRow = document.createElement('div'); pwRow.className = 'b64dpm-input-row'; pwRow.appendChild(pwI); pwRow.appendChild(btn);
chDetail.appendChild(t); chDetail.appendChild(nameRow); chDetail.appendChild(pwRow);
_chFocus = nameI;
} else if (_selCh) {
const t = document.createElement('div'); t.className = 'b64dpm-chtitle';
const nm = document.createElement('span'); nm.textContent = '# ' + channelDisplayName(_selCh);
const sub = document.createElement('span'); sub.className = 'b64dpm-chsub';
sub.textContent = getChannelNameMap()[_selCh] ? ('코드 ' + _selCh) : '채널 비번'; // 이름 있으면 실제 코드도 함께 표기
t.appendChild(nm); t.appendChild(sub);
// (표시 이름 편집은 별도 '채널명 관리' 창에서 — openChannelNameManager)
const list = document.createElement('div'); list.className = 'b64dpm-list';
(map[_selCh] || []).forEach((pw, i) => {
const item = document.createElement('div'); item.className = 'b64dpm-item';
const sp = document.createElement('span'); sp.className = 'b64dpm-pw'; sp.textContent = pw;
const del = document.createElement('button'); del.className = 'b64dpm-del'; del.textContent = '✕'; del.title = '삭제';
del.addEventListener('click', () => {
const m = getChannelPwMap(); m[_selCh] = (m[_selCh] || []).filter((_, j) => j !== i);
if (!m[_selCh].length) delete m[_selCh]; saveChannelPwMap(m); renderChannel();
});
item.appendChild(sp); item.appendChild(del); list.appendChild(item);
});
const pwI = mkInput('비번 추가 (쉼표로 구분)');
const btn = document.createElement('button'); btn.className = 'b64dpm-add'; btn.textContent = '추가';
const doAdd = () => {
const pws = pwI.value.split(',').map(s => s.trim()).filter(Boolean); // 쉼표 구분 + 각 항목 trim
if (!pws.length) { pwI.focus(); return; }
const m = getChannelPwMap(); m[_selCh] = [...new Set([...(m[_selCh] || []), ...pws])]; saveChannelPwMap(m); renderChannel();
};
btn.addEventListener('click', doAdd);
pwI.addEventListener('keydown', e => { if (e.key === 'Enter') doAdd(); });
const row = document.createElement('div'); row.className = 'b64dpm-input-row'; row.appendChild(pwI); row.appendChild(btn);
// 이 채널의 비번만 전체 삭제(확인 2회) — 기본 비번 탭과 동일한 mkConfirmClearBtn, 선택 채널만 대상.
const clr = mkConfirmClearBtn('이 채널 비번 전체 삭제', () => {
const m = getChannelPwMap(); delete m[_selCh]; saveChannelPwMap(m); renderChannel();
});
chDetail.appendChild(t); chDetail.appendChild(list); chDetail.appendChild(row); chDetail.appendChild(clr);
_chFocus = pwI;
}
// 채널 탭이 실제로 보일 때만 포커스(초기 숨김 상태에서 포커스 훔치지 않게).
if (_chFocus && paneChannel && !paneChannel.classList.contains('b64dpm-hide')) _chFocus.focus();
}
// ── 탭 구성(수정2): [기본 비번] / [채널별 비번]. 자동입력·StegCloak 모두 '채널 비번 선(先) → 없으면
// 기본 비번' 순으로 시도한다(getEffectivePwList/getStegPwList). 여기선 등록 UI만 탭으로 분리. ──
const paneGlobal = document.createElement('div');
paneGlobal.className = 'b64dpm-pane';
const gNote = document.createElement('div');
gNote.className = 'b64dpm-note';
gNote.textContent = '기본(전역) 비번 — 채널별 비번이 없을 때 사용됩니다.';
paneGlobal.appendChild(gNote);
paneGlobal.appendChild(listEl);
paneGlobal.appendChild(inputRow);
paneGlobal.appendChild(clearBtn);
const paneChannel = document.createElement('div');
paneChannel.className = 'b64dpm-pane b64dpm-hide';
const cNote = document.createElement('div');
cNote.className = 'b64dpm-note';
cNote.textContent = '채널별 비번 — 해당 채널에서 기본 비번보다 먼저 시도됩니다.';
paneChannel.appendChild(cNote);
paneChannel.appendChild(chGrid);
const tabs = document.createElement('div');
tabs.className = 'b64dpm-tabs';
const tabG = document.createElement('button');
tabG.className = 'b64dpm-tab on'; tabG.textContent = '기본 비번';
const tabC = document.createElement('button');
tabC.className = 'b64dpm-tab'; tabC.textContent = '채널별 비번';
const showTab = (which) => {
const g = which === 'global';
tabG.classList.toggle('on', g); tabC.classList.toggle('on', !g);
paneGlobal.classList.toggle('b64dpm-hide', !g);
paneChannel.classList.toggle('b64dpm-hide', g);
if (!g) renderChannel(); // 채널 탭 진입 시 렌더(+포커스)
};
tabG.addEventListener('click', () => showTab('global'));
tabC.addEventListener('click', () => showTab('channel'));
tabs.appendChild(tabG); tabs.appendChild(tabC);
const body = document.createElement('div');
body.className = 'b64dpm-body';
body.appendChild(tabs);
body.appendChild(paneGlobal);
body.appendChild(paneChannel);
renderChannel(); // 초기 구성(숨김 상태) — 포커스는 탭 진입 시에만
const panel = document.createElement('div');
panel.id = 'b64d-pw-manager';
panel.dataset.theme = getSystemTheme();
if (CFG.SETTINGS_SCALE !== 100) panel.style.zoom = CFG.SETTINGS_SCALE / 100; // UI 크기 설정 반영(수정)
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.
================================================================ */
const BRFINAL_DEFAULT = '끝 밑 밭 팥 솥 곁 볕 뭍 샅 녘 앞 옆 숲 잎 짚 늪 릎';
function openBrailleFinalManager() {
if (document.getElementById('b64d-brfinal')) return;
function getWords() {
return String(CFG.BRAILLE_FINAL || '').split(/\s+/).filter(Boolean);
}
function saveWords(arr) {
const uniq = [...new Set(arr)];
const str = uniq.join(' ');
CFG.BRAILLE_FINAL = str;
GM_setValue('brailleFinal', str);
BR.setFinalSet(str);
if (CFG.LIVE_APPLY) applyLive(); else schedule();
}
function closeBf() {
document.getElementById('b64d-brfinal')?.remove();
document.getElementById('b64d-brfinal-backdrop')?.remove();
}
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', closeBf);
hdr.appendChild(x);
const note = document.createElement('div');
note.className = 'b64dbf-note';
note.innerHTML = '어절 끝에서 <b>ㅌ·ㅋ·ㅍ 받침</b>과 <b>?·!·.</b> 의 점형이 같아 헷갈릴 때, 여기 등록된 단어는 받침으로 읽습니다. 예: <b>끝·앞·옆·무릎</b>';
const chips = document.createElement('div');
chips.className = 'b64dbf-chips';
const countEl = document.createElement('span');
countEl.className = 'b64dbf-count';
function renderChips() {
chips.innerHTML = '';
const words = getWords();
if (!words.length) {
const em = document.createElement('div');
em.className = 'b64dbf-empty';
em.textContent = '(등록된 단어 없음)';
chips.appendChild(em);
} else {
words.forEach(w => {
const chip = document.createElement('span');
chip.className = 'b64dbf-chip';
chip.appendChild(document.createTextNode(w));
const del = document.createElement('button');
del.className = 'b64dbf-chip-x'; del.type = 'button'; del.textContent = '✕'; del.title = '삭제';
del.addEventListener('click', () => { saveWords(getWords().filter(x => x !== w)); renderChips(); });
chip.appendChild(del);
chips.appendChild(chip);
});
}
countEl.textContent = words.length + '개';
}
const inp = document.createElement('input');
inp.className = 'b64dbf-input'; inp.type = 'text';
inp.placeholder = '단어 추가 (공백·쉼표로 여러 개)';
inp.setAttribute('autocomplete', 'off'); inp.spellcheck = false;
const addBtn = document.createElement('button');
addBtn.className = 'b64dbf-add'; addBtn.type = 'button'; addBtn.textContent = '추가';
function addWords() {
const newWords = inp.value.split(/[\s,]+/).filter(Boolean);
if (!newWords.length) return;
saveWords([...getWords(), ...newWords]);
inp.value = ''; renderChips(); inp.focus();
}
addBtn.addEventListener('click', addWords);
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addWords(); } });
const addRow = document.createElement('div');
addRow.className = 'b64dbf-add-row';
addRow.append(inp, addBtn);
const resetBtn = document.createElement('button');
resetBtn.className = 'b64dbf-reset'; resetBtn.type = 'button'; resetBtn.textContent = '기본값으로 복원';
resetBtn.addEventListener('click', () => { saveWords(BRFINAL_DEFAULT.split(/\s+/)); renderChips(); });
const foot = document.createElement('div');
foot.className = 'b64dbf-foot';
foot.append(countEl, resetBtn);
const body = document.createElement('div');
body.className = 'b64dbf-body';
body.append(note, chips, addRow, foot);
const panel = document.createElement('div');
panel.id = 'b64d-brfinal';
panel.dataset.theme = getSystemTheme();
if (CFG.SETTINGS_SCALE !== 100) panel.style.transform = `translate(-50%,-50%) scale(${CFG.SETTINGS_SCALE / 100})`;
panel.addEventListener('click', e => e.stopPropagation());
panel.append(hdr, body);
const bd = document.createElement('div');
bd.id = 'b64d-brfinal-backdrop';
bd.addEventListener('click', closeBf);
document.body.appendChild(bd);
document.body.appendChild(panel);
renderChips();
inp.focus();
}
/* ================================================================
설정 패널 (버튼 식 UI)
================================================================ */
function getSystemTheme() {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
function applyThemeToOverlays() {
const theme = getSystemTheme();
const s = document.getElementById('b64d-settings');
if (s) s.dataset.theme = theme;
const p = document.getElementById('b64d-pw-manager');
if (p) p.dataset.theme = theme;
const bf = document.getElementById('b64d-brfinal');
if (bf) bf.dataset.theme = theme;
const cn = document.getElementById('b64d-chname');
if (cn) cn.dataset.theme = theme;
}
/* ================================================================
채널명 관리 (별도 모달) — 코드↔표시 이름 매핑 확인·수정.
채널 페이지 방문 시 captureChannelName이 자동으로 채우고, 여기서 수동 보정.
관리 대상 = 이름 있는 채널 + 비번 있는 채널 + 현재 채널(합집합).
================================================================ */
function openChannelNameManager() {
if (document.getElementById('b64d-chname')) return;
function close() {
document.getElementById('b64d-chname')?.remove();
document.getElementById('b64d-chname-backdrop')?.remove();
}
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', close);
hdr.appendChild(x);
const note = document.createElement('div');
note.className = 'b64dpm-note';
note.textContent = '채널 코드에 표시 이름을 붙입니다. 채널 페이지 방문 시 자동으로 채워지며, 여기서 직접 고칠 수 있습니다(비우면 코드로 표시). 이 이름이 팝업·비번 안내에 표시됩니다.';
const CN_PER_PAGE = 5; // 목록 고정 높이에 들어가는 행 수(초과분은 페이지 버튼으로)
let _page = 0, _adding = false, _flash = null; // _flash = 이동·강조할 코드(중복 추가 시)
// 현재 관리 대상 채널 코드 목록(이름 있는 채널 + 비번 있는 채널 + 현재 채널, 정렬)
function allCodes() {
const names = getChannelNameMap(), pw = getChannelPwMap(), cur = getChannelCode();
return [...new Set([...Object.keys(names), ...Object.keys(pw), ...(cur ? [cur] : [])])].sort();
}
const list = document.createElement('div');
list.className = 'b64dpm-list';
// 기존 채널 행: 코드 칩(고정폭) + 이름 입력(flex). 현재 채널은 칩만 accent, _flash면 잠깐 강조.
function buildRow(code) {
const names = getChannelNameMap(), cur = getChannelCode();
const row = document.createElement('div');
row.className = 'b64dcn-row' + (code === cur ? ' on' : '') + (code === _flash ? ' flash' : '');
const c = document.createElement('span');
c.className = 'b64dcn-code'; c.textContent = '# ' + code;
c.title = code === cur ? (code + ' (현재 채널)') : code;
const inp = document.createElement('input');
inp.className = 'b64dpm-input b64dcn-name'; inp.type = 'text';
inp.setAttribute('autocomplete', 'off');
inp.placeholder = '표시 이름 (비우면 코드)';
inp.value = names[code] || '';
const save = () => setChannelName(code, inp.value); // 빈 값 저장 = 매핑 해제(코드로 폴백)
inp.addEventListener('change', save);
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { save(); inp.blur(); } });
row.appendChild(c); row.appendChild(inp);
if (code === _flash) { // 강조 1회: 스크롤·포커스 후 애니메이션 클래스 제거
_flash = null;
requestAnimationFrame(() => { try { row.scrollIntoView({ block: 'nearest' }); inp.focus(); } catch (e) {} });
setTimeout(() => row.classList.remove('flash'), 1200);
}
return row;
}
// 수동 추가 인라인 행(시안 A): 코드 입력 + 이름 입력 + ✓. page 0 최상단에 표시.
function buildAddRow() {
const row = document.createElement('div');
row.className = 'b64dcn-row b64dcn-addrow';
const ci = document.createElement('input');
ci.className = 'b64dpm-input b64dcn-codein'; ci.type = 'text';
ci.setAttribute('autocomplete', 'off'); ci.placeholder = '코드';
const ni = document.createElement('input');
ni.className = 'b64dpm-input b64dcn-name'; ni.type = 'text';
ni.setAttribute('autocomplete', 'off'); ni.placeholder = '표시 이름';
const ok = document.createElement('button');
ok.className = 'b64dcn-ok'; ok.type = 'button'; ok.textContent = '✓'; ok.title = '추가';
const submit = () => doManualAdd(ci.value, ni.value);
ok.addEventListener('click', submit);
ci.addEventListener('keydown', e => { if (e.key === 'Enter') ni.focus(); });
ni.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
row.append(ci, ni, ok);
requestAnimationFrame(() => { try { ci.focus(); } catch (e) {} });
return row;
}
// 수동 추가 실행: 이미 있으면 그 채널로 이동·강조(중복 생성 안 함), 없으면 이름 매핑 저장.
function doManualAdd(codeRaw, nameRaw) {
const code = resolveChannelInput(codeRaw); // 이름을 넣어도 알려진 이름이면 코드로 해석
const name = String(nameRaw || '').replace(/\s+/g, ' ').trim();
if (!code) { showNavToast('채널 코드를 입력하세요'); return; }
const names = getChannelNameMap(), pw = getChannelPwMap(), cur = getChannelCode();
const exists = names[code] !== undefined || (pw[code] && pw[code].length) || code === cur;
if (exists) {
_adding = false; _flash = code;
_page = Math.floor(allCodes().indexOf(code) / CN_PER_PAGE);
showNavToast('이미 등록된 채널입니다');
render(); return;
}
if (!name || name === code) { showNavToast('표시 이름을 입력하세요'); return; }
setChannelName(code, name);
_adding = false; _flash = code;
_page = Math.floor(allCodes().indexOf(code) / CN_PER_PAGE);
render();
}
function renderPager(totalPages) {
pagerWrap.innerHTML = '';
if (totalPages <= 1) return;
const mk = (label, page, disabled, active) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'b64dcn-pg' + (active ? ' on' : '');
b.textContent = label; if (disabled) b.disabled = true;
if (!disabled && !active) b.addEventListener('click', () => { _page = page; _adding = false; render(); });
return b;
};
pagerWrap.appendChild(mk('‹', _page - 1, _page <= 0, false));
let s = Math.max(0, Math.min(_page - 2, totalPages - 5)); const e = Math.min(totalPages, s + 5); s = Math.max(0, e - 5);
for (let p = s; p < e; p++) pagerWrap.appendChild(mk(String(p + 1), p, false, p === _page));
pagerWrap.appendChild(mk('›', _page + 1, _page >= totalPages - 1, false));
}
function render() {
list.innerHTML = '';
const codes = allCodes();
const totalPages = Math.max(1, Math.ceil(codes.length / CN_PER_PAGE));
if (_adding) _page = 0;
_page = Math.min(Math.max(_page, 0), totalPages - 1);
if (_adding && _page === 0) list.appendChild(buildAddRow());
if (!codes.length && !_adding) {
const em = document.createElement('div');
em.className = 'b64dpm-empty';
em.textContent = '(관리할 채널이 없습니다 — 채널 페이지 방문·채널 비번 등록·수동 추가 시 나타납니다)';
list.appendChild(em);
} else {
codes.slice(_page * CN_PER_PAGE, (_page + 1) * CN_PER_PAGE).forEach(code => list.appendChild(buildRow(code)));
}
addBtn.classList.toggle('on', _adding);
renderPager(totalPages);
}
// 하단: 좌측 '+ 수동 추가' + 우측 페이지 버튼
const footer = document.createElement('div');
footer.className = 'b64dcn-foot';
const addBtn = document.createElement('button');
addBtn.className = 'b64dcn-add'; addBtn.type = 'button'; addBtn.textContent = '+ 수동 추가';
addBtn.addEventListener('click', () => { _adding = !_adding; if (_adding) _page = 0; render(); });
const pagerWrap = document.createElement('div');
pagerWrap.className = 'b64dcn-pager';
footer.append(addBtn, pagerWrap);
const body = document.createElement('div');
body.className = 'b64dpm-body';
body.appendChild(note);
body.appendChild(list);
body.appendChild(footer);
const panel = document.createElement('div');
panel.id = 'b64d-chname';
panel.dataset.theme = getSystemTheme();
if (CFG.SETTINGS_SCALE !== 100) panel.style.zoom = CFG.SETTINGS_SCALE / 100;
panel.addEventListener('click', e => e.stopPropagation());
panel.appendChild(hdr);
panel.appendChild(body);
const bd = document.createElement('div');
bd.id = 'b64d-chname-backdrop';
bd.addEventListener('click', close);
document.body.appendChild(bd);
document.body.appendChild(panel);
render();
}
// kone.gg가 <html class="dark"> 토글 방식을 사용하므로 class 변화 감지
new MutationObserver(applyThemeToOverlays).observe(
document.documentElement, { attributes: true, attributeFilter: ['class'] }
);
function closeSettingsPanel() {
if (_settingsCaptureCleanup) { _settingsCaptureCleanup(); _settingsCaptureCleanup = null; }
document.getElementById('b64d-settings')?.remove();
document.getElementById('b64d-settings-backdrop')?.remove();
document.getElementById('b64d-stip')?.remove();
}
function openSettingsPanel() {
if (document.getElementById('b64d-settings')) { closeSettingsPanel(); return; }
dlpHide();
const tipEl = document.createElement('div');
tipEl.id = 'b64d-stip'; tipEl.className = 'b64d-stip'; tipEl.style.display = 'none';
tipEl.style.fontSize = (11 * CFG.SETTINGS_SCALE / 100) + 'px'; // 설정 화면 크기에 맞춰 글자 확대(좌표 왜곡 없는 방식)
document.body.appendChild(tipEl);
function mkToggle(key, stKey, label, tooltip, onChange, opts) {
const btn = document.createElement('button');
btn.className = `b64ds-toggle${CFG[key] ? ' active' : ''}${opts && opts.sub ? ' b64ds-sub' : ''}`;
btn.innerHTML = `<span>${label}</span><span class="b64ds-badge"></span>`;
if (tooltip) {
btn.addEventListener('mouseenter', () => {
tipEl.textContent = tooltip; tipEl.style.display = 'block';
const r = btn.getBoundingClientRect();
tipEl.style.left = r.left + 'px'; tipEl.style.top = '0px';
requestAnimationFrame(() => {
let top = r.top - tipEl.offsetHeight - 6;
if (top < 4) top = r.bottom + 6;
let left = r.left;
if (left + tipEl.offsetWidth > window.innerWidth - 4) left = window.innerWidth - tipEl.offsetWidth - 4;
tipEl.style.top = top + 'px'; tipEl.style.left = left + 'px';
});
});
btn.addEventListener('mouseleave', () => { tipEl.style.display = 'none'; });
}
btn.addEventListener('click', () => {
CFG[key] = !CFG[key];
GM_setValue(stKey, CFG[key]);
btn.classList.toggle('active', CFG[key]);
if (key === 'LIVE_APPLY') return;
if (onChange) onChange(CFG[key]);
// opts.noApply: DOM/카드와 무관한 순수 동작 토글(예: W/S 미리보기) → 재렌더 생략.
// (applyLive가 카드를 다시 만들면 W/S 카드 선택이 풀리고 _kpIdx가 초기화되는 문제 방지)
if (opts && opts.noApply) return;
if (CFG.LIVE_APPLY) applyLive(); else schedule();
});
return btn;
}
function mkAction(t, fn) {
const btn = document.createElement('button');
btn.className = 'b64ds-action'; btn.textContent = t;
btn.addEventListener('click', fn); return btn;
}
// 설명용 작은 안내문(여러 줄). innerHTML은 고정 문자열만(XSS 무관).
function mkNote(html) {
const d = document.createElement('div');
d.className = 'b64ds-note'; d.innerHTML = html; return d;
}
// 단축키 캡처 행 공용 생성기(optimized+ 통합 — 과거 설정/TXT 검사용 ~45줄 블록 2벌 복붙).
// 반환 { row, cancel }: row=설정 행 요소, cancel=캡처 중이면 리스너 해제(창 닫힘 시 호출).
function mkHotkeyRow(labelText, initKey, onSet) {
const row = document.createElement('div');
row.className = 'b64ds-hotkey-row';
const lbl = document.createElement('span');
lbl.className = 'b64ds-hotkey-label';
lbl.textContent = labelText;
const r = document.createElement('div');
r.className = 'b64ds-hotkey-r';
const keyEl = document.createElement('span');
keyEl.className = 'b64ds-hotkey-key';
keyEl.textContent = initKey;
const btn = document.createElement('button');
btn.className = 'b64ds-hotkey-btn';
btn.textContent = '변경';
let capturing = false, listener = null;
const cancel = () => {
if (!capturing) return;
capturing = false;
btn.classList.remove('capturing');
btn.textContent = '변경';
document.removeEventListener('keydown', listener, true);
};
btn.addEventListener('click', () => {
if (capturing) return;
capturing = true;
btn.classList.add('capturing');
btn.textContent = '입력…';
listener = e => {
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return; // 수식키만 누름: 대기
if (e.key !== 'Escape' && !(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)) return; // 평범한 키: 통과
e.preventDefault(); e.stopPropagation();
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
const combo = buildCombo(e);
keyEl.textContent = combo;
onSet(combo);
}
cancel();
};
document.addEventListener('keydown', listener, true);
});
r.append(keyEl, btn);
row.append(lbl, r);
return { row, cancel };
}
const hkMain = mkHotkeyRow('설정 단축키', HOTKEY, c => { HOTKEY = c; GM_setValue('hotkey', c); });
const hkDup = mkHotkeyRow('TXT 검사 단축키', DUP_HOTKEY, c => { DUP_HOTKEY = c; GM_setValue('dupHotkey', c); });
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 mkTriple(key, stKey, label, opts, onChange) {
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));
if (onChange) { onChange(value); return; }
document.querySelectorAll('.b64-link.kp-focused, .b64-product-link.kp-focused')
.forEach(el => el.classList.remove('kp-focused'));
_kpIdx = -1;
// 클래스 변경은 MO childList를 트리거하지 않으므로 즉시 재적용
if (CFG.LIVE_APPLY) applyLive(); else schedule();
});
btns.appendChild(b);
});
row.appendChild(lbl);
row.appendChild(btns);
return row;
}
// 그룹 제목 아이콘 (제목 → Feather 스타일 SVG)
const GROUP_ICONS = {
'번역': svg('<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>'),
'작성 도구': svg('<path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/>'),
'댓글': svg('<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>'),
'카드': svg('<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/>'),
'모아보기': svg('<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>'),
'링크 확인': ICO.link,
'미리보기': svg('<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'),
'화면': svg('<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>'),
'단축키': svg('<rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="10"/><line x1="10" y1="10" x2="10" y2="10"/><line x1="14" y1="10" x2="14" y2="10"/><line x1="18" y1="10" x2="18" y2="10"/><line x1="8" y1="14" x2="16" y2="14"/>'),
'적용': svg('<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>'),
'비번': svg('<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'),
'업데이트': svg('<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>'),
};
// 그룹 카드: 아이콘+제목 헤더 + 항목들을 하나의 섹션 카드로 묶음 (구분성·통일감)
function mkGroup(title, ...items) {
const s = document.createElement('div'); s.className = 'b64ds-section';
if (title) {
const h = document.createElement('div'); h.className = 'b64ds-group-title';
if (GROUP_ICONS[title]) h.innerHTML = GROUP_ICONS[title];
const t = document.createElement('span'); t.textContent = title; h.appendChild(t);
s.appendChild(h);
}
items.forEach(el => el && s.appendChild(el));
return s;
}
function mkSlider(key, stKey, label, min, max, step, unit, defaultVal, onInput, onChange) {
const row = document.createElement('div');
row.className = 'b64ds-slider-row';
const lbl = document.createElement('span');
lbl.className = 'b64ds-triple-label'; lbl.textContent = label;
const right = document.createElement('div');
right.className = 'b64ds-slider-r';
const valEl = document.createElement('span');
valEl.className = 'b64ds-slider-val'; valEl.textContent = CFG[key] + unit;
const inp = document.createElement('input');
inp.type = 'range'; inp.min = min; inp.max = max; inp.step = step; inp.value = CFG[key];
inp.className = 'b64ds-slider';
inp.tabIndex = -1;
inp.addEventListener('input', () => {
CFG[key] = +inp.value;
GM_setValue(stKey, +inp.value);
valEl.textContent = inp.value + unit;
if (onInput) onInput(+inp.value);
});
inp.addEventListener('pointerup', () => { inp.blur(); if (onChange) onChange(+inp.value); });
inp.addEventListener('change', () => { if (onChange) onChange(+inp.value); });
if (defaultVal !== undefined) {
const rst = document.createElement('button');
rst.type = 'button'; rst.className = 'b64ds-slider-reset';
rst.textContent = '↺'; rst.title = `기본값 (${defaultVal}${unit})`;
rst.addEventListener('click', () => { inp.value = defaultVal; inp.dispatchEvent(new Event('input')); inp.dispatchEvent(new Event('change')); inp.blur(); });
right.append(valEl, rst, inp);
} else {
right.append(valEl, inp);
}
row.append(lbl, right);
return row;
}
// 종속 옵션: 부모 토글이 꺼지면 자식 옵션을 흐리고 비활성화
const setSubEnabled = (el, on) => el.classList.toggle('b64ds-disabled', !on);
// 링크 확인 딜레이 — '링크 생존 확인'의 하위 옵션
const delaySlider = mkSlider('LINK_CHECK_DELAY', 'linkCheckDelay', '링크 확인 딜레이', 0, 5000, 250, 'ms', 0);
delaySlider.classList.add('b64ds-sub');
// 링크 만료 OFF 시 미리보기 자동 ON을 위해 previewToggleBtn 참조 연결
let previewToggleBtn = null;
const linkCheckToggle = mkToggle('LINK_CHECK', 'linkCheck', '링크 생존 확인', null, (isOn) => {
setSubEnabled(delaySlider, isOn);
if (!isOn && !CFG.DLSITE_PREVIEW && previewToggleBtn) {
CFG.DLSITE_PREVIEW = true;
GM_setValue('dlsitePreview', true);
previewToggleBtn.classList.add('active');
}
});
setSubEnabled(delaySlider, CFG.LINK_CHECK);
// W/S 선택 시 미리보기 — '카드 미리보기'의 하위 옵션(미리보기 꺼지면 비활성). noApply: DOM 재렌더 없이 동작만.
// W/S 미리보기 위치: 기존(카드 옆) | 가운데 | 왼쪽 | 오른쪽 고정. onChange 지정 → applyLive 생략(다음 미리보기부터 반영).
const kpPosTriple = mkTriple('KP_PREVIEW_POS', 'kpPreviewPos', 'W/S 미리보기 위치', [
{ value: 'card', text: '기존' }, { value: 'center', text: '가운데' }, { value: 'left', text: '왼쪽' }, { value: 'right', text: '오른쪽' },
], () => {});
kpPosTriple.classList.add('b64ds-sub');
const kpPreviewToggle = mkToggle('KP_PREVIEW', 'kpPreview', 'W/S 선택 시 미리보기', 'W/S(↑/↓)로 카드를 옮길 때 그 카드의 미리보기도 자동으로 띄움', (on) => setSubEnabled(kpPosTriple, on), { sub: true, noApply: true });
const previewToggle = mkToggle('DLSITE_PREVIEW', 'dlsitePreview', '카드 미리보기', null, (on) => { setSubEnabled(kpPreviewToggle, on); setSubEnabled(kpPosTriple, on && CFG.KP_PREVIEW); });
setSubEnabled(kpPreviewToggle, CFG.DLSITE_PREVIEW);
setSubEnabled(kpPosTriple, CFG.DLSITE_PREVIEW && CFG.KP_PREVIEW);
previewToggleBtn = previewToggle;
// 탭 구조: 본문 / 링크 / 미리보기 / 시스템
const tabDefs = ['본문', '링크', '미리보기', '시스템'];
const tabPanels = tabDefs.map(() => {
const p = document.createElement('div');
p.className = 'b64ds-tabpanel';
return p;
});
const [pBonmun, pLink, pPreview, pSystem] = tabPanels;
// 본문 탭
// 점자 변환 하위 옵션: UEB 토글 + 받침 보정 단어(별도 모달 버튼). └ 기호 없이 들여쓰기(b64ds-sub)로 계층 표현.
const brUebToggle = mkToggle('BRAILLE_UEB', 'brailleUeb', 'UEB(영어) 점자 디코딩',
'외부 변환기(dencode 등)로 만든 영어 UEB 점자도 해독. URL·영어로 감지된 점자만 UEB로 풀고, 한글은 그대로. 영어 점자를 자주 보는 경우만 ON.',
(on) => { if (CFG.LIVE_APPLY) applyLive(); else schedule(); });
brUebToggle.classList.add('b64ds-sub');
// 받침 보정 단어 — 비번 목록 관리처럼 버튼 → 별도 칩 편집 모달. 하위 옵션 최하단에 배치.
const brFinalBtn = mkAction('⠿ 받침 보정 단어', openBrailleFinalManager);
brFinalBtn.classList.add('b64ds-sub');
const brailleDecodeToggle = mkToggle('BRAILLE_DECODE', 'brailleDecode', '본문 점자 변환', '본문 속 한글 점자(⠿)를 한글로 자동 변환', (on) => { setSubEnabled(brUebToggle, on); setSubEnabled(brFinalBtn, on); });
setSubEnabled(brUebToggle, CFG.BRAILLE_DECODE);
setSubEnabled(brFinalBtn, CFG.BRAILLE_DECODE);
// 작성 도구(작성 페이지 변환 패널) — 본문 탭으로 이동. 부모(패널 표시)·자식(항상 열기) 종속.
const brPinnedToggle = mkToggle('BR_PINNED', 'brPinned', '변환 패널 항상 열기', '작성 페이지에서 변환 패널을 펼친 채로 표시', () => { const e = document.getElementById('b64d-br'); if (e) e.classList.toggle('open', CFG.BR_PINNED); }, { sub: true });
const braillePanelToggle = mkToggle('BRAILLE_PANEL', 'braillePanel', '작성 변환 패널', '작성 페이지 입력창 옆에 점자/base64 변환 패널 표시', (on) => { syncBraillePanel(); setSubEnabled(brPinnedToggle, on); });
setSubEnabled(brPinnedToggle, CFG.BRAILLE_PANEL);
pBonmun.append(
mkGroup('번역',
mkToggle('CONTENT_DECODE', 'contentDecode', '본문 번역 (base64)', 'base64 등으로 인코딩된 본문을 자동 해독'),
brailleDecodeToggle,
brUebToggle,
brFinalBtn,
mkToggle('STEG_DECODE', 'stegDecode', '숨은 코드 자동 복원', '본문/댓글의 숨은 코드(StegCloak)를 채널 비번으로 자동 복원. 실패하면 🔒 배지 표시'),
mkToggle('LIST_DECODE', 'listDecode', '목록 페이지 제목 번역'),
mkToggle('DRAG_DECODE', 'dragDecode', '드래그 자동 변환'),
),
mkGroup('작성 도구',
braillePanelToggle,
brPinnedToggle,
),
mkGroup('댓글',
mkToggle('EXPAND_MORE', 'expandMore', '댓글 더 보기 자동 클릭', '"댓글 더 보기" 버튼을 자동으로 눌러 접힌 댓글을 모두 불러옴'),
mkToggle('EXPAND_REPLIES', 'expandReplies', '대댓글 자동 펼치기', '"답글 N개" 버튼을 자동으로 눌러 답글(대댓글)을 펼침'),
),
);
// 링크 탭
const panelTargetTriple = mkTriple('PANEL_TARGET', 'panelTarget', '모아보기 표시', [
{ value: 'both', text: '모두' }, { value: 'products', text: '작품' }, { value: 'links', text: '다운로드' },
]);
panelTargetTriple.classList.add('b64ds-sub');
const linkPanelToggle = mkToggle('LINK_PANEL', 'linkPanel', '링크 모아보기', null, (on) => setSubEnabled(panelTargetTriple, on));
setSubEnabled(panelTargetTriple, CFG.LINK_PANEL);
pLink.append(
mkGroup('카드',
mkToggle('LINK_CARD', 'linkCard', '링크 카드'),
mkToggle('GENRE_BAR', 'genreBar', '장르·태그 별도 바', 'DLsite 장르/태그 검색 링크를 인라인 칩 대신 본문 하단 별도 바에 모아 표시 (코네 재렌더로 칩이 증식하는 문제 방지)'),
mkToggle('COPY_CODE', 'copyCode', '작품 카드 복사 시 코드 복사'),
mkToggle('SCROLL_TO_FIRST', 'scrollToFirst', '첫 다운로드 링크로 자동 스크롤'),
mkToggle('NO_DUPLICATE_CARD', 'noDuplicateCard', '중복 카드 생성 방지', 'ON 시 같은 작품·링크 코드가 반복되어도 첫 번째만 카드 생성'),
mkToggle('TXT_LIVE', 'txtLive', 'TXT 작품 보유 확인', '마지막으로 업로드한 TXT 기준으로 카드 색상 표시'),
mkTriple('NAV_TARGET', 'navTarget', 'w/s 탐색 범위', [
{ value: 'both', text: '모두' }, { value: 'products', text: '작품' }, { value: 'links', text: '다운로드' },
]),
),
mkGroup('모아보기', linkPanelToggle, panelTargetTriple),
mkGroup('링크 확인', linkCheckToggle, delaySlider),
);
// 미리보기 탭
const tagsExpandedToggle = mkToggle('PREVIEW_TAGS_EXPANDED', 'previewTagsExpanded', '태그 기본 펼침', '미리보기를 열자마자 태그를 펼친 상태로 표시', null, { sub: true });
const tagsToggle = mkToggle('PREVIEW_TAGS', 'previewTags', '작품 태그', 'DLsite 공식 장르 태그 표시 (T 키 또는 탭으로 펼침)', (on) => setSubEnabled(tagsExpandedToggle, on));
setSubEnabled(tagsExpandedToggle, CFG.PREVIEW_TAGS);
pPreview.append(
mkGroup('미리보기',
previewToggle,
kpPreviewToggle,
kpPosTriple,
mkToggle('PREVIEW_META', 'previewMeta', '상세정보', '별점 · 판매수 · 출시일 표시 (DLsite/Steam)'),
tagsToggle,
tagsExpandedToggle,
mkSlider('PREVIEW_SCALE', 'previewScale', '미리보기 크기', 50, 200, 10, '%', 100, (v) => {
document.documentElement.style.setProperty('--b64-dlp-scale', v / 100);
}),
),
);
pSystem.append(
mkGroup('화면',
mkSlider('SETTINGS_SCALE', 'settingsScale', 'UI 크기', 70, 150, 5, '%', 100, null, (v) => {
const p = document.getElementById('b64d-settings');
if (p) p.style.zoom = v / 100;
const d = document.getElementById('b64-dup-modal');
if (d) d.style.zoom = v / 100;
const t = document.getElementById('b64d-stip');
if (t) t.style.fontSize = (11 * v / 100) + 'px';
const br = document.getElementById('b64d-br');
if (br) { br.style.transform = 'scale(' + (v / 100) + ')'; brReposition(); }
}),
mkToggle('NAV_TOAST', 'navToast', '페이지 전환 알림', '작성↔읽기 페이지로 전환될 때 화면 상단에 잠깐 알림 표시'),
mkToggle('LIVE_APPLY', 'liveApply', '설정 변경 즉시 적용', 'OFF 시 다음 페이지 로드에 반영 · 긴 글에서 렉 방지'), // '적용' 그룹 통합 → 화면 최하단
),
mkGroup('비번',
mkToggle('PW_AUTO', 'pwAuto', '비번 자동입력'),
mkAction('🔑 비번 목록 관리', openPwManager),
),
mkGroup('단축키', hkMain.row, hkDup.row),
mkGroup('업데이트',
mkToggle('UPDATE_CHECK', 'updateCheck', '자동 업데이트 확인', '접속 시 GreasyFork 최신 버전을 확인해 새 버전이 있으면 상단에 알림(6시간 간격). GreasyFork 설치본에서만 동작.'),
mkAction('🔄 지금 업데이트 확인', () => runUpdateCheck(true)),
mkNote('완전 무인 업데이트를 원하면 <b>Tampermonkey → 설정 → 업데이트 확인 간격</b>을 켜 두세요(매니저가 백그라운드로 자동 설치).'),
),
);
// 탭 바 생성
const tabBar = document.createElement('div');
tabBar.className = 'b64ds-tabs';
let activeTabIdx = GM_getValue('settingsTab', 0);
const tabBtns = tabDefs.map((label, i) => {
const btn = document.createElement('button');
btn.className = 'b64ds-tab' + (i === activeTabIdx ? ' active' : '');
btn.textContent = label;
btn.addEventListener('click', () => {
tabBtns.forEach((b, j) => {
b.classList.toggle('active', j === i);
tabPanels[j].classList.toggle('active', j === i);
});
GM_setValue('settingsTab', i);
});
return btn;
});
tabBtns.forEach(b => tabBar.appendChild(b));
tabPanels[activeTabIdx].classList.add('active');
body.appendChild(tabBar);
tabPanels.forEach(p => body.appendChild(p));
const panel = document.createElement('div');
panel.id = 'b64d-settings';
panel.dataset.theme = getSystemTheme();
panel.style.zoom = CFG.SETTINGS_SCALE / 100;
panel.addEventListener('click', e => e.stopPropagation());
panel.appendChild(hdr);
panel.appendChild(body);
const bd = document.createElement('div');
bd.id = 'b64d-settings-backdrop';
bd.addEventListener('click', closeSettingsPanel);
document.body.appendChild(bd);
document.body.appendChild(panel);
// 설정창이 닫힐 때 캡처 모드 리스너 정리
_settingsCaptureCleanup = () => { hkMain.cancel(); hkDup.cancel(); };
// 가장 긴 탭에 맞춰 모든 패널 높이 고정 (탭 전환 시 창 크기 변동 방지)
requestAnimationFrame(() => {
let maxH = 0;
tabPanels.forEach(tp => {
const prev = tp.style.cssText;
tp.style.cssText = 'display:flex!important;visibility:hidden;position:absolute;';
maxH = Math.max(maxH, tp.scrollHeight);
tp.style.cssText = prev;
});
if (maxH > 0) tabPanels.forEach(tp => { tp.style.minHeight = maxH + 'px'; });
});
}
function maybeInitPwSetup() {
if (getPwList() !== null) return;
setTimeout(() => {
const ok = window.confirm('🔑 KONE +\n\n비번 자동입력이 처음 실행됩니다.\n지금 비번을 등록하시겠습니까?');
if (!ok) { savePwList([]); return; }
const input = window.prompt('비번 입력 (쉼표로 구분):\n예: pass1, 2024국룰');
if (input) {
const pws = input.split(',').map(s => s.trim()).filter(Boolean);
savePwList(pws);
window.alert(`✅ ${pws.length}개 등록됨.`);
} else { savePwList([]); }
}, 2000);
}
refreshMenu();
maybeInitPwSetup();
// 최초 설치 시 설정 패널 자동 오픈 (kone.gg에서만)
if (!GM_getValue('_b64d_installed', false)) {
GM_setValue('_b64d_installed', true);
if (/kone\.gg/.test(location.hostname)) {
setTimeout(openSettingsPanel, 2000);
}
}
/* ================================================================
CSS
================================================================ */
if (!document.getElementById('b64d-style')) {
const s = document.createElement('style');
s.id = 'b64d-style';
s.textContent = `
/* ── 다운로드 링크 카드 ── */
.b64-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 0;margin:2px 0;
background:#f4f4f5;border:0.5px solid #d4d4d8;border-radius:8px;text-decoration:none;color:#18181b;
font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;overflow:hidden;
transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;}
.b64-link:hover{background:#e4e4e7;border-color:#a1a1aa;}
/* 수정1: 댓글 안에서 카드(inline-flex)가 이름·시간과 같은 flex 줄에 끼어 레이아웃이 틀어지던 문제.
댓글 영역에서만 카드를 별도 줄(flex-basis:100%)로 내리고, 과폭 방지로 max-width 제한.
★부모 flex 줄바꿈은 '카드가 든 flex'에만 :has로 한정한다 — 전역으로 주면 우측 액션 바(수정/삭제 버튼)
flex까지 줄바꿈돼 버튼이 밀리거나 사라지던 부작용이 있었다(사용자 지적). */
#post-comment .b64-link,#post-comment .b64-product-link{flex-basis:100%;max-width:460px;box-sizing:border-box;margin-top:3px;}
#post-comment .flex.gap-2:has(.b64-link,.b64-product-link){flex-wrap:wrap;}
/* 요구1·2: 댓글 변환 툴바 — 코네 '코네콘/작성' 버튼과 같은 줄·유사 모양. mr-auto로 좌측, 자체 버튼은 우측. */
.b64-ctool-bar{display:flex;align-items:center;gap:6px;margin-right:auto;}
.b64-ctool-btn{display:inline-flex;align-items:center;gap:5px;font:500 14px/1 inherit;color:#3f3f46;
background:#f4f4f5;border:0.5px solid #d4d4d8;border-radius:9999px;padding:9px 16px;cursor:pointer;
transition:background .15s,border-color .15s;}
.b64-ctool-btn:hover{background:#e4e4e7;border-color:#a1a1aa;}
.b64-ctool-ic{flex-shrink:0;opacity:.8;}
/* 코드(StegCloak) 미니멀 칩 스트립 — 무채색. 툴바 아래(비번 안내 + 한 줄 컨트롤). */
.b64-ctool-code{display:flex;flex-direction:column;gap:5px;width:100%;flex-basis:100%;margin-top:6px;}
.b64-ctool-code-hide{display:none;}
.b64-ctool-row{display:flex;align-items:center;gap:8px;}
.b64-ctool-pwnote{font-size:11px;color:#71717a;padding:0 4px;line-height:1.4;}
.b64-ctool-pwnote-warn{color:#b45309;}
html.dark .b64-ctool-pwnote{color:#a1a1aa;}
html.dark .b64-ctool-pwnote-warn{color:#fbbf24;}
.b64-ctool-regen{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;
border:0.5px solid #d4d4d8;border-radius:9999px;background:#f4f4f5;color:#3f3f46;cursor:pointer;transition:background .12s;}
.b64-ctool-regen:hover{background:#e4e4e7;}
.b64-ctool-regen .b64-ctool-ic{opacity:.85;}
.b64-ctool-cover{flex:1;min-width:0;height:32px;box-sizing:border-box;border:0.5px solid #d4d4d8;border-radius:9999px;
padding:0 14px;font:400 12.5px/1 inherit;color:#3f3f46;background:#f4f4f5;outline:none;transition:background .12s,border-color .12s;}
.b64-ctool-cover:focus{border-color:#a1a1aa;background:#fff;}
.b64-ctool-gen{flex-shrink:0;border:0;border-radius:9999px;padding:8px 18px;font:500 13px/1 inherit;background:#18181b;color:#fafafa;cursor:pointer;transition:background .12s;}
.b64-ctool-gen:hover{background:#000;}
.b64-ctool-gen:disabled{opacity:.6;cursor:default;}
html.dark .b64-ctool-regen{background:rgba(255,255,255,.08);color:#e5e7eb;border-color:rgba(209,213,219,.42);}
html.dark .b64-ctool-regen:hover{background:rgba(255,255,255,.13);}
html.dark .b64-ctool-cover{background:rgba(255,255,255,.08);color:#e5e7eb;border-color:rgba(209,213,219,.42);}
html.dark .b64-ctool-cover:focus{background:rgba(255,255,255,.13);border-color:rgba(209,213,219,.6);}
html.dark .b64-ctool-gen{background:#fafafa;color:#18181b;}
html.dark .b64-ctool-gen:hover{background:#fff;}
html.dark .b64-ctool-btn{color:#e5e7eb;background:rgba(255,255,255,.08);border-color:rgba(209,213,219,.42);}
html.dark .b64-ctool-btn:hover{background:rgba(255,255,255,.13);border-color:rgba(209,213,219,.60);}
.b64-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.55;}
.b64-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}
.b64-link.lk-checking .bl-arrow{animation:b64spin .8s linear infinite;display:inline-block;}
@keyframes b64spin{to{transform:rotate(360deg);}}
.b64-link.lk-alive{background:#f0fdf4;border-color:#86efac;color:#14532d;}
.b64-link.lk-dead{background:#fef2f2;border-color:#fca5a5;color:#7f1d1d;}
.b64-link.lk-alive .bl-icon,.b64-link.lk-dead .bl-icon,
.b64-link.lk-alive .bl-arrow,.b64-link.lk-dead .bl-arrow{opacity:.7;}
/* ── 상품 링크 카드 (DLsite) ── */
.b64-product-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 0;margin:2px 0;
background:#f5f3ff;border:0.5px solid #c4b5fd;border-radius:8px;text-decoration:none;color:#4c1d95;
font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;overflow:hidden;
transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;position:relative;}
/* DLsite 장르/태그 검색 칩 (작은 노란 알약) */
.b64-dlsite-tag{display:inline-flex;align-items:center;padding:1px 9px;margin:2px 3px;border-radius:999px;
background:#fde68a;border:1px solid #f59e0b;color:#78350f;font-size:12px;font-weight:600;line-height:1.5;
text-decoration:none;vertical-align:middle;cursor:pointer;white-space:nowrap;transition:background .12s,border-color .12s;}
.b64-dlsite-tag:hover{background:#fcd34d;border-color:#d97706;}
html.dark .b64-dlsite-tag{background:rgba(250,204,21,.15);color:#fde68a;border-color:rgba(250,204,21,.45);}
/* 장르·태그 별도 바 (요구1 C안) — 본문 하단에 칩을 한 벌로 모음 */
.b64-genre-bar{margin-top:14px;padding-top:10px;border-top:2px dashed #e4e4e7;display:flex;flex-wrap:wrap;align-items:center;gap:2px;}
.b64-genre-bar-label{font-size:11px;color:#71717a;font-weight:600;letter-spacing:.05em;margin-right:6px;}
html.dark .b64-genre-bar{border-top-color:#3f3f46;}
html.dark .b64-genre-bar-label{color:#a1a1aa;}
/* 숨은 코드(StegCloak) 본문 복원 결과 / 실패 배지 (요구2·수정1) */
.b64-steg-done{background:rgba(34,197,94,.14);border-radius:4px;padding:0 3px;box-decoration-break:clone;-webkit-box-decoration-break:clone;}
.b64-steg-badge{display:inline-flex;align-items:center;gap:4px;margin-left:6px;vertical-align:middle;
background:#e0e7ff;border:1px solid #a5b4fc;color:#3730a3;border-radius:9999px;padding:1px 10px;
font-size:12px;font-weight:600;line-height:1.5;cursor:pointer;white-space:nowrap;transition:background .12s;}
.b64-steg-badge:hover{background:#c7d2fe;}
html.dark .b64-steg-done{background:rgba(34,197,94,.2);}
html.dark .b64-steg-badge{background:rgba(99,102,241,.18);color:#c7d2fe;border-color:rgba(129,140,248,.45);}
html.dark .b64-steg-badge:hover{background:rgba(99,102,241,.3);}
html.dark .b64-dlsite-tag:hover{background:rgba(250,204,21,.28);}
.b64-tl-owned::after,.b64-tl-new::after{content:'';position:absolute;right:0;top:0;bottom:0;width:5px;}
.b64-tl-owned::after{background:#ef4444;}
.b64-tl-new::after{background:#22c55e;}
.b64-product-link:hover{background:#ede9fe;border-color:#a78bfa;}
.b64-product-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.6;}
.b64-product-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-product-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}
/* ── Steam 카드 ── */
.b64-product-link.pl-steam{background:#f0f9ff;border-color:#7dd3fc;color:#075985;}
.b64-product-link.pl-steam:hover{background:#e0f2fe;border-color:#38bdf8;}
/* ── Patreon / Getcu / Fanza / Fanbox 카드 — 공통 오렌지 ── */
.b64-product-link.pl-patreon,
.b64-product-link.pl-getcu,
.b64-product-link.pl-fanza,
.b64-product-link.pl-fanbox{background:#fff7ed;border-color:#fdba74;color:#7c2d12;}
.b64-product-link.pl-patreon:hover,
.b64-product-link.pl-getcu:hover,
.b64-product-link.pl-fanza:hover,
.b64-product-link.pl-fanbox:hover{background:#ffedd5;border-color:#fb923c;}
/* ── 설정 패널 / 비번 모달 ── */
/* 세련된 한글 폰트: Pretendard(가능하면 CDN 로드) → 없으면 시스템 한글 폰트로 폴백. CSP 차단 시 폴백만 적용. */
@font-face{font-family:'Pretendard Variable';font-weight:45 920;font-style:normal;font-display:swap;
src:url('https://cdn.jsdelivr.net/gh/orioncactus/[email protected]/dist/web/variable/woff2/PretendardVariable.woff2') format('woff2-variations');}
#b64d-settings,#b64d-pw-manager,#b64d-brfinal,#b64d-chname{
--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;
--b64-font:'Pretendard Variable',Pretendard,-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo','Segoe UI','Malgun Gothic','Noto Sans KR',Roboto,sans-serif;
--b64-mono:ui-monospace,'SF Mono','JetBrains Mono','Cascadia Code','Roboto Mono',Menlo,Consolas,'Pretendard Variable',monospace;
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
max-width:95vw;max-height:82vh;overflow-y:auto;border-radius:14px;
background:var(--b64-bg);color:var(--b64-text);
box-shadow:0 16px 48px rgba(0,0,0,.65);z-index:2147483641;
font-family:var(--b64-font);font-size:13px;letter-spacing:-0.01em;-webkit-font-smoothing:antialiased;}
#b64d-settings[data-theme="light"],#b64d-pw-manager[data-theme="light"],#b64d-brfinal[data-theme="light"],#b64d-chname[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,#b64d-brfinal-backdrop,#b64d-chname-backdrop{
position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483640;backdrop-filter:blur(2px);}
#b64d-settings{width:480px;}
#b64d-pw-manager{width:376px;z-index:2147483642;}
#b64d-pw-backdrop{z-index:2147483641;}
#b64d-brfinal{width:380px;z-index:2147483646;}
#b64d-brfinal-backdrop{z-index:2147483645;}
/* 채널명 관리 모달 (시안 C: 코드=칩, 행=카드) — 비번 모달과 동일 테마.
왼쪽 칩 104px 고정 + 오른쪽 입력 flex → 모든 행 좌우 너비 통일. 현재 채널은 코드 칩만 강조. */
#b64d-chname{width:400px;z-index:2147483646;}
#b64d-chname-backdrop{z-index:2147483645;}
#b64d-chname .b64dpm-body{padding:12px;display:flex;flex-direction:column;gap:8px;}
/* 목록 고정 높이(≈5행) — 초과분은 하단 페이지 버튼으로. 수동 추가 행이 있으면 그만큼만 스크롤. */
#b64d-chname .b64dpm-list{height:266px;overflow-y:auto;gap:0;padding:2px;}
#b64d-chname .b64dcn-row{display:flex;align-items:center;gap:9px;padding:8px;margin-bottom:7px;background:var(--b64-bg2);border-radius:10px;}
#b64d-chname .b64dcn-row:last-child{margin-bottom:0;}
#b64d-chname .b64dcn-code{flex:0 0 104px;box-sizing:border-box;background:var(--b64-bg);border:1px solid var(--b64-bg3);border-radius:7px;
padding:6px 9px;font-family:var(--b64-mono);font-size:12px;letter-spacing:.01em;color:var(--b64-text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#b64d-chname .b64dcn-row.on .b64dcn-code{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;font-weight:600;}
#b64d-chname .b64dcn-name{flex:1;min-width:0;background:var(--b64-bg);font-size:13px;}
/* 수동 추가 인라인 행(시안 A): 점선 accent 카드 + 코드 입력(칩과 동일 폭) + ✓ */
#b64d-chname .b64dcn-addrow{background:var(--b64-input-bg);border:1px dashed var(--b64-on-bg);}
#b64d-chname .b64dcn-codein{flex:0 0 104px;box-sizing:border-box;font-family:var(--b64-mono);font-size:12px;padding:7px 9px;}
#b64d-chname .b64dcn-ok{flex:0 0 auto;width:30px;height:30px;border:0;border-radius:8px;background:var(--b64-on-bg);color:var(--b64-on-text);font-size:14px;cursor:pointer;transition:filter .12s;}
#b64d-chname .b64dcn-ok:hover{filter:brightness(1.15);}
#b64d-chname .b64dcn-row.flash{outline:2px solid #f97316;outline-offset:1px;animation:b64cn-flash 1.2s ease;}
@keyframes b64cn-flash{0%,100%{outline-color:transparent;}20%,80%{outline-color:#f97316;}}
/* 하단: 좌측 수동 추가 + 우측 페이지 버튼 */
#b64d-chname .b64dcn-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:10px;border-top:1px solid var(--b64-bg3);}
#b64d-chname .b64dcn-add{border:1px solid var(--b64-input-bd);border-radius:8px;background:var(--b64-bg2);color:var(--b64-text2);
font-family:inherit;font-size:12px;font-weight:500;padding:8px 13px;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}
#b64d-chname .b64dcn-add:hover{background:var(--b64-bg3);color:var(--b64-text);}
#b64d-chname .b64dcn-add.on{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
#b64d-chname .b64dcn-pager{display:flex;gap:3px;}
#b64d-chname .b64dcn-pg{min-width:22px;border:1px solid var(--b64-bg3);border-radius:5px;background:none;color:var(--b64-text2);
font-size:11px;padding:2px 6px;line-height:18px;cursor:pointer;font-family:inherit;transition:background .12s;}
#b64d-chname .b64dcn-pg:hover:not(.on):not(:disabled){background:var(--b64-bg2);}
#b64d-chname .b64dcn-pg.on{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
#b64d-chname .b64dcn-pg:disabled{opacity:.35;cursor:default;}
/* 비번 관리 채널 목록 하단 '채널명 관리' 전체폭 버튼 (chSide 바닥에 고정) */
.b64dpm-chname-open{width:100%;margin-top:auto;flex-shrink:0;padding:9px 8px;border:1px solid var(--b64-input-bd);border-radius:8px;
background:var(--b64-bg2);color:var(--b64-text2);font-family:inherit;font-size:12px;font-weight:500;letter-spacing:-.01em;cursor:pointer;transition:background .12s,color .12s;}
.b64dpm-chname-open:hover{background:var(--b64-bg3);color:var(--b64-text);}
/* 받침 보정 단어 편집 모달 — 비번 목록과 구분되는 칩(태그) 기반 UI */
.b64dbf-body{padding:14px 16px 16px;display:flex;flex-direction:column;gap:11px;}
.b64dbf-note{font-size:11.5px;line-height:1.55;color:var(--b64-text2);background:var(--b64-bg2);border-radius:9px;padding:9px 11px;}
.b64dbf-note b{color:var(--b64-text);font-weight:600;}
.b64dbf-chips{display:flex;flex-wrap:wrap;gap:6px;max-height:190px;overflow-y:auto;padding:3px 1px;align-content:flex-start;}
.b64dbf-empty{font-size:12px;color:var(--b64-sep-c);padding:6px 2px;}
.b64dbf-chip{display:inline-flex;align-items:center;gap:5px;background:var(--b64-bg2);border:1px solid var(--b64-bg3);
border-radius:999px;padding:4px 6px 4px 11px;font-size:13px;font-weight:600;color:var(--b64-text);line-height:1.3;}
.b64dbf-chip-x{background:none;border:none;cursor:pointer;color:var(--b64-sep-c);font-size:13px;line-height:1;
padding:0;width:16px;height:16px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;transition:.12s;}
.b64dbf-chip-x:hover{background:var(--b64-del-c);color:#fff;}
.b64dbf-add-row{display:flex;gap:6px;}
.b64dbf-input{flex:1;min-width:0;font-size:13px;padding:7px 10px;border:1px solid var(--b64-input-bd);border-radius:8px;
background:var(--b64-input-bg);color:var(--b64-text);outline:none;}
.b64dbf-input:focus{border-color:var(--b64-on-bg);}
.b64dbf-add{flex-shrink:0;border:none;border-radius:8px;background:var(--b64-on-bg);color:var(--b64-on-text);
font-size:13px;font-weight:600;padding:0 16px;cursor:pointer;font-family:inherit;}
.b64dbf-add:hover{background:var(--b64-on-hov);}
.b64dbf-foot{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:1px;}
.b64dbf-count{font-size:11px;color:var(--b64-sep-c);}
.b64dbf-reset{border:1px solid var(--b64-bg3);border-radius:8px;background:transparent;color:var(--b64-text2);
font-size:12px;padding:6px 12px;cursor:pointer;font-family:inherit;}
.b64dbf-reset:hover{background:var(--b64-bg2);color:var(--b64-text);}
.b64ds-header{
display:flex;align-items:center;justify-content:space-between;
padding:14px 16px;background:var(--b64-bg2);border-radius:14px 14px 0 0;
font-weight:700;font-size:15px;letter-spacing:-.01em;position:sticky;top:0;z-index:1;}
.b64ds-close{background:none;border:none;color:var(--b64-sep-c);cursor:pointer;font-size:18px;padding:0 4px;line-height:1;border-radius:4px;transition:color .15s;}
.b64ds-close:hover{color:var(--b64-text);}
.b64ds-slider-row{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:7px 10px;}
.b64ds-slider-r{display:flex;align-items:center;gap:4px;}
.b64ds-slider-val{font-size:11px;color:var(--b64-text2);min-width:34px;text-align:right;flex-shrink:0;}
.b64ds-slider-reset{background:none;border:1px solid var(--b64-bg3,#d4d4d8);border-radius:4px;color:var(--b64-text2);cursor:pointer;font-size:12px;padding:0 4px;line-height:1.5;flex-shrink:0;}
.b64ds-slider-reset:hover{background:var(--b64-bg2);color:var(--b64-text);}
.b64ds-slider{flex:1;min-width:80px;max-width:120px;cursor:pointer;accent-color:var(--b64-on-bg);}
.b64d-stip{position:fixed;z-index:2147483649;background:#18181b;color:#fafafa;font-size:11px;line-height:1.5;padding:0.45em 0.82em;border-radius:6px;max-width:20em;white-space:normal;pointer-events:none;box-shadow:0 2px 10px rgba(0,0,0,.4);}
.b64ds-body{padding:0;}
.b64ds-tabs{display:flex;background:var(--b64-bg2);border-bottom:1px solid var(--b64-bg3);padding:0 12px;overflow:hidden;}
.b64ds-tab{flex:1;padding:10px 0;font-size:13px;font-weight:600;cursor:pointer;border:none;background:none;color:var(--b64-text2);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s,background .12s;font-family:inherit;}
.b64ds-tab:hover{color:var(--b64-text);background:rgba(127,127,127,.1);}
.b64ds-tab.active{color:var(--b64-text);border-bottom-color:#3b82f6;}
.b64ds-tabpanel{display:none;flex-direction:column;gap:10px;padding:16px 16px 26px;}
.b64ds-tabpanel.active{display:flex;}
.b64ds-sep{font-size:11px;font-weight:700;color:var(--b64-sep-c);letter-spacing:.06em;text-transform:uppercase;padding:16px 4px 4px;}
.b64ds-sep:first-child{padding-top:2px;}
.b64ds-section{background:var(--b64-bg2);border-radius:12px;padding:2px 6px;}
.b64ds-group-title{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--b64-text2);letter-spacing:.05em;text-transform:uppercase;padding:7px 9px;margin:2px 0 5px;border-radius:7px;background:linear-gradient(90deg,rgba(56,189,248,.20),rgba(56,189,248,.02));}
.b64ds-group-title svg{width:14px;height:14px;flex-shrink:0;color:#38bdf8;}
.b64ds-section > :not(.b64ds-group-title) + :not(.b64ds-group-title){position:relative;}
.b64ds-section > :not(.b64ds-group-title) + :not(.b64ds-group-title)::before{content:'';position:absolute;top:0;left:10px;right:10px;border-top:0.5px solid var(--b64-bg3);pointer-events:none;}
.b64ds-toggle{
display:flex;align-items:center;justify-content:space-between;gap:10px;
width:100%;padding:10px;border-radius:8px;cursor:pointer;
border:none;background:transparent;color:var(--b64-text);text-align:left;
font-size:13px;font-family:inherit;transition:background .12s;}
.b64ds-toggle:hover,.b64ds-toggle.active:hover{background:var(--b64-bg3);}
.b64ds-sub{margin-left:18px;width:calc(100% - 18px);box-sizing:border-box;}
.b64ds-disabled{opacity:.42;pointer-events:none;}
.b64ds-badge{position:relative;width:36px;height:20px;border-radius:999px;flex-shrink:0;background:var(--b64-bg3);transition:background .15s;}
.b64ds-badge::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.3);}
.b64ds-toggle.active .b64ds-badge{background:var(--b64-on-bg);}
.b64ds-toggle.active .b64ds-badge::after{left:18px;}
/* 활성 강조: 켜짐=또렷+세미볼드, 꺼짐=글자만 옅게(스위치는 또렷) → 상위 OFF 흐림(.b64ds-disabled)과 구분 */
.b64ds-toggle{color:var(--b64-text2);font-weight:400;}
.b64ds-toggle.active{color:var(--b64-text);font-weight:500;}
.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-sub) 액션 버튼: .b64ds-action의 width:100%가 .b64ds-sub의 calc를 덮어써 margin-left(18px)만큼
오른쪽으로 삐져나오던 문제 → 더 구체적인 선택자로 너비를 (100% - 18px)로 고정 */
.b64ds-action.b64ds-sub{width:calc(100% - 18px);}
.b64ds-note{font-size:11px;line-height:1.55;color:var(--b64-text2);padding:6px 12px 2px;}
.b64ds-note b{color:var(--b64-text);font-weight:600;}
.b64ds-hotkey-row{
display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:9px 14px;border-radius:9px;background:var(--b64-bg2);}
.b64ds-hotkey-label{font-size:13px;color:var(--b64-text2);}
.b64ds-hotkey-r{display:flex;align-items:center;gap:6px;}
.b64ds-hotkey-key{
font-size:11px;font-family:monospace;padding:3px 9px;border-radius:6px;
background:var(--b64-bg3);color:var(--b64-text);border:1px solid var(--b64-input-bd);}
.b64ds-hotkey-btn{
font-size:11px;padding:3px 9px;border-radius:6px;cursor:pointer;
border:1px solid var(--b64-input-bd);background:var(--b64-bg2);color:var(--b64-text2);
font-family:inherit;transition:background .12s,color .12s;}
.b64ds-hotkey-btn:hover{background:var(--b64-bg3);}
.b64ds-hotkey-btn.capturing{color:var(--b64-on-text);background:var(--b64-on-bg);border-color:transparent;}
.b64dpm-body{padding:12px;display:flex;flex-direction:column;gap:8px;}
/* 고정 높이 — 탭을 바꿔도 패널 크기가 흔들리지 않게(설정창처럼). 내부에서만 스크롤. */
.b64dpm-pane{display:flex;flex-direction:column;height:300px;}
.b64dpm-pane > .b64dpm-list{flex:1;min-height:0;max-height:none;}
.b64dpm-pane > .b64dpm-chgrid{flex:1;min-height:0;}
.b64dpm-list{display:flex;flex-direction:column;gap:5px;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;
padding:9px 10px;border-radius:9px;background:var(--b64-bg2);}
.b64dpm-item span{font-size:13px;color:var(--b64-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* 순서 번호(뮤트 거터) + 비번(세련된 고정폭) — 번호 뒤에만 구분선 */
.b64dpm-num{flex:0 0 20px;text-align:center;font-size:12px;color:var(--b64-sep-c);font-variant-numeric:tabular-nums;}
.b64dpm-pw{flex:1;min-width:0;margin-right:8px;font-family:var(--b64-mono);font-size:13.5px;letter-spacing:.02em;color:var(--b64-text);}
.b64dpm-num + .b64dpm-pw{border-left:1px solid var(--b64-input-bd);padding-left:10px;margin-left:10px;}
.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-tabs{display:flex;gap:2px;background:var(--b64-bg2);border-radius:9px;padding:3px;margin-bottom:10px;}
.b64dpm-tab{flex:1;font-size:13px;font-weight:500;padding:7px 0;border-radius:7px;border:none;cursor:pointer;background:none;color:var(--b64-sep-c);font-family:inherit;transition:background .15s,color .15s;}
.b64dpm-tab.on{background:var(--b64-bg);color:var(--b64-text);box-shadow:0 1px 3px rgba(0,0,0,.12);}
.b64dpm-note{font-size:11px;color:var(--b64-sep-c);margin-bottom:10px;line-height:1.4;}
.b64dpm-hide{display:none;}
/* 채널별 비번 — 사이드바 마스터-디테일(버전 A) */
.b64dpm-chgrid{display:grid;grid-template-columns:120px 1fr;gap:12px;align-items:stretch;}
.b64dpm-chside{display:flex;flex-direction:column;gap:2px;overflow-y:auto;margin:-2px;padding:2px;min-height:0;}
.b64dpm-chdetail{display:flex;flex-direction:column;min-height:0;}
.b64dpm-chdetail > .b64dpm-list{flex:1;min-height:0;max-height:none;}
.b64dpm-chitem{display:flex;align-items:center;gap:6px;width:100%;text-align:left;font-size:13px;color:var(--b64-text2);
background:none;border:none;border-radius:8px;padding:8px 10px;cursor:pointer;font-family:inherit;transition:background .12s,color .12s;}
.b64dpm-chitem:hover{background:var(--b64-bg2);color:var(--b64-text);}
.b64dpm-chitem.on{background:var(--b64-on-bg);color:var(--b64-on-text);font-weight:500;}
.b64dpm-chname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.b64dpm-chcount{font-size:11px;opacity:.65;flex-shrink:0;font-variant-numeric:tabular-nums;}
.b64dpm-chadd{color:var(--b64-on-bg);}
.b64dpm-chadd.on{color:var(--b64-on-text);}
.b64dpm-chdetail{border-left:1px solid var(--b64-input-bd);padding-left:12px;min-width:0;}
.b64dpm-chtitle{font-size:13px;font-weight:600;color:var(--b64-text);margin-bottom:9px;display:flex;align-items:baseline;gap:7px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.b64dpm-chsub{font-size:11px;font-weight:400;color:var(--b64-sep-c);flex-shrink:0;}
.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;}
/* ── 모바일: 미리보기 뷰포트 중앙 고정, FAB 버튼 ── */
@media (hover:none) and (pointer:coarse){
#b64d-dlsite-preview{
left:50%!important;top:50%!important;
transform:translate(-50%,-50%);
max-width:calc(100vw - 24px)!important;
width:min(calc(100vw - 24px), calc(480px * var(--b64-dlp-scale,1)))!important;
}
}
#b64d-fab{
position:fixed;bottom:20px;right:20px;z-index:2147483645;display:none;
align-items:center;justify-content:center;
width:46px;height:46px;border-radius:50%;padding:0;
background:#6d28d9;color:#fff;border:none;font-size:22px;
box-shadow:0 2px 10px rgba(0,0,0,.4);cursor:pointer;touch-action:manipulation;
}
@media (hover:none) and (pointer:coarse){#b64d-fab{display:flex;}}
/* ── 제목 카드 바 ── */
.b64-title-card-bar{margin-top:14px;padding-top:10px;border-top:2px dashed #e4e4e7;display:flex;flex-direction:column;gap:6px;}
.b64-title-card-label{font-size:11px;color:#71717a;font-weight:600;letter-spacing:.05em;margin-bottom:2px;}
@keyframes dlp-spin{to{transform:rotate(360deg);}}
#b64d-dlsite-preview{
position:fixed;z-index:2147483646;background:rgba(0,0,0,.88);
border:1px solid rgba(255,255,255,.12);
width:calc(480px * var(--b64-dlp-scale,1));height:calc(360px * var(--b64-dlp-scale,1));
overflow:hidden;display:flex;align-items:center;justify-content:center;border-radius:10px;
pointer-events:auto;box-shadow:0 8px 32px rgba(0,0,0,.6);}
#b64d-dlsite-preview img{width:100%;height:100%;object-fit:contain;display:block;}
#b64d-dlsite-preview .dlp-spinner{
width:calc(38px * var(--b64-dlp-scale,1));height:calc(38px * var(--b64-dlp-scale,1));border-radius:50%;
border:3px solid rgba(255,255,255,.18);border-top-color:rgba(255,255,255,.85);
animation:dlp-spin .7s linear infinite;}
#b64d-dlsite-preview .dlp-err{
font-size:calc(13px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.55);text-align:center;padding:12px;}
#b64d-dlsite-preview .dlp-panel{
position:absolute;bottom:0;left:0;right:0;
padding:0;background:transparent;
display:flex;flex-direction:column;gap:0;pointer-events:none;}
/* 칩 또는 내비가 생긴 순간 JS가 has-content 클래스를 추가 → 그라디언트 활성화 */
#b64d-dlsite-preview .dlp-panel.has-content{
padding:calc(44px * var(--b64-dlp-scale,1)) calc(11px * var(--b64-dlp-scale,1)) calc(10px * var(--b64-dlp-scale,1));
background:linear-gradient(transparent,rgba(0,0,0,.9) 40%);
gap:calc(5px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-chips{display:flex;flex-wrap:wrap;gap:calc(5px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-nav-row{display:flex;align-items:center;gap:calc(8px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-tags-bar{font-size:calc(11px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.72);cursor:pointer;user-select:none;white-space:nowrap;pointer-events:auto;}
#b64d-dlsite-preview .dlp-tags-list{display:flex;flex-wrap:wrap;gap:calc(4px * var(--b64-dlp-scale,1));max-height:calc(66px * var(--b64-dlp-scale,1));overflow-y:auto;margin-top:calc(4px * var(--b64-dlp-scale,1));pointer-events:auto;}
#b64d-dlsite-preview .dlp-tag{font-size:calc(10px * var(--b64-dlp-scale,1));padding:calc(1px * var(--b64-dlp-scale,1)) calc(7px * var(--b64-dlp-scale,1));border-radius:999px;background:rgba(255,255,255,.14);color:#fafafa;white-space:nowrap;}
#b64d-dlsite-preview .dlp-chip{
font-size:calc(16px * var(--b64-dlp-scale,1));font-weight:700;line-height:1;
color:rgba(255,255,255,.92);background:rgba(255,255,255,.13);
border:1px solid rgba(255,255,255,.18);
border-radius:calc(4px * var(--b64-dlp-scale,1));
padding:calc(5px * var(--b64-dlp-scale,1)) calc(11px * var(--b64-dlp-scale,1));
white-space:nowrap;}
#b64d-dlsite-preview .dlp-chip-star{color:#fde68a;background:rgba(253,230,138,.1);border-color:rgba(253,230,138,.28);}
#b64d-dlsite-preview .dlp-chip-sales{color:#93c5fd;background:rgba(147,197,253,.08);border-color:rgba(147,197,253,.25);}
#b64d-dlsite-preview .dlp-chip-date{color:rgba(255,255,255,.6);}
#b64d-dlsite-preview .dlp-chip-review{color:#86efac;background:rgba(134,239,172,.08);border-color:rgba(134,239,172,.22);}
#b64d-dlsite-preview .dlp-nav{
margin-left:auto;font-size:calc(12px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.7);text-align:right;line-height:1.3;
text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 8px rgba(0,0,0,.8);}
/* W/S 미리보기 좌상단 작품 코드 라벨 */
#b64d-dlsite-preview .dlp-kp-code{
position:absolute;top:calc(7px * var(--b64-dlp-scale,1));left:calc(7px * var(--b64-dlp-scale,1));z-index:2;
font-size:calc(12px * var(--b64-dlp-scale,1));font-weight:700;line-height:1;letter-spacing:.02em;
color:#fafafa;background:rgba(0,0,0,.72);border:1px solid rgba(255,255,255,.18);
border-radius:calc(5px * var(--b64-dlp-scale,1));
padding:calc(3px * var(--b64-dlp-scale,1)) calc(8px * var(--b64-dlp-scale,1));
pointer-events:none;text-shadow:0 1px 3px rgba(0,0,0,.9);}
/* ── 키보드 링크 탐색 포커스 ── */
.b64-link.kp-focused,.b64-product-link.kp-focused{outline:2px solid #3b82f6;outline-offset:2px;box-shadow:0 0 0 4px rgba(59,130,246,.15);}
html.dark .b64-link.kp-focused,html.dark .b64-product-link.kp-focused{outline-color:#60a5fa;box-shadow:0 0 0 4px rgba(96,165,250,.2);}
/* ── 설정: 3-way 탐색 범위 선택 ── */
.b64ds-triple-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 10px;background:transparent;}
.b64ds-triple-label{font-size:13px;color:var(--b64-text2);}
.b64ds-triple-btns{display:flex;gap:4px;}
.b64ds-triple-btn{font-size:11px;padding:4px 10px;border-radius:6px;cursor:pointer;border:1px solid var(--b64-input-bd);background:var(--b64-bg3);color:var(--b64-text2);font-family:inherit;transition:background .12s,color .12s;}
.b64ds-triple-btn.active{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
.b64ds-triple-btn:hover:not(.active){background:var(--b64-bg);}
.b64ds-triple-pair{display:flex;gap:6px;}
.b64ds-triple-pair .b64ds-triple-row{flex:1;flex-direction:column;align-items:flex-start;gap:6px;}
/* ── 링크 복사 버튼 — 카드 왼쪽 전체 영역, 세로선 구분 ── */
.bl-copy{align-self:stretch;display:flex;align-items:center;justify-content:center;
padding:0 9px 0 11px;flex-shrink:0;cursor:pointer;opacity:.28;
border-right:1px solid rgba(0,0,0,.1);
transition:opacity .15s,background .15s,color .15s;}
.bl-copy:hover{opacity:.8;background:rgba(0,0,0,.07);}
.bl-copy.bl-copy-ok{opacity:1 !important;color:#16a34a;}
.b64-link .bl-copy .bl-icon,.b64-product-link .bl-copy .bl-icon{width:14px;height:14px;}
/* ── 링크 모아보기 패널 ── */
#b64d-link-panel{
margin:0 0 16px;padding:12px 14px;
background:#f4f4f5;border:1px solid #e4e4e7;border-radius:12px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
box-sizing:border-box;}
.b64lp-header{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:10px;font-size:12px;font-weight:700;
color:#71717a;letter-spacing:.04em;text-transform:uppercase;}
.b64lp-close{
background:none;border:none;cursor:pointer;color:#a1a1aa;
font-size:14px;padding:0 2px;line-height:1;border-radius:4px;
transition:color .15s;flex-shrink:0;}
.b64lp-close:hover{color:#18181b;}
.b64lp-section{display:flex;flex-direction:column;gap:6px;}
.b64lp-section+.b64lp-section{margin-top:10px;padding-top:10px;border-top:1px solid #e4e4e7;}
.b64lp-label{font-size:11px;font-weight:700;color:#a1a1aa;letter-spacing:.06em;text-transform:uppercase;}
.b64lp-cards{display:flex;flex-wrap:wrap;gap:4px;}
/* ── kone.gg 자체 다크모드 (html.dark 클래스 방식) ── */
html.dark .bl-copy{border-right-color:rgba(255,255,255,.12);}
html.dark .bl-copy:hover{background:rgba(255,255,255,.09);}
html.dark #b64d-link-panel{background:#27272a;border-color:#3f3f46;}
html.dark .b64lp-section+.b64lp-section{border-top-color:#3f3f46;}
html.dark .b64lp-close:hover{color:#fafafa;}
html.dark .b64-link{background:rgba(255,255,255,.08);border-color:rgba(209,213,219,.42);color:#e5e7eb;}
html.dark .b64-link:hover{background:rgba(255,255,255,.13);border-color:rgba(209,213,219,.60);}
html.dark .b64-link.lk-alive{background:rgba(22,163,74,.14);border-color:rgba(74,222,128,.58);color:#86efac;}
html.dark .b64-link.lk-alive:hover{background:rgba(22,163,74,.22);border-color:rgba(74,222,128,.78);}
html.dark .b64-link.lk-dead{background:rgba(220,38,38,.14);border-color:rgba(248,113,113,.58);color:#fca5a5;}
html.dark .b64-link.lk-dead:hover{background:rgba(220,38,38,.22);border-color:rgba(248,113,113,.78);}
html.dark .b64-product-link{background:rgba(109,40,217,.18);border-color:rgba(167,139,250,.62);color:#c4b5fd;}
html.dark .b64-product-link:hover{background:rgba(109,40,217,.28);border-color:rgba(167,139,250,.82);}
html.dark .b64-product-link.pl-steam{background:rgba(30,58,138,.18);border-color:rgba(96,165,250,.58);color:#93c5fd;}
html.dark .b64-product-link.pl-steam:hover{background:rgba(30,58,138,.28);border-color:rgba(96,165,250,.78);}
html.dark .b64-product-link.pl-patreon,
html.dark .b64-product-link.pl-getcu,
html.dark .b64-product-link.pl-fanza,
html.dark .b64-product-link.pl-fanbox{background:rgba(120,53,15,.18);border-color:rgba(251,146,60,.58);color:#fdba74;}
html.dark .b64-product-link.pl-patreon:hover,
html.dark .b64-product-link.pl-getcu:hover,
html.dark .b64-product-link.pl-fanza:hover,
html.dark .b64-product-link.pl-fanbox:hover{background:rgba(120,53,15,.28);border-color:rgba(251,146,60,.78);}
html.dark .b64-title-card-bar{border-top-color:#3f3f46;}
/* ── 채널 대문 TXT 중복 검사 버튼 ── */
#b64-dup-btn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid #e4e4e7;background:transparent;cursor:pointer;color:#71717a;padding:0;}
#b64-dup-btn:hover{background:#f4f4f5;color:#18181b;}
#b64-dup-btn .bl-icon{width:16px;height:16px;}
html.dark #b64-dup-btn{border-color:#3f3f46;color:#a1a1aa;}
html.dark #b64-dup-btn:hover{background:#27272a;color:#f4f4f5;}
/* ── TXT 중복 검사 모달 ── */
#b64-dup-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:99990;display:flex;align-items:center;justify-content:center;}
#b64-dup-modal{background:#fff;border-radius:14px;width:700px;max-width:95vw;height:min(710px,90vh);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,.22);overflow:hidden;}
html.dark #b64-dup-modal{background:#1c1c1e;color:#f4f4f5;}
#b64-dup-modal .dup-hdr{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid #e4e4e7;font-weight:600;font-size:14px;flex-shrink:0;}
html.dark #b64-dup-modal .dup-hdr{border-bottom-color:#3f3f46;}
#b64-dup-modal .dup-x{background:none;border:none;cursor:pointer;font-size:16px;padding:2px 6px;color:inherit;opacity:.5;border-radius:6px;}
#b64-dup-modal .dup-x:hover{opacity:1;background:rgba(0,0,0,.06);}
html.dark #b64-dup-modal .dup-x:hover{background:rgba(255,255,255,.08);}
#b64-dup-modal .dup-layout{display:flex;flex:1;overflow:hidden;}
#b64-dup-modal .dup-sidebar{width:185px;flex-shrink:0;border-right:1px solid #e4e4e7;overflow:hidden;padding:10px 8px;display:flex;flex-direction:column;gap:4px;}
html.dark #b64-dup-modal .dup-sidebar{border-right-color:#3f3f46;}
#b64-dup-modal .dup-sidebar-title{font-size:11px;font-weight:700;color:#71717a;letter-spacing:.05em;padding:2px 4px 6px;}
#b64-dup-modal .dup-hist-search{width:100%;box-sizing:border-box;padding:5px 8px;border:1px solid #e4e4e7;border-radius:6px;font-size:11px;font-family:inherit;background:transparent;color:inherit;outline:none;margin-bottom:4px;}
#b64-dup-modal .dup-hist-search:focus{border-color:#7dd3fc;}
html.dark #b64-dup-modal .dup-hist-search{border-color:#3f3f46;}
html.dark #b64-dup-modal .dup-hist-search:focus{border-color:#38bdf8;}
#b64-dup-modal .dup-hist-pager{display:flex;align-items:center;justify-content:center;gap:3px;padding:6px 4px 2px;border-top:1px solid #e4e4e7;flex-shrink:0;}
html.dark #b64-dup-modal .dup-hist-pager{border-top-color:#3f3f46;}
#b64-dup-modal .dup-hist-pager-pages{display:flex;gap:2px;}
#b64-dup-modal .dup-hist-pager-btn,#b64-dup-modal .dup-hist-pager-num{background:none;border:1px solid #d4d4d8;border-radius:4px;padding:1px 6px;font-size:11px;cursor:pointer;color:#71717a;font-family:inherit;line-height:18px;min-width:22px;}
#b64-dup-modal .dup-hist-pager-btn:hover,#b64-dup-modal .dup-hist-pager-num:hover{background:#f4f4f5;}
#b64-dup-modal .dup-hist-pager-num.active{background:#0284c7;color:#fff;border-color:transparent;}
#b64-dup-modal .dup-hist-pager-btn:disabled{opacity:.35;cursor:default;}
html.dark #b64-dup-modal .dup-hist-pager-btn,html.dark #b64-dup-modal .dup-hist-pager-num{border-color:#3f3f46;color:#a1a1aa;}
html.dark #b64-dup-modal .dup-hist-pager-btn:hover,html.dark #b64-dup-modal .dup-hist-pager-num:hover{background:#27272a;}
#b64-dup-modal .dup-hist-date-group{font-size:10px;font-weight:700;color:#a1a1aa;letter-spacing:.06em;padding:8px 4px 3px;border-bottom:1px solid #e4e4e7;margin-bottom:2px;}
html.dark #b64-dup-modal .dup-hist-date-group{color:#71717a;border-bottom-color:#3f3f46;}
#b64-dup-modal .dup-body{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px;}
#b64-dup-modal .dup-pick{display:flex;align-items:center;gap:8px;}
#b64-dup-modal .dup-file-btn{padding:6px 14px;background:#0284c7;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;white-space:nowrap;}
#b64-dup-modal .dup-file-btn:hover{background:#0369a1;}
#b64-dup-modal .dup-file-name{font-size:12px;color:#71717a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}
#b64-dup-modal .dup-result{font-size:13px;display:flex;flex-direction:column;gap:6px;}
#b64-dup-modal .dup-result-pager{display:flex;align-items:center;justify-content:center;gap:3px;padding:4px 0 2px;margin-top:3px;}
#b64-dup-modal .dup-summary{font-size:12px;color:#71717a;}
#b64-dup-modal .dup-ok{color:#16a34a;font-weight:500;}
html.dark #b64-dup-modal .dup-ok{color:#4ade80;}
#b64-dup-modal .dup-label{font-size:12px;font-weight:600;color:#ef4444;margin-bottom:2px;}
html.dark #b64-dup-modal .dup-label{color:#f87171;}
#b64-dup-modal .dup-page-link{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border:1px solid #c7d2fe;border-radius:8px;font-size:12px;color:#0284c7;text-decoration:none;background:#eef2ff;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#b64-dup-modal .dup-page-link:hover{background:#e0e7ff;border-color:#7dd3fc;}
html.dark #b64-dup-modal .dup-page-link{background:#1e1b4b;border-color:#0369a1;color:#7dd3fc;}
html.dark #b64-dup-modal .dup-page-link:hover{background:#312e81;border-color:#38bdf8;}
#b64-dup-modal .dup-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:2px;}
#b64-dup-modal .dup-list li{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#f4f4f5;border-radius:6px;}
html.dark #b64-dup-modal .dup-list li{background:#27272a;}
#b64-dup-modal .dup-code{font-family:monospace;font-weight:600;font-size:13px;}
#b64-dup-modal .dup-badge{font-size:11px;background:#fde68a;color:#92400e;border-radius:999px;padding:1px 7px;font-weight:600;}
html.dark #b64-dup-modal .dup-badge{background:#451a03;color:#fbbf24;}
#b64-dup-modal .dup-empty{color:#71717a;font-size:13px;}
#b64-dup-modal .dup-section-title{font-size:11px;font-weight:700;letter-spacing:.04em;margin:8px 0 3px;color:#52525b;}
html.dark #b64-dup-modal .dup-section-title{color:#a1a1aa;}
#b64-dup-modal .dup-owned-title{color:#ef4444;}
html.dark #b64-dup-modal .dup-owned-title{color:#f87171;}
#b64-dup-modal .dup-new-title{color:#16a34a;}
html.dark #b64-dup-modal .dup-new-title{color:#4ade80;}
#b64-dup-modal .dup-badge-ok{background:#fee2e2;color:#dc2626;border-radius:999px;padding:1px 7px;font-size:11px;font-weight:600;}
html.dark #b64-dup-modal .dup-badge-ok{background:#7f1d1d;color:#fca5a5;}
#b64-dup-modal .dup-list li.dup-goto{cursor:pointer;}
#b64-dup-modal .dup-list li.dup-goto:hover{background:#e4e4e7;outline:1px solid #a1a1aa;}
html.dark #b64-dup-modal .dup-list li.dup-goto:hover{background:#3f3f46;outline-color:#71717a;}
@keyframes b64-card-flash{0%,100%{outline:2px solid transparent;outline-offset:3px}15%{outline:2px solid #f97316;outline-offset:3px}85%{outline:2px solid #f97316;outline-offset:3px}}
.b64-card-flash{animation:b64-card-flash 1.8s ease;}
#b64-dup-modal .dup-hist-list{display:flex;flex-direction:column;gap:4px;flex:1 1 auto;min-height:0;overflow-y:auto;}
#b64-dup-modal .dup-hist-row{display:flex;flex-direction:column;gap:2px;padding:7px 8px;border-radius:6px;cursor:pointer;font-size:12px;font-family:inherit;color:inherit;border:1px solid #e4e4e7;background:#fafafa;width:100%;text-align:left;}
#b64-dup-modal .dup-hist-row:hover{background:#f4f4f5;border-color:#d4d4d8;}
html.dark #b64-dup-modal .dup-hist-row{background:#1c1c1e;border-color:#3f3f46;}
html.dark #b64-dup-modal .dup-hist-row:hover{background:#27272a;border-color:#52525b;}
#b64-dup-modal .dup-hist-file{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;}
#b64-dup-modal .dup-hist-meta{display:flex;justify-content:space-between;gap:4px;}
#b64-dup-modal .dup-hist-date{color:#71717a;font-size:11px;}
#b64-dup-modal .dup-hist-stat{color:#71717a;font-size:11px;}
#b64-dup-modal .dup-hist-row.dup-hist-sel{background:#eff6ff;border-color:#93c5fd;}
html.dark #b64-dup-modal .dup-hist-row.dup-hist-sel{background:#1e3a5f;border-color:#3b82f6;}
#b64-dup-modal .dup-detail-hint{font-size:10px;color:#71717a;margin-top:4px;text-align:center;}
#b64-dup-modal .dup-detail-section{margin-top:8px;padding-top:8px;border-top:1px solid #e4e4e7;}
html.dark #b64-dup-modal .dup-detail-section{border-top-color:#3f3f46;}
#b64-dup-modal .dup-detail-title{font-size:11px;font-weight:600;color:#52525b;margin-bottom:4px;}
html.dark #b64-dup-modal .dup-detail-title{color:#a1a1aa;}
/* ── 작성 페이지 변환 패널 (C안: 좌측 탭 → 슬라이드 서랍) ── */
#b64d-br{position:fixed;right:0;top:130px;z-index:40;display:flex;align-items:stretch;transform-origin:right top;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
--brbg:#ffffff;--brbg2:#f4f4f5;--brbg3:#e4e4e7;--brtx:#18181b;--brtx2:#52525b;
--bron:#0369a1;--bronh:#0284c7;--bronx:#ffffff;--bracc:#0284c7;--brbd:#d4d4d8;}
html.dark #b64d-br{--brbg:#18181b;--brbg2:#27272a;--brbg3:#3f3f46;--brtx:#fafafa;--brtx2:#a1a1aa;
--bron:#0c4a6e;--bronh:#1a6a8f;--bronx:#e0f2fe;--bracc:#38bdf8;--brbd:#3f3f46;}
#b64d-br-tab{flex-shrink:0;width:30px;background:var(--bron);color:var(--bronx);border:none;cursor:pointer;
border-radius:10px 0 0 10px;display:flex;align-items:center;justify-content:center;padding:14px 0;
box-shadow:-2px 0 10px rgba(0,0,0,.35);}
#b64d-br-tab span{writing-mode:vertical-rl;text-orientation:mixed;font-size:12px;letter-spacing:3px;font-weight:500;}
#b64d-br-draw{width:0;overflow:hidden;transition:width .18s ease;background:var(--brbg);color:var(--brtx);
border-radius:12px 0 0 12px;box-sizing:border-box;}
#b64d-br.open #b64d-br-draw{width:236px;border:.5px solid var(--brbd);border-right:none;box-shadow:-6px 0 24px rgba(0,0,0,.4);}
#b64d-br.open #b64d-br-tab{border-radius:0;}
#b64d-br-inner{width:236px;padding:12px;display:flex;flex-direction:column;gap:9px;box-sizing:border-box;}
#b64d-br .br-h{font-size:12px;font-weight:500;color:var(--brtx);display:flex;align-items:center;gap:6px;}
#b64d-br .br-h .br-x{margin-left:auto;cursor:pointer;color:var(--brtx2);font-size:15px;line-height:1;background:none;border:none;}
#b64d-br .br-seg{display:flex;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;padding:2px;gap:2px;}
#b64d-br .br-seg button{flex:1;font-size:11px;padding:5px 0;border-radius:5px;color:var(--brtx2);background:none;border:none;cursor:pointer;font-family:inherit;}
#b64d-br .br-seg button.on{background:var(--bron);color:var(--bronx);font-weight:500;}
#b64d-br .br-row{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--brtx);}
#b64d-br .br-step{display:flex;align-items:center;border:.5px solid var(--brbd);border-radius:6px;overflow:hidden;}
#b64d-br .br-step button{width:24px;font-size:14px;padding:2px 0;color:var(--brtx);background:var(--brbg2);border:none;cursor:pointer;}
#b64d-br .br-step b{width:30px;text-align:center;font-size:12px;font-weight:500;border-left:.5px solid var(--brbd);border-right:.5px solid var(--brbd);}
#b64d-br .br-sw{position:relative;width:30px;height:17px;border-radius:99px;background:var(--brbg3);flex-shrink:0;cursor:pointer;transition:background .15s;}
#b64d-br .br-sw::after{content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:left .15s;}
#b64d-br .br-sw.on{background:var(--bron);}
#b64d-br .br-sw.on::after{left:15px;}
#b64d-br .br-lab{font-size:10px;color:var(--brtx2);display:flex;justify-content:space-between;margin-bottom:3px;}
#b64d-br textarea{width:100%;height:64px;resize:vertical;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;
padding:7px 9px;font-size:12px;color:var(--brtx);font-family:inherit;box-sizing:border-box;line-height:1.5;}
#b64d-br .br-out{width:100%;max-height:78px;overflow:auto;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;
padding:7px 9px;font-size:13px;color:var(--bracc);box-sizing:border-box;word-break:break-all;white-space:pre-wrap;min-height:34px;line-height:1.4;}
#b64d-br .br-btns{display:flex;gap:6px;}
#b64d-br .br-btns button{flex:1;font-size:11px;padding:7px 0;border-radius:6px;cursor:pointer;font-family:inherit;border:.5px solid var(--brbd);background:var(--brbg2);color:var(--brtx2);}
#b64d-br .br-btns button.br-pri{background:var(--bron);color:var(--bronx);border-color:transparent;font-weight:500;}
#b64d-br .br-btns button:hover{filter:brightness(1.15);}
#b64d-br .br-hide{display:none !important;}
/* 코드 모드 겉보기/비번 입력(팝오버 대체 — 패널 내부 삽입) */
#b64d-br .br-code-box{display:flex;flex-direction:column;gap:6px;}
#b64d-br .br-cover-ctl{display:flex;align-items:center;gap:5px;}
#b64d-br .br-cover-sel{background:var(--brbg2);border:.5px solid var(--brbd);border-radius:6px;color:var(--brtx);font-family:inherit;font-size:11px;padding:3px 5px;max-width:120px;}
#b64d-br .br-cover-gen{flex-shrink:0;font-size:12px;line-height:1;padding:4px 7px;border-radius:6px;border:.5px solid var(--brbd);background:var(--brbg2);color:var(--brtx);cursor:pointer;}
#b64d-br .br-cover-gen:hover{filter:brightness(1.15);}
#b64d-br .br-cover-txt{width:100%;height:40px;resize:vertical;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;padding:6px 8px;font-size:12px;color:var(--brtx);font-family:inherit;box-sizing:border-box;line-height:1.45;}
#b64d-br .br-code-pw{background:var(--brbg2);border:.5px solid var(--brbd);border-radius:6px;color:var(--brtx);font-family:inherit;font-size:11px;padding:4px 7px;max-width:150px;}
.b64-braille{cursor:default;} /* 마커 전용(복원/툴팁) — 시각표시 없이 네이티브처럼; 내부 카드/링크 정상 상호작용 */
`;
(document.head || document.documentElement).appendChild(s);
}
/* ================================================================
SVG 아이콘
================================================================ */
function svg(path) {
return `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`;
}
const ICO = {
world: svg('<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'),
link: svg('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'),
linkoff: svg('<path d="m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="m5.17 11.75-1.72 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71"/><line x1="2" y1="2" x2="22" y2="22"/>'),
check: svg('<polyline points="20 6 9 17 4 12"/>'),
alert: svg('<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'),
spin: `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.22-8.56"/></svg>`,
tag: svg('<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>'),
gamepad: svg('<line x1="6" y1="11" x2="10" y2="11"/><line x1="8" y1="9" x2="8" y2="13"/><line x1="15" y1="12" x2="15.01" y2="12"/><line x1="18" y1="10" x2="18.01" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 16 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.544-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>'),
patreon: svg('<circle cx="15" cy="9" r="5.5"/><line x1="3" y1="2" x2="3" y2="22"/>'),
copy: svg('<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
hash: svg('<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>'),
};
/* ================================================================
카드 유틸
================================================================ */
// 카드 <a> 왼쪽에 복사 버튼 추가 — 모든 make*Card 함수에서 공통 사용
// code를 넘기면 COPY_CODE 설정에 따라 링크/코드 중 선택 복사 (작품 카드 전용)
function wrapCard(a, code) {
if (code) a.dataset.cardCode = String(code);
const btn = document.createElement('span');
btn.className = 'bl-copy';
btn.title = (CFG.COPY_CODE && code) ? `코드 복사 (${code})` : '링크 복사';
btn.innerHTML = ICO.copy;
a.insertBefore(btn, a.firstChild);
return a;
}
// 카드 공통 골격(optimized+ 통합) — 모든 make*Card·makeLinkCard가 이 팩토리를 쓴다.
// 과거엔 '<a> 생성→href/target/rel→DONE_ATTR→className→아이콘/타이틀/서브/화살표 innerHTML
// →hookDlp→wrapCard' 골격이 9곳에 복붙돼 있었다(bl-arrow 템플릿 9벌).
// o: { cls, icon, title, sub, url, code?, preview? } — title/sub는 여기서 esc 처리.
function makeCardBase(o) {
const a = document.createElement('a');
a.href = o.url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.className = o.cls;
a.innerHTML = `<span class="bl-icon-wrap">${o.icon}</span>
<span class="bl-text">
<span class="bl-title">${esc(o.title)}</span>
<span class="bl-sub">${esc(o.sub)}</span>
</span><span class="bl-arrow">↗</span>`;
if (o.preview && CFG.DLSITE_PREVIEW) hookDlp(a);
return wrapCard(a, o.code || null);
}
/* ================================================================
링크 모아보기 패널 — 페이지 상단에 상품/다운로드 링크 중복 없이 모아 표시
================================================================ */
function buildLinkPanel(contentRoots) {
if (!CFG.LINK_PANEL || !CFG.LINK_CARD || !contentRoots || !contentRoots.size) {
document.getElementById('b64d-link-panel')?.remove();
_panelCardMap.clear();
return;
}
const showProducts = CFG.PANEL_TARGET !== 'links';
const showDownloads = CFG.PANEL_TARGET !== 'products';
// ★수정3: 먼저 '카드 집합 시그니처'를 구해, 기존 패널과 같으면 재빌드하지 않는다. 코네 재렌더로 flush가
// 자주 도는데, 매번 패널을 부수고 다시 만들면 그 안(본문 밖) 카드에 둔 W/S 선택·호버가 계속 풀렸다.
const sigKeys = [], sigSeen = new Set();
contentRoots.forEach(root => {
const sel = [showProducts && '.b64-product-link[href]', showDownloads && '.b64-link[href]'].filter(Boolean).join(',');
if (!sel) return;
root.querySelectorAll(sel).forEach(a => {
if (a.closest('#b64d-link-panel')) return;
const k = a.dataset.cardCode ? 'c:' + a.dataset.cardCode : 'h:' + a.href;
if (!sigSeen.has(k)) { sigSeen.add(k); sigKeys.push(k); }
});
});
const sig = sigKeys.join('|');
const existingPanel = document.getElementById('b64d-link-panel');
if (existingPanel && existingPanel.dataset.sig === sig) return; // 집합 동일 → 유지(선택/호버 보존)
// 재빌드 확정 → 기존 패널 제거 + 맵 초기화
existingPanel?.remove();
_panelCardMap.clear();
const seenHrefs = new Set();
const products = [];
const downloads = [];
function cloneCard(a) {
const href = a.href;
if (!href) return null;
// 상품 카드는 작품 코드 기준, 그 외(다운로드 링크)는 href 기준으로 중복 제거
// → 같은 작품이 코드·URL 등 다른 형태로 여러 번 카드화돼도 모아보기엔 1개만
const dedupKey = a.dataset.cardCode ? 'c:' + a.dataset.cardCode : 'h:' + href;
if (seenHrefs.has(dedupKey)) return null;
seenHrefs.add(dedupKey);
const clone = a.cloneNode(true);
clone.removeAttribute(DONE_ATTR);
clone.removeAttribute(ORIG_ATTR);
clone.removeAttribute('data-lkch');
// cloneNode은 이벤트 리스너를 복사하지 않으므로 미리보기 훅 재연결
if (CFG.DLSITE_PREVIEW && clone.dataset._dlpHooked) {
clone.addEventListener('mouseenter', dlpShow);
if (IS_MOBILE) _addMobileDlpTouch(clone);
}
if (_linkCheckCache.has(href)) {
const { alive, msg } = _linkCheckCache.get(href);
_applyLinkState(clone, alive, msg);
}
_panelCardMap.set(href, clone);
return clone;
}
contentRoots.forEach(root => {
if (showProducts) root.querySelectorAll('.b64-product-link[href]').forEach(a => {
const c = cloneCard(a); if (c) products.push(c);
});
if (showDownloads) root.querySelectorAll('.b64-link[href]').forEach(a => {
const c = cloneCard(a); if (c) downloads.push(c);
});
});
if (!products.length && !downloads.length) return;
const panel = document.createElement('div');
panel.id = 'b64d-link-panel';
panel.dataset.sig = sig; // 수정3: 다음 flush에서 집합 동일 판정 → 불필요한 재빌드 방지
// DONE_ATTR: MO 재트리거 방지 + 내부 링크를 convertProductLinks가 재처리하지 않도록
panel.setAttribute(DONE_ATTR, '');
const hdr = document.createElement('div');
hdr.className = 'b64lp-header';
const title = document.createElement('span');
title.textContent = '링크 모아보기';
const closeBtn = document.createElement('button');
closeBtn.className = 'b64lp-close';
closeBtn.textContent = '✕';
closeBtn.title = '닫기';
closeBtn.addEventListener('click', () => panel.remove());
hdr.appendChild(title);
hdr.appendChild(closeBtn);
panel.appendChild(hdr);
function makeSection(label, cards) {
const sec = document.createElement('div');
sec.className = 'b64lp-section';
const lbl = document.createElement('div');
lbl.className = 'b64lp-label';
lbl.textContent = label;
const wrap = document.createElement('div');
wrap.className = 'b64lp-cards';
cards.forEach(c => wrap.appendChild(c));
sec.appendChild(lbl);
sec.appendChild(wrap);
return sec;
}
if (products.length) panel.appendChild(makeSection('작품', products));
if (downloads.length) panel.appendChild(makeSection('다운로드', downloads));
// #post-article 최상단에 삽입 (없으면 첫 contentRoot 앞)
const articleEl = document.getElementById('post-article');
if (articleEl) {
articleEl.insertBefore(panel, articleEl.firstChild);
} else {
const first = [...contentRoots][0];
first.parentNode?.insertBefore(panel, first);
}
}
// 복사 버튼 클릭 이벤트 (캡처 단계 — <a> 기본 네비게이션 차단)
document.addEventListener('click', e => {
const btn = e.target.closest && e.target.closest('.bl-copy');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const card = btn.closest('a[href]');
if (!card) return;
const code = card.dataset.cardCode;
const textToCopy = (CFG.COPY_CODE && code) ? code : card.href;
const onOk = () => {
btn.innerHTML = ICO.check;
btn.classList.add('bl-copy-ok');
setTimeout(() => { btn.innerHTML = ICO.copy; btn.classList.remove('bl-copy-ok'); }, 1500);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(textToCopy).then(onOk).catch(() => {});
} else {
try {
const ta = document.createElement('textarea');
ta.value = textToCopy; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0;pointer-events:none;';
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); onOk();
} catch(_) {}
}
}, true);
/* ================================================================
base64 디코딩
================================================================ */
const DEC_STRICT = new TextDecoder('utf-8', { fatal: true });
const DEC_LENIENT = new TextDecoder('utf-8', { fatal: false });
// URL-safe base64('-'→'+', '_'→'/')도 허용. 정규화 후 표준 base64로 체크.
function looksLikeB64(str) {
const norm = str.replace(/-/g, '+').replace(/_/g, '/');
return norm.length >= MIN_B64 && norm.length % 4 !== 1 &&
/^[A-Za-z0-9+/]+={0,2}$/.test(norm);
}
// 맥락상(kone.gg=한국어) 거의 안 나오는 문자군 — base64 오탐(랜덤 바이트)의 강한 신호
function isSuspiciousScript(c) {
return (c >= 0x0300 && c <= 0x036f) || // 결합 분음 기호
(c >= 0x0400 && c <= 0x07ff) || // 키릴·아르메니아·히브리·아랍·시리아
(c >= 0x0900 && c <= 0x0fff) || // 데바나가리 등 인도계·타이·티베트
(c >= 0x1ab0 && c <= 0x1aff) || (c >= 0x1dc0 && c <= 0x1dff); // 결합 기호 확장
}
function isProbablyText(str) {
if (!str) return false;
let bad = 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)) bad++;
else if (isSuspiciousScript(c)) bad++; // 예: 'Vtuber'→'Vۛz' 의 ۛ(U+06DB) 같은 오탐 차단
if (c === 0xfffd) repl++;
}
// 대체문자(U+FFFD)는 0개여야 통과 — 깨진 디코딩(부분만 유효한 가비지)이 '정상'으로 통과해
// 트래쉬 카드/누적을 만들던 근본 문제 차단. 깨끗한 base64는 strict 디코딩으로 U+FFFD가 0개다.
return bad / str.length < 0.1 && repl === 0;
}
// 공통 디코딩 코어: 표준 base64 문자열(URL-safe 변환 완료)을 패딩 보정 → atob → UTF-8 디코딩.
// strict 먼저 시도, 실패 시 lenient(대체 문자 허용)로 폴백. atob 실패(비정상 입력)는 호출부 try가 흡수.
// (decodeB64Once·decodeShortB64가 동일 로직을 2벌 갖고 있던 중복 제거 — optimized+)
function b64DecodeCore(norm) {
let s = norm;
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); }
}
// URL-safe base64('-', '_')를 표준으로 변환한 뒤 디코딩
function decodeB64Once(str) {
// 다단계 인코딩의 중간 결과가 줄바꿈/공백을 포함할 수 있으므로 제거 후 판정·디코딩.
// (findAllMatches가 넘기는 토큰엔 공백이 없어 1단계 동작엔 영향 없음 — 다단계 재디코딩만 개선)
const compact = str.replace(/\s+/g, '');
if (!looksLikeB64(compact)) return null;
try { return b64DecodeCore(compact.replace(/-/g, '+').replace(/_/g, '/')); }
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;
}
// 짧은 base64(값 4~7자) 디코딩 — MIN_B64 임계 무시. '토큰/문단 전체가 base64'일 때만 findAllMatches
// 특례에서 쓰며, 오탐은 isProbablyText(repl===0)가 방어한다(수정사항2). 예 'bmFqYXI'→'najar'.
function decodeShortB64(s) {
const norm = s.replace(/-/g, '+').replace(/_/g, '/');
if (norm.length < 4 || norm.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(norm)) return null;
try { return b64DecodeCore(norm); }
catch (e) { return null; }
}
/* ================================================================
매칭 패턴 정의
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 라틴 코드 – 접두어·숫자 사이 공백 허용, 대소문자 무관
// 후행은 \b 대신 (?![0-9]) — 코드 뒤에 '_'(단어문자)가 붙어도(예 'RJ01645247_빈틈투성이!')
// \b가 숫자↔'_' 경계를 못 만들어 매칭 실패하던 문제(수정사항7). 숫자로만 끝나면 되므로 안전.
const DLSITE_LATIN_RE = /\b((?:RJ|BJ|VJ|RE|BE|VE))\s?(\d{6,8})(?![0-9])/gi;
// DLsite 한글 IME – 꺼(Rj)/거(rj) 또는 분리 자모 ㄲㅓ/ㄱㅓ + 선택적 공백
const DLSITE_KR_RE = /(꺼|거|ㄲㅓ|ㄱㅓ)\s?(\d{6,8})/g;
// Steam – 스팀, Steam, steam, st/St/ST (단어 경계), 구분자 공백·대시 허용
const STEAM_CODE_RE = /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*(\d{4,10})/g;
// 크리에이터 플랫폼 코드 – Getcu(GC) / Fanza(FZ) / Fanbox(FB)
const CREATOR_CODE_RE = /\b((?:GC|FZ|FB))\s?(\d{4,8})(?![0-9])/gi;
// base64 매칭(URL-safe '-_' 포함, g 플래그) — findAllMatches 핫패스 재사용용.
// g 정규식은 lastIndex 상태를 가지므로 사용 전 반드시 0으로 리셋한다.
const B64_MATCH_RE = new RegExp(`[A-Za-z0-9+/\\-_]{${MIN_B64},}={0,2}`, 'g');
// base64 존재 여부 테스트(표준 문자만, 비전역) — flushListTitles 후보 필터용. test()는 무상태라 리셋 불필요.
const B64_TEST_RE = new RegExp(`[A-Za-z0-9+/]{${MIN_B64},}={0,2}`);
// URL 매칭(g, 대소문자 무관). ★문자클래스에서 한글/일문/한자/전각을 '제외'한다 — 디코딩된 URL 뒤에
// 공백 없이 붙은 한글이 URL의 일부로 흡수되던 문제(수정사항2·예시1: mypikpak URL 뒤 'ㄱㄹ7일…'가
// %E3%84%B1… 로 흡수)를 차단. URL은 ASCII(퍼센트 인코딩)이므로 CJK를 만나면 URL 종료로 본다.
// ★또한 룩어헤드 (?!https?:\/\/)로 '다음 URL 시작' 전에 멈춘다 — 디코딩 결과에서 공백 없이 두 URL이
// 붙어(https://a…https://b…) 한 URL로 삼켜져 href·dedup키가 깨지던 문제(다운로드 카드 중복·손상) 차단.
// g 정규식이라 사용 전 lastIndex=0 리셋 필수.
const URL_RE = /((?:https?:\/\/|www\.)(?:(?!https?:\/\/)[^\s<>"'()ᄀ-ᇿ -〿-ヿ-㐀-䶿一-鿿ꥠ-가-豈--])+)/gi;
/* ── URL → 작품코드 / 사이트 판별 공통 헬퍼 (optimized+ 단일화) ──────────
같은 정규식·호스트 목록이 makeLinkCard·buildNodes·convertProductLinks(본문/OG)에
제각기 복붙돼 있어, 한 곳만 고치면 dedup 키가 어긋나는 drift 위험이 있었다 → 여기로 통합.
※ dlpShow의 dlsiteCodeMatch(product_id 전용)는 의도적으로 미통합 — CDN fast-path는
www 상품 페이지 형식에만 유효해, [/=]코드 보조 패턴까지 넓히면 동작이 달라진다. */
// DLsite 상품 URL → 코드 (www의 product_id/RJ… + ch.dlsite.com 등 '=코드'·'/코드' 형식)
const DLSITE_URL_ID_RE1 = /product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i;
const DLSITE_URL_ID_RE2 = /[/=]((?:RJ|BJ|VJ|RE|BE|VE)\d{6,8})\b/i;
function dlsiteCodeOf(url) {
const m = url.match(DLSITE_URL_ID_RE1) || url.match(DLSITE_URL_ID_RE2);
return m ? m[1].toUpperCase() : null;
}
// Steam 상품 URL → appId (호스트 확인은 호출부 책임)
function steamAppOf(url) { const m = url.match(/\/app\/(\d+)/); return m ? m[1] : null; }
// Getchu 상품 URL → 숫자 ID (item/{digits} 또는 ?id={digits}).
// ※ buildNodes lkKey용 '비숫자 item 코드'([a-z0-9_-]) 매칭은 의도적으로 별도 유지.
function getchuIdOf(url) { const m = url.match(/\/item\/(\d+)|[?&]id=(\d+)/); return m ? (m[1] || m[2]) : null; }
// 다운로드/파일공유 사이트(비번 자동입력·생존확인 대상) 여부 — PW_SITES/DEAD_PATTERNS 기준
function isDownloadHost(h) {
const hb = h.replace(/^www\./, '');
return !!(CFG.PW_SITES[h] || CFG.PW_SITES[hb] || CFG.DEAD_PATTERNS[h] || CFG.DEAD_PATTERNS[hb]);
}
// 카드화 지원 호스트 목록(단일 소스). 과거 buildNodes(isKnownHost)·convertProductLinks(OG known)에
// 표현이 미묘하게 다른 목록 2벌이 있었다(=== vs includes vs endsWith → drift 시작 상태였음).
// kone.gg는 호출부에서 별도 처리(내부 링크는 카드화 금지 / 평문 링크 유지).
function isKnownCardHost(h) {
const hb = h.replace(/^www\./, '');
return hb === 'dlsite.com' || h.endsWith('.dlsite.com')
|| h === 'store.steampowered.com'
|| hb === 'patreon.com'
|| hb === 'getcu.com' || hb === 'getchu.com'
|| hb === 'dmm.co.jp' || h.endsWith('.dmm.co.jp')
|| hb === 'fanza.jp'
|| hb === 'fanbox.cc' || h.endsWith('.fanbox.cc')
|| isDownloadHost(h);
}
/* ================================================================
findAllMatches (base64 + 점자 + DLsite 코드 + Steam 코드)
결과 캐시(영구 + 개수 상한): findAllMatches는 입력 문자열만으로 결과가
정해지는 순수 함수이고(CFG/DOM 미참조), 반환 배열은 어느 호출처에서도
변형되지 않으므로 공유 캐시가 안전하다. Svelte 재렌더로 동일 텍스트가
매 flush마다 재분석되던 비용을 제거한다.
================================================================ */
const _famCache = new Map();
const FAM_CACHE_MAX = 2000;
function findAllMatches(raw) {
const cached = _famCache.get(raw);
if (cached !== undefined) return cached; // []도 유효한 캐시값 (매칭 없음)
// ── 짧은 base64 특례 (수정사항2) ──
// 정규 base64 매칭은 MIN_B64=8 이상만 잡아 'bmFqYXI'(값 7자) 같은 짧은 인코딩을 놓쳤다.
// '토큰/문단 전체'가 순수 base64이고 패딩 제외 값이 8자 미만이면 개별 디코딩한다. 문장 속이 아니라
// 통째가 base64일 때만이라 오탐 위험이 낮고, 깨진 결과는 isProbablyText(repl===0)가 거른다.
// (여러 짧은 base64가 각 문단으로 나열돼도 processContentNode 경로에서 개별 카드화된다.)
{
const w = raw.trim();
const core = w.replace(/=+$/, '');
if (w.length >= 4 && core.length < MIN_B64 && /^[A-Za-z0-9+/_-]+={0,2}$/.test(w)) {
const dec = decodeShortB64(w);
if (dec !== null && dec !== w && isProbablyText(dec)) {
const one = [{ index: raw.indexOf(w), length: w.length, decoded: dec, type: 'base64' }];
if (_famCache.size >= FAM_CACHE_MAX) _famCache.delete(_famCache.keys().next().value);
_famCache.set(raw, one);
return one;
}
}
}
const hits = [];
// ── base64 (URL-safe '-_' 포함) ──
// URL-safe base64는 '+' 대신 '-', '/' 대신 '_' 를 사용하므로 함께 매칭
// 1) 원시 base64 토큰 위치 수집
B64_MATCH_RE.lastIndex = 0; // 공유 g 정규식 — 사용 전 위치 초기화
const _toks = []; let m;
while ((m = B64_MATCH_RE.exec(raw)) !== null) _toks.push({ index: m.index, end: m.index + m[0].length, text: m[0] });
// 2) '줄바꿈/공백으로 쪼개진 한 덩어리' base64 병합 디코딩 (수정사항3 ③).
// 긴 base64가 줄바꿈으로 쪼개져 한 텍스트노드에 들어오면(코네 본문) 각 줄이 따로 매칭돼
// 부분 디코딩(엉뚱한 결과)이 됐다. decodeB64Once는 compact(공백제거) 후 디코딩하므로,
// 조각을 이어붙여 통째로 디코딩하면 정상이 된다. 단, 병합은 '미완결 조각'에만 적용:
// 앞 조각이 '=' 패딩으로 끝나면 그건 '완결 단위'(예 ex2: 공백구분 별개 base64)이므로 병합 안 함.
// 토큰 사이가 공백뿐(\s+)일 때만 같은 base64로 보고 확장. 병합본 디코딩 실패 시 단일 토큰으로 폴백.
let last = 0, i = 0;
while (i < _toks.length) {
if (_toks[i].index < last) { i++; continue; }
let j = i;
while (j + 1 < _toks.length
&& !/=$/.test(_toks[j].text)
&& /^\s+$/.test(raw.slice(_toks[j].end, _toks[j + 1].index))) j++;
let consumed = false;
if (j > i) {
const seg = raw.slice(_toks[i].index, _toks[j].end);
const decoded = fullyDecode(seg);
if (decoded) {
hits.push({ index: _toks[i].index, length: _toks[j].end - _toks[i].index, decoded, type: 'base64' });
last = _toks[j].end; i = j + 1; consumed = true;
}
}
if (!consumed) {
const decoded = fullyDecode(_toks[i].text);
if (decoded) { hits.push({ index: _toks[i].index, length: _toks[i].text.length, decoded, type: 'base64' }); last = _toks[i].end; }
i++;
}
}
// ── DLsite 코드 (라틴, 대소문자 무관) ──
DLSITE_LATIN_RE.lastIndex = 0;
while ((m = DLSITE_LATIN_RE.exec(raw)) !== null) {
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) {
const code = (m[1] + m[2]).toUpperCase();
hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
}
}
// ── DLsite 코드 (한글 IME: 거=rj, 꺼=Rj → RJ 정규화) ──
DLSITE_KR_RE.lastIndex = 0;
while ((m = DLSITE_KR_RE.exec(raw)) !== null) {
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) {
const code = 'RJ' + m[2];
hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
}
}
// ── Steam 코드 ──
STEAM_CODE_RE.lastIndex = 0;
while ((m = STEAM_CODE_RE.exec(raw)) !== null) {
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) hits.push({ index: m.index, length: m[0].length, decoded: `Steam ${m[1]}`, type: 'steam', appId: m[1] });
}
// ── 크리에이터 플랫폼 코드 (GC/FZ/FB) ──
CREATOR_CODE_RE.lastIndex = 0;
while ((m = CREATOR_CODE_RE.exec(raw)) !== null) {
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) {
const code = (m[1] + m[2]).toUpperCase();
hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'creator', code });
}
}
hits.sort((a, b) => a.index - b.index);
const clean = []; let end = 0;
for (const h of hits) {
if (h.index >= end) { clean.push(h); end = h.index + h.length; }
}
// 상한 초과 시 가장 오래된 항목부터 제거 (Map 삽입 순서 = FIFO)
if (_famCache.size >= FAM_CACHE_MAX) _famCache.delete(_famCache.keys().next().value);
_famCache.set(raw, clean);
return clean;
}
/* ================================================================
링크 생존 확인
================================================================ */
// url → {alive, msg}: 동일 URL 재확인 생략용 캐시 (세션 유지)
const _linkCheckCache = new Map();
// url → 패널 클론 카드: 원본 체크 결과를 패널에 동기화
const _panelCardMap = new Map();
// url → Set<cardEl>: 진행 중인 URL의 대기 카드 목록 (중복 요청 제거)
const _lcPending = new Map();
// 링크 체크 동시 실행 제한 (GM 풀 포화 방지 → 미리보기 GM 요청 우선 확보)
const _lcQueue = [];
let _lcActive = 0;
const LC_MAX = 2;
function _lcRelease() {
_lcActive--;
if (_lcQueue.length) { _lcActive++; _lcQueue.shift()(); }
}
function _lcEnqueue(fn) {
if (_lcActive < LC_MAX) { _lcActive++; fn(); }
else _lcQueue.push(fn);
}
function _applyLinkState(el, alive, msg) {
el.classList.remove('lk-checking', 'lk-alive', 'lk-dead');
if (alive === true) el.classList.add('lk-alive');
else if (alive === false) el.classList.add('lk-dead');
const sub = el.querySelector('.bl-sub');
const wrap = el.querySelector('.bl-icon-wrap');
const arr = el.querySelector('.bl-arrow');
if (sub) sub.textContent = msg;
if (wrap) wrap.innerHTML = alive === true ? ICO.link : alive === false ? ICO.linkoff : ICO.world;
if (arr) arr.innerHTML = alive === true ? ICO.check : alive === false ? ICO.alert : '↗';
}
function setLinkState(cardEl, alive, msg) {
_applyLinkState(cardEl, alive, msg);
const url = cardEl.href;
if (url) {
_linkCheckCache.set(url, { alive, msg });
// 같은 URL을 기다리던 중복 카드에 결과 일괄 적용
const waiting = _lcPending.get(url);
if (waiting) {
waiting.forEach(el => { if (el !== cardEl) _applyLinkState(el, alive, msg); });
_lcPending.delete(url);
}
const pc = _panelCardMap.get(url);
if (pc && pc !== cardEl) _applyLinkState(pc, alive, msg);
}
_lcRelease();
}
function beginCheck(cardEl) {
cardEl.classList.add('lk-checking');
const arr = cardEl.querySelector('.bl-arrow');
if (arr) arr.innerHTML = ICO.spin;
}
function checkLinkByHead(url, cardEl) {
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'HEAD', url, timeout: 3000,
onload(res) {
const alive = res.status >= 200 && res.status < 400;
let h = '';
try { h = new URL(url).hostname; } catch(e) {}
setLinkState(cardEl, alive, alive ? `${h} · 링크 정상` : `${h} · 링크 없음 (${res.status})`);
},
onerror() { setLinkState(cardEl, false, '연결 실패'); },
ontimeout() { setLinkState(cardEl, false, '응답 없음'); },
});
}
// kio.ac/kiosk.ac: SPA라서 dead 텍스트가 JS 렌더링됨 → HTTP 상태 + JSON 패턴 병행 체크
function checkKioLink(url, cardEl, siteName) {
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'GET', url, timeout: 3000,
onload(res) {
if (res.status >= 400) {
setLinkState(cardEl, false, `${siteName} · 링크 만료 (${res.status})`);
return;
}
const body = (res.responseText || '').toLowerCase();
const dead = [
'컬렉션을 찾을 수 없음', '만료되었습니다', '찾을 수 없습니다', '존재하지 않',
'"notfound"', '"not_found"', '"not found"', '"status":404',
'"status":"error"', '"error":true', 'collection not found',
].some(p => body.includes(p));
setLinkState(cardEl, !dead, dead ? `${siteName} · 링크 만료` : `${siteName} · 링크 정상`);
},
onerror() { setLinkState(cardEl, false, `${siteName} · 연결 실패`); },
ontimeout() { setLinkState(cardEl, false, `${siteName} · 응답 없음`); },
});
}
// mega.nz: CS API — folder/file 링크 구분하여 각각 적절한 커맨드 사용
function checkMegaLink(url, cardEl) {
const folderM = url.match(/mega\.nz\/folder\/([A-Za-z0-9_-]+)/);
const fileM = url.match(/mega\.nz\/file\/([A-Za-z0-9_-]+)/);
const m = folderM || fileM;
if (!m) { checkLinkByHead(url, cardEl); return; }
const handle = m[1];
const isFolder = !!folderM;
// folder: ?n=HANDLE + {"a":"f","c":1} / file: body에 핸들 포함 {"a":"g","p":HANDLE}
const apiUrl = isFolder
? `https://g.api.mega.co.nz/cs?id=0&app=webclient&n=${handle}`
: 'https://g.api.mega.co.nz/cs?id=0&app=webclient';
const data = isFolder
? JSON.stringify([{ a: 'f', c: 1 }])
: JSON.stringify([{ a: 'g', p: handle, ssl: 0 }]);
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'POST', url: apiUrl, data,
headers: {
'Content-Type': 'application/json',
'Origin': 'https://mega.nz',
'Referer': 'https://mega.nz/',
},
timeout: 3000,
onload(res) {
try {
const d = JSON.parse(res.responseText);
// 에러: [-9], [-2] 등 음수 배열 또는 단독 음수, 또는 {e: -N}
const code = Array.isArray(d) ? d[0] : (typeof d === 'number' ? d : d?.e);
const dead = typeof code === 'number' && code < 0;
setLinkState(cardEl, !dead, dead ? 'mega.nz · 링크 만료' : 'mega.nz · 링크 정상');
} catch(e) { setLinkState(cardEl, true, 'mega.nz · 링크 정상'); }
},
onerror() { setLinkState(cardEl, false, 'mega.nz · 연결 실패'); },
ontimeout() { setLinkState(cardEl, false, 'mega.nz · 응답 없음'); },
});
}
// mypikpak.com: device_id + X-Client-Id 헤더 포함
// 진단: 실제 API 응답을 콘솔에 출력해 어떤 필드가 오는지 확인
function checkPikpakLink(url, cardEl) {
const m = url.match(/mypikpak\.com\/s\/([^/?#]+)/);
if (!m) { checkLinkByHead(url, cardEl); return; }
const shareId = m[1];
const deviceId = (crypto && crypto.randomUUID)
? crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'GET',
url: `https://api-drive.mypikpak.com/drive/v1/share?share_id=${shareId}&thumbnail_size=SIZE_MEDIUM&pass_code=&device_id=${deviceId}`,
headers: {
// mypikpak 웹 클라이언트 식별 헤더 (공개 공유 API 접근용)
'X-Client-Id': 'YNxT9w7GMdWvEOKa',
'X-Client-Version': '1.0.0',
'X-Device-Name': 'Chrome Browser',
'Accept': 'application/json, text/plain, */*',
},
timeout: 3000,
onload(res) {
if (res.status === 404) { setLinkState(cardEl, false, 'mypikpak · 링크 만료'); return; }
if (res.status >= 500) { setLinkState(cardEl, null, `mypikpak · 확인 불가 (${res.status})`); return; }
try {
const d = JSON.parse(res.responseText);
// share_status: "OK" / "EXPIRED" / "CANCELLED" 등
if (d.share_status) {
// 비번 보호 공유: 링크는 정상이고 비번만 필요 → '만료' 아님
if (/pass/i.test(d.share_status)) { setLinkState(cardEl, true, 'mypikpak · 정상 (비번 필요)'); return; }
const alive = ['OK', 'ACTIVE', 'NORMAL'].includes(d.share_status);
setLinkState(cardEl, alive, alive ? 'mypikpak · 링크 정상' : `mypikpak · 링크 만료 (${d.share_status})`); return;
}
// 에러 응답: {error:'INVALID', message:'...'} or {code:-N}
if (d.error || (typeof d.code === 'number' && d.code !== 0)) {
// error가 'NOT_FOUND' 계열이면 만료, 그 외(인증 에러 등)는 불명
const errStr = String(d.error || d.message || '').toLowerCase();
if (/pass/i.test(errStr)) { setLinkState(cardEl, true, 'mypikpak · 정상 (비번 필요)'); return; }
const dead = errStr.includes('not_found') || errStr.includes('not found') ||
errStr.includes('expired') || errStr.includes('invalid');
setLinkState(cardEl, dead ? false : null,
dead ? 'mypikpak · 링크 만료' : `mypikpak · 확인 불가 (${d.error || d.code})`); return;
}
} catch(e) {}
setLinkState(cardEl, null, 'mypikpak · 확인 불가');
},
onerror() { setLinkState(cardEl, null, 'mypikpak · 확인 불가'); },
ontimeout() { setLinkState(cardEl, null, 'mypikpak · 응답 없음'); },
});
}
function checkLink(url, cardEl) {
// 캐시 조회: 이미 체크한 URL이면 즉시 결과 적용, 네트워크 요청 생략
if (_linkCheckCache.has(url)) {
const { alive, msg } = _linkCheckCache.get(url);
_applyLinkState(cardEl, alive, msg);
return;
}
// 동일 URL이 이미 진행 중이면 대기 목록에만 추가 (네트워크 요청 중복 방지)
if (_lcPending.has(url)) {
_lcPending.get(url).add(cardEl);
beginCheck(cardEl);
return;
}
_lcPending.set(url, new Set([cardEl]));
// 동시 실행 제한 큐: GM 풀 포화 방지 → 미리보기 요청에 슬롯 확보
_lcEnqueue(() => {
let h = '';
try { h = new URL(url).hostname; } catch(e) { return checkLinkByHead(url, cardEl); }
if (h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac')
return checkKioLink(url, cardEl, h);
if (h === 'mega.nz') return checkMegaLink(url, cardEl);
if (h === 'mypikpak.com') return checkPikpakLink(url, cardEl);
// 검증 불가 사이트(사유 고정) — 네트워크 요청 없이 즉시 회색 안내.
// gofile.io: /contents API 유료 전용 + SPA라 페이지 HTML에도 생사 정보 없음
// workupload.com: Cloudflare 봇 감지로 자동 요청 차단
// transfer.it: SPA + MEGA API 헤더 제약으로 만료 감지 불가
if (h === 'gofile.io') return setLinkState(cardEl, null, 'gofile.io · 만료 검증 불가');
if (h === 'workupload.com') return setLinkState(cardEl, null, 'workupload.com · Cloudflare 봇 감지로 검증 불가');
if (h === 'transfer.it') return setLinkState(cardEl, null, 'transfer.it · 만료 검증 불가');
// kio·mega·mypikpak 외 사이트는 자동 검증 불가 → 즉시 안내
setLinkState(cardEl, null, h + ' · 만료 검증 불가');
});
}
/* ================================================================
유틸
================================================================ */
function esc(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function hookDlp(el) {
el.dataset._dlpHooked = '1';
el.addEventListener('mouseenter', dlpShow);
if (IS_MOBILE) _addMobileDlpTouch(el);
}
function _addMobileDlpTouch(el) {
let _tsY = 0;
el.addEventListener('touchstart', e => { _tsY = e.touches[0].clientY; }, { passive: true });
el.addEventListener('touchend', e => {
if (Math.abs(e.changedTouches[0].clientY - _tsY) > 10) return; // 스크롤 제스처 무시
dlpShowTouch(e);
}, { passive: false });
}
/* ================================================================
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';
return makeCardBase({
cls: 'b64-product-link', icon: ICO.tag,
title: `DLsite · ${code}`, sub: 'dlsite.com · 호버 시 미리보기',
url: `https://www.dlsite.com/${section}/work/=/product_id/${code}.html`,
code, preview: true,
});
}
/* ================================================================
Steam 상품 카드
================================================================ */
function makeSteamCard(appId) {
const a = makeCardBase({
cls: 'b64-product-link pl-steam', icon: ICO.gamepad,
title: `Steam · ${appId}`, sub: 'store.steampowered.com · 호버 시 미리보기',
url: `https://store.steampowered.com/app/${appId}/`,
code: 'ST' + appId, preview: true,
});
// 게임 이름 비동기 취득
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) {}
},
});
return a;
}
/* ================================================================
Patreon 상품 카드
================================================================ */
function makePatreonCard(url) {
const m = url.match(/patreon\.com\/(?:c\/)?([^/?#]+)/i);
const creator = m ? m[1] : '';
return makeCardBase({
cls: 'b64-product-link pl-patreon', icon: ICO.patreon,
title: 'Patreon' + (creator ? ' · ' + creator : ''), sub: 'patreon.com',
url, code: creator || null,
});
}
/* ================================================================
크리에이터 플랫폼 카드 (Getchu / Fanza / Fanbox)
================================================================ */
// href: URL에서 진입할 때 원본 URL 보존용 (없으면 getchu.com/item/{digits} 생성)
function makeGetcuCard(code, href) {
const digits = code.replace(/^GC/i, '');
return makeCardBase({
cls: 'b64-product-link pl-getcu', icon: ICO.tag,
title: `Getchu · ${digits}`, sub: 'getchu.com',
url: href || `https://getchu.com/item/${digits}`,
code, preview: true,
});
}
function makeFanzaCard(code) {
const digits = code.replace(/^FZ/i, '');
return makeCardBase({
cls: 'b64-product-link pl-fanza', icon: ICO.tag,
title: `Fanza · ${code}`, sub: 'dmm.co.jp',
url: `https://www.dmm.co.jp/dc/doujin/-/detail/=/cid=d_${digits}/`,
code,
});
}
// href: URL에서 진입할 때 원본 URL 보존용 (없으면 www.fanbox.cc/posts/{digits} 생성)
// subLabel: 카드 하단 보조 텍스트 (기본 'fanbox.cc')
function makeFanboxCard(code, href, subLabel) {
const digits = code.replace(/^FB/i, '');
// code가 순수 숫자가 아니면 작가명으로 전달된 것 → "FB" 접두어 없이 표시
const displayCode = /^\d+$/.test(digits) ? ('FB' + digits) : code;
return makeCardBase({
cls: 'b64-product-link pl-fanbox', icon: ICO.patreon,
title: `Fanbox · ${displayCode}`, sub: subLabel || 'fanbox.cc',
url: href || `https://www.fanbox.cc/posts/${digits}`,
code: displayCode,
});
}
// Fanbox 크리에이터 페이지 카드 (포스트 ID 없이 서브도메인만 있는 경우)
// 예: https://mangamaterials.fanbox.cc/posts → 크리에이터명 'mangamaterials' 표시
function makeFanboxCreatorCard(creator, url) {
return makeCardBase({
cls: 'b64-product-link pl-fanbox', icon: ICO.patreon,
title: `Fanbox · ${creator}`, sub: 'fanbox.cc · 크리에이터',
url, code: creator,
});
}
function makeCreatorCard(code) {
const prefix = code.slice(0, 2).toUpperCase();
if (prefix === 'GC') return makeGetcuCard(code);
if (prefix === 'FZ') return makeFanzaCard(code);
if (prefix === 'FB') return makeFanboxCard(code);
return document.createTextNode(code);
}
/* ================================================================
DLsite 검색/태그·장르 카드 (상품이 아닌 /fsr/ 검색·목록 링크)
================================================================ */
// DLsite 장르 ID→이름 영구 캐시. 한 번 받으면 GM 저장소에 보관 → 다시는 네트워크 안 씀.
let _dlGenre = null;
function _dlGenreMap() {
if (!_dlGenre) { try { _dlGenre = JSON.parse(GM_getValue('dlGenreNames', '{}')); } catch (e) { _dlGenre = {}; } }
return _dlGenre;
}
const _dlGenreFetching = new Set();
function fetchDlGenreName(id, url) {
if (_dlGenreFetching.has(id)) return;
_dlGenreFetching.add(id);
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'Referer': 'https://www.dlsite.com/' },
timeout: 8000,
onload(res) {
_dlGenreFetching.delete(id);
// <title>의 「장르명」만 정규식으로 추출 — DOMParser 없이 처리해 가볍고 빠름
const t = (res.responseText.match(/<title>([^<]*)<\/title>/i) || ['', ''])[1];
const m = t.match(/[「『]([^」』]+)[」』]/);
const name = m && m[1].trim();
if (!name) return;
_dlGenreMap()[id] = name;
try { GM_setValue('dlGenreNames', JSON.stringify(_dlGenre)); } catch (e) {}
document.querySelectorAll('.b64-dlsite-tag[data-genre="' + id + '"]').forEach(el => { el.textContent = '# ' + name; });
},
onerror() { _dlGenreFetching.delete(id); },
ontimeout() { _dlGenreFetching.delete(id); },
});
}
function makeDlsiteSearchCard(url) {
const kw = url.match(/\/keyword\/([^/]+)/i);
const genre = url.match(/\/genre\/(\d+)/i);
let label, genreId = null;
if (kw) { try { label = decodeURIComponent(kw[1]); } catch (e) { label = kw[1]; } }
else if (genre){ genreId = genre[1]; label = _dlGenreMap()[genreId] || ('장르 ' + genreId); }
else { label = '검색'; }
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.dataset._dlpHooked = '1'; // 미리보기 훅 제외 (상품 아님)
a.className = 'b64-dlsite-tag';
if (genreId) a.dataset.genre = genreId;
a.textContent = '# ' + label;
// 캐시에 없을 때만 1회 비동기 취득 → 이후 영구 캐시
if (genreId && !_dlGenreMap()[genreId]) fetchDlGenreName(genreId, url);
return a;
}
// 장르/키워드 dedup 키: 같은 장르가 여러 URL 형태(정렬·페이지 파라미터 차이 등)로 와도 1개로 묶는다.
function _genreKey(href) {
const g = href.match(/\/genre\/(\d+)/i);
if (g) return 'g:' + g[1];
const k = href.match(/\/keyword\/([^/]+)/i);
if (k) { try { return 'k:' + decodeURIComponent(k[1]); } catch (e) { return 'k:' + k[1]; } }
return 'u:' + href.split(/[?#]/)[0];
}
/* ================================================================
장르·태그 별도 바 (요구1 — C안)
DLsite /fsr/ 장르·키워드·태그 검색 링크를 인라인 칩으로 만들지 않고, 본문 하단에 '한 벌'로 모은다.
핵심: 코네(SvelteKit)가 work.genre 태그를 주기적으로 여러 벌 재렌더 → 인라인 칩화하면 flush·토글
마다 누적 증식했다. 이 바는 flush마다 통째로 제거 후 dedup해 재생성하므로 개수가 '현재 유니크
장르 수'로 항상 수렴(누적 불가). 원본 <a>는 숨겨 인라인 중복 표시를 막고, data-b64genre로 표시해
applyLive(토글 OFF 등)가 원상 복구할 수 있게 한다.
================================================================ */
function buildGenreBar(contentRoots) {
if (!CFG.LINK_CARD || !CFG.GENRE_BAR || !contentRoots || !contentRoots.size) {
document.querySelectorAll('.b64-genre-bar').forEach(el => el.remove());
return;
}
// 1) 원본 코네 장르 <a>는 '항상' 수집하며 새 것은 숨긴다(코네가 매 flush 새 <a>로 재렌더해도 인라인
// 중복 표시 방지). 동시에 장르 키를 모아 시그니처를 만든다.
const seen = new Set();
const found = []; // [key, href]
contentRoots.forEach(root => {
root.querySelectorAll('a[href]').forEach(a => {
if (a.classList.contains('b64-dlsite-tag')) return; // 우리가 만든 칩 제외
if (a.closest('#b64d-link-panel') || a.closest('.b64-genre-bar')) return;
const href = a.href || '';
let host = ''; try { host = new URL(href).hostname; } catch (e) { return; }
if (!host.includes('dlsite.com') || !/\/fsr\//i.test(href)) return;
if (/product_id\/(?:RJ|BJ|VJ|RE|BE|VE)/i.test(href)) return; // 상품 링크는 장르 아님
if (!a.hasAttribute(DONE_ATTR)) { // 원본 숨김(한 번만) + applyLive 복구용 표식
a.setAttribute(DONE_ATTR, '');
a.dataset.b64genre = '1';
a.style.setProperty('display', 'none', 'important');
}
const key = _genreKey(href);
if (seen.has(key)) return;
seen.add(key);
found.push([key, href]);
});
});
// 2) ★수정3: 장르 칩 집합이 그대로면 바를 재빌드하지 않는다(재빌드 시 그 안 카드의 호버가 풀리고 깜빡임).
// (원본 숨김은 위에서 항상 처리하므로 새 <a>도 안전.)
const sig = found.map(f => f[0]).join('|');
const existing = document.querySelector('.b64-genre-bar');
if (!found.length) { existing?.remove(); return; }
if (existing && existing.dataset.sig === sig) return; // 집합 동일 → 유지
existing?.remove();
const bar = document.createElement('div');
bar.className = 'b64-genre-bar';
bar.dataset.sig = sig;
bar.setAttribute(DONE_ATTR, ''); // MO 재트리거·재처리 방지
const label = document.createElement('span');
label.className = 'b64-genre-bar-label';
label.textContent = '장르·태그';
bar.appendChild(label);
found.forEach(([, href]) => bar.appendChild(makeDlsiteSearchCard(href)));
// 본문 하단에 삽입(본문에서 벗어나지 않게). #post-article 있으면 그 끝, 없으면 첫 contentRoot 끝.
const articleEl = document.getElementById('post-article');
const host = articleEl || [...contentRoots][0];
if (host) host.appendChild(bar);
}
// 리디렉션 URL 해석: google.com/url?url=<진짜주소> 같은 래퍼는 '진짜 주소'로 치환.
// (수정사항8) 이런 링크는 hostname이 google.com이지만 쿼리에 dmm.co.jp 등 지원 사이트 주소가
// 들어 있어, href 문자열 검색으로 사이트를 판정하던 곳에서 엉뚱한 작품 카드로 오인됐다.
// 지원하는 리디렉터의 타깃만 뽑아 실제 대상으로 카드화하고, 그 외 구글 리디렉션은 원본 그대로 둔다.
function resolveRedirect(u) {
try {
const p = new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u);
const host = p.hostname.replace(/^www\./, '');
// 구글 리디렉터(/url) — url= 또는 q= 파라미터가 진짜 목적지
if (host === 'google.com' && (p.pathname === '/url' || p.pathname === '/urlq')) {
const t = p.searchParams.get('url') || p.searchParams.get('q');
if (t && /^https?:\/\//i.test(t)) return t;
}
} catch (e) {}
return u;
}
/* ================================================================
다운로드 링크 카드
- DLsite/Steam/Patreon URL은 LINK_CARD 설정 시 상품 카드로 라우팅
================================================================ */
function makeLinkCard(rawUrl) {
let url = /^https?:\/\//i.test(rawUrl) ? rawUrl : 'https://' + rawUrl;
url = resolveRedirect(url);
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')) {
// product_id/RJxxx (www) + ch.dlsite.com/.../VJxxx 등 '=코드'·'/코드' 형식 모두 인식
// (convertProductLinks·buildNodes dedup 키와 동일 헬퍼 → 세 경로 카드/키 일치)
const dlCode = dlsiteCodeOf(url);
if (dlCode) return makeDlsiteCard(dlCode);
// 장르/태그/키워드 검색·목록 링크(/fsr/=/...)는 상품이 아니므로 별도 검색 카드로
if (/\/fsr\//i.test(url)) return makeDlsiteSearchCard(url);
// ci-en.dlsite.com(작가 페이지)만 기본 카드로 유지 — 전용 미리보기 백엔드(cienMatch)가 있어
// 카드가 의미 있게 동작한다(사용자 결정: ci-en만 유지).
if (h === 'ci-en.dlsite.com') {
return makeCardBase({
cls: 'b64-product-link', icon: ICO.tag,
title: h, sub: 'dlsite.com',
url, preview: true,
});
}
// 그 외 DLsite URL(홈·카테고리·검색 등 '작품이 아닌 단순 페이지')은 카드화하지 않고 평문 링크로 둔다.
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;
}
// Steam URL → 상품 카드
if (h === 'store.steampowered.com') {
const appId = steamAppOf(url);
if (appId) return makeSteamCard(appId);
}
// Patreon URL → 상품 카드 (경로 있는 경우만)
if (h === 'patreon.com' || h === 'www.patreon.com') {
try { if (new URL(url).pathname.length > 1) return makePatreonCard(url); } catch(e) {}
}
// Getcu URL → 크리에이터 카드 (원본 URL 보존)
if (h === 'getcu.com' || h === 'www.getcu.com') {
const m = url.match(/\/item\/(\d+)/i);
const code = m ? 'GC' + m[1] : 'Getcu';
return makeGetcuCard(code, url);
}
// Fanza/DMM URL → 크리에이터 카드
// dlsoft.dmm.co.jp/detail/코드/ 형식도 지원
if (h.includes('dmm.co.jp') || h === 'fanza.jp' || h === 'www.fanza.jp') {
// 쿼리 파라미터 cid= 방식 (www.dmm.co.jp)
let m = url.match(/cid=d_?([a-z0-9_]+)/i);
// 경로 방식 (dlsoft.dmm.co.jp/detail/코드/)
if (!m) m = url.match(/\/detail\/([a-z0-9][a-z0-9_-]*[a-z0-9])\/?(?:[?#]|$)/i);
const code = m ? 'FZ' + m[1] : 'Fanza';
return makeFanzaCard(code);
}
// Fanbox URL → 크리에이터/포스트 카드 (원본 URL 보존)
// 작가코드.fanbox.cc/posts → 크리에이터 카드
// 작가코드.fanbox.cc/posts/작품코드 → 포스트 카드 (작가코드 서브도메인 유지)
if (h === 'www.fanbox.cc' || h.endsWith('.fanbox.cc')) {
const creatorM = h.match(/^([^.]+)\.fanbox\.cc$/);
const creator = (creatorM && creatorM[1] !== 'www') ? creatorM[1] : null;
const postM = url.match(/\/posts\/(\d+)/i);
if (postM) {
// 작가 서브도메인이 있으면 작가명 표시 + 원본 URL 유지
if (creator) return makeFanboxCard(creator, url, 'fanbox.cc · 포스트');
return makeFanboxCard('FB' + postM[1]);
}
if (creator) return makeFanboxCreatorCard(creator, url);
return makeFanboxCard('Fanbox');
}
// Getchu URL → 크리에이터 카드 (getchu.com/item/코드/, 원본 URL 보존)
if (h === 'getchu.com' || h === 'www.getchu.com') {
const m = url.match(/\/item\/([a-z0-9_-]+)\/?/i) || url.match(/[?&]id=(\d+)/i);
const code = m ? 'GC' + m[1] : 'Getchu';
return makeGetcuCard(code, url);
}
}
// 일반 카드
const isKio = h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac';
// link-lock: URL 조각(#암호화 데이터)이 매우 길어 제목에 그대로 두면 지저분 → 깔끔한 라벨로 표기.
const isLinkLock = h === 'jstrieb.github.io' && /\/link-lock\//i.test(url);
// 채널별 비번(요구3): 다운로드 사이트 카드면 '이 URL → 현재 채널' 매핑을 저장해 둔다.
// (다운로드 사이트에서 비번 자동입력이 자기 URL로 채널을 조회해 그 채널 비번만 시도)
const isDlHost = isKio || isDownloadHost(h);
if (isDlHost) {
const _ch = getChannelCode();
if (_ch) setChannelForUrl(url, _ch);
}
let a;
if (CFG.LINK_CARD) {
a = makeCardBase({
cls: 'b64-link',
icon: (isKio || isLinkLock) ? ICO.link : ICO.world,
title: isLinkLock ? 'Link Lock · 잠긴 링크' : rawUrl,
sub: isLinkLock ? 'jstrieb.github.io · 비밀번호 필요' : h,
url,
});
} else {
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:#1a73e8;text-decoration:underline;word-break:break-all;';
}
if (isDlHost) {
const _ch2 = getChannelForCurrentSite() || getChannelCode();
if (_ch2) a.dataset.channel = _ch2; // 보이지 않는 채널코드(디버그·모아보기 dedup 참고용)
}
if (CFG.LINK_CHECK) setTimeout(() => checkLink(url, a), CFG.LINK_CHECK_DELAY);
return a;
}
/* ================================================================
디코딩 텍스트 → DOM 노드
URL / DLsite 코드 / Steam 코드를 별도 regex로 처리 (i 플래그 분리)
================================================================ */
let _bnBrDepth = 0; // buildNodes 점자 재귀 깊이 가드(중첩 디코딩 폭주 방지)
function buildNodes(text) {
// URL 범위 먼저 파악 — URL 안에 박힌 base64(예: link-lock 'https://…/#eyJ…')는 디코딩하면 안 됨
// (URL의 일부다). 과거엔 findAllMatches가 URL 속 base64를 풀어 URL을 JSON으로 깨뜨렸다(수정사항4 점자).
const _urlRanges = [];
URL_RE.lastIndex = 0; let _um;
while ((_um = URL_RE.exec(text)) !== null) _urlRanges.push([_um.index, _um.index + _um[0].length]);
const _inUrl = (idx, len) => _urlRanges.some(([s, e]) => idx < e && idx + len > s);
// 디코딩 결과 내 embedded base64 재처리 (복합 인코딩 지원)
// base64만 필터 (DLsite/Steam/Creator 코드는 아래 기존 로직이 처리). URL 내부 base64는 제외.
const b64Hits = findAllMatches(text).filter(h => h.type === 'base64' && !_inUrl(h.index, h.length));
if (b64Hits.length) {
const out = [];
let pos = 0;
for (const h of b64Hits) {
if (h.index > pos) out.push(...buildNodes(text.slice(pos, h.index)));
out.push(...buildNodes(h.decoded)); // 재귀: 디코딩 결과도 동일하게 처리
pos = h.index + h.length;
}
if (pos < text.length) out.push(...buildNodes(text.slice(pos)));
return out;
}
// 점자 런 재귀 디코딩 (복합/중첩 인코딩): base64를 푼 결과가 다시 점자인 경우(예: test→점자→base64→점자)
// 안쪽 점자가 그대로 남던 문제 — buildNodes가 base64만 재귀하고 점자는 안 풀었던 게 원인.
// 공백 토큰화는 decodeBrailleIn과 동일(URL 보존). 디코딩 결과를 재귀 처리해 점자↔base64 다중 중첩까지 해결.
if (CFG.BRAILLE_DECODE && _bnBrDepth < 8 && BR_RUN_RE.test(text)) {
const parts = text.split(/(\s+)/);
if (parts.some(p => p && BR_RUN_RE.test(p))) {
_bnBrDepth++;
const out = [];
for (const part of parts) {
if (!part) continue;
if (BR_RUN_RE.test(part)) {
let dec;
if (CFG.BRAILLE_UEB && BR.isLikelyUEB(part)) dec = BR.decodeUEB(part);
else { dec = BR.decode(part); if (/(?:https?:\/\/|www\.)/i.test(dec)) dec = dec.replace(/“/g, '?'); }
out.push(...buildNodes(dec));
} else out.push(document.createTextNode(part));
}
_bnBrDepth--;
return out;
}
}
// 기존 로직: URL / DLsite 코드 / Steam 코드 / 크리에이터 코드
const matches = [];
let m;
// URL (대소문자 무관, CJK 제외 — URL_RE 정의 참조: 디코딩 URL 뒤 한글 흡수 방지)
URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'url', raw: m[0] });
// DLsite 코드 (라틴, 대소문자 무관)
DLSITE_LATIN_RE.lastIndex = 0;
while ((m = DLSITE_LATIN_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: (m[1] + m[2]).toUpperCase() });
// DLsite 코드 (한글 IME)
DLSITE_KR_RE.lastIndex = 0;
while ((m = DLSITE_KR_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: 'RJ' + m[2] });
// Steam 코드
STEAM_CODE_RE.lastIndex = 0;
while ((m = STEAM_CODE_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'steam', appId: m[1] });
// 크리에이터 플랫폼 코드 (GC/FZ/FB)
CREATOR_CODE_RE.lastIndex = 0;
while ((m = CREATOR_CODE_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'creator', code: (m[1] + m[2]).toUpperCase() });
// 위치순 정렬 + 중첩 제거
matches.sort((a, b) => a.index - b.index);
const clean = []; let end = 0;
for (const mt of matches) {
if (mt.index >= end) { clean.push(mt); end = mt.end; }
}
const nodes = []; let last = 0;
for (const mt of clean) {
if (mt.index > last) nodes.push(document.createTextNode(text.slice(last, mt.index)));
if (mt.type === 'dlsite') {
const dKey = 'dl:' + mt.code;
const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(dKey);
if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(dKey);
if (CFG.LINK_CARD && !skip) { const el = makeDlsiteCard(mt.code); el.dataset.b64key = dKey; _liveCards.set(dKey, el); nodes.push(el); }
else nodes.push(document.createTextNode(mt.code));
} else if (mt.type === 'steam') {
const sKey = 'st:' + mt.appId;
const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(sKey);
if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(sKey);
if (CFG.LINK_CARD && !skip) { const el = makeSteamCard(mt.appId); el.dataset.b64key = sKey; _liveCards.set(sKey, el); nodes.push(el); }
else nodes.push(document.createTextNode(`Steam ${mt.appId}`));
} else if (mt.type === 'creator') {
const cKey = 'cr:' + mt.code;
const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(cKey);
if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(cKey);
const gcD = mt.code.startsWith('GC') ? mt.code.slice(2) : null;
const gcSkip = CFG.NO_DUPLICATE_CARD && gcD !== null && _gcSeenInFlush.has(gcD);
const crEl = CFG.LINK_CARD && !skip && !gcSkip ? makeCreatorCard(mt.code) : null;
if (crEl && gcD !== null) _gcSeenInFlush.add(gcD);
if (crEl) { crEl.dataset.b64key = cKey; _liveCards.set(cKey, crEl); }
nodes.push(crEl || document.createTextNode(mt.code));
} else {
// URL: 후행 구두점 제거
let url = mt.raw;
const trail = (url.match(/[.,;:!?)\]}'"]+$/) || [''])[0];
if (trail) url = url.slice(0, -trail.length);
const fullUrl = /^https?:\/\//i.test(url) ? url : 'https://' + url;
let urlHost = '';
try { urlHost = new URL(fullUrl).hostname; } catch(e) {}
// 알려진 지원 사이트만 링크 카드 생성; 미지원 사이트는 일반 밑줄 링크.
// 호스트 목록은 isKnownCardHost(단일 소스) — kone.gg만 여기서 별도 허용(내부 링크는
// makeLinkCard가 평문 링크로 처리).
const isKnownHost = /kone\.gg$/.test(urlHost) || isKnownCardHost(urlHost);
if (isKnownHost) {
// 중복 카드 방지: 같은 flush 내 동일 작품은 두 번째부터 일반 링크로.
// 상품 URL은 코드 기준 키로 정규화 → 같은 작품이 코드·URL·디코딩(점자/base64)
// 형태로 섞여도 dedup 키가 일치(텍스트 코드 dl:코드, convertProductLinks와 동일).
// DLsite=dl:코드 / Steam=st:appId / Getchu=cr:GC숫자(+_gcSeenInFlush 교차)
// 일반(lk:) URL은 query/hash를 떼어 convertProductLinks의 키와 동일하게 정규화
// (같은 링크가 평문<a>·디코딩으로 섞여도 ?쿼리 유무로 키가 갈리지 않게).
let lkKey = 'lk:' + fullUrl.split(/[?#]/)[0];
let gcLkDigits = null;
if (urlHost.includes('dlsite.com')) {
const dCode = dlsiteCodeOf(fullUrl);
if (dCode) lkKey = 'dl:' + dCode;
} else if (urlHost === 'store.steampowered.com') {
const aId = steamAppOf(fullUrl);
if (aId) lkKey = 'st:' + aId;
} else if (urlHost === 'getchu.com' || urlHost === 'www.getchu.com') {
// 비숫자 item 코드(문자 포함 슬러그)도 키에 반영해야 해 getchuIdOf(숫자 전용)보다 넓게 매칭
const gcM = fullUrl.match(/\/item\/([a-z0-9_-]+)/i) || fullUrl.match(/[?&]id=(\d+)/i);
if (gcM) { lkKey = 'cr:GC' + gcM[1]; gcLkDigits = gcM[1]; }
}
const gcLkDup = CFG.NO_DUPLICATE_CARD && gcLkDigits !== null && _gcSeenInFlush.has(gcLkDigits);
const lkSkip = gcLkDup || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(lkKey));
if (!lkSkip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(lkKey);
if (!lkSkip) {
const lkEl = makeLinkCard(url);
if (lkEl && lkEl.dataset) { lkEl.dataset.b64key = lkKey; _liveCards.set(lkKey, lkEl); }
nodes.push(lkEl);
if (gcLkDigits !== null) _gcSeenInFlush.add(gcLkDigits);
} else {
// NO_DUP: 같은 작품/링크의 2번째+ 등장 → 카드 대신 평문 링크.
// ★반드시 b64key를 박는다. 과거엔 키 없는 평문 DONE 링크였는데, 이러면
// convertProductLinks의 부활 로직(DONE+b64key+카드사멸 → 재카드화)이 이 링크를
// 못 살린다. 코네 하이드레이션이 '첫 카드'(유일한 카드)를 떼어내면 되살릴 씨앗이
// 하나도 없어 'NO_DUP ON 진입 시 다운로드 카드 0개'가 됐다(라이브 진단 확증).
// 키를 부여하면 카드가 살아있을 땐 그대로 평문(중복)으로 두고, 카드가 죽으면
// 다음 flush에서 이 링크가 부활해 카드가 된다(보이는 카드 항상 1개 보장).
const a = document.createElement('a');
a.href = fullUrl; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, ''); a.textContent = url;
a.dataset.b64key = lkKey;
a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
nodes.push(a);
}
} else {
const a = document.createElement('a');
a.href = fullUrl; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.textContent = url;
a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
nodes.push(a);
}
if (trail) nodes.push(document.createTextNode(trail));
}
last = mt.end;
}
if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
return nodes;
}
/* ================================================================
본문 텍스트 노드 처리
중복 감지:
- processedRaws: Map<key, WeakRef<wrap>>
- 같은 내용이 또 들어올 때, 기존 wrap이 살아있고 같은 부모이면
Svelte 재삽입으로 판단 → 숨김.
- 기존 wrap이 없거나 다른 부모이면 정상 중복 → 재디코딩.
================================================================ */
const processedRaws = new Map(); // key → WeakRef<wrap>
const processedListRaws = new Map(); // key → WeakRef<decodedSpan> (목록 노드)
const _cardMap = new WeakMap(); // 원본 <a> → 생성된 카드 (Svelte 재렌더링 후 카드 재생성 판단용)
const _seenProductCodes = new Set(); // flush 내 상품 코드 중복 방지 (NO_DUPLICATE_CARD)
const _gcSeenInFlush = new Set(); // GC 코드·URL 교차 중복 방지 (설정 무관, 항상 적용)
// 키(dl:/st:/lk:…) → 그 키로 만든 마지막 카드. flush 간 유지(영구). 코네가 <a> 노드를 통째 갈아치워
// _cardMap(노드 신원)이 끊겨도, 이 키 기준 생존 확인으로 '카드가 죽었으면 재생성'을 판단한다.
const _liveCards = new Map();
const _cardKeyAlive = (key) => { const c = key && _liveCards.get(key); return !!(c && document.contains(c)); };
function processContentNode(node) {
if (!node.parentNode) return;
const raw = node.nodeValue;
if (!raw || !raw.trim()) return;
let anc = node.parentNode;
while (anc) {
if (anc.nodeType === Node.ELEMENT_NODE) {
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
if (anc.nodeName === 'A') return;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
}
anc = anc.parentNode;
}
const key = raw.trim();
if (processedRaws.has(key)) {
const existingWrap = processedRaws.get(key)?.deref();
// 같은 부모 + wrap이 살아있음 = Svelte/React 재삽입 → 숨김
// 다른 부모(다른 섹션/독립 단락) = 별도 위치 → 재디코딩 허용
const isSvelte = existingWrap &&
document.contains(existingWrap) &&
existingWrap.parentNode === node.parentNode;
if (isSvelte) {
const hide = document.createElement('span');
hide.setAttribute(DONE_ATTR, '');
hide.style.cssText = 'display:none!important;';
hide.textContent = raw;
node.parentNode.replaceChild(hide, node);
return;
}
// wrap이 없거나 다른 부모(다른 섹션) → 재디코딩
processedRaws.delete(key);
}
const hits = findAllMatches(raw);
// 평문 URL(예: 선두 공백/탭으로 코네가 자동 링크하지 않은 dlsite URL)도 카드화 대상.
// findAllMatches는 URL을 잡지 않으므로(코드만), URL이 있으면 buildNodes로 통째 처리한다.
// (자동 링크된 URL은 <A> 안이라 위에서 이미 스킵됨 → 중복 처리 없음)
const hasUrl = /(?:https?:\/\/|www\.)\S/i.test(raw);
if (!hits.length && !hasUrl) return;
// buildNodes가 base64 재귀·URL·DLsite/Steam/크리에이터 코드·dedup(_seenProductCodes)을 모두 처리.
// (평문 URL 속 RJ 코드만 따로 카드화돼 URL이 조각나던 문제 → URL 전체를 한 카드로)
const frag = document.createDocumentFragment();
buildNodes(raw).forEach(n => frag.appendChild(n));
const wrap = document.createElement('span');
wrap.setAttribute(DONE_ATTR, '');
wrap.setAttribute(RAW_ATTR, raw);
wrap.style.cssText = 'all:unset;display:contents;';
wrap.appendChild(frag);
processedRaws.set(key, new WeakRef(wrap));
node.parentNode.replaceChild(wrap, node);
}
/* ================================================================
목록 텍스트 노드 처리
================================================================ */
function processListNode(node) {
const raw = node.nodeValue;
if (!raw || !raw.trim()) return;
const p = node.parentNode;
if (!p) return;
if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute(LTDONE_ATTR)) return;
let anc = p;
while (anc) {
if (anc.nodeType === Node.ELEMENT_NODE) {
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
}
anc = anc.parentNode;
}
const key = raw.trim();
// processContentNode가 같은 내용을 이미 본문에 디코딩했으면 목록 디코딩 스킵
if (processedRaws.has(key)) {
const contentWrap = processedRaws.get(key)?.deref();
if (contentWrap && document.contains(contentWrap)) return;
}
if (processedListRaws.has(key)) {
const existingSpan = processedListRaws.get(key)?.deref();
const isSvelte = existingSpan &&
document.contains(existingSpan) &&
existingSpan.parentNode === p;
if (isSvelte) {
const hide = document.createElement('span');
hide.setAttribute(LTDONE_ATTR, '');
hide.style.cssText = 'display:none!important;';
hide.textContent = raw;
p.replaceChild(hide, node);
return;
}
processedListRaws.delete(key);
}
const hits = findAllMatches(raw);
if (!hits.length) return;
// 목록 페이지: RJ/Steam 코드는 인라인 카드로 만들지 않음 (썸네일이 대신 표시)
// 텍스트만 변환 (base64 / 점자)
let result = '', last = 0;
for (const { index, length, decoded } of hits) {
result += raw.slice(last, index) + decoded;
last = index + length;
}
result += raw.slice(last);
if (result === raw) return;
const hideWrap = document.createElement('span');
hideWrap.setAttribute(LTDONE_ATTR, 'src');
hideWrap.style.cssText = 'display:none!important;';
hideWrap.textContent = raw;
const decodedSpan = document.createElement('span');
decodedSpan.setAttribute(LTDONE_ATTR, '');
decodedSpan.style.cssText = 'all:unset;';
decodedSpan.textContent = result;
processedListRaws.set(key, new WeakRef(decodedSpan));
p.insertBefore(hideWrap, node);
p.insertBefore(decodedSpan, node);
p.removeChild(node);
}
/* ================================================================
공통 walker
================================================================ */
function walkAndProcess(root, handler) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const p = node.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
const tag = p.nodeName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA') return NodeFilter.FILTER_REJECT;
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
let anc = p;
while (anc && anc !== root) {
if (anc.nodeType === Node.ELEMENT_NODE) {
// contenteditable="true" 영역(Froala 에디터 등) 내부는 절대 처리하지 않음
if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT;
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
}
anc = anc.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const nodes = []; let n;
while ((n = walker.nextNode())) nodes.push(n);
nodes.forEach(handler);
}
/* ================================================================
인접 블록 합산 디코딩
walkAndProcess 보다 먼저 실행.
이유: walkAndProcess가 먼저 실행되면 개별 디코딩된 요소에 DONE_ATTR가 붙어
원본 base64 조각 정보가 사라지므로, 합산 시도 자체가 불가능해짐.
전략: 개별 디코딩이 불가능한(분할된) base64 줄만 그룹에 포함,
개별 디코딩 가능한 줄은 제외해 walkAndProcess에 위임.
================================================================ */
function trySiblingMerge(root) {
const BLOCK = new Set(['P','DIV','LI','BLOCKQUOTE','DD','DT']);
function isUnprocessed(el) {
return !el.querySelector('[' + DONE_ATTR + '], [' + LTDONE_ATTR + ']');
}
// 텍스트가 60%+ base64 문자로 구성인지 확인 (순수 한국어 단락 오합산 방지)
// 한국어 접두사 + base64 혼합이나 공백 포함 base64도 통과할 수 있도록 여유 있게 설정
function looksB64ish(txt) {
const c = txt.replace(/\s/g, '');
if (c.length < MIN_B64) return false;
let n = 0;
for (let i = 0; i < c.length; i++) {
const ch = c[i];
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|| ch === '+' || ch === '/' || ch === '-' || ch === '_' || ch === '=') n++;
}
return n / c.length >= 0.6;
}
// 한글/한자/가나(CJK) 포함 여부. base64는 ASCII뿐이라 CJK가 있으면 base64 조각이 아니라
// 제목/문장이다(형제 합산에서 제외해 과합산 방지). 한글 자모/완성형 + CJK통합한자 + 히라/가타.
function hasCJK(txt) {
return /[ᄀ-ᇿ-ヿ-㐀-䶿一-鿿가-힣]/.test(txt);
}
// 합산 성공 시 true, 아니면 false(호출부가 개별 처리로 폴백). 잘못 묶인 경우(한 base64 아님)는
// 합산본 디코딩에 U+FFFD가 생기는데 isProbablyText(repl===0)가 거부해 base64 hit이 없으므로 false.
function applyMerge(els) {
if (els.length < 2) return false;
const parts = els.map(e => (e.textContent || '').trim()).filter(Boolean);
const combined = parts.join('\n'); // \n: 개별 라인 구분 유지 → 각 라인 독립 매칭 보장
const stripped = combined.replace(/\s+/g, ''); // 줄바꿈 제거 → 분할 base64 합산
let hits = findAllMatches(combined); let src = combined;
if (!hits.some(h => h.type === 'base64') && stripped.length >= MIN_B64) {
const h2 = findAllMatches(stripped);
if (h2.some(h => h.type === 'base64')) { hits = h2; src = stripped; }
}
if (!hits.some(h => h.type === 'base64')) return false;
let res = '', p = 0;
for (const { index, length, decoded } of hits) { res += src.slice(p, index) + decoded; p = index + length; }
res += src.slice(p);
const wrap = document.createElement('span');
wrap.setAttribute(DONE_ATTR, ''); wrap.setAttribute('data-b64merge', 'block'); wrap.style.cssText = 'all:unset;display:contents;';
wrap.setAttribute('data-b64merge-html', els[0].innerHTML); // SvelteKit 프로퍼티 소실 대비
buildNodes(res).forEach(nd => wrap.appendChild(nd));
// 원본 조각 보존(즉시 토글 복원용): els[0] 원래 자식 + 합쳐져 사라질 형제 블록 참조.
const orig = document.createDocumentFragment();
while (els[0].firstChild) orig.appendChild(els[0].firstChild);
wrap._b64mergeOrig = orig;
wrap._b64mergeSibs = els.slice(1);
els[0].appendChild(wrap);
for (let i = 1; i < els.length; i++) els[i].remove();
return true;
}
// 같은 블록 안에서 글자색 등 인라인 스타일로 base64가 여러 <span>(=여러 텍스트 노드)으로 쪼개진 것을
// 합쳐 디코딩한다. 진단(콘솔)으로 확인된 두 실패 형태를 모두 처리:
// · 한 줄 base64가 색상 span 2개로 분할 (<br> 없음)
// · <br>/개행으로 나뉜 '여러 줄' base64가 각 줄마다 또 색상 분할 (이전엔 줄을 통째 뭉개 ◇? 깨짐)
// 전략: 인라인 내용을 <br>/개행 기준 '줄'로 나눠, '줄 단위'로만 합산·디코딩한다(줄 간 합치기 절대 금지).
// 안전장치:
// ⓐ 자식 블록(p/div/li/…)이 있으면 위임(잎 블록만). <br>는 줄 경계로 처리(스킵 아님).
// ⓑ 줄 디코딩 결과에 U+FFFD(깨짐)가 있으면 그 줄은 디코딩하지 않고 원문 텍스트로 유지.
// ⓒ 실제로 '한 줄이 2개 이상 텍스트 노드로 쪼개진' 경우가 있을 때만 교체(단일 노드는 walkAndProcess에 위임).
function inlineSplitB64(el) {
// 자식 블록 있으면 위임(잎 블록만). ★button 포함 블록도 제외 — 코네 댓글은 본문과 액션 버튼(수정/삭제)이
// 같은 컨테이너에 있어, 이 재구성이 버튼을 orig로 옮겨 DOM에서 없애던 문제(요구0). 버튼 블록은 하위 재귀에 위임.
if (el.querySelector('p,div,li,blockquote,dd,dt,button')) return false;
const REPL = String.fromCharCode(0xFFFD);
// 인라인 내용을 줄 배열로 수집. 각 줄: { text, n=비어있지 않은 텍스트노드 수 }
const lines = [{ text: '', n: 0 }];
let ok = true;
(function walk(node) {
for (const ch of node.childNodes) {
if (!ok) return;
if (ch.nodeType === Node.TEXT_NODE) {
const parts = ch.nodeValue.split('\n');
for (let k = 0; k < parts.length; k++) {
if (k > 0) lines.push({ text: '', n: 0 });
const last = lines[lines.length - 1];
last.text += parts[k];
if (parts[k].trim()) last.n++;
}
} else if (ch.nodeType === Node.ELEMENT_NODE) {
const tag = ch.nodeName;
if (tag === 'BR') { lines.push({ text: '', n: 0 }); }
else if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA') { ok = false; return; }
else if (ch.hasAttribute(DONE_ATTR) || ch.hasAttribute(LTDONE_ATTR) || ch.isContentEditable) { ok = false; return; }
else walk(ch);
}
}
})(el);
if (!ok) return false;
// 줄별 디코딩. 비어있지 않은 줄만. 색상 분할(한 줄에 노드 2+)된 줄이 하나라도 실제 디코딩될 때만 교체.
const built = []; // 각 항목 = 그 줄을 이룰 노드 배열
let splitDecoded = false;
for (const line of lines) {
if (!line.text.trim()) continue;
const hits = findAllMatches(line.text);
const b64 = hits.filter(h => h.type === 'base64');
const valid = b64.length && b64.every(h => h.decoded.indexOf(REPL) === -1); // ⓑ
if (valid) {
// 디코딩 결과와 주변 텍스트를 분리해서 buildNodes 호출 — 하나로 합치면 URL regex가
// 디코딩된 URL 뒤에 붙은 한글 등을 URL 일부로 삼키는 문제 방지.
const segNodes = [];
let p = 0;
for (const { index, length, decoded } of hits) {
if (index > p) segNodes.push(...buildNodes(line.text.slice(p, index)));
segNodes.push(...buildNodes(decoded));
p = index + length;
}
if (p < line.text.length) segNodes.push(...buildNodes(line.text.slice(p)));
built.push(segNodes);
if (line.n >= 2) splitDecoded = true; // ⓒ 이 줄이 여러 노드로 쪼개져 있었음(분할 복구 대상)
} else {
built.push([document.createTextNode(line.text)]);
}
}
if (!splitDecoded || !built.length) return false;
const wrap = document.createElement('span');
wrap.setAttribute(DONE_ATTR, ''); wrap.setAttribute('data-b64merge', 'inline'); wrap.style.cssText = 'all:unset;display:contents;';
wrap.setAttribute('data-b64merge-html', el.innerHTML); // SvelteKit이 JS 프로퍼티를 잃어도 복원 가능
built.forEach((nodes, i) => {
if (i > 0) wrap.appendChild(document.createElement('br')); // 줄 사이 줄바꿈 복원
nodes.forEach(nd => wrap.appendChild(nd));
});
const orig = document.createDocumentFragment();
while (el.firstChild) orig.appendChild(el.firstChild); // 원본 보존(즉시 토글 복원용)
wrap._b64mergeOrig = orig;
el.appendChild(wrap);
return true;
}
// 미처리 단일 블록 개별 처리: 색상 span으로 쪼개진 한 줄이면 inlineSplitB64, 아니면 하위 재귀.
// (sibling-merge에서 빠지거나 합산 실패한 블록의 폴백 — 색상인코딩(수정1)·자기완결 base64가
// 여기로 와서 제대로 처리된다.)
function processSingle(ch) {
if (!isUnprocessed(ch)) return;
const txt = (ch.textContent || '').trim();
if (BLOCK.has(ch.nodeName) && looksB64ish(txt) && inlineSplitB64(ch)) return;
scan(ch);
}
function scan(parent) {
let run = [];
// run을 비울 때: 2개+면 형제 합산 시도, 실패하면(또는 1개면) 각 블록을 개별 처리로 폴백.
// → '연속 base64 조각'은 합산되고, 잘못 묶였거나 단일인 블록은 개별 디코딩/색상처리로 정확히 간다.
// 합산이 멋대로 다른 콘텐츠를 삼키지 않게(과합산 방지) 하면서, 무엇도 미처리로 누락되지 않게 보장.
const flushRun = () => {
if (run.length >= 2 && applyMerge(run)) { run = []; return; }
run.forEach(processSingle);
run = [];
};
for (const ch of Array.from(parent.children)) {
const txt = (ch.textContent || '').trim();
// 형제 합산 후보 — 단일 판정으로 통합(과거 hasLongB64Run·_selfComplete·!개별디코딩 패치 폐기):
// 블록 + 미처리 + base64ish(0.6) + CJK없음 + 이미지없음.
// · CJK없음: 제목/파일명/문장('서양/[OnlyFans]…','오늘 영상…')은 한글/한자/가나가 섞여 제외 →
// 과합산(사진 사라짐·UI 평문화: 수정2·3) 방지. 순수 base64 조각은 CJK가 없어 통과.
// · 줄 길이 무관: 짧은 줄 base64도 통과(hasLongB64Run이 이를 제외해 '평문 그대로'이던 회귀 제거).
// ★button 포함 블록은 후보에서 제외(요구0): 코네 댓글의 '본문+액션 버튼' 혼재 컨테이너를 합산/재구성
// 대상으로 삼으면 버튼이 사라진다. 그 안의 base64는 하위 재귀·walkAndProcess가 개별 처리해 카드가 된다.
const candidate = BLOCK.has(ch.nodeName) && isUnprocessed(ch)
&& looksB64ish(txt) && !hasCJK(txt) && !ch.querySelector('img, button');
if (candidate) {
run.push(ch);
if (run.length >= 8) flushRun(); // 과도한 합산 방지
} else {
flushRun();
processSingle(ch);
}
}
flushRun();
}
scan(root);
}
/* ================================================================
드래그 자동 변환
================================================================ */
let dragTooltip = null;
// 드래그 툴팁 제거 시 내부 카드에 열려 있던 미리보기도 같이 닫음
function removeDragTooltip() {
if (dragTooltip) {
// 툴팁 안의 카드에서 미리보기가 열려 있으면 함께 닫기
if (dlp.showing) dlpHide();
dragTooltip.remove();
dragTooltip = null;
}
}
document.addEventListener('mouseup', (e) => {
if (!CFG.DRAG_DECODE) return;
if (isWritePage()) return;
// 툴팁 안을 클릭한 경우 닫지 않음 (카드 링크 클릭 보호)
if (dragTooltip && dragTooltip.contains(e.target)) return;
removeDragTooltip();
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
const text = sel.toString().trim();
if (!text) return;
// ── 본문 인-플레이스 교체 (팝업과 동일한 디코딩, DOM 직접 교체) ──
// 단일 텍스트 노드 내 선택일 때만 in-place. 선택이 요소(색상 박스/칸 등) 경계를 넘으면
// deleteContents가 박스를 쪼개 '오른쪽에 빈 칸이 하나 더' 생기므로, 그 경우는 비파괴적 팝업으로 폴백.
const rIp = sel.rangeCount ? sel.getRangeAt(0) : null;
if (rIp) {
const r = rIp;
// text = sel.toString().trim() 이므로 팝업과 완전히 동일한 소스 사용
let ipHits = findAllMatches(text);
let ipSrc = text;
if (!ipHits.length) {
const stripped = text.replace(/[\r\n\s]+/g, '');
if (stripped.length >= MIN_B64 && stripped !== text) {
const h2 = findAllMatches(stripped);
if (h2.length) { ipHits = h2; ipSrc = stripped; }
}
}
// 디코딩/카드 대상(base64·DLsite/Steam/크리에이터 코드·URL)이 있으면 드래그한 그 자리에
// 카드를 직접 삽입한다(팝업 X). buildNodes가 base64 재귀·URL·코드·dedup을 모두 처리.
const ipHasUrl = /(?:https?:\/\/|www\.)\S/i.test(ipSrc);
if (ipHits.length || ipHasUrl) {
const rNodes = buildNodes(ipSrc);
// 실제로 카드/링크 등 변환이 생겼을 때만 교체 (순수 텍스트면 그대로 둠)
const changed = rNodes.some(nd => nd.nodeType === Node.ELEMENT_NODE)
|| (rNodes.length === 1 && rNodes[0].nodeType === Node.TEXT_NODE && rNodes[0].nodeValue !== ipSrc);
if (changed) {
const wrap = document.createElement('span');
wrap.setAttribute(DONE_ATTR, '');
wrap.style.cssText = 'all:unset;display:contents;';
rNodes.forEach(nd => wrap.appendChild(nd));
// ★ 단일 텍스트노드뿐 아니라 '여러 노드/색상 칸을 가로지르는' 선택도 본문을 직접 대체(팝업 X).
// deleteContents가 색상 span을 쪼개 남기는 '빈 껍데기'는 삽입 후 형제 기준으로 정리.
try {
r.deleteContents();
r.insertNode(wrap);
const parent = wrap.parentNode;
if (parent) Array.from(parent.children).forEach(x => {
if (x === wrap || x.contains(wrap) || x.hasAttribute(DONE_ATTR)) return;
if (!x.textContent.trim() && !x.querySelector('img,br,svg')) x.remove();
});
sel.removeAllRanges();
return;
} catch(_) {}
}
}
}
// ── 팝업 표시 (인-플레이스 불가 시) ──
// 줄바꿈 제거 후 통합 base64 시도 (여러 줄로 분리된 경우 대응)
let hits = findAllMatches(text);
let effectiveText = text;
if (!hits.length) {
const stripped = text.replace(/[\r\n\s]+/g, '');
if (stripped !== text && stripped.length >= MIN_B64) {
const h2 = findAllMatches(stripped);
if (h2.length) { hits = h2; effectiveText = stripped; }
}
}
if (!hits.length) return;
// 디코딩 결과 텍스트 재조립
let result = '', last = 0;
for (const { index, length, decoded } of hits) { result += effectiveText.slice(last, index) + decoded; last = index + length; }
result += effectiveText.slice(last);
// 상품 코드(DLsite/Steam) 추출
const productHits = hits.filter(h => h.type === 'dlsite' || h.type === 'steam');
const hasNewText = result !== effectiveText;
if (!hasNewText && !productHits.length) return;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
dragTooltip = document.createElement('div');
dragTooltip.id = 'b64d-drag-tooltip';
// 텍스트 변환 결과
if (hasNewText) {
const textEl = document.createElement('div');
textEl.className = 'b64d-drag-text';
textEl.textContent = result;
dragTooltip.appendChild(textEl);
}
// 상품 카드
if (productHits.length) {
const seen = new Set();
const cardsWrap = document.createElement('div');
cardsWrap.className = 'b64d-drag-cards';
for (const hit of productHits) {
const id = hit.type === 'dlsite' ? hit.code : hit.appId;
if (seen.has(id)) continue;
seen.add(id);
cardsWrap.appendChild(
hit.type === 'dlsite' ? makeDlsiteCard(hit.code) : makeSteamCard(hit.appId)
);
}
dragTooltip.appendChild(cardsWrap);
}
dragTooltip.style.top = `${rect.bottom + window.scrollY + 6}px`;
dragTooltip.style.left = `${rect.left + window.scrollX}px`;
document.body.appendChild(dragTooltip);
});
document.addEventListener('mousedown', (e) => {
if (dragTooltip && dragTooltip.contains(e.target)) return;
removeDragTooltip();
});
// 모바일: 터치로 텍스트 선택 후 손 뗄 때 드래그 디코딩 실행
document.addEventListener('touchend', () => {
if (!CFG.DRAG_DECODE || isWritePage()) return;
setTimeout(() => {
const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.toString().trim()) {
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: false, cancelable: false }));
}
}, 80);
}, { passive: true });
// 모바일: 다른 곳 탭 시 툴팁 닫기
document.addEventListener('touchstart', (e) => {
if (dragTooltip && dragTooltip.contains(e.target)) return;
removeDragTooltip();
}, { passive: true });
document.addEventListener('keydown', removeDragTooltip);
/* ================================================================
미리보기 팝업 (DLsite 및 Steam 공통)
키보드 a/d 또는 ← → 로 이미지 전환, 휠 스크롤 가능
================================================================ */
let _dlpRestore = null, _dlpRestoreTimer = null;
// Getchu 페이지 파싱 결과(이미지 URL 목록) 캐시 — 재호버 시 페이지 fetch 1회 절약.
// 값은 짧은 URL 문자열 배열뿐(블롭 아님)이라 메모리 부담 미미. 개수 상한(FIFO) 적용.
const _gcPageCache = new Map();
const GC_CACHE_MAX = 60;
const dlp = { el: null, images: [], idx: 0, showing: false, srcWatcher: null, chipsEl: null, panelEl: null, navEl: null, site: null, blobUrls: null, _pending: null, href: null, _touchHide: null, tags: null, tagsBarEl: null, tagsListEl: null, tagsExpanded: false, kpCodeEl: null, _kpClickHide: null, _kp: false };
// 미리보기 팝업에 에러 메시지 표시: 내용 비우고 에러 div + (칩 있으면) 패널 재부착
function renderDlpError(msg) {
if (!dlp.el) return;
dlp.el.innerHTML = '';
const d = document.createElement('div');
d.className = 'dlp-err';
d.textContent = msg;
dlp.el.appendChild(d);
if (dlp.panelEl && dlp.chipsEl?.children.length) dlp.el.appendChild(dlp.panelEl);
}
// 네비 텍스트("N/총 ←→ a·d") 갱신 — 이미지 1장 이하면 비움
function dlpUpdateNav() {
if (dlp.navEl) dlp.navEl.textContent = dlp.images.length > 1 ? `${dlp.idx+1}/${dlp.images.length} ←→ a·d` : '';
}
// 작품 태그 영역 렌더 (기본 접힘 → T키/탭으로 펼침). 공식 장르만.
function renderTags() {
if (!dlp.tagsBarEl || !dlp.tagsListEl) return;
const tags = dlp.tags || [];
if (!CFG.PREVIEW_TAGS || !tags.length) {
dlp.tagsBarEl.style.display = 'none';
dlp.tagsListEl.style.display = 'none';
dlp.tagsListEl.innerHTML = '';
return;
}
if (dlp.panelEl) dlp.panelEl.classList.add('has-content');
const hint = IS_MOBILE ? '탭' : 'T';
dlp.tagsBarEl.style.display = '';
dlp.tagsBarEl.textContent = dlp.tagsExpanded ? `🏷 태그 ${tags.length}개 · ${hint} 접기` : `🏷 태그 ${tags.length}개 · ${hint} 펼치기`;
if (dlp.tagsExpanded) {
dlp.tagsListEl.style.display = '';
dlp.tagsListEl.innerHTML = '';
for (const t of tags) {
const chip = document.createElement('span');
chip.className = 'dlp-tag';
chip.textContent = t;
dlp.tagsListEl.appendChild(chip);
}
} else {
dlp.tagsListEl.style.display = 'none';
dlp.tagsListEl.innerHTML = '';
}
}
function dlpUpdateImage() {
if (!dlp.el || !dlp.images.length) return;
const newSrc = dlp.images[dlp.idx];
// 진행 중인 교체 프리로드 취소
if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }
function hookErr(el) {
el.onerror = () => {
if (!dlp.el) return;
dlp.images.splice(dlp.idx, 1);
if (dlp.idx >= dlp.images.length) dlp.idx = Math.max(0, dlp.images.length - 1);
if (dlp.images.length) { dlpUpdateImage(); return; }
renderDlpError('이미지를 불러올 수 없습니다.');
};
}
function updatePanel() {
// W/S 코드 라벨: dlpUpdateImage가 첫 이미지에서 el.innerHTML을 비우므로 매 갱신마다 재부착(좌상단 고정).
if (dlp.kpCodeEl && dlp.el && !dlp.el.contains(dlp.kpCodeEl)) dlp.el.appendChild(dlp.kpCodeEl);
if (!dlp.panelEl) return;
dlpUpdateNav();
dlp.el.appendChild(dlp.panelEl);
}
let img = dlp.el.querySelector('img');
if (!img) {
// 최초 호출: 스피너 제거 후 img 생성
dlp.el.innerHTML = '';
img = document.createElement('img');
if (dlp.site !== 'getchu') img.referrerPolicy = 'no-referrer';
dlp.el.appendChild(img);
hookErr(img);
img.src = newSrc;
updatePanel();
return;
}
if (img.src === newSrc) { updatePanel(); return; }
// URL이 다를 때: DOM 밖에서 미리 로드 후 원자적 교체 → 로딩 화면 없음
const next = document.createElement('img');
if (dlp.site !== 'getchu') next.referrerPolicy = 'no-referrer';
dlp._pending = next;
next.onload = () => {
if (!dlp.el || dlp._pending !== next) return;
dlp._pending = null;
if (dlp.el.contains(img)) dlp.el.replaceChild(next, img); else dlp.el.appendChild(next);
hookErr(next);
updatePanel();
};
next.onerror = () => {
if (!dlp.el || dlp._pending !== next) return;
dlp._pending = null;
dlp.images.splice(dlp.idx, 1);
if (dlp.idx >= dlp.images.length) dlp.idx = Math.max(0, dlp.images.length - 1);
if (dlp.images.length) { dlpUpdateImage(); return; }
renderDlpError('이미지를 불러올 수 없습니다.');
};
next.src = newSrc;
}
function dlpMove(e) {
if (!dlp.el) return;
const pw = dlp.el.offsetWidth || 50;
const ph = dlp.el.offsetHeight || 50;
const x = (e.clientX + 16 + pw > window.innerWidth) ? Math.max(0, e.clientX - pw - 16) : e.clientX + 16;
const y = (e.clientY + 16 + ph > window.innerHeight) ? Math.max(0, e.clientY - ph - 16) : e.clientY + 16;
dlp.el.style.left = `${x}px`;
dlp.el.style.top = `${y}px`;
}
// W/S 미리보기 위치 고정(가운데/왼쪽/오른쪽). 커서추적(dlpMove) 대신 화면을 좌/우 반으로 나눠 '빈공간의
// 정중앙'(좌=25%, 가운데=50%, 우=75%)에 비율로 핀 → 화면 폭이 달라져도 양쪽 여백 한가운데 유지.
function applyKpFixedPos(el, pos, cardEl) {
const vw = window.innerWidth;
// 본문 '글 칼럼'의 좌우 경계를 찾아, 그 바깥 여백의 정중앙에 둔다(칼럼 침범 방지). 폭 휴리스틱(walk-up)은
// 너무 넓은 조상을 잡아 여백이 0에 수렴→침범했음. 코네 새 본문은 .tiptap.ProseMirror(=실제 글 칼럼)이므로
// 카드 기준 closest로 칼럼을 직접 특정한다.
const colEl = (cardEl && (cardEl.closest('.tiptap.ProseMirror') || cardEl.closest('#post-article') || cardEl.closest('article')))
|| document.querySelector('.tiptap.ProseMirror') || document.getElementById('post-article');
let colL = vw * 0.25, colR = vw * 0.75; // 폴백
if (colEl) { const r = colEl.getBoundingClientRect(); if (r.width > 40) { colL = r.left; colR = r.right; } }
const half = (el.offsetWidth || Math.round(480 * (CFG.PREVIEW_SCALE / 100))) / 2;
const gap = 12;
let cx;
// 여백 중앙을 원칙으로 하되, 여백이 좁으면 '칼럼을 침범하지 않는 선'(칼럼 모서리에 붙임)으로 보정.
if (pos === 'left') cx = Math.min(colL / 2, colL - gap - half);
else if (pos === 'right') cx = Math.max((colR + vw) / 2, colR + gap + half);
else cx = vw / 2; // center
// 칼럼 경계 우선 보호 → 뷰포트 밖으로 나가더라도 칼럼 침범 안 함.
// 뷰포트 클램프는 그 다음에 적용하되, 칼럼 경계는 재침범하지 않도록 한 번 더 보정.
if (pos === 'left') cx = Math.min(cx, colL - gap - half);
else if (pos === 'right') cx = Math.max(cx, colR + gap + half);
const vpMin = half + 4, vpMax = vw - half - 4;
cx = Math.min(Math.max(cx, vpMin), vpMax);
if (pos === 'left' && cx + half > colL - gap) cx = colL - gap - half;
else if (pos === 'right' && cx - half < colR + gap) cx = colR + gap + half;
el.style.right = 'auto'; el.style.bottom = 'auto'; el.style.top = '50%';
el.style.left = Math.round(cx) + 'px';
el.style.transform = 'translate(-50%,-50%)';
}
function dlpHide() {
// 카드 DOM 교체 시(Svelte 재렌더 등) 빠른 재표시를 위해 미리보기 상태 임시 보존 (600ms)
if (dlp.showing && dlp.href && dlp.images.length > 0) {
clearTimeout(_dlpRestoreTimer);
// Getchu blob URL은 revoke하지 않고 _dlpRestore에 이관 (타이머 만료 시 revoke)
const savedBlobs = (dlp.site === 'getchu' && dlp.blobUrls) ? dlp.blobUrls : null;
if (savedBlobs) dlp.blobUrls = null;
const chips = dlp.chipsEl ? [...dlp.chipsEl.children].map(c => ({ text: c.textContent, cls: c.className })) : [];
_dlpRestore = { images: dlp.images.slice(), idx: dlp.idx, href: dlp.href, site: dlp.site, chips, blobUrls: savedBlobs, tags: dlp.tags, tagsExpanded: dlp.tagsExpanded };
_dlpRestoreTimer = setTimeout(() => {
if (_dlpRestore?.blobUrls) _dlpRestore.blobUrls.forEach(u => URL.revokeObjectURL(u));
_dlpRestore = null;
}, 600);
}
if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }
if (dlp.srcWatcher) { dlp.srcWatcher.disconnect(); dlp.srcWatcher = null; }
if (dlp.el) { dlp.el.remove(); dlp.el = null; }
if (dlp.blobUrls) { dlp.blobUrls.forEach(u => URL.revokeObjectURL(u)); dlp.blobUrls = null; }
dlp.showing = false; dlp.images = []; dlp.idx = 0; dlp.href = null;
dlp.chipsEl = null; dlp.panelEl = null; dlp.navEl = null; dlp.site = null;
dlp.tags = null; dlp.tagsBarEl = null; dlp.tagsListEl = null; dlp.tagsExpanded = false;
dlp.kpCodeEl = null;
document.removeEventListener('mousemove', dlpMove);
document.removeEventListener('keydown', dlpKey);
if (dlp._touchHide) { document.removeEventListener('touchstart', dlp._touchHide, true); dlp._touchHide = null; }
// W/S 미리보기 바깥 클릭 시 해제용 리스너 정리
if (dlp._kpClickHide) { document.removeEventListener('mousedown', dlp._kpClickHide, true); dlp._kpClickHide = null; }
}
function dlpKey(e) {
if (!dlp.showing) return;
if (document.getElementById('b64d-settings') || document.getElementById('b64-dup-overlay')) return;
if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; // Shift+D 등 수식키 조합은 단축키로 처리
// T: 작품 태그 펼치기/접기 (이미지 1장이어도 동작)
if (e.code === 'KeyT' && CFG.PREVIEW_TAGS && dlp.tags && dlp.tags.length) {
e.preventDefault(); dlp.tagsExpanded = !dlp.tagsExpanded; renderTags(); return;
}
if (dlp.images.length <= 1) return;
if (e.key === 'ArrowLeft' || e.code === 'KeyA') { e.preventDefault(); dlp.idx = (dlp.idx - 1 + dlp.images.length) % dlp.images.length; dlpUpdateImage(); }
if (e.key === 'ArrowRight' || e.code === 'KeyD') { e.preventDefault(); dlp.idx = (dlp.idx + 1) % dlp.images.length; dlpUpdateImage(); }
}
function dlpShowTouch(e) {
if (!CFG.DLSITE_PREVIEW) return;
const card = e.currentTarget;
// 같은 카드 두 번째 탭: 미리보기 닫고 링크 열기 허용 (preventDefault 안 함 → click 발생)
if (dlp.showing && dlp.href === card.href) { dlpHide(); return; }
// 첫 번째 탭: 미리보기 표시, click(링크 이동) 차단
// touchend에서 preventDefault → 스크롤에는 영향 없음
e.preventDefault();
const rect = card.getBoundingClientRect();
dlpShow({ clientX: rect.left + 4, clientY: rect.bottom, currentTarget: card });
}
function dlpShow(e) {
if (!CFG.DLSITE_PREVIEW) return;
const _isKp = !!(e && e._kp);
// 고정 모드(좌/우/가운데) W/S 미리보기 간 이동은 박스를 부수지 않고 '제자리 갱신' → 매번 껐다 켜며 로딩하는
// 깜빡임 제거. (기존='card' 모드는 카드마다 위치가 달라 종전대로 재생성.)
const reuse = !!(e && e._kpReuse && dlp.el && dlp.showing && dlp._kp);
if (!reuse) {
if (dlp.showing) dlpHide();
dlp.showing = true;
dlp.el = document.createElement('div');
dlp.el.id = 'b64d-dlsite-preview';
dlp.el.setAttribute('data-b64d', ''); // MO schedule() 재트리거 방지
dlp.el.innerHTML = '<div class="dlp-spinner"></div>';
// 패널 구조 (persistent: innerHTML 초기화 후 재부착)
dlp.panelEl = document.createElement('div'); dlp.panelEl.className = 'dlp-panel';
dlp.chipsEl = document.createElement('div'); dlp.chipsEl.className = 'dlp-chips';
dlp.navEl = document.createElement('div'); dlp.navEl.className = 'dlp-nav';
dlp.tagsBarEl = document.createElement('div'); dlp.tagsBarEl.className = 'dlp-tags-bar'; dlp.tagsBarEl.style.display = 'none';
dlp.tagsBarEl.addEventListener('click', () => { dlp.tagsExpanded = !dlp.tagsExpanded; renderTags(); });
dlp.tagsListEl = document.createElement('div'); dlp.tagsListEl.className = 'dlp-tags-list'; dlp.tagsListEl.style.display = 'none';
dlp.tags = null; dlp.tagsExpanded = !!CFG.PREVIEW_TAGS_EXPANDED; // '태그 기본 펼침' 설정 시 펼친 상태로 시작
// 태그바(좌) + 페이지표기(우)를 한 줄에 배치
const navRow = document.createElement('div'); navRow.className = 'dlp-nav-row';
navRow.appendChild(dlp.tagsBarEl);
navRow.appendChild(dlp.navEl);
dlp.panelEl.appendChild(dlp.chipsEl);
dlp.panelEl.appendChild(navRow);
dlp.panelEl.appendChild(dlp.tagsListEl);
document.body.appendChild(dlp.el);
// 위치: 일반·'카드 옆(기존)'은 커서/카드 좌표 기준(dlpMove). 고정 옵션이면 화면 비율 위치에 핀.
const _kpPin = _isKp && CFG.KP_PREVIEW_POS && CFG.KP_PREVIEW_POS !== 'card';
if (_kpPin) applyKpFixedPos(dlp.el, CFG.KP_PREVIEW_POS, e && e.currentTarget);
else dlpMove(e);
// W/S로 띄운 미리보기는 마우스를 따라다니지 않도록 mousemove 추적 생략(카드 위치/고정 유지).
if (!IS_MOBILE && !_isKp) document.addEventListener('mousemove', dlpMove);
document.addEventListener('keydown', dlpKey);
if (_isKp) {
// 미리보기 '바깥' 클릭 시 해제. 현재 키 입력과 분리해 다음 틱부터 청취.
dlp._kpClickHide = (ev) => { if (dlp.el && dlp.el.contains(ev.target)) return; dlpHide(); _clearKpNav(); };
setTimeout(() => { if (dlp._kpClickHide) document.addEventListener('mousedown', dlp._kpClickHide, true); }, 0);
}
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 });
// 모바일: 좌우 스와이프로 이미지 전환
if (IS_MOBILE) {
let _swX = 0;
dlp.el.addEventListener('touchstart', te => { _swX = te.touches[0].clientX; }, { passive: true });
dlp.el.addEventListener('touchend', te => {
const dx = te.changedTouches[0].clientX - _swX;
if (Math.abs(dx) < 40 || dlp.images.length <= 1) return;
dlp.idx = dx < 0
? (dlp.idx + 1) % dlp.images.length
: (dlp.idx - 1 + dlp.images.length) % dlp.images.length;
dlpUpdateImage();
}, { passive: true });
}
} else {
// ── 제자리 갱신: 박스·리스너·위치 유지, 새 카드의 메타/이미지만 교체. 기존 <img>는 새 이미지 로드 전까지
// 그대로 둠(dlpUpdateImage가 오프DOM 프리로드 후 원자적 replaceChild) → 스피너/빈 화면/깜빡임 없음.
if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }
if (dlp.srcWatcher) { dlp.srcWatcher.disconnect(); dlp.srcWatcher = null; }
if (dlp.blobUrls) { dlp.blobUrls.forEach(u => URL.revokeObjectURL(u)); dlp.blobUrls = null; }
dlp.images = []; dlp.idx = 0; dlp.site = null;
dlp.tags = null; dlp.tagsExpanded = !!CFG.PREVIEW_TAGS_EXPANDED;
if (dlp.chipsEl) dlp.chipsEl.innerHTML = '';
if (dlp.tagsListEl) { dlp.tagsListEl.innerHTML = ''; dlp.tagsListEl.style.display = 'none'; }
if (dlp.tagsBarEl) dlp.tagsBarEl.style.display = 'none';
if (dlp.panelEl) dlp.panelEl.classList.remove('has-content');
}
dlp._kp = _isKp;
// 코드 라벨(생성·재사용 공통): 좌상단 작품 코드(updatePanel이 이미지 갱신마다 재부착).
if (_isKp) {
const _kpCode = (e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.cardCode) || '';
if (_kpCode) {
if (!dlp.kpCodeEl || !dlp.el.contains(dlp.kpCodeEl)) { dlp.kpCodeEl = document.createElement('div'); dlp.kpCodeEl.className = 'dlp-kp-code'; dlp.el.appendChild(dlp.kpCodeEl); }
dlp.kpCodeEl.textContent = _kpCode; dlp.kpCodeEl.style.display = '';
} else if (dlp.kpCodeEl) { dlp.kpCodeEl.style.display = 'none'; }
}
const thisEl = dlp.el;
const href = e.currentTarget.href;
dlp.href = href; // dlpHide 시 상태 보존용
const cardAnchor = e.currentTarget; // work→announce 폴백 시 href 업데이트용
const steamMatch = href && href.match(/store\.steampowered\.com\/app\/(\d+)/);
const getchuMatch = href && href.includes('getchu.com/');
const cienMatch = href && href.includes('ci-en.dlsite.com/');
// 카드 DOM 교체 시 이전 위치·메타 즉시 복원 (Svelte 재렌더 등 600ms 이내 재호출 대비)
let skipAsync = false;
if (_dlpRestore) {
if (_dlpRestore.href === href) {
clearTimeout(_dlpRestoreTimer);
dlp.images = _dlpRestore.images;
dlp.idx = _dlpRestore.idx;
dlp.site = _dlpRestore.site;
if (_dlpRestore.blobUrls) dlp.blobUrls = _dlpRestore.blobUrls; // Getchu blob 재활용
// 칩(별점·판매수·날짜) 즉시 복원 — 상세정보 깜빡임 방지
for (const { text, cls } of _dlpRestore.chips) {
const c = document.createElement('span'); c.className = cls; c.textContent = text;
dlp.chipsEl.appendChild(c);
}
if (_dlpRestore.chips.length) dlp.panelEl?.classList.add('has-content');
dlp.tags = _dlpRestore.tags || null; dlp.tagsExpanded = !!_dlpRestore.tagsExpanded; renderTags(); // 태그·펼침상태 즉시 복원
dlpUpdateImage(); // 캐시에서 즉시 이전 위치 표시
skipAsync = true; // 비동기 재요청 불필요 (이미지·메타 모두 유효)
} else {
clearTimeout(_dlpRestoreTimer);
if (_dlpRestore.blobUrls) _dlpRestore.blobUrls.forEach(u => URL.revokeObjectURL(u));
}
_dlpRestore = null;
}
// 공통 에러 표시 헬퍼
const showErr = msg => {
if (thisEl !== dlp.el) return;
renderDlpError(msg);
};
// 메타 칩 추가 헬퍼
const addChip = (text, cls) => {
if (!dlp.chipsEl || !text) return;
const c = document.createElement('span');
c.className = 'dlp-chip' + (cls ? ' ' + cls : '');
c.textContent = text;
dlp.chipsEl.appendChild(c);
dlp.panelEl?.classList.add('has-content');
};
if (skipAsync) {
// 복원 완료 — 비동기 재로딩 건너뜀 (Steam/DLsite/Getchu 공통)
} else if (steamMatch) {
// ── Steam: 스크린샷 + 출시일 + 리뷰 ──
dlp.site = 'steam';
const setMeta = (key, val) => {
if (!dlp.chipsEl) return;
dlp.chipsEl.dataset[key] = val;
dlp.chipsEl.innerHTML = '';
if (dlp.chipsEl.dataset.review) addChip(dlp.chipsEl.dataset.review, 'dlp-chip-review');
if (dlp.chipsEl.dataset.date) addChip(dlp.chipsEl.dataset.date, 'dlp-chip-date');
};
const steamFilters = CFG.PREVIEW_META ? 'screenshots,release_date' : 'screenshots';
GM_xmlhttpRequest({
method: 'GET',
url: `https://store.steampowered.com/api/appdetails?appids=${steamMatch[1]}&filters=${steamFilters}`,
timeout: 5000,
onload(res) {
if (thisEl !== dlp.el) return;
try {
const data = JSON.parse(res.responseText);
const info = data[steamMatch[1]];
if (info?.success && info.data?.screenshots?.length) {
dlp.images = info.data.screenshots.map(s => s.path_thumbnail || s.path_full);
dlp.idx = 0;
dlpUpdateImage();
} else {
showErr('스크린샷 없음');
}
if (CFG.PREVIEW_META && info?.data?.release_date?.date) setMeta('date', info.data.release_date.date);
} catch { showErr('미리보기 로드 실패'); }
},
onerror() { showErr('연결 실패'); },
ontimeout() { showErr('응답 없음'); },
});
if (CFG.PREVIEW_META) GM_xmlhttpRequest({
method: 'GET',
url: `https://store.steampowered.com/appreviews/${steamMatch[1]}?json=1&language=all`,
timeout: 5000,
onload(res) {
if (thisEl !== dlp.el) return;
try {
const s = JSON.parse(res.responseText)?.query_summary;
if (s?.review_score_desc) {
setMeta('review', `${s.review_score_desc} (${(s.total_positive||0).toLocaleString()}/${(s.total_reviews||0).toLocaleString()})`);
}
} catch {}
},
});
} else if (getchuMatch) {
// ── Getchu: 페이지 파싱 → 이미지 GM_xmlhttpRequest 프록시(Referer 우회) ──
dlp.site = 'getchu';
dlp.blobUrls = [];
const gcIdMatch = href.match(/\/item\/(\d+)|[?&]id=(\d+)/);
const gcId = gcIdMatch?.[1] || gcIdMatch?.[2];
// pairs(이미지 URL 목록) → 실제 다운로드·표시. 캐시 적중/페이지 파싱이 공유하는 경로.
const loadGetchuPairs = (pairs) => {
if (thisEl !== dlp.el) return;
const rawUrls = pairs.map(p => p.full);
const blobSlots = new Array(rawUrls.length).fill(null); // null=대기, false=실패, string=blob URL
let doneCount = 0;
let thumbShown = false;
const maybeUpdate = () => {
if (thisEl !== dlp.el) return;
const avail = blobSlots.filter(u => typeof u === 'string');
// 풀사이즈가 생겼거나, 썸네일만 표시 중이었다면 교체
if (avail.length > 0 && (avail.length > dlp.images.length || thumbShown)) {
dlp.images = avail; dlp.idx = Math.min(dlp.idx, Math.max(0, avail.length - 1));
thumbShown = false;
dlpUpdateImage();
}
if (doneCount === rawUrls.length && !dlp.images.length) showErr('이미지를 불러올 수 없습니다.');
};
// 첫 번째 이미지 썸네일 빠른 표시 (소용량, Referer 우회)
const firstThumb = pairs[0].thumb;
const firstFull = pairs[0].full;
if (firstThumb !== firstFull) {
GM_xmlhttpRequest({
method: 'GET', url: firstThumb,
responseType: 'arraybuffer',
headers: { 'Referer': 'https://www.getchu.com/' },
timeout: 8000,
onload(r) {
// 풀사이즈가 이미 도착했으면 스킵
if (thisEl !== dlp.el || blobSlots[0] !== null) return;
const ct = (r.responseHeaders||'').match(/content-type:\s*([^\r\n;]+)/i)?.[1]?.trim() || 'image/jpeg';
const burl = URL.createObjectURL(new Blob([r.response], { type: ct }));
if (dlp.blobUrls) dlp.blobUrls.push(burl);
if (!dlp.images.length) {
dlp.images = [burl]; dlp.idx = 0;
thumbShown = true;
dlpUpdateImage();
}
},
});
}
// 풀사이즈 전체 동시 다운로드
rawUrls.forEach((url, i) => {
GM_xmlhttpRequest({
method: 'GET', url,
responseType: 'arraybuffer',
headers: { 'Referer': 'https://www.getchu.com/' },
timeout: 12000,
onload(r) {
if (thisEl !== dlp.el) { blobSlots[i] = false; doneCount++; return; }
const ct = (r.responseHeaders||'').match(/content-type:\s*([^\r\n;]+)/i)?.[1]?.trim() || 'image/jpeg';
const burl = URL.createObjectURL(new Blob([r.response], { type: ct }));
blobSlots[i] = burl;
if (dlp.blobUrls) dlp.blobUrls.push(burl);
doneCount++; maybeUpdate();
},
onerror() { blobSlots[i] = false; doneCount++; maybeUpdate(); },
ontimeout(){ blobSlots[i] = false; doneCount++; maybeUpdate(); },
});
});
};
// 캐시 적중: 페이지 fetch 생략 (재호버 시 왕복 1회 절약). 이미지는 캐시하지 않고 매번 새로 받음.
const cachedPairs = gcId ? _gcPageCache.get(gcId) : null;
if (cachedPairs) {
loadGetchuPairs(cachedPairs);
} else {
const fetchUrl = gcId ? `https://www.getchu.com/soft.phtml?id=${gcId}` : href;
GM_xmlhttpRequest({
method: 'GET', url: fetchUrl,
headers: { 'Referer': 'https://www.getchu.com/' },
timeout: 10000,
onload(res) {
if (thisEl !== dlp.el) return;
const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
const pairs = []; // [{full, thumb}]
doc.querySelectorAll('a.highslide').forEach(a => {
const im = a.querySelector('img');
let full = a.getAttribute('href') || '';
if (!full || !/\.(jpe?g|png|gif|webp)/i.test(full)) {
full = im ? (im.getAttribute('src') || '').replace(/_s(\.(jpe?g|png|gif|webp))$/i, '$1') : '';
}
if (!full) return;
if (full.startsWith('/')) full = 'https://www.getchu.com' + full;
if (!/^https?:/.test(full)) return;
let thumb = im ? (im.getAttribute('src') || '') : full;
if (thumb.startsWith('/')) thumb = 'https://www.getchu.com' + thumb;
if (!pairs.some(p => p.full === full)) pairs.push({ full, thumb });
});
pairs.sort((a, b) => (b.full.includes('package') ? 1 : 0) - (a.full.includes('package') ? 1 : 0));
if (!pairs.length) { showErr('이미지를 찾을 수 없습니다.'); return; }
// 파싱 결과 캐시 (URL 문자열만, 상한 초과 시 FIFO 제거)
if (gcId) {
if (_gcPageCache.size >= GC_CACHE_MAX) _gcPageCache.delete(_gcPageCache.keys().next().value);
_gcPageCache.set(gcId, pairs);
}
loadGetchuPairs(pairs);
},
onerror() { showErr('미리보기 로드 실패'); },
ontimeout() { showErr('응답 없음'); },
});
}
} else if (cienMatch) {
// ── Ci-en (ci-en.dlsite.com) ──
dlp.site = 'cien';
GM_xmlhttpRequest({
method: 'GET', url: href,
headers: { 'Referer': 'https://ci-en.dlsite.com/' },
timeout: 10000,
onload(res) {
if (thisEl !== dlp.el) return;
const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
const seen = new Set();
const imgs = [];
const push = u => { if (u && u.startsWith('http') && !seen.has(u)) { seen.add(u); imgs.push(u); } };
// 1. SSR JSON 스크립트 내 URL 추출 (Nuxt __NUXT_DATA__ 등)
for (const s of doc.querySelectorAll('script')) {
const txt = s.textContent || '';
if (!txt.includes('media.ci-en.jp')) continue;
for (const m of txt.matchAll(/https:\\?\/\\?\/media\.ci-en\.jp\\?\/[^"'\s\\]+\.(?:jpg|jpeg|png|gif|webp)[^"'\s\\]*/gi)) {
push(m[0].replace(/\\\/|\\u002[Ff]/g, '/').split('"')[0]);
}
}
// 2. img src / data-src
doc.querySelectorAll('img[src*="media.ci-en.jp"], img[data-src*="media.ci-en.jp"], [data-src*="media.ci-en.jp"]').forEach(el => {
push(el.getAttribute('src') || '');
push(el.dataset?.src || '');
});
// private attachment 우선, 없으면 article_cover
const attachments = imgs.filter(u => u.includes('/attachment/'));
const covers = imgs.filter(u => u.includes('/article_cover/'));
const final = attachments.length ? attachments : covers;
if (final.length) {
dlp.images = final; dlp.idx = 0; dlpUpdateImage();
} else {
showErr('이미지 없음 (비공개 게시물이거나 로그인 필요)');
}
},
onerror() { showErr('미리보기 로드 실패'); },
ontimeout() { showErr('응답 없음'); },
});
} else {
// ── DLsite ──
dlp.site = 'dlsite';
let smpActive = true;
let smpStarted = false;
const dlsiteCodeMatch = href.match(/product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i);
const sectionMatch = href.match(/dlsite\.com\/(\w+)\//);
const code = dlsiteCodeMatch ? dlsiteCodeMatch[1].toUpperCase() : null;
const section = sectionMatch ? sectionMatch[1] : null;
// smp probe — 호출측(guess 또는 info API)에 관계없이 최초 1회만 실행
const startSmpProbes = (workImgUrl) => {
if (smpStarted) return;
smpStarted = true;
if (!smpActive || !workImgUrl || thisEl !== dlp.el) return;
const smpStem = workImgUrl.replace(/_img_main(\.\w+)$/, '_img_smp');
const smpExt = (workImgUrl.match(/_img_main(\.\w+)$/) || ['', '.jpg'])[1];
if (smpStem === workImgUrl) return;
const SMP_MAX = 12;
const smpSlots = new Array(SMP_MAX).fill(null);
const addSmpOrdered = () => {
if (!smpActive || thisEl !== dlp.el) return;
for (let i = 0; i < SMP_MAX; i++) {
if (smpSlots[i] === null) break;
if (smpSlots[i] === true) {
const u = `${smpStem}${i + 1}${smpExt}`;
if (!dlp.images.includes(u)) dlp.images.push(u);
}
}
dlpUpdateNav();
};
for (let i = 0; i < SMP_MAX; i++) {
const probe = new Image();
probe.referrerPolicy = 'no-referrer';
probe.onload = () => { smpSlots[i] = true; addSmpOrdered(); };
probe.onerror = () => { smpSlots[i] = false; addSmpOrdered(); };
probe.src = `${smpStem}${i + 1}${smpExt}`;
}
};
// ── FAST PATH: CDN URL 즉시 예측 (API 응답 없이 ~0.5s) ──
// img.dlsite.jp/modpub/images2/work/{cat}/{prefix}{floor/1000*1000}/{code}_img_main.jpg
if (code) {
const codeNum = parseInt(code.replace(/^[A-Z]+/, ''), 10);
const codePrefix = code.replace(/\d+$/, '');
const codePad = String(codeNum).padStart(8, '0');
const dirPad = codePrefix + String(Math.floor(codeNum / 1000) * 1000).padStart(8, '0');
let guessShown = false;
for (const cat of ['doujin', 'professional', 'translation', 'books']) {
const guessUrl = `https://img.dlsite.jp/modpub/images2/work/${cat}/${dirPad}/${codePrefix}${codePad}_img_main.jpg`;
const p = new Image();
p.referrerPolicy = 'no-referrer';
p.onload = () => {
if (guessShown || thisEl !== dlp.el) return;
guessShown = true;
if (!dlp.images.length) { dlp.images = [guessUrl]; dlp.idx = 0; dlpUpdateImage(); }
startSmpProbes(guessUrl);
};
p.src = guessUrl;
}
}
// ── HTML 페이지 (확정 이미지 리스트, work→announce 자동 폴백) ──
const isWorkUrl = href.includes('/work/=/');
const fetchDlsiteHtml = (url, isRetry) => {
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'User-Agent': navigator.userAgent, 'Referer': 'https://www.dlsite.com/' },
timeout: 15000,
onload(res) {
if (thisEl !== dlp.el) return;
// work URL 4xx → announce 재시도 (smpActive 유지)
if (!isRetry && isWorkUrl && res.status >= 400) {
fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
return;
}
smpActive = false;
const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
// 공식 장르 태그 추출 (href에 work.genre 포함) — 추가 요청 없이 작품 HTML에서 그대로
if (CFG.PREVIEW_TAGS && (!dlp.tags || !dlp.tags.length)) {
const tg = [...new Set([...doc.querySelectorAll('a[href*="work.genre"]')].map(el => (el.textContent || '').trim()).filter(Boolean))];
if (tg.length) { dlp.tags = tg; renderTags(); }
}
const imgs = [];
for (const sel of ['.product-slider-data > div[data-src]', '.main-image-slider img', '.product-slider img', 'img[src*="img.dlsite.jp"]:not([src*="/resize/"])', 'img[src*="img.dlsite.com"]']) {
doc.querySelectorAll(sel).forEach(el => {
const rawSrc = el.dataset.src || el.getAttribute('src') || '';
const src = rawSrc.startsWith('//') ? 'https:' + rawSrc : rawSrc;
if (src && /^https?:\/\//.test(src) && !src.includes('/resize/') && !imgs.includes(src)) imgs.push(src);
});
if (imgs.length) break;
}
if (imgs.length) {
// announce 폴백 성공 → 카드 링크도 announce URL로 갱신
if (isRetry) try { cardAnchor.href = url; } catch(_) {}
const prevSrc = dlp.images[dlp.idx] || '';
const prevIdx = dlp.idx;
// 파일명 기반 매칭: CDN 도메인(img.dlsite.jp vs .com)·쿼리스트링 차이 허용
const fnOf = u => { const p = u.split('/').pop(); return p ? p.split('?')[0] : ''; };
const prevFn = fnOf(prevSrc);
const keepIdx = prevFn ? imgs.findIndex(u => fnOf(u) === prevFn) : -1;
if (keepIdx !== -1) {
// 파일명 매칭 성공 → HTML 목록으로 교체
// 이미 로드된 URL은 그대로 유지해 불필요한 재로드 방지
const fnMap = new Map(dlp.images.map(u => [fnOf(u), u]));
dlp.images = imgs.map(u => fnMap.get(fnOf(u)) || u);
dlp.idx = keepIdx;
dlpUpdateImage();
} else if (imgs.length < dlp.images.length) {
// HTML이 SMP보다 적게 반환(JS 로딩 페이지 등) → 기존 목록 유지
// 진짜 새 파일만 뒤에 추가
let added = false;
for (const u of imgs) {
if (!dlp.images.some(e => fnOf(e) === fnOf(u))) { dlp.images.push(u); added = true; }
}
if (added) dlpUpdateNav();
} else {
// 완전히 다른 이미지 목록이고 HTML이 더 크거나 같음 → 교체 + 인덱스 클램프
// (imgs.length >= dlp.images.length > prevIdx이므로 실제로 클램프 발생 안 함)
dlp.images = imgs;
dlp.idx = Math.min(prevIdx, imgs.length - 1);
dlpUpdateImage();
}
} else if (!isRetry && isWorkUrl) {
// 200이지만 이미지 없음(출시 전 work 페이지 등) → announce 재시도
smpActive = true; // 재시도 동안 smp 유지
fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
} else if (!dlp.images.length) {
showErr('이미지를 찾을 수 없습니다.');
}
},
onerror() {
if (!isRetry && isWorkUrl) {
fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
} else {
smpActive = false;
if (!dlp.images.length) showErr('미리보기 로드 실패');
}
},
ontimeout() { smpActive = false; if (!dlp.images.length) showErr('응답 없음 (15s)'); },
});
};
fetchDlsiteHtml(href, false);
// ── Info API (메타 정보 + fast path 실패 시 fallback) ──
if (code && section) {
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.dlsite.com/${section}/product/info/ajax?product_id=${code}`,
timeout: 8000,
onload(res) {
if (thisEl !== dlp.el) return;
try {
const info = JSON.parse(res.responseText)?.[code];
if (!info) return;
if (info.work_image && !dlp.images.length) {
const workImg = info.work_image.startsWith('//') ? 'https:' + info.work_image : info.work_image;
dlp.images = [workImg]; dlp.idx = 0; dlpUpdateImage();
}
const wi = info.work_image ? (info.work_image.startsWith('//') ? 'https:' + info.work_image : info.work_image) : null;
startSmpProbes(wi);
if (CFG.PREVIEW_META && dlp.chipsEl) {
if (info.rate_average_2dp != null) addChip(`★${Number(info.rate_average_2dp).toFixed(2)}${info.rate_count ? ` (${info.rate_count})` : ''}`, 'dlp-chip-star');
if (info.dl_count != null) addChip(`판매 ${Number(info.dl_count).toLocaleString()}건`, 'dlp-chip-sales');
if (info.regist_date) addChip(info.regist_date.slice(0, 10), 'dlp-chip-date');
}
} catch {}
},
});
}
}
const srcEl = e.currentTarget;
if (IS_MOBILE) {
const th = te => { if (dlp.el && dlp.el.contains(te.target)) return; if (srcEl.contains(te.target)) return; dlpHide(); };
dlp._touchHide = th;
document.addEventListener('touchstart', th, true);
} else if (!_isKp) {
// W/S 미리보기는 호버 기반이 아니므로 카드 mouseleave로 닫지 않음(바깥 클릭·이동으로 해제).
// 제자리 갱신(reuse) 시 죽은 {once} 리스너가 카드마다 쌓이는 것도 방지.
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, _pass = 0) {
if (!CFG.LINK_CARD) return;
if (!contentRoots.size) return; // 본문 루트 미매칭 = kone.gg 본문 페이지 아님
// contentRoots 내 링크만 처리 (OG 프리뷰 등 외부 중복 방지, 동일 ID 중복 카드 방지)
const seen = new Set();
contentRoots.forEach(root => {
root.querySelectorAll('a').forEach(a => {
if (a.hasAttribute(LTDONE_ATTR)) return;
if (a.hasAttribute(DONE_ATTR)) {
// 이 <a>로 만든 카드가 아직 살아있나? 두 가지로 확인:
// · _cardMap(노드 신원): 같은 <a> 노드가 유지될 때.
// · _liveCards(키 기준): 코네가 <a>를 '새 노드'로 갈아치워 _cardMap이 끊겨도, <a>에 박아둔
// dataset.b64key로 그 키의 카드가 DOM에 살아있는지 확인(중복으로 숨긴 <a>도 같은 키 공유).
// 둘 다 죽었으면 → DONE 해제 후 재생성(다운로드 카드가 코네 재렌더로 사라지던 문제의 핵심 보강).
const prevCard = _cardMap.get(a);
const ownAlive = prevCard && document.contains(prevCard);
// '과거 카드였던 <a>'(b64key 보유 또는 _cardMap 등록)만 부활 대상. buildNodes가 만든
// 평문 DONE 앵커(중복 lkSkip 링크·미지원 호스트 링크)는 카드였던 적이 없어 b64key가 없다.
// 이들의 DONE을 벗기면 hasProcessedAncestor 완화 이후 재카드화·숨김되어 텍스트가 증발하므로,
// 카드 이력이 없으면 손대지 않고 그대로 둔다(현 위치 텍스트 링크 유지).
const hadCard = !!prevCard || !!a.dataset.b64key;
if (hadCard && !ownAlive && !_cardKeyAlive(a.dataset.b64key)) {
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)
// → dlsite/steam 등 지원 상품이면 우리 카드로 '대체'(평문/base64/점자와 동일하게 카드화),
// 그 외 지원 사이트면 링크 카드로, 미지원은 종전대로 숨김만. (그래야 OG 임베드 URL도 카드가 됨)
if (a.firstElementChild) {
if (a.classList.contains('not-prose') && a.classList.contains('h-24')) {
const ogHref = a.href || '';
let ogCard = null, ogKey = null;
try {
const h = new URL(ogHref).hostname;
if (CFG.LINK_CARD && !/kone\.gg$/.test(h)) {
if (h.includes('dlsite.com')) {
const c = dlsiteCodeOf(ogHref);
if (c) ogKey = 'dl:' + c;
} else if (h === 'store.steampowered.com') {
const aId = steamAppOf(ogHref); if (aId) ogKey = 'st:' + aId;
} else {
// fanbox/dmm 등: 평문 카드 키(convertProductLinks의 'cr:'+href)와 통일 → 같은 작품의
// '평문 링크 + OG 프리뷰'가 카드 2개로 중복 생성되던 문제 방지(수정: fanbox 중복).
ogKey = 'cr:' + ogHref.split(/[?#]/)[0];
}
const known = isKnownCardHost(h); // 단일 소스 (kone.gg는 상위 조건에서 이미 배제)
if (known) {
// OG 프리뷰는 평문 링크의 '부가 표시'이므로 NO_DUP과 무관하게 항상 중복 제거한다
// (평문 카드가 이미 있으면 OG는 카드화하지 않고 숨김만 — 코네 OG는 CSS로 어차피 숨겨짐).
const dup = ogKey && (seen.has(ogKey) || _seenProductCodes.has(ogKey));
if (!dup) {
ogCard = makeLinkCard(ogHref);
if (ogKey) { seen.add(ogKey); if (CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(ogKey); }
}
}
}
} catch (e) {}
a.setAttribute(DONE_ATTR, '');
// 키↔카드 등록: 하이드레이션이 OG <a>(또는 카드)를 떼어내도 키 기준으로 생존확인·재생성.
// 숨긴 중복 OG <a>도 같은 키를 박아 둬야 아래 NO_DUP 안전망이 되살릴 수 있다.
if (ogKey) a.dataset.b64key = ogKey;
a.style.setProperty('display', 'none', 'important');
if (ogCard) {
ogCard.setAttribute(ORIG_ATTR, a.outerHTML);
a.parentNode.insertBefore(ogCard, a);
_cardMap.set(a, ogCard);
if (ogKey) { ogCard.dataset.b64key = ogKey; _liveCards.set(ogKey, ogCard); }
}
return;
}
// OG(not-prose h-24)가 아닌데 자식 요소가 있는 <a>: 코네가 URL에 인라인 서식(<u>/<b>/<i>/
// <mark> 등)을 입히면 <a> 안에 그 태그가 들어가 firstElementChild가 생긴다. 과거엔 여기서
// 무조건 return → '밑줄 친 transfer.it URL' 등이 카드화되지 않았다(수정3: 드래그하면 되던 이유는
// 드래그가 convertProductLinks가 아닌 processContentNode 경로라서). 미디어 임베드(img/video 등)만
// 아니면 일반 링크로 보고 아래 href 기반 카드화를 계속한다(미디어 포함은 특수 임베드라 스킵).
if (a.querySelector('img, picture, video, iframe, svg')) return;
}
let href = a.href || '';
// 리디렉션 래퍼(google.com/url?url=…)는 진짜 목적지로 해석 (수정사항8)
href = resolveRedirect(href);
// ★사이트 판정은 href 문자열 검색이 아니라 hostname으로 한다. 과거엔 href.includes('dmm.co.jp')
// 식이라, google.com/url?…&url=https%3A%2F%2Fwww.dmm.co.jp%2F… 같은 리디렉션 링크의 '쿼리에
// 든' 도메인까지 걸려 엉뚱한 작품 카드가 생성됐다(수정사항8). hostname 기준이면 쿼리 무관.
let hHost = '';
try { hHost = new URL(href).hostname; } catch(e) {}
const hBase = hHost.replace(/^www\./, '');
// kone.gg 내부 링크 → 절대 카드로 변환하지 않음
if (/kone\.gg$/.test(hHost)) return;
let card = null;
let seenKey = null;
// ── hostname 기준 ──
if (hHost.includes('dlsite.com')) {
// www.dlsite.com: /product_id/RJxxx, ch.dlsite.com: /VJxxx 또는 /product/VJxxx
const dlCode = dlsiteCodeOf(href);
if (dlCode) {
seenKey = 'dl:' + dlCode;
card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard(dlCode);
if (card && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
} else if (CFG.LINK_CARD) {
// 작품 코드가 없는 DLsite URL 중 '/fsr/ 검색·장르 링크'(칩)와 'ci-en.dlsite.com'(작가 페이지
// 카드)만 처리. 그 외(홈·카테고리·검색 등 '작품이 아닌 단순 페이지')는 카드화하지 않고 원래
// 링크를 그대로 둔다(사용자 결정: ci-en만 유지).
const isFsr = /\/fsr\//i.test(href);
if (!isFsr && hHost !== 'ci-en.dlsite.com') return;
// ★fsr 칩은 GENRE_BAR ON이면 인라인으로 안 만들고 건너뜀 → buildGenreBar가 본문 하단 바로 모음
// (인라인 칩은 코네 work.genre 재렌더마다 배수 증식했음, 요구1). ci-en 카드는 영향 없음.
if (isFsr && CFG.GENRE_BAR) return;
// fsr(칩)·ci-en(카드) → makeLinkCard가 각각 반환. _cardKeyAlive로 교차 flush 중복 차단.
seenKey = 'dl:' + href.split(/[?#]/)[0];
// ★수정4 근본: NO_DUP ON일 때, 이 키의 칩/카드가 '이미 DOM에 살아있으면'(코네가 genre <a>를
// flush마다 새 노드로 재렌더해도) 새로 만들지 않고 <a>만 숨긴다. _seenProductCodes는 flush마다
// 비워지므로 그것만으로는 재렌더 누적을 못 막았다 — _cardKeyAlive(_liveCards, flush 넘어 유지)로
// 교차 flush 중복을 차단. (GENRE_BAR OFF 상태 장르 칩 증식의 실제 원인.)
if (CFG.NO_DUPLICATE_CARD && !seen.has(seenKey) && _cardKeyAlive(seenKey)) {
a.setAttribute(DONE_ATTR, '');
a.dataset.b64key = seenKey;
a.style.setProperty('display', 'none', 'important');
return;
}
const dupTag = seen.has(seenKey) || _seenProductCodes.has(seenKey);
card = dupTag ? null : makeLinkCard(href);
if (card) { seen.add(seenKey); _seenProductCodes.add(seenKey); }
}
} else if (hHost === 'store.steampowered.com' && href.includes('/app/')) {
const appId = steamAppOf(href);
if (appId) {
seenKey = 'st:' + appId;
card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeSteamCard(appId);
if (card && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
}
} else if (hBase === 'patreon.com') {
try {
const u = new URL(href);
if (u.pathname.length > 1) {
seenKey = 'pt:' + href.split(/[?#]/)[0];
card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makePatreonCard(href);
}
} catch(e) {}
} else if (hHost.includes('getchu.com') || hHost.includes('getcu.com')) {
const gcDigits = getchuIdOf(href);
if (gcDigits) {
seenKey = 'cr:GC' + gcDigits;
if (CFG.NO_DUPLICATE_CARD && _gcSeenInFlush.has(gcDigits)) {
// 텍스트 GC 코드로 이미 카드 생성됨 → <a> 숨김만
a.setAttribute(DONE_ATTR, '');
a.style.setProperty('display', 'none', 'important');
return;
}
card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeGetcuCard('GC' + gcDigits, href);
if (card) _gcSeenInFlush.add(gcDigits);
} else {
seenKey = 'lk:' + href.split(/[?#]/)[0];
card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makeLinkCard(href);
}
} else if (hHost.includes('dmm.co.jp') || hBase === 'fanza.jp' || hHost.endsWith('fanbox.cc')) {
seenKey = 'cr:' + href.split(/[?#]/)[0];
card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makeLinkCard(href);
}
// ── href가 알려진 다운로드/파일 공유 사이트 → 일반 링크 카드 (★ 텍스트 코드 검색보다 먼저!) ──
// 다운로드 URL 경로에 우연히 상품 코드꼴(예: gofile.io/d/Ve123456 → 'VE123456',
// workupload.com/file/BE12345678, mypikpak.com/s/VJ…, transfer.it/t/ST…)이 들어가면
// 아래 텍스트 기반 검색이 DLsite/Steam 상품으로 '오분류' → NO_DUP에서 그 가짜 코드가 중복 판정되면
// card=null이 되며 다운로드 카드조차 안 생기던 문제. href가 다운로드 사이트면 여기서 먼저 확정.
if (!card && !seenKey && href) {
try {
const u = new URL(href);
// 경로가 없는 사이트 루트 URL (예: https://transfer.it/) → 카드 생성 안 함
if (u.pathname.length <= 1) return;
if (isDownloadHost(u.hostname)) {
// 다운로드 카드는 'flush 내 중복(seen)'만으로 제거. _seenProductCodes(교차/페이지) 억제는
// 쓰지 않는다 — 그게 첫 다운로드 카드까지 취소시켜 '카드 자체가 안 생기던' 원인이었음.
seenKey = 'lk:' + href.split(/[?#]/)[0];
card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makeLinkCard(href);
}
} catch(e) {}
}
// ── href로 분류 안 된 경우만 링크 텍스트에서 코드 탐색 (다운로드 href는 위에서 처리됨) ──
if (!card && !seenKey) {
const t = a.textContent.trim();
DLSITE_LATIN_RE.lastIndex = 0; const dlLatin = DLSITE_LATIN_RE.exec(t);
DLSITE_KR_RE.lastIndex = 0; const dlKr = DLSITE_KR_RE.exec(t);
STEAM_CODE_RE.lastIndex = 0; const st = STEAM_CODE_RE.exec(t);
CREATOR_CODE_RE.lastIndex = 0; const cr = CREATOR_CODE_RE.exec(t);
if (dlLatin) { seenKey = 'dl:' + (dlLatin[1]+dlLatin[2]).toUpperCase(); card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard((dlLatin[1]+dlLatin[2]).toUpperCase()); }
else if (dlKr) { seenKey = 'dl:RJ' + dlKr[2]; card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard('RJ' + dlKr[2]); }
else if (st) { seenKey = 'st:' + st[1]; card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeSteamCard(st[1]); }
else if (cr) {
const crCode = (cr[1]+cr[2]).toUpperCase();
seenKey = 'cr:' + crCode;
const crGcD = crCode.startsWith('GC') ? crCode.slice(2) : null;
const crGcDup = CFG.NO_DUPLICATE_CARD && crGcD !== null && _gcSeenInFlush.has(crGcD);
if (crGcDup) {
a.setAttribute(DONE_ATTR, ''); a.style.setProperty('display', 'none', 'important'); return;
}
card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeCreatorCard(crCode);
if (card && crGcD !== null) _gcSeenInFlush.add(crGcD);
}
}
if (card) {
if (seenKey && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
if (seenKey) seen.add(seenKey);
card.setAttribute(ORIG_ATTR, a.outerHTML);
a.setAttribute(DONE_ATTR, '');
if (seenKey) { a.dataset.b64key = seenKey; card.dataset.b64key = seenKey; _liveCards.set(seenKey, card); } // 키↔카드 등록(재생성·중복정리 판단)
a.style.setProperty('display', 'none', 'important');
a.parentNode.insertBefore(card, a);
_cardMap.set(a, card); // 카드 제거 감지용 (Svelte 재렌더링 대응)
} else if (seenKey && CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) {
// NO_DUP ON: 같은 작품/링크의 2번째+ 등장(또는 processContentNode 선행 카드와 중복).
// ★과거엔 display:none으로 숨겼는데, 표 셀/단독 단락이 그 링크 하나뿐이면 빈칸(깨진 표)으로
// 남았다(⑥). 사용자 의도: 숨기지 말고 '보이는 평문 링크'로 둔다(buildNodes dup·다운로드
// 링크와 일관). b64key는 유지 → 카드가 소멸하면 위 DONE-부활 로직이 이 링크를 카드화한다.
// (NO_DUP OFF면 위 card 분기에서 매 등장 카드화되어 여기 안 옴 = 표/평문도 카드 생성.)
a.setAttribute(DONE_ATTR, '');
a.dataset.b64key = seenKey;
}
});
});
// ── NO_DUP 하이드레이션 안전망(★ '카드 통째로 빔' 근본 대응) ──
// 핵심 원인: 코네(SvelteKit) 하이드레이션이 이전 렌더 트리를 'display:none'으로 남겨둔다(=DOM에는
// 있지만 안 보임). 그 안의 카드가 document.contains()로는 '살아있다'고 잡혀 NO_DUP이 '보이는' 새 <a>를
// 억제 → 결국 보이는 카드가 0개. (OFF면 형태별로 다 만들어 보이는 트리에도 카드가 남아 정상.)
// 그래서 '문서 연결'이 아니라 '실제 가시성(offsetParent)' 기준으로 키별 '보이는 카드 정확히 1개'를 강제.
if (CFG.NO_DUPLICATE_CARD && _pass < 2) {
const isVis = el => el.isConnected && el.offsetParent !== null; // display:none 서브트리/분리 카드 = 안 보임
const cardsByKey = new Map(); // key → 우리 카드[] (보임/숨김 혼재)
const anchorsByKey = new Map(); // key → 숨긴 원본 <a>[] (카드로 교체돼 display:none)
const push = (m, k, v) => { let a = m.get(k); if (!a) m.set(k, a = []); a.push(v); };
contentRoots.forEach(root => {
// ★.b64-dlsite-tag(장르 칩)도 '카드'로 계수한다(수정4). 빠져 있으면 안전망이 '이 키엔 보이는
// 카드 없음'으로 오판해 숨긴 원본 <a>를 되살리고, 그게 재칩화되어 flush마다 장르 칩이 증식했다.
root.querySelectorAll('.b64-product-link[data-b64key], .b64-link[data-b64key], .b64-dlsite-tag[data-b64key]').forEach(c => {
if (c.closest('#b64d-link-panel')) return; // 모아보기 패널 복제본 제외
if (c.dataset.b64key) push(cardsByKey, c.dataset.b64key, c);
});
root.querySelectorAll('a[data-b64key]:not(.b64-product-link):not(.b64-link)').forEach(a => {
if (a.style.display !== 'none' || a.closest('#b64d-link-panel')) return;
if (a.dataset.b64key) push(anchorsByKey, a.dataset.b64key, a);
});
});
// (1) 보이는 중복 카드 정리: 키별 '보이는 카드'가 2개+면 첫 개만 남기고 제거(짧은 틈에 둘 생긴 경우).
cardsByKey.forEach(cards => { const vis = cards.filter(isVis); vis.slice(1).forEach(c => c.remove()); });
// (2) 보이는 카드 0개인데 숨긴 원본 <a>가 있으면 '보이는 트리'의 <a> 하나를 되살려 재카드화.
let revived = false;
anchorsByKey.forEach((anchors, key) => {
if ((cardsByKey.get(key) || []).some(isVis)) return; // 이미 보이는 카드 있음
const a = anchors.find(x => x.parentElement && x.parentElement.offsetParent !== null) // 보이는 트리 우선
|| anchors.find(x => x.parentNode);
if (!a) return;
a.removeAttribute(DONE_ATTR);
a.style.removeProperty('display');
delete a.dataset.b64key;
_cardMap.delete(a);
_seenProductCodes.delete(key); // 억제기록 해제(안 풀면 재패스에서 또 null)
if (key.startsWith('cr:GC')) _gcSeenInFlush.delete(key.slice(5));
revived = true;
});
if (revived) { convertProductLinks(contentRoots, _pass + 1); return; } // 되살린 <a> 재카드화(1회)
}
// preview 훅: 카드로 교체되지 않은 DLsite/Steam 링크에도 hover 달기
if (CFG.DLSITE_PREVIEW) {
const previewSel = 'a[href*="dlsite.com"], a[href*="ch.dlsite.com"], a[href*="store.steampowered.com/app/"], a[href*="getchu.com/"]';
contentRoots.forEach(root => {
root.querySelectorAll(previewSel).forEach(a => {
if (a.dataset._dlpHooked) return;
if (a.firstElementChild) return; // OG 프리뷰 등 블록형 링크 제외
hookDlp(a);
});
});
}
}
/* ================================================================
비번 자동입력 공통 유틸
================================================================ */
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) {
el.focus();
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 InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
// 버튼이 없거나 비활성일 때 Enter 키 이벤트로 폼 제출 (generic/formpost/transfer/linklock 공용 —
// optimized+ 통합, 과거 동일 2줄 디스패치가 4벌 복붙돼 있었다)
function submitViaEnter(input) {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }));
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, 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: '',
GRACE: 3000, FALLBACK: 2000, RETRY_MS: 50, RETRY_MAX: 8,
};
function kioGetBtn(scope) {
return [...(scope||document).querySelectorAll('button')]
.find(b => isVisible(b) && /^(확인|ok|confirm|submit|enter)$/i.test(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 = getEffectivePwList().filter(Boolean).map(v => ({ value: v }));
KIO.idx = 0; KIO.tried = new Set(); KIO.active = null;
KIO.lastAdvKey = '';
}
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.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 && 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:'' });
kioTickBusy = false;
});
document.addEventListener('visibilitychange', () => { if (!document.hidden) kioTick().catch(()=>{}); });
setTimeout(() => kioTick().catch(()=>{}), 0);
}
if (siteCfg.mode === 'generic') {
async function genericTryPw() {
const pws = getEffectivePwList();
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) && /^(확인|ok|confirm|submit|enter|unlock|access)$/i.test(normText(b.textContent))
);
if (btn && !btn.disabled) break;
await new Promise(r => setTimeout(r, 50));
}
if (btn && !btn.disabled) {
btn.click();
} else {
submitViaEnter(input); // 버튼이 disabled이거나 없으면 Enter 키로 폼 제출 시도
}
await new Promise(r => setTimeout(r, 1000));
const postInput = getInputEl(siteCfg.inputSel);
// 비번 입력창이 사라졌으면 성공 (페이지 이동, AJAX 폼 제거 등)
if (!postInput) break;
// 에러 패턴 먼저 확인 (successSel보다 우선 — premature match 방지)
if (findPwError(postInput, siteCfg.errorPat)) continue;
// 성공 요소 체크: 입력창이 남아있지만 성공 콘텐츠가 나타난 경우 (AJAX)
if (siteCfg.successSel && document.querySelector(siteCfg.successSel)) break;
}
}
const gObs = new MutationObserver(() => {
const input = getInputEl(siteCfg.inputSel);
if (input && !input.dataset._gTried) { input.dataset._gTried = '1'; setTimeout(genericTryPw, 300); }
});
gObs.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
const i = getInputEl(siteCfg.inputSel);
if (i && !i.dataset._gTried) { i.dataset._gTried = '1'; genericTryPw(); }
}, siteCfg.triggerDelay || 1500);
}
if (siteCfg.mode === 'formpost') {
// 전통적 form POST: 비번 제출 시 페이지 전체 리로드됨
// → DOM 플래그 방식으로는 인덱스 유지 불가 → GM_setValue로 영속화
// PERSIST_KEY는 파일 URL별로 분리 (다른 파일과 인덱스 혼용 방지)
const PERSIST_KEY = `_fpIdx_${location.hostname}${location.pathname}`;
const pws = getEffectivePwList();
const input = getInputEl(siteCfg.inputSel);
if (!input) {
// 비번 폼 없음 → 성공 페이지(다운로드 버튼 있음)인지 확인
if (siteCfg.dlBtnSel) {
const dlBtn = document.querySelector(siteCfg.dlBtnSel);
if (dlBtn && isVisible(dlBtn)) {
GM_setValue(PERSIST_KEY, 0); // 성공: 인덱스 초기화
setTimeout(() => {
const b = document.querySelector(siteCfg.dlBtnSel);
if (b && isVisible(b)) b.click();
}, siteCfg.triggerDelay || 1000);
}
}
return; // 관련 없는 페이지 → 아무 동작 안 함
}
// 비번 폼 존재 → 저장된 인덱스로 다음 비번 시도
const idx = GM_getValue(PERSIST_KEY, 0);
if (!pws.length || idx >= pws.length) {
GM_setValue(PERSIST_KEY, 0); return; // 비번 목록 없음 or 전부 실패 → 초기화 후 중단
}
setTimeout(() => {
const inp = getInputEl(siteCfg.inputSel);
if (!inp) return;
const pw = pws[idx];
if (!pw) { GM_setValue(PERSIST_KEY, 0); return; }
// 제출 전에 다음 인덱스 저장: 페이지 리로드 후 이 값을 읽어 다음 비번으로 진행
GM_setValue(PERSIST_KEY, idx + 1);
setNativeValue(inp, pw);
setTimeout(() => {
const btn = document.querySelector(siteCfg.btnSel);
if (btn && !btn.disabled) btn.click();
else submitViaEnter(inp);
}, siteCfg.btnDelay || 300);
}, siteCfg.triggerDelay || 1500);
}
if (siteCfg.mode === 'transfer') {
// ★transfer 실제 흐름(사용자 확인): 비번 제출 → 입력창 사라지고 로딩 → (오답)다이얼로그 재등장 /
// (정답)다운로드 준비 화면. 게다가 재시도할수록 로딩이 점점 길어진다(레이트리밋). while 루프 폴링은 이
// 재마운트 + 길어지는 로딩 타이밍에 취약해, 재등장 후 다음 비번을 못 넣는 문제가 있었다(사용자 증상).
// → KIO처럼 인터벌 상태머신으로 재작성: '다이얼로그가 present이고 처리 중이 아닐 때' 다음 비번을 넣고,
// 사라졌다 재등장(재마운트)/오류문구면 다음 인덱스로. 성공은 오직 '다운로드 준비 UI'로만 판정(로딩 시간 무관).
let _tIdx = 0, _tDone = false, _tItv = null;
let _tState = 'idle'; // idle(입력 대기) | submitted(결과 대기)
let _tSubmitAt = 0, _tGoneSeen = false;
// ★transfer: 'Download all'(.js-download) 버튼은 잠금(비번 프롬프트) 화면에도 이미 떠 있고 pw 입력창과 '공존'한다
// (사용자 콘솔 확인). 그래서 다운로드 버튼은 성공 신호가 될 수 없다(그걸로 판정하면 첫 비번 로딩 중 오판→멈춤).
// 유일하게 확실한 구분: 오답이면 pw 다이얼로그가 '되돌아오고', 정답이면 '되돌아오지 않는다'(닫힌 채 다운로드).
// ⇒ 성공을 능동 판정하지 않는다. '다이얼로그가 되돌아올 때(재마운트)만 다음 비번'을 넣고, 안 되돌아오면(정답)
// 그대로 대기 = 사실상 성공(더 할 일 없음). 시간 임계값을 안 쓰므로 로딩이 아무리 길어져도 안전.
function tTick() {
if (_tDone) return;
const input = getInputEl(siteCfg.inputSel);
if (_tState === 'submitted') {
if (!input) { _tGoneSeen = true; return; } // 닫힘(로딩 or 성공) — 되돌아올 때까지 계속 대기
if (_tGoneSeen || findPwError(input, siteCfg.errorPat)) { // 되돌아옴(재마운트) or 오류문구 = 오답
_tIdx++; _tGoneSeen = false;
if (_tIdx >= getEffectivePwList().length) { _tDone = true; if (_tItv) clearInterval(_tItv); return; } // 소진
_tState = 'idle'; return; // 다음 tick에서 다음 비번(재등장 안정화 버퍼 ~틱간격)
}
if (Date.now() - _tSubmitAt > 120000) { _tIdx++; _tState = 'idle'; _tGoneSeen = false; return; } // 안전장치(클릭 미반영 등)
return; // 제출 직후 아직 그대로(사라지지도 오류도 아님) — 대기
}
// idle: 비번 프롬프트가 있으면 다음 비번 입력. 없으면(정답으로 닫힘/미오픈) 아무것도 안 함.
if (!input) return;
const pws = getEffectivePwList();
if (_tIdx >= pws.length) { _tDone = true; if (_tItv) clearInterval(_tItv); return; } // 비번 소진 → 중단
const pw = pws[_tIdx];
if (!pw) { _tIdx++; return; }
setNativeValue(input, pw);
_tState = 'submitted'; _tSubmitAt = Date.now(); _tGoneSeen = false;
setTimeout(() => {
const inp2 = getInputEl(siteCfg.inputSel);
if (!inp2) return;
const btn = document.querySelector(siteCfg.btnSel);
if (btn && !btn.disabled) btn.click();
else submitViaEnter(inp2);
}, siteCfg.btnDelay || 250);
}
_tItv = setInterval(tTick, 350);
// 페이지 로드 시 다운로드 버튼이 있으면 클릭해 비번 다이얼로그 열기(이미 열려 있으면 스킵).
if (siteCfg.dlBtnSel) {
setTimeout(() => {
if (getInputEl(siteCfg.inputSel)) return;
const dlBtn = document.querySelector(siteCfg.dlBtnSel);
if (dlBtn && isVisible(dlBtn)) dlBtn.click();
}, siteCfg.triggerDelay || 1500);
}
}
if (siteCfg.mode === 'linklock') {
// link-lock: 오답 시 인라인 .error 박스 표시(+'Try again'으로 폼 복원), 정답 시 리다이렉트.
const _wait = ms => new Promise(r => setTimeout(r, ms));
const _errVisible = () => { const b = document.querySelector(siteCfg.errorSel || '.error'); return !!(b && isVisible(b)); };
const _clickRetry = () => {
const re = siteCfg.retryText || /try again/i;
const b = [...document.querySelectorAll('button')].find(x => isVisible(x) && re.test(normText(x.textContent)));
if (b) { b.click(); return true; } return false;
};
let _llRunning = false, _llDone = false;
async function linklockTry() {
if (_llRunning || _llDone) return;
_llRunning = true;
try {
const pws = getEffectivePwList();
for (const pw of pws) {
if (!pw) continue;
if (_errVisible()) { _clickRetry(); await _wait(400); } // 오류 상태면 폼 복원 후 재시도
let input = getInputEl(siteCfg.inputSel);
if (!input) break;
setNativeValue(input, pw);
await _wait(120);
const btn = document.querySelector(siteCfg.btnSel);
if (btn && !btn.disabled) btn.click();
else submitViaEnter(input);
// 결과 폴링: 오류박스 표시=오답, 폼(input) 사라짐=성공(리다이렉트). 최대 ~8초.
const deadline = Date.now() + 8000;
let verdict = 'timeout';
await _wait(300);
while (Date.now() < deadline) {
if (_errVisible()) { verdict = 'error'; break; } // 오류박스 표시 = 오답
if (!getInputEl(siteCfg.inputSel)) {
// 폼 사라짐 — 리다이렉트(성공)인지, 에러박스로 교체된 건지 유예 후 재확인
await _wait(300);
if (_errVisible()) { verdict = 'error'; break; }
if (!getInputEl(siteCfg.inputSel)) { verdict = 'success'; break; }
}
await _wait(250);
}
if (verdict === 'success') { _llDone = true; return; }
// error/timeout → 다음 비번(loop 처음에서 Try again으로 폼 복원)
}
} finally { _llRunning = false; }
}
const llObs = new MutationObserver(() => {
if (_llDone || _llRunning) return;
if (getInputEl(siteCfg.inputSel)) linklockTry();
});
llObs.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => { if (!_llRunning && !_llDone && getInputEl(siteCfg.inputSel)) linklockTry(); }, siteCfg.triggerDelay || 800);
}
}
/* ================================================================
flush / observe
================================================================ */
let pending = false, mo = null, _scrolledOnce = false, _kpIdx = -1, _wasWritePage = false, _urlFlushTimer = null;
let _settingsCaptureCleanup = null;
let _cardRmFlushes = 0, _cardRmWindow = 0; // 카드 제거 감지 재flush 폭주 방지(코네가 계속 떼어낼 때)
let _hoveredCard = null;
// 카드 호버 추적 (이벤트 위임). 본문·모아보기 패널 클론 모두 개별 대상 — 호버한 그 카드 기준으로
// w/s가 위/아래로 이동한다(요구4: 클론도 개별 인식).
document.addEventListener('mouseover', e => {
const card = e.target.closest && e.target.closest('.b64-link, .b64-product-link');
_hoveredCard = card || null;
}, { passive: true, capture: false });
function isOurNode(node) {
let n = node;
while (n) {
if (n.nodeType === Node.ELEMENT_NODE &&
(n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR))) return true;
n = n.parentNode;
}
return false;
}
function getContentRoots() {
// 글 작성 페이지면 아무것도 처리 안 함
if (isWritePage()) return { roots: new Set(), allRoots: new Set() };
// 우선순위 그룹: 상위 그룹에서 1개라도 매칭되면 하위 그룹은 건너뜀.
// kone.gg 가 #post-article 과 .article-body 를 별개 요소로 렌더링할 때
// 글 내용이 2배로 보이는 현상을 방지한다.
const PRIORITY_GROUPS = [
['#post-article', '#post-comment'],
['.article-body', 'div.fr-view.article-content', 'body div.article-body > div.fr-view'],
['p.text-sm.whitespace-pre-wrap', 'div.text-sm.whitespace-pre-wrap'],
];
// allRoots: 우선순위 불문 매칭된 모든 요소 → flushListTitles 전역 스캔 배제에 사용
const allRoots = new Set();
let candidates = new Set();
let activeFound = false;
for (const group of PRIORITY_GROUPS) {
const groupSet = new Set();
for (const sel of group) {
document.querySelectorAll(sel).forEach(el => {
// contenteditable="true" 조상 → 에디터 내부 → 제외
let anc = el;
while (anc) {
if (anc.contentEditable === 'true') return;
anc = anc.parentElement;
}
// 요소 내부에 Froala 에디터 잔존 → write→read 전환 중 → 제외
if (el.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) return;
groupSet.add(el);
allRoots.add(el); // 우선순위 무관하게 전체 수집
});
}
if (!activeFound && groupSet.size > 0) {
candidates = groupSet;
activeFound = true;
// break 제거: 낮은 우선순위 그룹도 allRoots 에 수집해야 하므로 계속 순회
}
}
// 중첩 제거: 다른 루트의 자손인 요소는 제외
const roots = new Set();
for (const el of candidates) {
let dominated = false;
for (const other of candidates) {
if (other !== el && other.contains(el)) { dominated = true; break; }
}
if (!dominated) roots.add(el);
}
return { roots, allRoots };
}
function flushListTitles(contentRoots, allContentRoots = contentRoots) {
if (!CFG.LIST_DECODE || !document.body) return;
CFG.LIST_SELECTORS.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
let anc = el;
while (anc) { if (allContentRoots.has(anc)) return; anc = anc.parentElement; }
walkAndProcess(el, processListNode);
});
});
const gw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const p = node.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
const tag = p.nodeName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
let anc = p;
while (anc) {
if (anc.nodeType === Node.ELEMENT_NODE) {
if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT; // Froala 에디터 내부 제외
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
if (allContentRoots.has(anc)) return NodeFilter.FILTER_REJECT;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
}
anc = anc.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const listNodes = []; let ln;
while ((ln = gw.nextNode())) {
const v = ln.nodeValue;
if (B64_TEST_RE.test(v) ||
/\b(?:RJ|BJ|VJ|RE|BE|VE)\s?\d{6,8}(?![0-9])/i.test(v) ||
/(꺼|거|ㄲㅓ|ㄱㅓ)\s?\d{6,8}/.test(v) ||
/(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*\d{4,10}/.test(v) ||
/\b(?:GC|FZ|FB)\s?\d{4,8}(?![0-9])/i.test(v))
listNodes.push(ln);
}
listNodes.forEach(processListNode);
}
/* ================================================================
제목 카드 삽입
CONTENT_SELECTORS 본문 영역이 감지될 때, 같은 컨테이너 내
h1/h2/h3 에서 RJ·Steam 코드를 찾아 본문 하단에 카드 바 추가
================================================================ */
function injectTitleCards(contentRoots) {
if (!CFG.LINK_CARD) return;
// CONTENT_SELECTORS가 매칭된 경우에만 실행 (목록 페이지 제외)
const roots = [...contentRoots].filter(r => !r.hasAttribute(TITLE_CARD_ATTR));
if (!roots.length) return;
// content root 밖의 heading 요소 수집
const allHeadings = [...document.querySelectorAll('h1, h2, h3')].filter(h => {
for (const r of contentRoots) if (r.contains(h)) return false;
return true;
});
for (const root of roots) {
root.setAttribute(TITLE_CARD_ATTR, '1');
if (!allHeadings.length) continue;
// root의 조상을 거슬러 올라가며 heading을 포함하는 가장 가까운 컨테이너 탐색
let scoped = allHeadings;
let anc = root.parentElement;
while (anc && anc !== document.body) {
const found = allHeadings.filter(h => anc.contains(h));
if (found.length) { scoped = found; break; }
anc = anc.parentElement;
}
// heading 텍스트에서 DLsite/Steam/크리에이터 코드 추출
const seen = new Set();
const cards = [];
for (const h of scoped) {
const hits = findAllMatches(h.textContent)
.filter(hit => hit.type === 'dlsite' || hit.type === 'steam' || hit.type === 'creator');
for (const hit of hits) {
const id = hit.type === 'steam' ? hit.appId : hit.code;
if (seen.has(id)) continue;
seen.add(id);
if (hit.type === 'dlsite') cards.push(makeDlsiteCard(hit.code));
else if (hit.type === 'steam') cards.push(makeSteamCard(hit.appId));
else cards.push(makeCreatorCard(hit.code));
}
}
if (!cards.length) continue;
// 카드 바 생성 후 본문 하단에 삽입
const bar = document.createElement('div');
bar.setAttribute(DONE_ATTR, '');
bar.className = 'b64-title-card-bar';
const label = document.createElement('div');
label.className = 'b64-title-card-label';
label.textContent = '📌 제목에서 발견된 작품';
bar.appendChild(label);
cards.forEach(c => bar.appendChild(c));
root.appendChild(bar);
}
}
/* ── 카드 키보드 탐색 (w/s, ↑/↓) ── */
function kpGetLinks() {
const sel = CFG.NAV_TARGET === 'products' ? '.b64-product-link'
: CFG.NAV_TARGET === 'both' ? '.b64-link, .b64-product-link'
: '.b64-link';
// ★모아보기 패널(#b64d-link-panel) 클론도 '개별 카드'로 순회 대상에 포함한다(요구4). 본문 카드와
// 모아보기 클론은 서로 독립된 카드로 인식돼야 하며(동시 선택/해제 금지), w/s는 문서 순서대로
// 본문·모아보기 카드를 하나씩 이동한다. (과거엔 클론을 제외+동일작품 동시 하이라이트로 묶었으나,
// 본문 카드 선택 시 모아보기 선택이 풀리는 문제로 복원.)
return Array.from(document.querySelectorAll(sel));
}
// 포커스 적용: 기존 포커스 해제 후 '그 카드 하나만' 하이라이트(요구4 — 개별 인식).
function _setKpFocus(cardEl) {
document.querySelectorAll(KP_FOCUSED_SEL).forEach(el => el.classList.remove('kp-focused'));
if (cardEl) cardEl.classList.add('kp-focused');
}
// W/S 선택 상태를 완전히 해제(선택 인덱스 + 하이라이트). 미리보기를 바깥클릭으로 끌 때 호출해
// '선택이 안 풀린 채 남아 다음 w/s가 안 먹던' 문제를 없앤다(요구4).
function _clearKpNav() {
_kpIdx = -1;
document.querySelectorAll(KP_FOCUSED_SEL).forEach(el => el.classList.remove('kp-focused'));
}
// W/S 선택 시 그 카드의 미리보기를 자동으로 띄움(옵션). 미리보기 훅이 달린 카드만 대상.
let _kpPreviewTimer = null;
function kpPreviewPos(cardEl) {
// 좌표를 화면 안으로 클램프 → 카드가 화면 밖/가장자리여도 미리보기가 안 보이거나 끝에 걸치지 않게.
const r = cardEl.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight;
return {
clientX: Math.min(Math.max(r.right + 4, 8), vw - 8),
clientY: Math.min(Math.max(r.top + 4, 8), vh - 8),
};
}
function kpMaybePreview(cardEl) {
clearTimeout(_kpPreviewTimer);
if (!CFG.KP_PREVIEW || !CFG.DLSITE_PREVIEW) return;
const fixed = CFG.KP_PREVIEW_POS && CFG.KP_PREVIEW_POS !== 'card'; // 좌/우/가운데 고정 모드
const hooked = cardEl && cardEl.dataset && cardEl.dataset._dlpHooked;
// 고정 모드에서 '훅 카드 → 훅 카드' 이동은 박스를 닫지 않고 제자리 갱신(reuse) → 깜빡임 제거.
// 그 외('card' 모드/훅 없는 카드)는 종전대로 즉시 닫고 디바운스 후 다시.
const reuse = fixed && hooked && dlp.showing && dlp._kp;
if (!reuse && dlp.showing) dlpHide();
if (!hooked) return;
_kpPreviewTimer = setTimeout(() => {
if (!CFG.KP_PREVIEW || !CFG.DLSITE_PREVIEW) return;
if (!document.contains(cardEl) || !cardEl.classList.contains('kp-focused')) return; // 여전히 이 카드가 포커스일 때만
const p = kpPreviewPos(cardEl);
dlpShow({ clientX: p.clientX, clientY: p.clientY, currentTarget: cardEl, _kp: true, _kpReuse: fixed && dlp.showing && dlp._kp });
}, 130); // 짧게 → 마우스 호버에 가깝게 반응(연타 중엔 안 뜨고, 멈추면 즉시)
}
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; // 경계에서 정지 (순환 없음)
}
_kpIdx = next;
_setKpFocus(links[_kpIdx]);
links[_kpIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
kpMaybePreview(links[_kpIdx]);
return true;
}
// capture:true — 버블링 단계에서 사이트가 이벤트를 막아도 동작하도록
window.addEventListener('keydown', e => {
const tg = e.target;
if (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA' || tg.isContentEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (document.getElementById('b64d-settings') || document.getElementById('b64-dup-overlay')) return;
const isDown = e.code === 'KeyS' || e.key === 'ArrowDown';
const isUp = e.code === 'KeyW' || e.key === 'ArrowUp';
if (isDown || isUp) {
// 마우스가 카드 위에 있으면 그 카드 기준으로 바로 위/아래 작품으로 이동
if (_hoveredCard && document.contains(_hoveredCard)) {
const allCards = kpGetLinks();
const hovIdx = allCards.indexOf(_hoveredCard);
if (hovIdx >= 0) {
if (_kpIdx !== hovIdx) {
// 첫 번째 키: 포커스가 다른 곳에 있으면 먼저 호버 카드로 이동만
_kpIdx = hovIdx;
} else {
// 두 번째 키: 이미 호버 카드에 포커스 → 방향대로 한 칸 이동
_kpIdx = isDown
? Math.min(hovIdx + 1, allCards.length - 1)
: Math.max(hovIdx - 1, 0);
}
_setKpFocus(allCards[_kpIdx]);
allCards[_kpIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
kpMaybePreview(allCards[_kpIdx]);
e.preventDefault();
}
return;
}
if (isDown) { if (kpNavigate(1)) e.preventDefault(); }
else { if (kpNavigate(-1)) e.preventDefault(); }
} else if (e.key === 'Enter' && _kpIdx >= 0) {
const links = kpGetLinks();
if (links[_kpIdx]) { links[_kpIdx].click(); e.preventDefault(); }
}
}, { capture: true, passive: false });
const WRITE_PAGE_RE = /kone\.gg\/s\/[^/]+(?:\/[^/]+)?\/write(?:\/|[?#]|$)/;
function isWritePage(href = location.href) {
return WRITE_PAGE_RE.test(href);
}
// kone.gg OG 프리뷰 카드(not-prose h-24)를 CSS로 숨김
/* ================================================================
채널 대문 TXT 중복 검사
================================================================ */
const DUP_HIST_KEY = 'b64d_dup_hist';
const DUP_HIST_MAX = 20;
function getPageCardCodes() {
const codes = new Set();
document.querySelectorAll('a.b64-product-link').forEach(a => {
const code = a.dataset.cardCode;
if (code) codes.add(code.toUpperCase());
});
return codes;
}
function getDupHist() {
try { return JSON.parse(GM_getValue(DUP_HIST_KEY, '[]')); } catch { return []; }
}
function saveDupHist(entry) {
const h = getDupHist();
h.unshift(entry);
if (h.length > DUP_HIST_MAX) h.length = DUP_HIST_MAX;
GM_setValue(DUP_HIST_KEY, JSON.stringify(h));
}
function extractCodesFromTxt(text) {
// 쉼표·줄바꿈으로 항목 분리 후 각각에서 정규 식별자 추출
const items = text.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
const codes = [];
for (const raw of items) {
let m, id = null;
if ((m = raw.match(/\b(RJ|BJ|VJ|RE|BE|VE)\s?(\d{6,8})\b/i)))
id = m[1].toUpperCase() + m[2];
else if ((m = raw.match(/store\.steampowered\.com\/app\/(\d{4,10})/)))
id = 'ST' + m[1];
else if ((m = raw.match(/\bST(\d{4,10})\b/i)))
id = 'ST' + m[1];
else if ((m = raw.match(/\bGC(\d+)\b/i)))
id = 'GC' + m[1];
else if ((m = raw.match(/getchu\.com\/(?:soft\.phtml.*?id=|item\/)(\d+)/)))
id = 'GC' + m[1];
else if ((m = raw.match(/\bFZ(\w+)\b/i)))
id = 'FZ' + m[1].toUpperCase();
else if ((m = raw.match(/dmm\.co\.jp.*?\/cid=(?:d_)?(\w+)/)))
id = 'FZ' + m[1].toUpperCase();
else if ((m = raw.match(/\bFB(\d+)\b/i)))
id = 'FB' + m[1];
else if ((m = raw.match(/fanbox\.cc\/posts\/(\d+)/)))
id = 'FB' + m[1];
else if ((m = raw.match(/([a-z0-9_-]+)\.fanbox\.cc/i)) && m[1].toLowerCase() !== 'www')
id = 'FANBOX:' + m[1].toLowerCase();
else if ((m = raw.match(/patreon\.com\/(?:c\/)?([^/?#\s]+)/i)))
id = 'PATREON:' + m[1].toLowerCase();
else if (/^https?:\/\//.test(raw)) {
try {
const u = new URL(raw.trim());
id = 'URL:' + u.hostname.replace(/^www\./, '') + u.pathname.replace(/\/$/, '');
} catch { id = null; }
} else if (raw.length >= 4 && !/\s/.test(raw))
id = raw.toUpperCase();
if (id) codes.push(id);
}
return codes;
}
function findDupCodes(codes) {
const cnt = {};
for (const c of codes) cnt[c] = (cnt[c] || 0) + 1;
return Object.entries(cnt).filter(([, n]) => n > 1).map(([code, count]) => ({ code, count }));
}
function showDupModal() {
if (document.getElementById('b64-dup-overlay')) return;
dlpHide();
const overlay = document.createElement('div');
overlay.id = 'b64-dup-overlay';
const modal = document.createElement('div');
modal.id = 'b64-dup-modal';
if (CFG.SETTINGS_SCALE !== 100) modal.style.zoom = CFG.SETTINGS_SCALE / 100;
// Header
const hdr = document.createElement('div');
hdr.className = 'dup-hdr';
const hdrTitle = document.createElement('span');
hdrTitle.textContent = '📋 TXT 중복 검사';
const closeBtn = document.createElement('button');
closeBtn.className = 'dup-x'; closeBtn.type = 'button'; closeBtn.textContent = '✕';
closeBtn.addEventListener('click', () => overlay.remove());
hdr.append(hdrTitle, closeBtn);
// Body
const body = document.createElement('div');
body.className = 'dup-body';
// File picker
const pickRow = document.createElement('div');
pickRow.className = 'dup-pick';
const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.accept = '.txt,text/plain'; fileInput.style.display = 'none';
const pickBtn = document.createElement('button');
pickBtn.className = 'dup-file-btn'; pickBtn.type = 'button'; pickBtn.textContent = 'TXT 파일 선택';
const fileLabel = document.createElement('span');
fileLabel.className = 'dup-file-name'; fileLabel.textContent = '파일 미선택';
pickRow.append(fileInput, pickBtn, fileLabel);
pickBtn.addEventListener('click', () => fileInput.click());
// Result area
const resultEl = document.createElement('div');
resultEl.className = 'dup-result';
resultEl.innerHTML = '<span class="dup-empty">파일을 선택하면 결과가 표시됩니다.</span>';
function scrollToCard(code) {
const sel = `a.b64-product-link[data-card-code="${code.replace(/"/g, '\\"')}"]`;
const el = document.querySelector(sel);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.remove('b64-card-flash');
void el.offsetWidth; // reflow to restart animation
el.classList.add('b64-card-flash');
el.addEventListener('animationend', () => el.classList.remove('b64-card-flash'), { once: true });
}
// 결과 목록 페이저: 모달 높이를 일정하게 유지하기 위해 긴 목록은 페이지로 나눔
const SEC_PAGE = 15; // 섹션당 페이지 항목 수
function buildPager(totalPages, getCur, onGo) {
const el = document.createElement('div');
el.className = 'dup-result-pager';
function refresh() {
el.innerHTML = '';
const cur = getCur();
const prev = document.createElement('button');
prev.type = 'button'; prev.className = 'dup-hist-pager-btn'; prev.textContent = '←';
prev.disabled = cur <= 0; prev.addEventListener('click', () => onGo(cur - 1));
el.appendChild(prev);
const nums = document.createElement('div'); nums.className = 'dup-hist-pager-pages';
const win = 5;
let e = Math.min(totalPages, Math.max(cur + 3, win));
let s = Math.max(0, e - win); e = Math.min(totalPages, s + win);
for (let p = s; p < e; p++) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'dup-hist-pager-num' + (p === cur ? ' active' : '');
b.textContent = (p + 1); b.addEventListener('click', () => onGo(p));
nums.appendChild(b);
}
el.appendChild(nums);
const next = document.createElement('button');
next.type = 'button'; next.className = 'dup-hist-pager-btn'; next.textContent = '→';
next.disabled = cur >= totalPages - 1; next.addEventListener('click', () => onGo(cur + 1));
el.appendChild(next);
}
refresh();
return { el, refresh };
}
function makeSection(titleText, titleClass, items, badgeFn, clickFn) {
const wrap = document.createElement('div');
const t = document.createElement('div');
t.className = 'dup-section-title' + (titleClass ? ' ' + titleClass : '');
t.textContent = titleText;
wrap.appendChild(t);
if (!items.length) {
const e = document.createElement('span');
e.className = 'dup-empty'; e.textContent = '없음';
wrap.appendChild(e);
return wrap;
}
const ul = document.createElement('ul');
ul.className = 'dup-list';
wrap.appendChild(ul);
const totalPages = Math.ceil(items.length / SEC_PAGE);
let cur = 0;
const renderPage = () => {
ul.innerHTML = '';
items.slice(cur * SEC_PAGE, (cur + 1) * SEC_PAGE).forEach(item => {
const li = document.createElement('li');
li.innerHTML = badgeFn(item);
if (clickFn) {
li.classList.add('dup-goto');
li.title = '클릭하면 해당 카드로 이동';
li.addEventListener('click', () => clickFn(item));
}
ul.appendChild(li);
});
};
renderPage();
if (totalPages > 1) {
const pager = buildPager(totalPages, () => cur, (p) => {
cur = Math.max(0, Math.min(totalPages - 1, p));
renderPage(); pager.refresh();
});
wrap.appendChild(pager.el);
}
return wrap;
}
function renderEntry(entry) {
resultEl.innerHTML = '';
if (!entry) { resultEl.innerHTML = '<span class="dup-empty">파일을 선택하면 결과가 표시됩니다.</span>'; return; }
const hasPage = Array.isArray(entry.pageCodes) && entry.pageCodes.length > 0;
const sumEl = document.createElement('div');
sumEl.className = 'dup-summary';
sumEl.textContent = hasPage
? `TXT ${entry.total}개 · 페이지 카드 ${entry.pageCodes.length}개`
: `TXT ${entry.total}개 (${entry.unique}개 고유)`;
resultEl.appendChild(sumEl);
if (hasPage) {
const owned = entry.owned || [];
const notOwned = entry.notOwned || [];
if (entry.url) {
let path = '';
try { path = new URL(entry.url).pathname.replace(/\/$/, '') || '/'; } catch {}
const linkBtn = document.createElement('a');
linkBtn.className = 'dup-page-link';
linkBtn.href = entry.url;
linkBtn.target = '_blank';
linkBtn.rel = 'noopener noreferrer';
linkBtn.textContent = '↗ ' + (path || entry.url);
resultEl.appendChild(linkBtn);
}
resultEl.appendChild(makeSection(
`미보유 / 새 항목 (${notOwned.length}개)`, 'dup-new-title', notOwned,
c => `<span class="dup-code">${esc(c)}</span>`,
c => scrollToCard(c)
));
resultEl.appendChild(makeSection(
`이미 보유 (${owned.length}개)`, 'dup-owned-title', owned,
c => `<span class="dup-code">${esc(c)}</span><span class="dup-badge-ok">✓</span>`,
c => scrollToCard(c)
));
if (entry.duplicates && entry.duplicates.length) {
resultEl.appendChild(makeSection(
`TXT 내 중복 (${entry.duplicates.length}건)`, '', entry.duplicates,
({ code, count }) => `<span class="dup-code">${esc(code)}</span><span class="dup-badge">×${count}</span>`
));
}
} else {
const noteEl = document.createElement('span');
noteEl.className = 'dup-empty';
noteEl.textContent = '현재 페이지에 작품 카드 없음 — TXT 내 중복만 검사';
resultEl.appendChild(noteEl);
if (!entry.duplicates.length) {
const okEl = document.createElement('div');
okEl.className = 'dup-ok'; okEl.textContent = '✅ 중복 없음';
resultEl.appendChild(okEl);
} else {
resultEl.appendChild(makeSection(
`중복 ${entry.duplicates.length}건`, 'dup-label', entry.duplicates,
({ code, count }) => `<span class="dup-code">${esc(code)}</span><span class="dup-badge">×${count}</span>`
));
}
}
}
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
fileLabel.textContent = file.name;
const reader = new FileReader();
reader.onload = ev => {
const codes = extractCodesFromTxt(ev.target.result);
const dups = findDupCodes(codes);
const txtSet = new Set(codes.map(c => c.toUpperCase()));
const pageSet = getPageCardCodes();
const owned = [...pageSet].filter(c => txtSet.has(c));
const notOwned = [...pageSet].filter(c => !txtSet.has(c));
const entry = {
file: file.name,
date: new Date().toISOString(),
url: location.href,
total: codes.length,
unique: new Set(codes).size,
duplicates: dups,
allCodes: codes,
pageCodes: [...pageSet],
owned,
notOwned,
};
GM_setValue('libCodes', JSON.stringify([...new Set(codes.map(c => c.toUpperCase()))]));
saveDupHist(entry);
renderEntry(entry);
renderHist();
if (CFG.TXT_LIVE) applyTxtLiveColors();
};
reader.readAsText(file, 'utf-8');
});
// History sidebar
const histList = document.createElement('div');
histList.className = 'dup-hist-list';
function renderDetail(entry) {
let sec = resultEl.querySelector('.dup-detail-section');
if (sec) { sec.remove(); return; }
sec = document.createElement('div');
sec.className = 'dup-detail-section';
const title = document.createElement('div');
title.className = 'dup-detail-title';
title.textContent = `전체 항목 빈도 (${(entry.allCodes || []).length}개)`;
sec.appendChild(title);
if (!entry.allCodes || !entry.allCodes.length) {
const empty = document.createElement('span');
empty.className = 'dup-empty';
empty.textContent = '상세 데이터 없음 (이전 버전 기록)';
sec.appendChild(empty);
} else {
const cnt = {};
for (const c of entry.allCodes) cnt[c] = (cnt[c] || 0) + 1;
const sorted = Object.entries(cnt).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const ul = document.createElement('ul');
ul.className = 'dup-list';
sorted.forEach(([code, count]) => {
const li = document.createElement('li');
if (count < 2) li.style.opacity = '0.5';
const badge = count > 1 ? `<span class="dup-badge">${count}</span>` : '';
li.innerHTML = `<span class="dup-code">${esc(code)}</span>${badge}`;
ul.appendChild(li);
});
sec.appendChild(ul);
}
resultEl.appendChild(sec);
}
let _selRow = null;
const HIST_PAGE_SIZE = 9; // 페이지당 '항목(기록)' 수. 날짜 헤더는 별도이므로 고정 높이에 여유 있게.
const pagerEl = document.createElement('div');
pagerEl.className = 'dup-hist-pager';
function renderHist(q, page) {
page = page ?? 0;
histList.innerHTML = ''; _selRow = null;
const allEntries = getDupHist();
if (!allEntries.length) { histList.innerHTML = '<span class="dup-empty">기록 없음</span>'; pagerEl.innerHTML = ''; return; }
const now = new Date();
const todayKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
const yest = new Date(now); yest.setDate(now.getDate() - 1);
const yestKey = `${yest.getFullYear()}-${yest.getMonth()}-${yest.getDate()}`;
function makeGroup(dt) {
const yy = String(dt.getFullYear()).slice(-2);
const mm = String(dt.getMonth()+1).padStart(2,'0');
const dd = String(dt.getDate()).padStart(2,'0');
const m = dt.getMonth()+1, d = dt.getDate();
const dateCode = `${yy}.${mm}.${dd}`;
const k = `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
let label;
if (k === todayKey) label = `오늘 · ${dateCode}`;
else if (k === yestKey) label = `어제 · ${dateCode}`;
else label = dateCode;
const searchKey = `${label} ${dateCode} ${m}월 ${d}일 ${dt.getFullYear()}년 ${m}월 ${d}일`.toLowerCase();
return { k, label, searchKey };
}
const groupMap = new Map();
allEntries.forEach(entry => {
const dt = new Date(entry.date);
const g = makeGroup(dt);
if (!groupMap.has(g.k)) groupMap.set(g.k, { label: g.label, searchKey: g.searchKey, rows: [] });
groupMap.get(g.k).rows.push({ entry, dt });
});
// 항목(기록)만 평탄화 — 각 항목에 소속 날짜그룹(k/label)을 달아둔다.
// 페이지네이션을 '항목 수' 기준으로 하면, 페이지가 날짜그룹 중간에서 시작해도
// 렌더 시 그룹 변화마다(+ 페이지 첫 항목 앞) 날짜 헤더를 찍어 항상 날짜가 보인다.
const filter = (q || '').trim().toLowerCase();
const entryItems = [];
groupMap.forEach(({ label, searchKey, rows }, k) => {
if (filter && !searchKey.includes(filter)) return;
rows.forEach(({ entry, dt }) => entryItems.push({ entry, dt, label, k }));
});
if (!entryItems.length) { histList.innerHTML = '<span class="dup-empty">검색 결과 없음</span>'; pagerEl.innerHTML = ''; return; }
// 날짜헤더/행 생성 헬퍼
const makeHeader = (label) => {
const g = document.createElement('div');
g.className = 'dup-hist-date-group'; g.textContent = label; return g;
};
const makeRow = (entry, dt) => {
const row = document.createElement('button');
row.type = 'button'; row.className = 'dup-hist-row';
const timeStr = `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`;
const statText = (entry.pageCodes && entry.pageCodes.length)
? `보유 ${(entry.owned||[]).length}·신규 ${(entry.notOwned||[]).length}`
: `중복 ${(entry.duplicates||[]).length}건`;
if (entry.url) row.title = entry.url;
row.innerHTML = `<span class="dup-hist-file">${esc(entry.file)}</span><span class="dup-hist-meta"><span class="dup-hist-date">${esc(timeStr)}</span><span class="dup-hist-stat">${esc(statText)}</span></span>`;
row.addEventListener('click', () => {
if (_selRow === row) { renderDetail(entry); }
else {
if (_selRow) _selRow.classList.remove('dup-hist-sel');
_selRow = row; row.classList.add('dup-hist-sel');
renderEntry(entry); fileLabel.textContent = entry.file;
}
});
return row;
};
// 페이지 경계를 '실제 렌더 높이'로 계산(추정 X) → 날짜 헤더·gap·margin까지 정확 반영, 스크롤 0.
// histList에 항목을 직접 붙여보며 scrollHeight가 clientHeight를 넘는 순간을 페이지 경계로 삼는다.
// 모달 append 전 첫 렌더는 clientHeight=0 → 폴백(고정 개수). append 후 rAF 재렌더에서 정확히 다시 나눔.
const avail = histList.clientHeight || 0;
const pages = []; // 각 페이지 = [start, end)
if (avail >= 60) {
histList.innerHTML = '';
let start = 0, lastKp = null;
for (let i = 0; i < entryItems.length; i++) {
const it = entryItems[i];
const hdr = it.k !== lastKp ? makeHeader(it.label) : null;
if (hdr) histList.appendChild(hdr);
const row = makeRow(it.entry, it.dt);
histList.appendChild(row);
if (i > start && histList.scrollHeight > avail) {
// 넘침 → 이 항목은 다음 페이지로. 방금 붙인 요소 제거 후 페이지 확정.
histList.removeChild(row);
if (hdr) histList.removeChild(hdr);
pages.push([start, i]);
histList.innerHTML = '';
start = i; lastKp = null; i--; // 이 항목을 새 페이지에서 다시
} else {
lastKp = it.k;
}
}
pages.push([start, entryItems.length]);
histList.innerHTML = '';
} else {
for (let s = 0; s < entryItems.length; s += HIST_PAGE_SIZE) pages.push([s, Math.min(s + HIST_PAGE_SIZE, entryItems.length)]);
}
const totalPages = pages.length || 1;
const cur = Math.min(page, totalPages - 1);
let lastK = null;
const [ps, pe] = pages[cur] || [0, 0];
for (let i = ps; i < pe; i++) {
const it = entryItems[i];
if (it.k !== lastK) { histList.appendChild(makeHeader(it.label)); lastK = it.k; }
histList.appendChild(makeRow(it.entry, it.dt));
}
pagerEl.innerHTML = '';
if (totalPages <= 1) return;
const prev = document.createElement('button');
prev.type = 'button'; prev.className = 'dup-hist-pager-btn'; prev.textContent = '←';
prev.disabled = cur === 0;
prev.addEventListener('click', () => renderHist(q, cur - 1));
const pageNums = document.createElement('div');
pageNums.className = 'dup-hist-pager-pages';
let lo = Math.max(0, cur - 2), hi = Math.min(totalPages - 1, lo + 4);
lo = Math.max(0, hi - 4);
for (let p = lo; p <= hi; p++) {
const nb = document.createElement('button');
nb.type = 'button'; nb.className = `dup-hist-pager-num${p === cur ? ' active' : ''}`;
nb.textContent = p + 1;
const _p = p; nb.addEventListener('click', () => renderHist(q, _p));
pageNums.appendChild(nb);
}
const next = document.createElement('button');
next.type = 'button'; next.className = 'dup-hist-pager-btn'; next.textContent = '→';
next.disabled = cur === totalPages - 1;
next.addEventListener('click', () => renderHist(q, cur + 1));
pagerEl.append(prev, pageNums, next);
}
renderHist();
const sidebar = document.createElement('div');
sidebar.className = 'dup-sidebar';
const sidebarTitle = document.createElement('div');
sidebarTitle.className = 'dup-sidebar-title';
sidebarTitle.textContent = '이전 기록';
const histSearch = document.createElement('input');
histSearch.type = 'text';
histSearch.className = 'dup-hist-search';
histSearch.placeholder = '날짜 검색 (26.05, 오늘…)';
histSearch.setAttribute('autocomplete', 'off');
histSearch.addEventListener('input', () => renderHist(histSearch.value));
sidebar.append(sidebarTitle, histSearch, histList, pagerEl);
const layout = document.createElement('div');
layout.className = 'dup-layout';
body.append(pickRow, resultEl);
layout.append(sidebar, body);
modal.append(hdr, layout);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// append 후 histList가 실제 높이를 가질 때까지 기다렸다가 측정값으로 페이지를 다시 나눈다
// (clientHeight가 0인 동안 폴백 고정개수로 그리면 넘쳐서 스크롤이 생기므로, 측정 가능해질 때 1회 재렌더).
let _histSettle = 0;
(function settle() {
if (!document.getElementById('b64-dup-overlay')) return;
if (histList.clientHeight >= 60 || _histSettle >= 12) { renderHist(histSearch.value); return; }
_histSettle++; requestAnimationFrame(settle);
})();
window.addEventListener('resize', () => { if (document.getElementById('b64-dup-overlay')) renderHist(histSearch.value); });
overlay.addEventListener('click', ev => { if (ev.target === overlay) overlay.remove(); });
document.addEventListener('keydown', function escFn(ev) {
if (ev.key === 'Escape' && document.getElementById('b64-dup-overlay')) {
overlay.remove(); document.removeEventListener('keydown', escFn);
}
});
}
function injectDupCheckBtn() {
if (document.getElementById('b64-dup-btn')) return;
// 구독·서브 정보 버튼 그룹 우선, fallback: h1 기준 탐색
const anchor = document.querySelector(
'button[aria-label="서브 정보"], button[aria-label*="구독"]'
);
let btnArea = anchor?.parentElement;
if (!btnArea) {
const h1 = document.querySelector('main h1');
btnArea = h1?.parentElement?.parentElement?.children?.[1];
}
if (!btnArea) return;
const btn = document.createElement('button');
btn.id = 'b64-dup-btn'; btn.type = 'button'; btn.title = 'TXT 중복 검사';
btn.innerHTML = svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>');
btn.addEventListener('click', showDupModal);
btnArea.prepend(btn);
}
// inline style은 Svelte 재렌더링에 사라지지만 <head> CSS 규칙은 살아남음
function injectOGHideStyle() {
if (!document.head || document.getElementById('b64d-og-hide')) return;
const s = document.createElement('style');
s.id = 'b64d-og-hide';
s.textContent = 'a.not-prose.h-24.overflow-hidden{display:none!important}';
document.head.appendChild(s);
}
function applyTxtLiveColors() {
const cards = document.querySelectorAll('a.b64-product-link');
if (!CFG.TXT_LIVE) {
cards.forEach(a => a.classList.remove('b64-tl-owned', 'b64-tl-new'));
return;
}
let lib;
try { lib = new Set(JSON.parse(GM_getValue('libCodes', '[]'))); } catch { lib = new Set(); }
cards.forEach(a => {
const code = a.dataset.cardCode?.toUpperCase();
a.classList.remove('b64-tl-owned', 'b64-tl-new');
if (code) a.classList.add(lib.has(code) ? 'b64-tl-owned' : 'b64-tl-new');
});
}
/* ================================================================
댓글 자동 펼치기 (설정: EXPAND_REPLIES / EXPAND_MORE)
- "답글 N개" 클릭 → "답글 숨기기"로 글자가 바뀌어 재클릭 안 됨 (마킹 병행)
- "댓글 더 보기"는 같은 버튼이 남을 수 있어 타이머로 1회씩 페이싱
- 클릭 → DOM 변화 → MO → flush → 재호출되며 중첩 답글까지 이어서 펼침
- 버튼은 class가 아니라 글자로 매칭 (Svelte 재렌더에 강함)
================================================================ */
let _expandClicks = 0; // 페이지당 누적 클릭 (폭주 방지용 상한)
let _moreTimer = null; // "댓글 더 보기" 페이싱 타이머
const EXPAND_CAP = 200; // 안전 상한
function autoExpandComments() {
if (!CFG.EXPAND_REPLIES && !CFG.EXPAND_MORE) return;
const root = document.getElementById('post-comment');
if (!root) return;
if (_expandClicks > EXPAND_CAP) return;
let moreBtn = null, replyClicks = 0;
for (const b of root.querySelectorAll('button, a, [role="button"]')) {
const t = (b.textContent || '').replace(/\s+/g, ' ').trim();
if (!t) continue;
// 실제 이동 링크(href 있는 <a>)는 클릭 시 페이지 이탈 위험 → 제외
if (b.tagName === 'A') {
const href = b.getAttribute('href') || '';
if (href && href !== '#' && !/^javascript:/i.test(href)) continue;
}
// 대댓글 펼치기: "답글 N개" — "답글 숨기기"(이미 펼침)는 제외
if (CFG.EXPAND_REPLIES && !b.dataset._kpExpand
&& /답글/.test(t) && /\d/.test(t) && !/숨기기/.test(t)) {
b.dataset._kpExpand = '1';
b.click();
replyClicks++; _expandClicks++;
if (replyClicks >= 30) break; // 한 패스 제한 → 다음 flush에서 이어감
} else if (CFG.EXPAND_MORE && !moreBtn && t.replace(/\s/g, '') === '댓글더보기') {
moreBtn = b;
}
}
// "댓글 더 보기": 타이머 없을 때만 1회 클릭 후 로드 대기 → 과도한 연타·중복 로드 방지
if (moreBtn && !_moreTimer) {
moreBtn.click();
_expandClicks++;
_moreTimer = setTimeout(() => { _moreTimer = null; autoExpandComments(); }, 700);
}
}
/* ================================================================
StegCloak 호환 엔진 (요구2) — 비번(채널 비번)으로 비밀 메시지를 제로폭 문자에 숨겨 cover 문장에 심고,
읽을 때 역으로 복원한다. SubtleCrypto로 StegCloak(1.x) 포맷과 상호운용:
· 압축 lzutf8 → 보수(compliment, ~byte) → PBKDF2(salt8,10k,SHA-512,48B→IV16+Key32) →
AES-256-CTR → (무결성 시 HMAC-SHA256) → salt+hmac?+ct → byte→2bit→zwc(0..3), flag=zwc[0/1/2].
· zwc 6종=[200c,200d,2061,2062,2063,2064]. shrink(반복쌍 압축)은 복호(expand)만 완전 지원,
부호화는 무압축(no-op) — 실제 StegCloak가 그대로 복원 가능(상호운용 검증 완료).
· 부호화 압축은 리터럴 전용(유효 lzutf8) — 실제 StegCloak.decompress가 그대로 읽음.
※ 실제 도구 샘플('best of best'/somisoft)로 역산 검증 통과. */
const SC = (() => {
const ZWC = ['','','','','',''];
const _has = c => ZWC.indexOf(c) !== -1;
const _tableMap = () => [ZWC[0]+ZWC[1],ZWC[0]+ZWC[2],ZWC[0]+ZWC[3],ZWC[1]+ZWC[2],ZWC[1]+ZWC[3],ZWC[2]+ZWC[3]];
function _recReplace(data, patterns, repls) { for (let i = patterns.length-1; i >= 0; i--) data = data.split(patterns[i]).join(repls[i]); return data; }
function _expand(zwcStr) {
const chars = [...zwcStr], flag = chars[0], stream = chars.slice(1).join('');
const pair = _tableMap()[ZWC.indexOf(flag)].split('');
return _recReplace(stream, [ZWC[4],ZWC[5]], [pair[0]+pair[0], pair[1]+pair[1]]);
}
function _concealToData(zwcStr) {
const chars = [...zwcStr], fi = ZWC.indexOf(chars[0]);
const encrypt = fi === 0 || fi === 1, integrity = fi === 0;
let bin = '';
for (let i = 1; i < chars.length; i++) bin += ZWC.indexOf(chars[i]).toString(2).padStart(2,'0');
const bytes = [];
for (let i = 0; i + 8 <= bin.length; i += 8) bytes.push(parseInt(bin.slice(i, i+8), 2));
return { encrypt, integrity, data: new Uint8Array(bytes) };
}
function _dataToZWC(integrity, crypt, bytes) {
let bin = ''; for (const b of bytes) bin += b.toString(2).padStart(8,'0');
let out = (integrity && crypt) ? ZWC[0] : crypt ? ZWC[1] : ZWC[2];
for (let i = 0; i < bin.length; i += 2) out += ZWC[parseInt(bin.substr(i,2), 2)];
return out;
}
// lzutf8: 부호화는 리터럴 전용(UTF-8 바이트 그대로 = 유효 lzutf8). 복호는 매치 포인터까지 완전 지원.
function _lzCompress(str) { return new TextEncoder().encode(str); }
function _lzDecompress(input) {
const out = []; let op = 0; const push = v => { out[op++] = v; };
for (let rp = 0; rp < input.length; rp++) {
const v = input[rp];
if ((v >>> 6) !== 3) { push(v); continue; }
const seqId = v >>> 5;
if (rp === input.length-1 || (rp === input.length-2 && seqId === 7)) { push(v); continue; }
if ((input[rp+1] >>> 7) === 1) { push(v); }
else {
const mlen = v & 31; let md;
if (seqId === 6) { md = input[rp+1]; rp += 1; } else { md = (input[rp+1] << 8) | input[rp+2]; rp += 2; }
const mp = op - md;
for (let o = 0; o < mlen; o++) push(out[mp+o]);
}
}
return Uint8Array.from(out);
}
const _compliment = b => Uint8Array.from(b, x => (~x) & 0xff);
function _concat(arrs) { let n = 0; for (const a of arrs) n += a.length; const o = new Uint8Array(n); let p = 0; for (const a of arrs) { o.set(a, p); p += a.length; } return o; }
async function _pbkdf2(password, salt) {
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
return new Uint8Array(await crypto.subtle.deriveBits({ name:'PBKDF2', salt, iterations:10000, hash:'SHA-512' }, base, 48*8));
}
async function _aesCtr(keyBytes, iv, data, mode) {
const key = await crypto.subtle.importKey('raw', keyBytes, { name:'AES-CTR' }, false, ['encrypt','decrypt']);
const p = { name:'AES-CTR', counter:iv, length:128 };
return new Uint8Array(mode === 'encrypt' ? await crypto.subtle.encrypt(p, key, data) : await crypto.subtle.decrypt(p, key, data));
}
async function _hmac(keyBytes, data) {
const key = await crypto.subtle.importKey('raw', keyBytes, { name:'HMAC', hash:'SHA-256' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, data));
}
async function _decrypt(dataBytes, password, integrity) {
const salt = dataBytes.slice(0,8);
let hmacData = null, secret;
if (integrity) { hmacData = dataBytes.slice(8,40); secret = dataBytes.slice(40); } else { secret = dataBytes.slice(8); }
const ivKey = await _pbkdf2(password, salt);
const decrypted = await _aesCtr(ivKey.slice(16,48), ivKey.slice(0,16), secret, 'decrypt');
if (integrity) { const v = await _hmac(ivKey.slice(16,48), decrypted); if (hmacData.length !== v.length || !v.every((b,i) => b === hmacData[i])) throw new Error('HMAC 무결성 실패 (비번 또는 데이터 오류)'); }
return decrypted;
}
async function _encrypt(secret, password, integrity) {
const salt = crypto.getRandomValues(new Uint8Array(8));
const ivKey = await _pbkdf2(password, salt);
const ct = await _aesCtr(ivKey.slice(16,48), ivKey.slice(0,16), secret, 'encrypt');
if (integrity) return _concat([salt, await _hmac(ivKey.slice(16,48), secret), ct]);
return _concat([salt, ct]);
}
// 텍스트에서 zwc를 모두 순서대로 수집해 복원(도구별 분산 방식 무관). 반환: 비밀 문자열.
async function reveal(text, password) {
const stream = [...text].filter(_has).join('');
if (!stream) throw new Error('숨겨진 스트림이 없습니다');
const { encrypt, integrity, data } = _concealToData(_expand(stream));
const dec = encrypt ? await _decrypt(data, password, integrity) : data;
return new TextDecoder().decode(_lzDecompress(_compliment(dec)));
}
// 비밀(message)을 cover 문장에 숨긴다(2단어 이상). integrity=HMAC 무결성.
async function hide(message, password, cover, integrity = true) {
const arr = String(cover).split(' ');
if (arr.length < 2) throw new Error('cover 문장은 최소 2단어가 필요합니다');
const payload = await _encrypt(_compliment(_lzCompress(message)), password, integrity);
const zwcStream = ZWC[0] + _dataToZWC(integrity, true, payload); // no-op shrink(flag zwc0)+conceal
return arr.slice(0,1).concat([zwcStream + arr[1]]).concat(arr.slice(2)).join(' ');
}
const hasHidden = text => [...String(text)].some(_has);
return { reveal, hide, hasHidden, ZWC };
})();
/* ── 코드(StegCloak) 인코딩 팝오버 (요구2) ── 겉보기 문장(cover)에 비밀을 숨겨 반환. 채널 비번 사용. */
// cover 문장 풀(다양화) — 팝오버 열 때마다 무작위 1개를 기본 선택해 매번 겉보기가 달라지게 한다.
const STEG_COVERS = [
{ label: '일상 · 날씨', text: '오늘 날씨가 꽤 괜찮아서 잠깐 산책이라도 다녀올까 고민 중이에요.' },
{ label: '일상 · 음식', text: '방금 간단하게 요기했는데 생각보다 훨씬 맛있어서 좀 놀랐네요.' },
{ label: '일상 · 주말', text: '이번 주말엔 별다른 계획 없이 그냥 집에서 푹 쉬려고 합니다.' },
{ label: '인사 · 안부', text: '안녕하세요 오늘도 무리하지 마시고 좋은 하루 보내시길 바랍니다.' },
{ label: '인사 · 감사', text: '늘 챙겨주셔서 정말 감사드리고 다음에 꼭 다시 인사드리겠습니다.' },
{ label: '공지 · 안내', text: '문의해 주신 내용은 확인하는 대로 순차적으로 안내해 드리겠습니다.' },
{ label: 'EN · casual', text: 'Just finished my coffee and the whole morning already feels a bit brighter than before.' },
{ label: 'EN · weather', text: 'The sky looks clear today so maybe a short quiet walk would be a nice idea.' },
{ label: 'EN · greeting',text: 'Hello there, I really hope you are having a calm and pleasant day so far.' },
{ label: 'EN · quote', text: 'Every long journey always begins with a single small and quiet step forward.' },
{ label: 'Lorem ipsum', text: 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore.' },
];
// 로컬 cover 문장 생성기(템플릿+단어뱅크) — API/네트워크 없이 자연스러운 겉보기 문장을 즉석 생성('AI 생성' 대체).
function stegGenerateCover() {
const p = a => a[Math.floor(Math.random() * a.length)];
const KO = [
() => `${p(['오늘','요즘','방금','아까'])} ${p(['간단하게','오랜만에','대충'])} ${p(['밥을','커피를','간식을','라면을'])} ${p(['먹었는데','마셨는데'])} ${p(['생각보다','정말','의외로'])} ${p(['괜찮았어요','맛있더라고요','좋았어요'])}.`,
() => `${p(['오늘','내일','주말에'])} ${p(['날씨가','바람이','하늘이'])} ${p(['맑아서','선선해서','포근해서'])} ${p(['산책하기','나가기','쉬기'])} ${p(['딱 좋은','참 좋은','괜찮은'])} 하루네요.`,
() => `${p(['안녕하세요','반갑습니다'])} 오늘도 ${p(['좋은','편안한','즐거운'])} 하루 ${p(['보내세요','되세요','보내시길 바랍니다'])}.`,
() => `${p(['이번 주는','요새는','한동안은'])} ${p(['좀 바빴는데','정신없었는데','여유로웠는데'])} 그래도 ${p(['버틸 만해요','괜찮네요','즐겁습니다'])}.`,
];
const EN = [
() => `The ${p(['sky','weather','morning','air'])} looks ${p(['clear','calm','bright','nice'])} today so maybe a short ${p(['walk','break','coffee'])} would be ${p(['nice','great','good'])}.`,
() => `Just ${p(['finished','made','had'])} my ${p(['coffee','lunch','tea'])} and the day already feels a bit ${p(['brighter','calmer','better'])}.`,
() => `I really hope you are having a ${p(['calm','pleasant','wonderful','good'])} and ${p(['quiet','relaxing','easy'])} day so far.`,
];
return p(Math.random() < 0.6 ? KO : EN)();
}
/* ================================================================
댓글 변환 툴바 (요구1) — 댓글 작성창(textarea)에 점자/base64 변환 버튼 추가.
선택 텍스트가 있으면 그 부분만, 없으면 전체를 변환해 그 자리에 되쓴다(코네 UI와 어울리는 소형 바).
================================================================ */
function ctoolConvert(ta, mode) {
const s = ta.selectionStart || 0, e = ta.selectionEnd || 0;
const hasSel = e > s;
const src = hasSel ? ta.value.slice(s, e) : ta.value;
if (!src) return;
// 코드(StegCloak)는 mountCommentTools의 인라인 칩 스트립에서 처리(ctoolConvert는 점자/base64만).
let out;
try {
if (mode === 'braille') out = BR.encode(src);
else if (mode === 'base64') out = btoa(unescape(encodeURIComponent(src)));
else return;
} catch (err) { return; }
const val = hasSel ? ta.value.slice(0, s) + out + ta.value.slice(e) : out;
setNativeValue(ta, val); // React/코네 textarea에 값+input 이벤트 반영
ta.focus();
const np = hasSel ? s + out.length : out.length;
try { ta.setSelectionRange(np, np); } catch (err) {}
}
function mountCommentTools() {
if (!CFG.COMMENT_TOOLS) return;
const root = document.getElementById('post-comment');
if (!root) return;
root.querySelectorAll('textarea').forEach(ta => {
if (ta.dataset._ctool) return;
const ph = ta.getAttribute('placeholder') || '';
if (!/댓글|답글|comment/i.test(ph)) return; // 댓글 작성창만
ta.dataset._ctool = '1';
const bar = document.createElement('div');
bar.className = 'b64-ctool-bar';
// 이모지 대신 인라인 SVG 아이콘(세련화 + 점자 이모지가 폰트에 따라 안 보이던 문제 해결).
const _icBraille = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><circle cx="8.5" cy="6" r="2.1"/><circle cx="15.5" cy="6" r="2.1"/><circle cx="8.5" cy="12" r="2.1"/><circle cx="15.5" cy="12" r="2.1"/><circle cx="8.5" cy="18" r="2.1"/><circle cx="15.5" cy="18" r="2.1"/></svg>';
const _icCode64 = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
const _icLock = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="10.5" width="16" height="10" rx="2"/><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5"/></svg>';
const mk = (icon, label, mode, title) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'b64-ctool-btn'; b.title = title;
b.innerHTML = icon + '<span>' + label + '</span>';
b.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); ctoolConvert(ta, mode); });
return b;
};
bar.appendChild(mk(_icBraille, '점자', 'braille', '선택 또는 전체를 점자로 변환'));
bar.appendChild(mk(_icCode64, 'base64', 'base64', '선택 또는 전체를 base64로 변환'));
// 코드 버튼: 팝오버 대신 툴바 아래 '미니멀 칩' 스트립을 토글(버전 C). 칩=현재 겉보기 문장(클릭→새 문장),
// 무채색 '코드 생성' 버튼. 별도 비번칸 없음 — 채널 비번 사용, 없으면 토스트 안내.
const _icGen = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 8 16 8"/></svg>';
const codeBtn = document.createElement('button');
codeBtn.type = 'button'; codeBtn.className = 'b64-ctool-btn'; codeBtn.title = '숨은 코드(StegCloak) — 채널 비번 사용';
codeBtn.innerHTML = _icLock + '<span>코드</span>';
bar.appendChild(codeBtn);
const codeStrip = document.createElement('div');
codeStrip.className = 'b64-ctool-code b64-ctool-code-hide';
// 🔄 = 새 겉보기 문장 자동 생성 / 입력칸 = 직접 타이핑 가능(사용자 요청). 입력칸 값이 실제 cover.
// 비번 출처 안내(어떤 비번으로 코드화될지) — 채널 전용 비번이 없으면 '공동 비번' 경고 문구 표시.
const pwNote = document.createElement('div');
pwNote.className = 'b64-ctool-pwnote';
const updatePwNote = () => {
const info = getStegEncodePw();
pwNote.textContent = info ? ('비번: ' + stegPwLabel(info)) : '등록된 비번이 없습니다 — 비번 관리에서 채널/공동 비번을 등록하세요';
pwNote.classList.toggle('b64-ctool-pwnote-warn', !info || info.src === 'global');
};
const row = document.createElement('div');
row.className = 'b64-ctool-row';
const regen = document.createElement('button');
regen.type = 'button'; regen.className = 'b64-ctool-regen'; regen.title = '새 겉보기 문장 생성';
regen.innerHTML = _icGen;
const coverIn = document.createElement('input');
coverIn.type = 'text'; coverIn.className = 'b64-ctool-cover'; coverIn.setAttribute('autocomplete', 'off');
coverIn.placeholder = '겉보기 문장 (직접 입력 가능, 2단어 이상)';
coverIn.value = stegGenerateCover();
regen.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); coverIn.value = stegGenerateCover(); coverIn.focus(); });
const gen = document.createElement('button');
gen.type = 'button'; gen.className = 'b64-ctool-gen'; gen.textContent = '코드 생성';
gen.addEventListener('click', async ev => {
ev.preventDefault(); ev.stopPropagation();
const s = ta.selectionStart || 0, e = ta.selectionEnd || 0; const hasSel = e > s;
const secret = hasSel ? ta.value.slice(s, e) : ta.value;
if (!secret || !secret.trim()) { showNavToast('숨길 내용을 입력하세요'); return; }
const cover = coverIn.value.trim();
if (!cover || cover.split(/\s+/).length < 2) { showNavToast('겉보기 문장은 2단어 이상이어야 합니다'); coverIn.focus(); return; }
const info = getStegEncodePw();
if (!info) { showNavToast('등록된 비번이 없습니다 — 비번 관리에서 채널 또는 공동 비번을 등록하세요'); return; }
gen.disabled = true; const lab = gen.textContent; gen.textContent = '생성 중…';
try {
const out = await SC.hide(secret, info.pw, cover, true);
const val = hasSel ? ta.value.slice(0, s) + out + ta.value.slice(e) : out;
setNativeValue(ta, val); ta.focus();
const np = hasSel ? s + out.length : out.length; try { ta.setSelectionRange(np, np); } catch (err) {}
codeStrip.classList.add('b64-ctool-code-hide');
// 어떤 비번으로 코드화됐는지 표시(공동 비번을 쓴 경우 추가 문구 포함).
showNavToast(`코드 생성됨 · ${stegPwLabel(info)} 「${info.pw}」`);
} catch (err) { showNavToast('코드 생성 실패: ' + (err.message || err)); }
finally { gen.disabled = false; gen.textContent = lab; }
});
coverIn.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); gen.click(); } });
row.appendChild(regen); row.appendChild(coverIn); row.appendChild(gen);
codeStrip.appendChild(pwNote); codeStrip.appendChild(row);
codeBtn.addEventListener('click', ev => {
ev.preventDefault(); ev.stopPropagation();
// 열 때 입력칸 값은 보존(직접 쓴 문장 유지) — 자동 생성은 🔄로만. 비번 안내는 열 때마다 갱신.
if (codeStrip.classList.contains('b64-ctool-code-hide')) { updatePwNote(); codeStrip.classList.remove('b64-ctool-code-hide'); coverIn.focus(); }
else codeStrip.classList.add('b64-ctool-code-hide');
});
// 요구2: '코네콘/작성' 버튼과 같은 줄에 배치(그 justify-end flex를 space-between으로). 코드 칩 스트립은 그 줄 아래.
let host = ta.parentElement, btnBar = null;
for (let i = 0; i < 5 && host; i++) {
const cand = host.querySelector('.justify-end');
if (cand && cand.querySelector('button')) { btnBar = cand; break; }
host = host.parentElement;
}
if (btnBar) { btnBar.style.justifyContent = 'space-between'; btnBar.insertBefore(bar, btnBar.firstChild); btnBar.insertAdjacentElement('afterend', codeStrip); }
else { ta.insertAdjacentElement('afterend', bar); bar.insertAdjacentElement('afterend', codeStrip); }
});
}
function flush() {
pending = false;
if (mo) mo.disconnect();
if (!document.body) { observe(); return; }
injectOGHideStyle();
captureChannelName(); // 채널 페이지의 채널명 자동 추출(코드→이름 매핑, 1회면 채움). 작성 페이지 포함.
// 글 작성 페이지에서는 모든 처리 비활성화 (복호화 무한 증식 방지)
if (isWritePage()) { _wasWritePage = true; return; }
const { roots: contentRoots, allRoots: allContentRoots } = getContentRoots();
// write 페이지에서 돌아온 직후: Froala 에디터 DOM이 아직 남아있으면 재대기
// contenteditable 속성이 먼저 제거되는 경우 대비 → Froala 전용 클래스로 체크
if (_wasWritePage) {
if (document.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) {
pending = true;
setTimeout(flush, 300);
return;
}
_wasWritePage = false;
}
_seenProductCodes.clear();
_gcSeenInFlush.clear();
if (CFG.CONTENT_DECODE) {
// 1) 분할 base64 먼저: walkAndProcess 전에 실행해야 원본 조각 정보 보존.
// (요구0: trySiblingMerge가 '버튼 담은 블록'을 재구성/제거하지 않도록 scan/processSingle에서
// button 포함 블록을 후보에서 제외 — 댓글 우측 액션 버튼 보존 + 다운로드 카드 생성 둘 다 만족.)
contentRoots.forEach(el => trySiblingMerge(el));
// 2) 개별 노드 처리: 합산에서 제외된 요소들 (개별 디코딩 가능한 것들)
contentRoots.forEach(el => walkAndProcess(el, processContentNode));
}
if (CFG.BRAILLE_DECODE) contentRoots.forEach(el => decodeBrailleIn(el));
if (CFG.STEG_DECODE) contentRoots.forEach(el => decodeStegIn(el));
flushListTitles(contentRoots, allContentRoots);
injectTitleCards(contentRoots);
convertProductLinks(contentRoots);
buildGenreBar(contentRoots);
buildLinkPanel(contentRoots);
injectDupCheckBtn();
observe();
applyTxtLiveColors();
autoExpandComments();
mountCommentTools();
// 첫 번째 링크 카드로 스크롤 (페이지당 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> 복원.
// 복원한 카드의 키를 기록해 둔다(아래 1.5에서 '카드가 숨긴 원본 <a>'를 정확히 정리하기 위함).
const _restoredKeys = new Set();
document.querySelectorAll(`[${ORIG_ATTR}]`).forEach(card => {
if (!card.parentNode) return;
if (card.dataset.b64key) _restoredKeys.add(card.dataset.b64key);
const origHtml = card.getAttribute(ORIG_ATTR);
const tmp = document.createElement('div');
tmp.innerHTML = origHtml;
const origEl = tmp.firstChild;
if (origEl) card.parentNode.replaceChild(origEl, card);
});
// 1.5 카드로 대체됐던 <a>(DONE+b64key, 카드 클래스 아님) 처리. 두 갈래를 구분한다:
// (a) convertProductLinks가 카드로 바꾸며 '숨긴 원본 <a>'(display:none). 1단계가 그 카드를
// origHtml로 이미 복원했으므로(같은 키가 _restoredKeys에 있음) 이 숨긴 원본은 잉여 → 제거.
// ★과거엔 이걸 '복원(display 켜기)'해서, 복원본(1단계)+원본(여기) 2벌이 됐고 다음 flush가
// 둘 다 카드화 → 설정 토글마다 카드가 배수로 폭증(fanbox 등에서 수천 개)하던 근본 원인.
// (b) NO_DUP 중복으로 남긴 '보이는 평문 링크'(카드 짝 아님) → DONE/키만 해제해 다음 flush가 재평가.
document.querySelectorAll(`a[data-b64key][${DONE_ATTR}]:not(.b64-link):not(.b64-product-link)`).forEach(a => {
if (a.closest('#b64d-link-panel')) return;
if (a.style.display === 'none' && _restoredKeys.has(a.dataset.b64key)) { a.remove(); return; }
a.removeAttribute(DONE_ATTR);
a.style.removeProperty('display');
delete a.dataset.b64key;
});
// 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);
});
// 2.4 분할/색상 base64 합산 wrap(span[data-b64merge]) → 원본 조각 복원 (CONTENT_DECODE 즉시 토글 반영).
// 기존엔 RAW_ATTR가 없어 applyLive가 못 되돌려 '새로고침해야 적용'되던 문제(색깔 여러 개 인코딩).
document.querySelectorAll('span[data-b64merge]').forEach(el => {
const host = el.parentNode; if (!host) return;
let orig = el._b64mergeOrig;
const sibs = el._b64mergeSibs;
// SvelteKit이 DOM을 교체하면 JS 프로퍼티(_b64mergeOrig)가 소실 → data 속성에서 복원
if (!orig) {
const html = el.getAttribute('data-b64merge-html');
if (html) { const tmp = document.createElement('span'); tmp.innerHTML = html; orig = document.createDocumentFragment(); while (tmp.firstChild) orig.appendChild(tmp.firstChild); }
}
if (orig) host.replaceChild(orig, el); else host.removeChild(el); // wrap → 원본 조각
if (sibs && host.parentNode) { // applyMerge: 합쳐졌던 형제 블록 재삽입
let after = host;
sibs.forEach(s => { if (s) { host.parentNode.insertBefore(s, after.nextSibling); after = s; } });
}
});
// 2.5 점자 디코딩 span → 원본 점자 복원 (점자 변환 옵션 즉시 반영용)
document.querySelectorAll('span.b64-braille[data-b64br]').forEach(el => {
const orig = el.getAttribute('data-b64br');
if (el.parentNode) el.parentNode.replaceChild(document.createTextNode(orig), 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));
// 4.5 장르·태그 별도 바 제거 + 숨겨둔 원본 장르 <a> 원상복구(요구1). 다음 flush가 GENRE_BAR 상태에
// 맞춰 다시 그린다(ON=바 재생성+재숨김, OFF=코네 원본 장르 링크 그대로 노출).
document.querySelectorAll('.b64-genre-bar').forEach(el => el.remove());
document.querySelectorAll('a[data-b64genre]').forEach(a => {
a.removeAttribute(DONE_ATTR);
a.style.removeProperty('display');
delete a.dataset.b64genre;
});
// 4.6 숨은 코드(StegCloak) host를 원본 겉보기(cover) 텍스트로 원상복구(요구2/수정1). 성공 시엔 복호
// 결과가 들어있으므로 자식 이동이 아니라 data-b64steg의 원본 문자열로 되돌린다. 다음 flush가 재복원.
document.querySelectorAll('span.b64-steg-host[data-b64steg]').forEach(host => {
const parent = host.parentNode; if (!parent) return;
parent.replaceChild(document.createTextNode(host.getAttribute('data-b64steg') || ''), host);
});
// 중복 코드 Set도 초기화 → NO_DUPLICATE_CARD 켜고/끌 때 즉시 반영
_seenProductCodes.clear();
_gcSeenInFlush.clear();
processedRaws.clear();
processedListRaws.clear();
_kpIdx = -1;
schedule();
}
function schedule(delayMs) {
if (pending) return;
pending = true;
if (delayMs > 0) setTimeout(flush, delayMs);
else queueMicrotask(flush); // 일반 mutation: 페인트 전 즉시 실행 → 깜빡임 제거
}
function observe() {
if (!mo) {
mo = new MutationObserver(mutations => {
for (const mu of mutations) {
for (const added of mu.addedNodes) {
if (isOurNode(added)) continue;
if (added.nodeType === Node.ELEMENT_NODE &&
(added.hasAttribute(DONE_ATTR) || added.hasAttribute(LTDONE_ATTR))) continue;
schedule(); return;
}
// 우리가 본문에 끼운 카드가 코네 하이드레이션/재렌더로 떨어져 나가면 원본 <a>는 DONE으로
// 숨은 채 남아 '카드만' 사라진다. MO는 addedNodes만 보던 탓에 이 제거를 놓쳐 재생성이 안 됨
// (다운로드 링크 카드가 사라지던 근본 원인). 제거도 감지해 재flush → convertProductLinks가
// _cardMap/b64key로 재카드화. ★감지 대상: convertProductLinks 카드(ORIG_ATTR) +
// buildNodes가 본문에 직접 끼운 카드(.b64-link/.b64-product-link[data-b64key], ORIG_ATTR 없음).
// 후자가 빠져 있어, NO_DUP ON 진입 시 buildNodes 카드가 떨어져도 재flush가 안 떠 '카드 0개'가
// 유지됐다(라이브 진단). 이제 둘 다 감지. (모아보기 패널 클론은 b64key/ORIG_ATTR 없어 무시)
const CARD_RM_SEL = '[' + ORIG_ATTR + '], .b64-link[data-b64key], .b64-product-link[data-b64key]';
for (const removed of mu.removedNodes) {
if (removed.nodeType !== Node.ELEMENT_NODE) continue;
if ((removed.matches && removed.matches(CARD_RM_SEL)) || (removed.querySelector && removed.querySelector(CARD_RM_SEL))) {
const now = Date.now();
if (now - _cardRmWindow > 3000) { _cardRmWindow = now; _cardRmFlushes = 0; }
if (++_cardRmFlushes <= 8) { schedule(); return; } // 3초 내 8회까지만(코네가 계속 떼어낼 때 폭주 방지)
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(); }
});
// 작성↔읽기 페이지 전환 알림 토스트 (화면 상단 중앙, 1.8초)
let _navToastEl = null, _navToastTimer = null;
function showNavToast(msg) {
if (!document.body) return;
if (!_navToastEl) {
_navToastEl = document.createElement('div');
_navToastEl.id = 'b64d-nav-toast';
_navToastEl.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#18181b;color:#fafafa;font:600 13px/1.4 system-ui,sans-serif;padding:8px 14px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.5);pointer-events:none;max-width:90vw;white-space:nowrap;opacity:0;transition:opacity .3s;';
document.body.appendChild(_navToastEl);
}
_navToastEl.textContent = msg;
_navToastEl.style.opacity = '1';
clearTimeout(_navToastTimer);
_navToastTimer = setTimeout(() => { if (_navToastEl) _navToastEl.style.opacity = '0'; }, 1800);
}
/* ================================================================
GreasyFork 업데이트 확인 (CFG.UPDATE_CHECK)
- 설치본의 @updateURL(.meta.js)을 조회해 @version 비교 → 새 버전이면 알림 배너.
- 유저스크립트 매니저 정책상 '무음 설치'는 불가 → 원클릭 업데이트 링크(.user.js) 제공.
- GreasyFork에서 설치하면 매니저가 @updateURL/@downloadURL을 주입하므로 자동 동작.
수동 설치판(실험판 등)은 정보가 없어 조용히 무시(수동 확인 시 안내만).
================================================================ */
function cmpVer(a, b) {
const A = String(a).split('.').map(s => parseInt(s, 10) || 0);
const B = String(b).split('.').map(s => parseInt(s, 10) || 0);
for (let i = 0; i < Math.max(A.length, B.length); i++) { const x = A[i] || 0, y = B[i] || 0; if (x !== y) return x - y; }
return 0;
}
function notifyUpdate(latest, dlUrl) {
if (!document.body || document.getElementById('b64d-update-banner')) return;
const dark = document.documentElement.classList.contains('dark');
const bar = document.createElement('div');
bar.id = 'b64d-update-banner';
bar.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;'
+ `background:${dark ? '#0c4a6e' : '#0369a1'};color:#e0f2fe;font:600 13px/1.4 system-ui,sans-serif;`
+ 'padding:9px 10px 9px 14px;border-radius:10px;box-shadow:0 6px 22px rgba(0,0,0,.5);display:flex;align-items:center;gap:10px;max-width:92vw;';
// 좌측 텍스트(2줄): 안내 + '설치창이 열린다'는 점을 명확히
const txtWrap = document.createElement('div');
txtWrap.style.cssText = 'display:flex;flex-direction:column;gap:1px;min-width:0;';
const txt = document.createElement('span');
txt.innerHTML = `KONE + 새 버전 <b>v${esc(latest)}</b> 사용 가능`;
const sub = document.createElement('span');
sub.textContent = '‘업데이트’ → 매니저 설치창이 열립니다(거기서 설치 1회 클릭)';
sub.style.cssText = 'font-weight:400;font-size:11px;opacity:.82;';
txtWrap.append(txt, sub);
const go = document.createElement('a');
go.textContent = '업데이트'; go.href = dlUrl; go.target = '_blank'; go.rel = 'noopener noreferrer';
go.title = '클릭하면 매니저(Tampermonkey 등) 설치/업데이트 창이 열립니다';
go.style.cssText = 'background:#38bdf8;color:#04283b;text-decoration:none;font-weight:700;padding:4px 12px;border-radius:7px;white-space:nowrap;flex-shrink:0;';
const x = document.createElement('button');
x.textContent = '✕'; x.style.cssText = 'background:none;border:none;color:inherit;cursor:pointer;font-size:14px;opacity:.7;padding:0 2px;flex-shrink:0;';
x.addEventListener('click', () => bar.remove());
bar.append(txtWrap, go, x);
document.body.appendChild(bar);
}
function runUpdateCheck(manual) {
const info = (typeof GM_info !== 'undefined' && GM_info) ? GM_info : {};
const sc = info.script || {};
const cur = sc.version || info.version || '0';
const metaUrl = sc.updateURL || info.scriptUpdateURL || sc.downloadURL || '';
const dlUrl = sc.downloadURL || (metaUrl ? metaUrl.replace(/\.meta\.js(\?|$)/i, '.user.js$1') : '');
if (!metaUrl) { if (manual) showNavToast('업데이트 정보 없음 (수동 설치판)'); return; }
if (!manual) {
const last = +GM_getValue('lastUpdateCheck', 0);
if (Date.now() - last < 6 * 60 * 60 * 1000) return; // 6시간 throttle
}
GM_setValue('lastUpdateCheck', Date.now());
if (manual) showNavToast('업데이트 확인 중…');
GM_xmlhttpRequest({
method: 'GET',
url: metaUrl + (metaUrl.includes('?') ? '&' : '?') + '_=' + Date.now(),
timeout: 10000,
onload(r) {
const m = /@version\s+(\S+)/.exec(r.responseText || '');
if (!m) { if (manual) showNavToast('버전 정보를 읽지 못했습니다'); return; }
const latest = m[1];
if (cmpVer(latest, cur) > 0) notifyUpdate(latest, dlUrl || metaUrl);
else if (manual) showNavToast('최신 버전입니다 (v' + cur + ')');
},
onerror() { if (manual) showNavToast('업데이트 확인 실패'); },
ontimeout() { if (manual) showNavToast('업데이트 확인 시간 초과'); },
});
}
/* ================================================================
한글 점자 엔진 (인코딩/디코딩)
점자 매핑 출처: 국립국어원 「한국 점자 규정」(2024) — 문화체육관광부 고시,
저작권법 제7조에 따라 비보호(자유 이용). 영문 점자: Unified English Braille(UEB).
규정 PDF를 격자(윤곽+채움) 방식으로 추출·검증. dot번호 ↔ U+2800.
================================================================ */
const BR = (function () {
function d2c(d) { if (!d) return '⠀'; let m = 0; for (const c of d) m |= 1 << (+c - 1); return String.fromCharCode(0x2800 + m); }
function c2d(ch) { const m = ch.charCodeAt(0) - 0x2800; let s = ''; for (let i = 0; i < 6; i++) if (m & (1 << i)) s += (i + 1); return s; }
function vc(v) { return Array.isArray(v) ? v.map(d2c).join('') : d2c(v); }
const CHO = { 'ㄱ': '4', 'ㄴ': '14', 'ㄷ': '24', 'ㄹ': '5', 'ㅁ': '15', 'ㅂ': '45', 'ㅅ': '6', 'ㅇ': '', 'ㅈ': '46', 'ㅊ': '56', 'ㅋ': '124', 'ㅌ': '125', 'ㅍ': '145', 'ㅎ': '245' };
const JONG = { 'ㄱ': '1', 'ㄴ': '25', 'ㄷ': '35', 'ㄹ': '2', 'ㅁ': '26', 'ㅂ': '12', 'ㅅ': '3', 'ㅇ': '2356', 'ㅈ': '13', 'ㅊ': '23', 'ㅋ': '235', 'ㅌ': '236', 'ㅍ': '256', 'ㅎ': '356' };
const VOWEL = { 'ㅏ': '126', 'ㅑ': '345', 'ㅓ': '234', 'ㅕ': '156', 'ㅗ': '136', 'ㅛ': '346', 'ㅜ': '134', 'ㅠ': '146', 'ㅡ': '246', 'ㅣ': '135', 'ㅐ': '1235', 'ㅔ': '1345', 'ㅖ': '34', 'ㅘ': '1236', 'ㅚ': '13456', 'ㅢ': '2456', 'ㅒ': ['345', '1235'], 'ㅙ': ['1236', '1235'], 'ㅝ': '1234', 'ㅞ': ['1234', '1235'], 'ㅟ': ['134', '1235'] };
const DOEN = '6', ON = '123456', GUBUN = '36'; // 된소리표, 온표(단독자모), 구분표(모음연쇄)
const NUM_COLLIDE = new Set(['ㄴ', 'ㄷ', 'ㅁ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']); // 숫자와 혼동되는 초성(제44항 다만)
const CONS_ABBR = { 'ㄱ': '1246', 'ㄴ': '14', 'ㄷ': '24', 'ㅁ': '15', 'ㅂ': '45', 'ㅅ': '123', 'ㅈ': '46', 'ㅋ': '124', 'ㅌ': '125', 'ㅍ': '145', 'ㅎ': '245' };
// 초성과 점형이 같은 약자(나·다·마·바·자·카·타·파·하). 뒤에 모음 음절이 오면 초성으로 오독 →
// 제18항: 약자 뒤에 구분표(36)를 적어 구분. (가=1246·사=123은 충돌 없음 → 제외)
const COLLIDE_ABBR = new Set(['ㄴ', 'ㄷ', 'ㅁ', 'ㅂ', 'ㅈ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']);
const COLLIDE_ABBR_CELL = new Set(['14', '24', '15', '45', '46', '124', '125', '145', '245']);
const RIME_ABBR = { 'ㅓㄱ': '1456', 'ㅓㄴ': '23456', 'ㅓㄹ': '2345', 'ㅕㄴ': '16', 'ㅕㄹ': '1256', 'ㅕㅇ': '12456', 'ㅗㄱ': '1346', 'ㅗㄴ': '12356', 'ㅗㅇ': '123456', 'ㅜㄴ': '1245', 'ㅜㄹ': '12346', 'ㅡㄴ': '1356', 'ㅡㄹ': '2346', 'ㅣㄴ': '12345' };
const GEOT = ['456', '234'];
const ABBR_WORD = { '그래서': ['1', '234'], '그러나': ['1', '14'], '그러면': ['1', '25'], '그러므로': ['1', '26'], '그런데': ['1', '1345'], '그리고': ['1', '136'], '그리하여': ['1', '156'] };
const NUMMARK = '3456';
const DIGIT = { '0': '245', '1': '1', '2': '12', '3': '14', '4': '145', '5': '15', '6': '124', '7': '1245', '8': '125', '9': '24' };
const ROMAN = { a: '1', b: '12', c: '14', d: '145', e: '15', f: '124', g: '1245', h: '125', i: '24', j: '245', k: '13', l: '123', m: '134', n: '1345', o: '135', p: '1234', q: '12345', r: '1235', s: '234', t: '2345', u: '136', v: '1236', w: '2456', x: '1346', y: '13456', z: '1356' };
const ROM_START = '356', ROM_END = '256', CAP = '6';
// 문장부호 (제49항, 격자추출). 다중 칸은 배열.
const OPEN_DQ = '236', CLOSE_DQ = '356', OPEN_SQ = ['6', '236'], CLOSE_SQ = ['356', '3'];
const PUNCT = {
'.': '256', '?': '236', '!': '235', ',': '5',
'·': ['5', '23'], ':': ['5', '2'], '/': ['456', '34'], '…': ['6', '6', '6'],
'“': OPEN_DQ, '”': CLOSE_DQ, '‘': OPEN_SQ, '’': CLOSE_SQ,
'(': ['236', '3'], ')': ['6', '356'], '{': ['236', '2'], '}': ['5', '356'],
'[': ['236', '23'], ']': ['56', '356'],
'『': ['56', '236'], '』': ['356', '23'], '「': ['5', '236'], '」': ['356', '2'],
'《': ['56', '2356'], '》': ['2356', '23'], '〈': ['5', '2356'], '〉': ['2356', '2'],
'—': ['36', '36'], '–': '36', '-': '36', '~': ['4', '35'],
'○': ['456', '356', '123'], '×': ['456', '1346', '123'], '△': ['456', '346', '123'], '□': ['456', '2356', '123'],
// ASCII 기호(2024 규정). 받침/로마자표와 충돌하는 단일칸 수학기호(+26·등호25,25 등)와
// %(356,1234=로마자시작표+p 충돌)는 제외. URL에 흔한 다중칸 기호만 안전하게 추가.
'_': ['456', '36'], '@': ['4', '1'], '^': ['4', '26'], '#': ['456', '1456'],
'&': ['4', '12346'], '\\': ['456', '16'], '|': ['456', '1256'], ';': ['56', '23'],
};
const CHO_L = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
const JUNG_L = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
const JONG_L = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
const DOEN_MAP = { 'ㄲ': 'ㄱ', 'ㄸ': 'ㄷ', 'ㅃ': 'ㅂ', 'ㅆ': 'ㅅ', 'ㅉ': 'ㅈ' };
const DOEN_INV = { 'ㄱ': 'ㄲ', 'ㄷ': 'ㄸ', 'ㅂ': 'ㅃ', 'ㅅ': 'ㅆ', 'ㅈ': 'ㅉ' };
const JONG_SPLIT = { 'ㄳ': ['ㄱ', 'ㅅ'], 'ㄵ': ['ㄴ', 'ㅈ'], 'ㄶ': ['ㄴ', 'ㅎ'], 'ㄺ': ['ㄹ', 'ㄱ'], 'ㄻ': ['ㄹ', 'ㅁ'], 'ㄼ': ['ㄹ', 'ㅂ'], 'ㄽ': ['ㄹ', 'ㅅ'], 'ㄾ': ['ㄹ', 'ㅌ'], 'ㄿ': ['ㄹ', 'ㅍ'], 'ㅀ': ['ㄹ', 'ㅎ'], 'ㅄ': ['ㅂ', 'ㅅ'] };
function decomp(ch) { const c = ch.charCodeAt(0) - 0xAC00; return [CHO_L[Math.floor(c / 588)], JUNG_L[Math.floor((c % 588) / 28)], JONG_L[c % 28]]; }
function encJong(j) {
if (j === 'ㄲ') return d2c('1') + d2c('1');
if (j === 'ㅆ') return d2c('34');
if (JONG_SPLIT[j]) { const [a, b] = JONG_SPLIT[j]; return encJong(a) + encJong(b); }
return d2c(JONG[j]);
}
function encSyllable(ch) {
if (ch === '것') return vc(GEOT);
const [cho0, jung, jong] = decomp(ch);
let cho = cho0, doen = false;
if (DOEN_MAP[cho]) { doen = true; cho = DOEN_MAP[cho]; }
const pre = doen ? d2c(DOEN) : '';
if (jong === 'ㅇ' && jung === 'ㅓ' && (cho === 'ㅅ' || cho === 'ㅈ' || cho === 'ㅊ'))
return pre + d2c(CHO[cho]) + d2c(RIME_ABBR['ㅕㅇ']);
const rk = jung + jong;
if (jong && RIME_ABBR[rk]) return pre + (cho !== 'ㅇ' ? d2c(CHO[cho]) : '') + vc(RIME_ABBR[rk]);
if (jung === 'ㅏ' && CONS_ABBR[cho]) return pre + d2c(CONS_ABBR[cho]) + (jong ? encJong(jong) : '');
return pre + (cho !== 'ㅇ' ? d2c(CHO[cho]) : '') + vc(VOWEL[jung]) + (jong ? encJong(jong) : '');
}
function encJamo(ch) { // 단독 자모(제8항): 온표 + (자음=받침형 / 모음=모음)
if (VOWEL[ch]) return d2c(ON) + vc(VOWEL[ch]);
if (DOEN_MAP[ch]) return d2c(ON) + encJong(ch);
if (JONG[ch] !== undefined) return d2c(ON) + d2c(JONG[ch]);
return ch;
}
function encRoman(run) {
let s = d2c(ROM_START);
for (const ch of run) {
if (ch === ' ') { s += d2c(''); continue; }
const low = ch.toLowerCase();
if (ROMAN[low]) { if (ch !== low) s += d2c(CAP); s += d2c(ROMAN[low]); } else s += ch;
}
return s + d2c(ROM_END);
}
function isRoman(c) { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'; }
function encode(text) {
let out = '', i = 0, inNum = false, prev = null, dqOpen = false, sqOpen = false;
while (i < text.length) {
let w = null;
for (const k in ABBR_WORD) if (text.startsWith(k, i) && (!w || k.length > w.length)) w = k;
if (w) { out += vc(ABBR_WORD[w]); i += w.length; inNum = false; prev = null; continue; }
const ch = text[i];
if (ch >= '0' && ch <= '9') { if (!inNum) { out += d2c(NUMMARK); inNum = true; } out += d2c(DIGIT[ch]); i++; prev = null; continue; }
const wasNum = inNum; inNum = false;
if (isRoman(ch)) {
let j = i, run = '';
while (j < text.length) {
if (isRoman(text[j])) { run += text[j]; j++; }
else if (text[j] === ' ' && j + 1 < text.length && isRoman(text[j + 1])) { run += ' '; j++; }
else break;
}
out += encRoman(run); i = j; prev = null; continue;
}
if (ch >= '가' && ch <= '힣') {
const [cho, jung, jong] = decomp(ch);
// 제44항 [다만]: 숫자 뒤 ㄴ/ㄷ/ㅁ/ㅋ/ㅌ/ㅍ/ㅎ 초성 또는 '운'이 붙으면 띄어 씀
if (wasNum && (NUM_COLLIDE.has(cho) || (cho === 'ㅇ' && jung === 'ㅜ' && jong === 'ㄴ'))) out += ' ';
if (prev && prev.jong === '') { // 모음 연쇄 구분표(제11·12항)
if (cho === 'ㅇ' && jung === 'ㅖ') out += d2c(GUBUN);
else if (cho === 'ㅇ' && jung === 'ㅐ' && (prev.jung === 'ㅑ' || prev.jung === 'ㅘ' || prev.jung === 'ㅜ' || prev.jung === 'ㅝ')) out += d2c(GUBUN);
else if (cho === 'ㅇ' && prev.collideAbbr) out += d2c(GUBUN); // 초성형 약자 + 모음 음절 구분(제18항)
}
out += encSyllable(ch);
// 초성형 약자(받침 없는 ㄴ/ㄷ/…/하 + ㅏ, 된소리 아님)로 적혔는지 기록 → 다음 모음과 구분
prev = { jung, jong, collideAbbr: jung === 'ㅏ' && jong === '' && !DOEN_MAP[cho] && COLLIDE_ABBR.has(cho) };
i++; continue;
}
if (ch >= 'ㄱ' && ch <= 'ㅣ') { out += encJamo(ch); i++; prev = null; continue; }
if (ch === '"') { out += vc(dqOpen ? CLOSE_DQ : OPEN_DQ); dqOpen = !dqOpen; i++; prev = null; continue; }
if (ch === "'") { out += vc(sqOpen ? CLOSE_SQ : OPEN_SQ); sqOpen = !sqOpen; i++; prev = null; continue; }
if (PUNCT[ch] !== undefined) { out += vc(PUNCT[ch]); i++; prev = null; continue; }
out += ch; i++; prev = null;
}
return out;
}
// ── 디코딩 ──
const CHO_R = {}; for (const k in CHO) if (CHO[k]) CHO_R[CHO[k]] = k;
const JONG_R = {}; for (const k in JONG) JONG_R[JONG[k]] = k;
const VOW1 = {}; const VOW2 = []; for (const k in VOWEL) { const v = VOWEL[k]; if (Array.isArray(v)) VOW2.push([v[0], v[1], k]); else VOW1[v] = k; }
const SYL_R = {}; for (const k in CONS_ABBR) SYL_R[CONS_ABBR[k]] = k;
const RIME_R = {}; for (const k in RIME_ABBR) RIME_R[RIME_ABBR[k]] = k;
const DIGIT_R = {}; for (const k in DIGIT) DIGIT_R[DIGIT[k]] = k;
const WORD2_R = {}; for (const k in ABBR_WORD) WORD2_R[ABBR_WORD[k][1]] = k;
const ROMAN_R = {}; for (const k in ROMAN) ROMAN_R[ROMAN[k]] = k;
const PUNCT1_R = {}, PUNCT2_R = {}, PUNCT3_R = {};
for (const k in PUNCT) { const v = PUNCT[k]; if (!Array.isArray(v)) { if (!PUNCT1_R[v]) PUNCT1_R[v] = k; } else if (v.length === 2) { if (!PUNCT2_R[v.join(',')]) PUNCT2_R[v.join(',')] = k; } else if (v.length === 3) { if (!PUNCT3_R[v.join(',')]) PUNCT3_R[v.join(',')] = k; } }
const PLAIN_CHO = ['4', '24', '45', '6', '46'];
const SIOT_GROUP = new Set(['ㅅ', 'ㅆ', 'ㅈ', 'ㅉ', 'ㅊ']);
// 받침↔문장부호 충돌 셀: 236=ㅌ받침/물음표/여는따옴, 235=ㅋ받침/느낌표, 256=ㅍ받침/마침표.
// 어절 끝에서 둘 중 무엇인지 점자만으론 모호 → 받침으로 읽은 음절이 빈출 종결음절(FINAL_SET)에
// 있으면 받침, 아니면 문장부호로 분기(제3항 받침 vs 제5장 문장부호 동형 보정).
const COLLIDE = new Set(['236', '235', '256']);
let FINAL_SET = new Set();
function setFinalSet(words) { FINAL_SET = new Set((Array.isArray(words) ? words : String(words).split(/\s+/)).filter(Boolean)); }
function compose(cho, jung, jong) {
const ci = CHO_L.indexOf(cho), ji = JUNG_L.indexOf(jung), ki = JONG_L.indexOf(jong || '');
if (ci < 0 || ji < 0 || ki < 0) return null;
return String.fromCharCode(0xAC00 + (ci * 21 + ji) * 28 + ki);
}
function isVowelAt(ds, i) {
if (i >= ds.length || ds[i] == null) return null;
if (i + 1 < ds.length) for (const [a, b, nm] of VOW2) if (ds[i] === a && ds[i + 1] === b) return [nm, 2];
if (VOW1[ds[i]]) return [VOW1[ds[i]], 1];
return null;
}
function isChoAt(ds, i) {
if (i >= ds.length || ds[i] == null || CHO_R[ds[i]] === undefined) return null;
if (isVowelAt(ds, i + 1) || (ds[i + 1] != null && RIME_R[ds[i + 1]])) return CHO_R[ds[i]];
return null;
}
function jongAt(ds, i, n) {
if (i >= n || ds[i] == null) return null;
if (ds[i] === '34') return ['ㅆ', 1];
if (ds[i] === '1' && i + 1 < n && ds[i + 1] === '1') return ['ㄲ', 2];
if (JONG_R[ds[i]] !== undefined) return [JONG_R[ds[i]], 1];
return null;
}
function atBoundary(ds, i) { return i < 0 || i >= ds.length || ds[i] == null || ds[i] === ''; }
function finishJong(ds, j, n, a) {
let jong = a[0], nj = j + a[1];
if (!isChoAt(ds, nj)) {
const b = jongAt(ds, nj, n);
if (b) { const dbl = Object.keys(JONG_SPLIT).find(k => JONG_SPLIT[k][0] === jong && JONG_SPLIT[k][1] === b[0]); if (dbl) { jong = dbl; nj += b[1]; } }
}
return [jong, nj];
}
function readJong(ds, j, n, cho, jung) {
if (isChoAt(ds, j)) return ['', j];
if (ds[j] != null && PUNCT1_R[ds[j]] && (atBoundary(ds, j + 1) || (ds[j + 1] != null && PUNCT1_R[ds[j + 1]] !== undefined))) {
// 충돌 셀이면 빈출 종결음절 보정: 받침으로 읽은 음절이 FINAL_SET에 있으면 받침 채택
if (COLLIDE.has(ds[j]) && cho) {
const a = jongAt(ds, j, n);
if (a) { const syl = compose(cho, jung, a[0]); if (syl && FINAL_SET.has(syl)) return finishJong(ds, j, n, a); }
}
return ['', j]; // 그 외(쉼표 등 비충돌 부호 포함) = 문장부호
}
if (ds[j] != null && ds[j + 1] != null && PUNCT2_R[ds[j] + ',' + ds[j + 1]]) return ['', j]; // 2칸 문장부호 시작이면 종성 아님
const a = jongAt(ds, j, n); if (!a) return ['', j];
return finishJong(ds, j, n, a);
}
function parseSyllable(ds, i, n) {
let j = i, cho = 'ㅇ', doen = false;
if (ds[j] === DOEN && PLAIN_CHO.includes(ds[j + 1]) && (isVowelAt(ds, j + 2) || (ds[j + 2] != null && RIME_R[ds[j + 2]]))) { doen = true; j++; }
const c = isChoAt(ds, j);
if (c) { cho = c; j++; }
if (doen && DOEN_INV[cho]) cho = DOEN_INV[cho];
if (ds[j] != null && RIME_R[ds[j]]) {
let ju = RIME_R[ds[j]][0], jo = RIME_R[ds[j]].slice(1);
if (ju === 'ㅕ' && jo === 'ㅇ' && SIOT_GROUP.has(cho)) ju = 'ㅓ';
return [compose(cho, ju, jo), j + 1];
}
const v = isVowelAt(ds, j); if (!v) return null;
const [jong, nj] = readJong(ds, j + v[1], n, cho, v[0]);
const s = compose(cho, v[0], jong);
return s ? [s, nj] : null;
}
function decode(str) {
const ds = [], raw = [];
for (const ch of str) { const cc = ch.charCodeAt(0); if (cc >= 0x2800 && cc <= 0x28ff) { ds.push(c2d(ch)); raw.push(null); } else { ds.push(null); raw.push(ch); } }
let out = '', i = 0; const n = ds.length;
while (i < n) {
if (ds[i] == null) { out += raw[i]; i++; continue; }
if (ds[i] === '') { out += ' '; i++; continue; }
const d = ds[i];
if (d === ROM_START && i + 1 < n && (ROMAN_R[ds[i + 1]] || ds[i + 1] === CAP)) { // 356=시작표/닫는따옴 충돌 → 뒤가 로마자일 때만
i++; let cap = 0;
while (i < n && ds[i] != null) {
if (ds[i] === ROM_END) { i++; break; }
if (ds[i] === '') { out += ' '; i++; continue; }
if (ds[i] === CAP) { cap = cap ? 2 : 1; i++; continue; }
if (ROMAN_R[ds[i]]) { const L = ROMAN_R[ds[i]]; out += cap ? L.toUpperCase() : L; if (cap === 1) cap = 0; i++; continue; }
break;
}
continue;
}
if (d === NUMMARK) { i++; while (i < n && ds[i] != null && DIGIT_R[ds[i]]) { out += DIGIT_R[ds[i]]; i++; } continue; } // 규정 제44항대로 충돌 단위는 인코딩 시 띄어쓰므로 단순 처리
if (d === ON && i + 1 < n) { // 온표 → 단독 자모 (자음=받침형, 모음=모음)
if (JONG_R[ds[i + 1]] !== undefined) { out += JONG_R[ds[i + 1]]; i += 2; continue; }
const vv = isVowelAt(ds, i + 1); if (vv) { out += VOW1[ds[i + 1]] || vv[0]; i += 1 + vv[1]; continue; }
}
if (d === GUBUN && (ds[i + 1] === '34' || ds[i + 1] === '1235')) { i++; continue; } // 구분표 → 건너뜀
if (d === '1' && i + 1 < n && WORD2_R[ds[i + 1]] && atBoundary(ds, i - 1) && atBoundary(ds, i + 2)) { out += WORD2_R[ds[i + 1]]; i += 2; continue; }
if (i + 2 < n && PUNCT3_R[d + ',' + ds[i + 1] + ',' + ds[i + 2]]) { out += PUNCT3_R[d + ',' + ds[i + 1] + ',' + ds[i + 2]]; i += 3; continue; }
if (i + 1 < n && ds[i + 1] != null && PUNCT2_R[d + ',' + ds[i + 1]]) { out += PUNCT2_R[d + ',' + ds[i + 1]]; i += 2; continue; }
if (d === '456' && i + 1 < n && ds[i + 1] === '234') { out += '것'; i += 2; continue; }
const p = parseSyllable(ds, i, n);
if (p) { out += p[0]; i = p[1]; continue; }
let k = i, dn = false;
if (ds[k] === DOEN && (SYL_R[ds[k + 1]] || ds[k + 1] === '1246' || ds[k + 1] === '123')) { dn = true; k++; }
if (ds[k] != null && SYL_R[ds[k]]) {
const abbrCell = ds[k];
let cho2 = SYL_R[ds[k]]; if (dn && DOEN_INV[cho2]) cho2 = DOEN_INV[cho2]; k++;
let [jong, nj] = readJong(ds, k, n, cho2, 'ㅏ');
// 초성형 약자(나·다·…·하) + 구분표(36) + 모음 → 구분표 건너뜀(제18항: 약자/초성 모호성 복원)
if (jong === '' && !dn && COLLIDE_ABBR_CELL.has(abbrCell) && ds[nj] === GUBUN && isVowelAt(ds, nj + 1)) nj++;
out += compose(cho2, 'ㅏ', jong); i = nj; continue;
}
if (PUNCT1_R[d]) { out += (d === '236' && !atBoundary(ds, i + 1)) ? '“' : PUNCT1_R[d]; i++; continue; }
out += d2c(d); i++;
}
return out;
}
/* ── UEB(영어) 디코더 — 디코딩 전용 ──
외부 변환기(dencode 등)의 순수 UEB 점자엔 한글 로마자 시작/종료표가 없어 우리 decode()가
글자(125=h)를 한글(ㅌ)로 오독한다. 이 디코더는 점자를 UEB 영어로 해석. 기호 점형=UEB 규정. */
let UEB_SYM = {
'256': '.', '2': ',', '236': '?', '235': '!', '25': ':', '23': ';', '3': "'",
'36': '-', '356': '”',
'256,256,256': '…', '6,36': '—', '6,236': '‘', '6,356': '’',
'5,126': '(', '5,345': ')', '46,126': '[', '46,345': ']',
'5,235': '+', '5,36': '-', '5,236': '×', '5,34': '÷', '5,2356': '=',
'456,34': '/', '4,12346': '&', '456,1456': '#', '46,356': '%', '4,1': '@',
'46,36': '_',
};
function setUebSym(map) { UEB_SYM = map || {}; }
function cellsOf(token) {
const cells = [];
for (const ch of token) { const cc = ch.charCodeAt(0); cells.push((cc >= 0x2800 && cc <= 0x28ff) ? c2d(ch) : { raw: ch }); }
return cells;
}
function decodeUEB(token) {
const cs = cellsOf(token);
let out = '', i = 0, cap = 0, num = false; const n = cs.length;
while (i < n) {
const d = cs[i];
if (typeof d === 'object') { out += d.raw; cap = 0; num = false; i++; continue; }
if (d === '') { out += ' '; cap = 0; num = false; i++; continue; }
if (i + 2 < n && typeof cs[i + 1] === 'string' && typeof cs[i + 2] === 'string'
&& UEB_SYM[d + ',' + cs[i + 1] + ',' + cs[i + 2]] !== undefined) { out += UEB_SYM[d + ',' + cs[i + 1] + ',' + cs[i + 2]]; i += 3; cap = 0; num = false; continue; }
if (i + 1 < n && typeof cs[i + 1] === 'string'
&& UEB_SYM[d + ',' + cs[i + 1]] !== undefined) { out += UEB_SYM[d + ',' + cs[i + 1]]; i += 2; cap = 0; num = false; continue; }
if (d === '56') { num = false; i++; continue; } // grade-1 = 숫자모드 종료
if (d === CAP) { // 점6 = 대문자 관련 표
num = false;
if (cs[i + 1] === CAP) { cap = 2; i += 2; continue; } // ⠠⠠ = 대문자 단어/구절
if (cs[i + 1] === '3') { cap = 0; i += 2; continue; } // ⠠⠄ = 대문자 종료표(출력 없음)
cap = 1; i++; continue; // ⠠ = 단일 대문자
}
if (d === NUMMARK) { num = true; cap = 0; i++; continue; } // 3456 = 수표(대문자 지시자 효력 종료)
if (num && DIGIT_R[d]) { out += DIGIT_R[d]; i++; continue; } // 수표 뒤 a~j = 1~0
// UEB: 숫자 모드 내 마침표(256)·쉼표(2)는 소수점/자릿수 구분으로 모드 유지
if (num && (d === '256' || d === '2')) { out += (d === '256' ? '.' : ','); i++; continue; }
num = false;
if (UEB_SYM[d] !== undefined) { out += UEB_SYM[d]; cap = 0; i++; continue; }
if (ROMAN_R[d]) { const L = ROMAN_R[d]; out += cap ? L.toUpperCase() : L; if (cap === 1) cap = 0; i++; continue; }
out += d2c(d); i++; // 미지 셀 = 점자 그대로(잔여 = 한글 판정 신호)
}
return out;
}
// 토글 ON 시: 이 토큰을 UEB(영어)로 봐야 하는가? URL이면 확실, 그 외엔 잔여 점자 없고 글자 있으면 영어.
function isLikelyUEB(token) {
const cs = cellsOf(token); // 우리 한글 로마자 시작표(356+로마자/대문자) 패턴이면 우리 포맷 → 한글
for (let i = 0; i < cs.length - 1; i++)
if (cs[i] === '356' && typeof cs[i + 1] === 'string' && (ROMAN_R[cs[i + 1]] || cs[i + 1] === CAP)) return false;
const u = decodeUEB(token);
if (/(?:https?:\/\/|www\.)/i.test(u)) return true;
if (/[⠀-⣿]/.test(u)) return false; // 미해석 셀 잔여 = 한글
return /[A-Za-z]/.test(u);
}
return { encode, decode, setFinalSet, decodeUEB, setUebSym, isLikelyUEB };
})();
BR.setFinalSet(CFG.BRAILLE_FINAL); // 빈출 종결음절 목록 주입 (설정 변경 시 재주입)
// 읽기 페이지: 본문 속 점자 런을 한글로 변환 (CFG.BRAILLE_DECODE)
const BR_RUN_RE = /[⠀-⣿]/;
function decodeBrailleIn(root) {
if (!root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
if (!n.nodeValue || !BR_RUN_RE.test(n.nodeValue)) return NodeFilter.FILTER_REJECT;
const p = n.parentElement;
if (!p || p.closest('#b64d-br,#b64d-settings,textarea,[contenteditable],script,style')) return NodeFilter.FILTER_REJECT;
if (p.closest('[data-b64br]')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
const nodes = []; let nn; while ((nn = walker.nextNode())) nodes.push(nn);
for (const node of nodes) {
// 공백 기준 토큰화(런 기준 X): 점자 사이에 낀 비점자 ASCII(= _ 등 PUNCT에 없는 기호)도
// 같은 토큰으로 묶어 통째로 디코딩한다. URL은 공백이 없어 한 토큰이 되므로,
// 'https://…/work/=/product_id/RJ…'가 '='·'_'에서 끊겨 조각조각 카드화되던 문제 방지.
// (로마자 런 내부 공백은 인코딩 시 빈점자 U+2800로 들어가 일반 공백 분할의 영향을 받지 않음)
const parts = node.nodeValue.split(/(\s+)/);
if (!parts.some(p => p && BR_RUN_RE.test(p))) continue;
const frag = document.createDocumentFragment();
for (const part of parts) {
if (!part) continue;
if (BR_RUN_RE.test(part)) {
const span = document.createElement('span');
span.className = 'b64-braille'; span.setAttribute('data-b64br', part); // 원본 점자 저장(즉시 복원용)
span.setAttribute(DONE_ATTR, ''); // 후속 패스(convertProductLinks 등)가 내부를 재카드화하지 않도록
span.title = '점자 디코딩';
// base64 결과와 동일하게 URL→링크카드·상품코드→카드·중첩base64까지 처리
let dec;
// UEB 옵션 ON + 이 토큰이 UEB(영어/URL)로 감지되면 UEB로 디코딩(외부 변환기 점자 대응)
if (CFG.BRAILLE_UEB && BR.isLikelyUEB(part)) {
dec = BR.decodeUEB(part);
span.title = 'UEB 점자 디코딩';
} else {
dec = BR.decode(part);
// '?'와 여는따옴표 '“'는 규정상 둘 다 점 2-3-6 → decode가 비경계 '?'를 '“'로 오판.
// URL꼴 토큰엔 곡선따옴표가 정상 출현할 수 없으므로 '“'는 전부 '?'의 오디코딩 → 복원.
if (/(?:https?:\/\/|www\.)/i.test(dec)) dec = dec.replace(/“/g, '?');
}
buildNodes(dec).forEach(n => span.appendChild(n));
frag.appendChild(span);
} else frag.appendChild(document.createTextNode(part));
}
node.parentNode.replaceChild(frag, node);
}
}
/* ================================================================
숨은 코드(StegCloak) 자동 복원 (요구2) — 읽기 페이지 본문/댓글에서 제로폭 문자가 든 텍스트를
찾아 채널 비번으로 자동 복원한다. 성공하면 겉보기 문장 옆에 복원 결과를, 실패(비번 없음/오류)하면
'🔒 숨은 코드' 배지를 붙인다(클릭 시 비번 입력). 원본 겉보기 텍스트는 건드리지 않는다.
================================================================ */
// 이 kone 채널(/s/<채널>)에 등록된 비번 + 전역 pwList 순으로 시도.
function getStegPwList() {
const out = [];
const ch = getChannelCode();
const cm = getChannelPwMap();
if (ch && Array.isArray(cm[ch])) out.push(...cm[ch]);
const g = getPwList(); if (Array.isArray(g)) out.push(...g);
return [...new Set(out.filter(Boolean))];
}
// 코드화(인코딩)에 쓸 비번 결정: 현재 채널 비번 우선 → 없으면 공동(전역) 비번 1번. 무엇을 썼는지 함께 반환.
function getStegEncodePw() {
const ch = getChannelCode();
const cm = getChannelPwMap();
const chList = (ch && Array.isArray(cm[ch])) ? cm[ch].filter(Boolean) : [];
if (chList.length) return { pw: chList[0], src: 'channel', channel: ch };
const g = (getPwList() || []).filter(Boolean);
if (g.length) return { pw: g[0], src: 'global', channel: ch };
return null; // 채널·공동 어느 비번도 없음
}
// 비번 출처를 사람이 읽는 라벨로 (해당 채널 전용 비번이 없어 공동 비번을 쓰는 경우 추가 문구 포함).
function stegPwLabel(info) {
if (!info) return '등록된 비번 없음';
if (info.src === 'channel') return `채널 [${channelDisplayName(info.channel)}] 비번`;
return info.channel ? `공동 비번 1번 · 이 채널 전용 비번 없음` : '공동 비번 1번';
}
async function _stegTry(text, pws) {
for (const pw of pws) { try { const r = await SC.reveal(text, pw); if (r != null && r !== '') return r; } catch (e) {} }
return null;
}
// 성공 시 host 내용을 복호 결과로 교체(본문 삽입) — buildNodes로 URL/코드까지 카드화. 자동·배지클릭 공용.
function _stegFillHost(host, secret) {
host.textContent = '';
host.classList.add('b64-steg-done');
host.title = '숨은 코드 복원됨';
buildNodes(secret).forEach(n => host.appendChild(n));
}
function _stegMakeBadge(host, text) {
const badge = document.createElement('span');
badge.className = 'b64-steg-badge';
badge.setAttribute(DONE_ATTR, '');
badge.title = '숨은 코드가 있으나 복원 실패 — 클릭해 비번 입력';
badge.textContent = '🔒 숨은 코드';
badge.addEventListener('click', async (ev) => {
ev.preventDefault(); ev.stopPropagation();
const pw = window.prompt('숨은 코드 복원 비밀번호:');
if (!pw) return;
const r = await _stegTry(text, [pw]);
if (r != null) { if (host.isConnected) _stegFillHost(host, r); }
else { badge.textContent = '🔒 복원 실패'; setTimeout(() => { if (badge.isConnected) badge.textContent = '🔒 숨은 코드'; }, 1500); }
});
return badge;
}
function decodeStegIn(root) {
if (!CFG.STEG_DECODE || !root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
if (!n.nodeValue || !SC.hasHidden(n.nodeValue)) return NodeFilter.FILTER_REJECT;
const p = n.parentElement;
if (!p || p.closest('#b64d-br,#b64d-settings,#b64d-pw-manager,textarea,[contenteditable],script,style')) return NodeFilter.FILTER_REJECT;
if (p.closest('.b64-steg-host,[data-b64steg]')) return NodeFilter.FILTER_REJECT; // 이미 처리됨
return NodeFilter.FILTER_ACCEPT;
}
});
const nodes = []; let nn; while ((nn = walker.nextNode())) nodes.push(nn);
for (const node of nodes) {
const text = node.nodeValue;
if (!node.parentNode) continue;
// 텍스트 노드를 span으로 감싸 'claim'(비동기 완료 전 중복 처리 방지). data-b64steg에 원본 겉보기(cover)를
// 저장 → applyLive가 원상복구. 성공 시 base64/점자 디코딩처럼 '본문에 삽입'(cover를 복호 결과로 교체).
const hostEl = document.createElement('span');
hostEl.className = 'b64-steg-host';
hostEl.setAttribute('data-b64steg', text);
hostEl.setAttribute(DONE_ATTR, '');
node.parentNode.replaceChild(hostEl, node);
hostEl.appendChild(node); // 복호 완료 전까지는 원본 겉보기 텍스트 유지
(async () => {
const secret = await _stegTry(text, getStegPwList());
if (!hostEl.isConnected) return;
if (secret != null) _stegFillHost(hostEl, secret); // 본문 삽입(cover→복호 결과)
else hostEl.appendChild(_stegMakeBadge(hostEl, text)); // 실패 → 겉보기 유지 + 배지
})();
}
}
/* ================================================================
작성 페이지 변환 패널 (C안: 좌측 탭 → 서랍)
· 모드: 점자 | base64(N회) · 라이브 토글 · 복사/삽입(Tiptap)
================================================================ */
let _brEl = null, _brConvTimer = null, _brLastSel = null;
const BR_EDITOR_SEL = 'div.tiptap.ProseMirror, [contenteditable="true"][role="textbox"]';
function brEditor() { return document.querySelector(BR_EDITOR_SEL); }
// 에디터(글 입력창) 바로 옆에 붙도록 위치 계산
function brReposition() {
if (!_brEl) return;
const ed = document.querySelector('#edra-editor') || brEditor();
_brEl.style.left = 'auto';
if (!ed) { _brEl.style.right = (window.innerWidth - 60) + 'px'; _brEl.style.top = '130px'; return; }
const r = ed.getBoundingClientRect();
// 패널 우측 가장자리 = 에디터 왼쪽 가장자리 → 탭이 입력창 바로 왼쪽에, 서랍은 왼쪽으로 열림
_brEl.style.right = Math.max(0, Math.round(window.innerWidth - r.left)) + 'px';
_brEl.style.top = Math.max(8, Math.round(r.top)) + 'px';
}
// 사용자가 에디터에서 마지막으로 둔 커서(선택)를 기억 → 삽입 시 그 위치에
function brTrackSel() {
const ed = brEditor(); if (!ed) return;
const sel = window.getSelection();
if (sel && sel.rangeCount && ed.contains(sel.anchorNode)) _brLastSel = sel.getRangeAt(0).cloneRange();
}
function brB64Once(str) {
const bytes = new TextEncoder().encode(str); let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
return btoa(bin);
}
function brBase64(str, n) { let s = str; for (let i = 0; i < n; i++) s = brB64Once(s); return s; }
function brailleEncode(str) { return str ? BR.encode(str) : ''; }
function brConvert() {
if (!_brEl) return;
if (CFG.BR_MODE === 'code') return; // 코드 모드는 아래 brGenerateCode(패널 내부 UI)로만 생성
const txt = _brEl.querySelector('.br-in').value;
const res = (CFG.BR_MODE === 'base64') ? (txt ? brBase64(txt, CFG.BR_COUNT) : '') : brailleEncode(txt);
_brEl.dataset.full = res;
const out = _brEl.querySelector('.br-out');
out.textContent = res.length > 4000 ? res.slice(0, 4000) + ' …' : res;
const lenEl = _brEl.querySelector('.br-len');
if (lenEl) lenEl.textContent = res ? res.length.toLocaleString() + '자' : '';
}
// 코드(StegCloak) 생성: 패널 내부 UI(겉보기/비번)로 입력을 숨겨 결과 영역+dataset.full에 → 복사/삽입 재사용.
// (팝오버 없이 기존 변환기 UI 안에서 처리 — 사용자 요청.)
function brGenerateCode() {
if (!_brEl) return;
const secret = _brEl.querySelector('.br-in').value;
if (!secret || !secret.trim()) { showNavToast('숨길 내용을 입력하세요'); return; }
const cover = _brEl.querySelector('.br-cover-txt').value.trim();
if (!cover || cover.split(/\s+/).length < 2) { showNavToast('겉보기 문장은 2단어 이상이어야 합니다'); return; }
// 비번: 채널 우선 → 없으면 공동 1번 → 그것도 없으면 아래 입력칸. 무엇을 썼는지 토스트로 표시.
const info = getStegEncodePw();
const pw = info ? info.pw : _brEl.querySelector('.br-code-pw').value.trim();
const srcLabel = info ? stegPwLabel(info) : '입력한 비번';
if (!pw) { showNavToast('비밀번호를 입력하세요 (또는 채널/공동 비번 등록)'); return; }
const btn = _brEl.querySelector('.br-conv'); btn.disabled = true; const label = btn.textContent; btn.textContent = '생성 중…';
SC.hide(secret, pw, cover, true).then(out => {
_brEl.dataset.full = out;
_brEl.querySelector('.br-out').textContent = out;
const lenEl = _brEl.querySelector('.br-len'); if (lenEl) lenEl.textContent = out.length.toLocaleString() + '자';
showNavToast(`코드 생성됨 · ${srcLabel} 「${pw}」`);
}).catch(e => showNavToast('코드 생성 실패: ' + (e.message || e))).finally(() => { btn.disabled = false; btn.textContent = label; });
}
function brScheduleConvert() { if (CFG.BR_LIVE) { clearTimeout(_brConvTimer); _brConvTimer = setTimeout(brConvert, 150); } }
function brInsert() {
const full = _brEl && _brEl.dataset.full || ''; if (!full) return;
const ed = brEditor();
if (!ed) { showNavToast('에디터를 찾지 못했습니다'); return; }
ed.focus();
// 에디터에 마지막으로 둔 커서 위치 복원 (없으면 현재 위치). 최상단 강제 삽입 방지.
if (_brLastSel && ed.contains(_brLastSel.startContainer)) {
const sel = window.getSelection();
sel.removeAllRanges(); sel.addRange(_brLastSel);
}
try { document.execCommand('insertText', false, full); }
catch (e) { showNavToast('삽입 실패 — 복사 후 붙여넣어 주세요'); return; }
brTrackSel(); // 삽입 후 커서 위치 갱신(연속 삽입 대비)
}
function brSyncModeUI() {
if (!_brEl) return;
const isCode = CFG.BR_MODE === 'code';
_brEl.querySelectorAll('.br-seg button').forEach(b => b.classList.toggle('on', b.dataset.mode === CFG.BR_MODE));
_brEl.querySelector('.br-count-row').classList.toggle('br-hide', CFG.BR_MODE !== 'base64');
_brEl.querySelector('.br-live-row').classList.toggle('br-hide', isCode); // 코드엔 라이브 변환 없음
const conv = _brEl.querySelector('.br-conv');
conv.classList.toggle('br-hide', isCode ? false : CFG.BR_LIVE); // 코드는 '코드 생성' 항상 표시
conv.textContent = isCode ? '코드 생성' : '변환';
_brEl.querySelector('.br-outlab').textContent = isCode ? '코드' : (CFG.BR_MODE === 'base64' ? '결과' : '점자');
const ta = _brEl.querySelector('.br-in');
if (ta) ta.setAttribute('placeholder', isCode ? '숨길 내용 입력' : '한글/텍스트 입력');
// 코드 모드: 겉보기/비번 입력 UI를 패널 내부에 표시(팝오버 대체). 채널 비번이 있으면 비번 입력행 숨김.
const codeBox = _brEl.querySelector('.br-code-box');
if (codeBox) codeBox.classList.toggle('br-hide', !isCode);
if (isCode) {
const pwRow = _brEl.querySelector('.br-code-pw-row');
if (pwRow) pwRow.classList.toggle('br-hide', getStegPwList().length > 0);
}
}
function mountBraillePanel() {
if (!CFG.BRAILLE_PANEL || document.getElementById('b64d-br') || !document.body) return;
const wrap = document.createElement('div'); wrap.id = 'b64d-br';
wrap.innerHTML =
'<div id="b64d-br-draw"><div id="b64d-br-inner">' +
'<div class="br-h">변환기<button class="br-x" title="닫기">✕</button></div>' +
'<div class="br-seg"><button data-mode="braille">점자</button><button data-mode="base64">base64</button><button data-mode="code">코드</button></div>' +
'<div class="br-row br-count-row"><span>반복 횟수</span><span class="br-step"><button class="br-dec">−</button><b class="br-cnt">1</b><button class="br-inc">+</button></span></div>' +
'<div class="br-row br-live-row"><span>라이브 변환</span><span class="br-sw br-live"></span></div>' +
'<div class="br-code-box br-hide">' +
'<div class="br-row"><span>겉보기</span><span class="br-cover-ctl"><select class="br-cover-sel"></select><button class="br-cover-gen" type="button" title="새 문장 생성">🎲</button></span></div>' +
'<textarea class="br-cover-txt" placeholder="겉보기 문장 (2단어 이상)"></textarea>' +
'<div class="br-row br-code-pw-row"><span>비번</span><input class="br-code-pw" type="text" autocomplete="off" placeholder="채널 비번 없을 때"></div>' +
'</div>' +
'<div><div class="br-lab"><span>입력</span></div><textarea class="br-in" placeholder="한글/텍스트 입력"></textarea></div>' +
'<div><div class="br-lab"><span class="br-outlab">결과</span><span class="br-len"></span></div><div class="br-out"></div></div>' +
'<div class="br-btns"><button class="br-conv">변환</button><button class="br-copy">복사</button><button class="br-pri br-ins">삽입</button></div>' +
'</div></div>' +
'<button id="b64d-br-tab" title="변환기 열기/닫기"><span>⠿ 변환</span></button>';
document.body.appendChild(wrap);
_brEl = wrap;
wrap.querySelector('#b64d-br-tab').addEventListener('click', () => wrap.classList.toggle('open'));
wrap.querySelector('.br-x').addEventListener('click', () => wrap.classList.remove('open'));
wrap.querySelectorAll('.br-seg button').forEach(b => b.addEventListener('click', () => {
CFG.BR_MODE = b.dataset.mode; GM_setValue('brMode', CFG.BR_MODE); brSyncModeUI();
if (CFG.BR_MODE === 'code') { // 코드는 [코드 생성]으로 생성 → 모드 전환 시 이전 결과 비움
wrap.dataset.full = ''; wrap.querySelector('.br-out').textContent = '';
const l = wrap.querySelector('.br-len'); if (l) l.textContent = '';
} else brConvert();
}));
const cntEl = wrap.querySelector('.br-cnt'); cntEl.textContent = CFG.BR_COUNT;
wrap.querySelector('.br-dec').addEventListener('click', () => { CFG.BR_COUNT = Math.max(1, CFG.BR_COUNT - 1); GM_setValue('brCount', CFG.BR_COUNT); cntEl.textContent = CFG.BR_COUNT; brConvert(); });
wrap.querySelector('.br-inc').addEventListener('click', () => { CFG.BR_COUNT = Math.min(20, CFG.BR_COUNT + 1); GM_setValue('brCount', CFG.BR_COUNT); cntEl.textContent = CFG.BR_COUNT; brConvert(); });
const liveSw = wrap.querySelector('.br-live'); liveSw.classList.toggle('on', CFG.BR_LIVE);
liveSw.addEventListener('click', () => { CFG.BR_LIVE = !CFG.BR_LIVE; GM_setValue('brLive', CFG.BR_LIVE); liveSw.classList.toggle('on', CFG.BR_LIVE); brSyncModeUI(); brConvert(); });
wrap.querySelector('.br-in').addEventListener('input', brScheduleConvert);
// 변환 버튼: 코드 모드면 패널 내부 UI로 코드 생성(brGenerateCode), 그 외엔 일반 변환.
wrap.querySelector('.br-conv').addEventListener('click', () => { if (CFG.BR_MODE === 'code') brGenerateCode(); else brConvert(); });
wrap.querySelector('.br-copy').addEventListener('click', () => { const f = wrap.dataset.full || ''; if (f && navigator.clipboard) { navigator.clipboard.writeText(f); showNavToast('복사됨'); } });
wrap.querySelector('.br-ins').addEventListener('click', brInsert);
// 코드 모드 겉보기 문장 UI: 드롭다운=프리셋 불러오기, 🎲=자동 생성, 텍스트영역=실제 사용할 겉보기(편집 가능).
const coverSel = wrap.querySelector('.br-cover-sel'), coverTxt = wrap.querySelector('.br-cover-txt');
const optDef = document.createElement('option'); optDef.value = ''; optDef.textContent = '불러오기…'; coverSel.appendChild(optDef);
STEG_COVERS.forEach((c, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = c.label; coverSel.appendChild(o); });
coverSel.addEventListener('change', () => { if (coverSel.value !== '') { coverTxt.value = STEG_COVERS[+coverSel.value].text; coverSel.value = ''; } });
wrap.querySelector('.br-cover-gen').addEventListener('click', () => { coverTxt.value = stegGenerateCover(); });
coverTxt.value = stegGenerateCover(); // 초기 겉보기 문장(자동 생성 → 매번 다양)
brSyncModeUI();
if (CFG.BR_PINNED) wrap.classList.add('open'); // 항상 열어두기 옵션
// UI 크기(SETTINGS_SCALE) 반영 + 에디터(글 입력창) 옆에 위치
wrap.style.transform = 'scale(' + (CFG.SETTINGS_SCALE / 100) + ')';
brReposition();
[60, 250, 700, 1500].forEach(t => setTimeout(brReposition, t)); // 에디터가 늦게 뜨는 SPA 대비
window.addEventListener('scroll', brReposition, true);
window.addEventListener('resize', brReposition);
document.addEventListener('selectionchange', brTrackSel);
}
function unmountBraillePanel() {
window.removeEventListener('scroll', brReposition, true);
window.removeEventListener('resize', brReposition);
document.removeEventListener('selectionchange', brTrackSel);
_brEl = null; _brLastSel = null;
const e = document.getElementById('b64d-br'); if (e) e.remove();
}
function syncBraillePanel() {
if (CFG.BRAILLE_PANEL && isWritePage()) mountBraillePanel();
else unmountBraillePanel();
}
let _lastUrl = location.href;
function onUrlChange() {
const cur = location.href;
if (cur !== _lastUrl) {
const prev = _lastUrl;
_lastUrl = cur;
// 작성↔읽기 페이지 전환 시 알림 (설정 NAV_TOAST)
if (CFG.NAV_TOAST) {
const toW = isWritePage(cur), fromW = isWritePage(prev);
if (toW && !fromW) showNavToast('✏️ 작성 모드');
else if (!toW && fromW) showNavToast('📄 읽기 모드');
}
// write 페이지에서 떠나는 경우 표시 (flush() 실행 여부와 무관하게 URL로 직접 판단)
if (isWritePage(prev)) _wasWritePage = true;
syncBraillePanel(); // 작성↔읽기 전환에 따라 변환 패널 장착/해제
// 이전 URL 변경 타이머 취소 후 반드시 재시작 (pending=true 상태에서도 동작해야 함)
if (_urlFlushTimer) { clearTimeout(_urlFlushTimer); _urlFlushTimer = null; }
pending = false;
if (mo) mo.disconnect();
processedRaws.clear();
processedListRaws.clear();
_liveCards.clear(); // 페이지 이동 시 키↔카드 등록 비움(메모리 상한)
_scrolledOnce = false;
_kpIdx = -1;
_expandClicks = 0;
if (_moreTimer) { clearTimeout(_moreTimer); _moreTimer = null; }
document.querySelectorAll(KP_FOCUSED_SEL).forEach(el => el.classList.remove('kp-focused'));
dlpHide();
// SPA로 write 페이지 진입 시 즉시 DOM 복원 (에디터 간섭 방지)
if (!isWritePage(prev) && isWritePage(cur)) {
document.getElementById('b64d-link-panel')?.remove();
_panelCardMap.clear();
applyLive(); // DOM 복원 → schedule() → flush()에서 isWritePage()로 조기 종료
return;
}
// write 페이지 이탈 시: flush() early-return이 observe()를 생략해 MO가 끊겨있음.
// pending=false 상태 그대로 MO 즉시 재연결 → Svelte가 읽기 콘텐츠 추가 시 schedule() 즉시 동작.
// (pending=true를 먼저 세우면 MO → schedule()이 바로 return해버려 효과 없음)
if (isWritePage(prev)) {
observe(); // pending=false 유지 → MO 발화 시 schedule() 즉시 처리
_urlFlushTimer = setTimeout(() => { _urlFlushTimer = null; flush(); }, 500); // 폴백
return;
}
pending = true;
_urlFlushTimer = setTimeout(() => { _urlFlushTimer = null; pending = false; flush(); }, 500);
}
}
window.addEventListener('popstate', onUrlChange);
['pushState', 'replaceState'].forEach(method => {
const orig = history[method];
history[method] = function (...args) { const r = orig.apply(this, args); onUrlChange(); return r; };
});
// ── SPA 내비게이션 감지 보강 (코네=SvelteKit 대응) ──
// SvelteKit은 history.pushState 원본 참조를 선점해 위 패치를 우회하고, 일부는 Navigation API로만
// 라우팅한다. 그래서 popstate/pushState 훅이 write↔일반 전환을 놓친다.
// → Navigation API(즉시) + 0.3초 폴링(범용 폴백, 문자열 비교 1회라 비용 미미)으로 모든 URL 변경 포착.
if (window.navigation && typeof window.navigation.addEventListener === 'function') {
window.navigation.addEventListener('navigatesuccess', onUrlChange); // URL 확정 후 발화 → location.href 최신
}
setInterval(onUrlChange, 300);
syncBraillePanel(); // 초기 로드 시 작성 페이지면 변환 패널 장착
// 탭 포커스 이탈(다른 탭 클릭·새 탭 열기) 또는 새로고침 시 미리보기 강제 숨김
window.addEventListener('blur', dlpHide);
window.addEventListener('beforeunload', dlpHide);
document.documentElement.style.setProperty('--b64-dlp-scale', CFG.PREVIEW_SCALE / 100);
// 모바일 전용 FAB (설정 패널 진입점 — 단축키 대체)
if (IS_MOBILE && !document.getElementById('b64d-fab')) {
const fab = document.createElement('button');
fab.id = 'b64d-fab';
fab.title = 'KONE+ 설정';
fab.innerHTML = '⚙';
fab.addEventListener('touchstart', e => { e.preventDefault(); openSettingsPanel(); }, { passive: false });
document.body.appendChild(fab);
}
flush();
observe();
// ── 초기 로딩 하이드레이션 안전망 ──
// 코네(SvelteKit)가 페이지 진입 직후 본문을 재렌더하며 우리가 끼운 카드를 떼어내는 일이 있다.
// 특히 NO_DUP에선 '첫 카드'가 사라지면 나머지는 숨김 상태라 다운로드 카드가 통째로 비어 보였다.
// (토글로 켜면 이미 안착된 DOM이라 정상) → 하이드레이션이 끝날 시점들에 재flush해 _liveCards(키 기준)
// 생존확인으로 사라진 카드를 다시 만든다. flush는 멱등(이미 있는 카드는 건드리지 않음)이라 안전.
if (/kone\.gg$/.test(host)) [400, 1200, 2500, 4500].forEach(t => setTimeout(() => { if (!isWritePage()) schedule(); }, t));
// GreasyFork 자동 업데이트 확인 (kone.gg 접속 시 1회, 내부 6시간 throttle)
if (CFG.UPDATE_CHECK && /kone\.gg$/.test(host)) setTimeout(() => runUpdateCheck(false), 4000);
}); // domReady end
})();