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 16.0
// @description Base64/braille decoder, DLsite/Steam product cards, link-health checker, and password auto-fill for kone.gg.
// @description:ko base64/점자 디코딩 · DLsite/Steam 링크 카드 · 링크 활성화 체크 · 비번 자동입력. kone.gg 전용.
// @author KRUI
// @match *://kone.gg/*
// @match *://*.kone.gg/*
// @match *://kio.ac/*
// @match *://kiosk.ac/*
// @match *://mega.nz/*
// @match *://transfer.it/*
// @match *://gofile.io/*
// @match *://workupload.com/*
// @match *://mypikpak.com/*
// @exclude *://kone.gg/s/*/write
// @exclude *://kone.gg/s/*/write/*
// @exclude *://*.kone.gg/s/*/write
// @exclude *://*.kone.gg/s/*/write/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @connect dlsite.com
// @connect store.steampowered.com
// @connect kio.ac
// @connect kiosk.ac
// @connect *
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const host = location.hostname;
/* ================================================================
DOM 준비 후 실행
================================================================ */
function domReady(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn, { once: true });
}
domReady(function () {
/* ================================================================
설정
================================================================ */
const CFG = {
CONTENT_DECODE: GM_getValue('contentDecode', true),
LIST_DECODE: GM_getValue('listDecode', true),
LINK_CARD: GM_getValue('linkCard', true),
LINK_CHECK: GM_getValue('linkCheck', true),
DRAG_DECODE: GM_getValue('dragDecode', true),
BRAILLE_DECODE: GM_getValue('brailleDecode', true),
DLSITE_PREVIEW: GM_getValue('dlsitePreview', true),
SCROLL_TO_FIRST: GM_getValue('scrollToFirst', false),
NAV_TARGET: GM_getValue('navTarget', 'links'), // 'links' | 'products' | 'both'
PW_AUTO: GM_getValue('pwAuto', true),
LIVE_APPLY: GM_getValue('liveApply', true),
CONTENT_SELECTORS: [
'#post-article', '#post-comment', '.article-body',
'div.fr-view.article-content',
'body div.article-body > div.fr-view',
'p.text-sm.whitespace-pre-wrap',
'div.text-sm.whitespace-pre-wrap',
],
LIST_SELECTORS: [
'.title', '.subject', '.list-title', '.gallery-title', '.item-title',
'td.title', 'td.subject', 'td.title a', 'td.subject a',
'.board-list td a', '.list-body td a',
'li.list-item .title', '.post-list .title',
],
DEAD_PATTERNS: {
'kio.ac': ['컬렉션을 찾을 수 없음', '만료되었습니다'],
'kiosk.ac': ['컬렉션을 찾을 수 없음', '만료되었습니다'],
// mega.nz: 순수 SPA, 초기 HTML에 에러 없음, HEAD도 항상 200 → 감지 불가하여 제거
'transfer.it': ["can't find this transfer", 'oops', 'not found', 'expired'],
'gofile.io': ['does not exist', 'content not found', 'not found', 'error'],
'workupload.com': ['not found', 'expired'],
'mypikpak.com': ['share has expired', 'been deleted', 'been detected', 'link is invalid', 'does not exist'],
},
PW_SITES: {
'kio.ac': {
inputSel: '.overflow-auto.max-w-full.grow.p-1 input:nth-of-type(1)',
btnSel: '.flex.flex-col-reverse button:nth-of-type(1)',
successSel: '.files-list, #download-section',
errorPat: [/비밀번호가 일치하지 않/, /incorrect password/i, /invalid password/i],
mode: 'kio',
},
'kiosk.ac': {
inputSel: '.input.shadow-xl.flex-grow',
btnSel: '.btn.btn-ghost.w-full.mt-2.rounded-md',
successSel: '#vexplorer-body',
errorPat: [/비밀번호가 일치하지 않/, /incorrect/i],
mode: 'kio',
},
'mega.nz': {
inputSel: 'input[name="decrypt-link"], #password-decrypt-input',
btnSel: 'button.decrypt-button, .mega-component.decrypt-button',
dlBtnSel: '.mega-button.positive.js-default-download.js-standard-download, .mega-button.large.positive.download.continue-download',
successSel: '.mega-button.positive.js-default-download, .fm-item, .file-folder-view:not(.no-content) .item-type-folder',
errorPat: [/invalid password/i, /incorrect/i, /wrong password/i],
mode: 'generic',
triggerDelay: 2800,
btnDelay: 500, // React가 input 이벤트 처리 후 버튼 활성화까지 대기
},
'transfer.it': {
inputSel: 'input[name="msg-dialog-input"]',
btnSel: 'button.it-button.xl-size.js-positive-btn',
dlBtnSel: 'section.it-box.lg-shadow.modal.ready-to-download-box button.it-button.xl-size.js-download, .it-button.xl-size[class*="download"]',
successSel: '.ready-to-download-box, .file-manager-box',
errorPat: [/incorrect password/i, /wrong password/i, /invalid/i],
mode: 'transfer',
triggerDelay: 1500,
},
'gofile.io': {
inputSel: '#filesErrorPasswordInput',
btnSel: '#filesErrorPasswordButton',
successSel: '.row.align-items-center.contentRow',
errorPat: [/incorrect/i, /wrong/i, /invalid/i],
mode: 'generic',
triggerDelay: 1000,
},
'workupload.com': {
inputSel: '#passwordprotected_file_password',
btnSel: '#passwordprotected_file_submit',
dlBtnSel: '.btn.btn-prio',
successSel: '.btn.btn-prio',
errorPat: [/incorrect/i, /wrong/i],
mode: 'generic',
triggerDelay: 1000,
},
'mypikpak.com': {
inputSel: '.el-input__inner[placeholder*="Password"], .el-input__inner[placeholder*="password"]',
btnSel: '.el-button--primary, .el-dialog__footer button:last-of-type',
successSel: '.file-list, [class*="file-list"], .drive-main',
errorPat: [/incorrect/i, /wrong/i, /invalid/i],
mode: 'generic',
triggerDelay: 1500,
},
'drive.google.com': {
mode: 'gdrive',
},
'drive.usercontent.google.com': {
dlBtnSel: '.goog-inline-block.jfk-button.jfk-button-action, input[type="submit"]',
mode: 'gdrive-dl',
},
},
};
const DONE_ATTR = 'data-b64d';
const LTDONE_ATTR = 'data-b64lt';
const TITLE_CARD_ATTR = 'data-b64tc';
const RAW_ATTR = 'data-b64d-raw'; // processContentNode wrap에 원본 텍스트 저장 (즉시 적용 복원용)
const ORIG_ATTR = 'data-b64d-orig'; // convertProductLinks 교체 전 <a> outerHTML 저장 (즉시 적용 복원용)
const MIN_B64 = 8;
// 조상 중 이미 처리된(DONE_ATTR/LTDONE_ATTR) 노드가 있는지 확인
function hasProcessedAncestor(node) {
let n = node.parentElement;
while (n) {
if (n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR)) return true;
n = n.parentElement;
}
return false;
}
const MAX_DECODE = 5;
let HOTKEY = GM_getValue('hotkey', 'Alt+Shift+K');
function buildCombo(e) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Meta');
const k = e.key;
if (!['Control', 'Alt', 'Shift', 'Meta'].includes(k)) {
parts.push(k.length === 1 ? k.toUpperCase() : k);
}
return parts.join('+');
}
document.addEventListener('keydown', e => {
const tag = (document.activeElement?.tagName || '').toUpperCase();
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
if (buildCombo(e) === HOTKEY) { e.preventDefault(); openSettingsPanel(); }
});
/* ================================================================
비번 저장소
================================================================ */
function getPwList() {
try {
const raw = GM_getValue('pwList', null);
if (raw === null) return null;
return JSON.parse(raw);
} catch(e) { return []; }
}
function savePwList(arr) {
GM_setValue('pwList', JSON.stringify(arr.filter(Boolean)));
}
/* ================================================================
메뉴
================================================================ */
let menuIds = {};
function refreshMenu() {
Object.values(menuIds).forEach(id => { try { GM_unregisterMenuCommand(id); } catch(e){} });
menuIds = {};
menuIds['settings'] = GM_registerMenuCommand('⚙ KONE + 설정', openSettingsPanel);
}
/* ================================================================
비번 관리 UI (커스텀 모달)
================================================================ */
function openPwManager() {
if (document.getElementById('b64d-pw-manager')) return;
function closePm() {
document.getElementById('b64d-pw-manager')?.remove();
document.getElementById('b64d-pw-backdrop')?.remove();
}
function renderList(listEl) {
listEl.innerHTML = '';
const pws = getPwList() || [];
if (!pws.length) {
const em = document.createElement('div');
em.className = 'b64dpm-empty';
em.textContent = '(등록된 비번 없음)';
listEl.appendChild(em);
return;
}
pws.forEach((pw, i) => {
const item = document.createElement('div');
item.className = 'b64dpm-item';
const sp = document.createElement('span');
sp.textContent = `${i + 1}. ${pw}`;
const del = document.createElement('button');
del.className = 'b64dpm-del';
del.textContent = '✕';
del.title = '삭제';
del.addEventListener('click', () => {
const cur = getPwList() || [];
savePwList(cur.filter((_, j) => j !== i));
renderList(listEl);
});
item.appendChild(sp);
item.appendChild(del);
listEl.appendChild(item);
});
}
const hdr = document.createElement('div');
hdr.className = 'b64ds-header';
hdr.innerHTML = '<span>🔑 비번 목록</span>';
const x = document.createElement('button');
x.className = 'b64ds-close';
x.textContent = '✕';
x.addEventListener('click', closePm);
hdr.appendChild(x);
const listEl = document.createElement('div');
listEl.className = 'b64dpm-list';
const inp = document.createElement('input');
inp.className = 'b64dpm-input';
inp.type = 'text';
inp.placeholder = '추가할 비번 (쉼표로 구분)';
inp.setAttribute('autocomplete', 'off');
const addBtn = document.createElement('button');
addBtn.className = 'b64dpm-add';
addBtn.textContent = '추가';
addBtn.addEventListener('click', () => {
const val = inp.value.trim();
if (!val) return;
const cur = getPwList() || [];
const newPws = val.split(',').map(s => s.trim()).filter(Boolean);
savePwList([...new Set([...cur, ...newPws])]);
inp.value = '';
renderList(listEl);
});
inp.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
const inputRow = document.createElement('div');
inputRow.className = 'b64dpm-input-row';
inputRow.appendChild(inp);
inputRow.appendChild(addBtn);
const clearBtn = document.createElement('button');
clearBtn.className = 'b64dpm-clear';
clearBtn.textContent = '전체 삭제';
let clearConfirming = false;
clearBtn.addEventListener('click', () => {
if (!clearConfirming) {
clearConfirming = true;
clearBtn.textContent = '정말 삭제하시겠습니까? (다시 클릭)';
clearBtn.style.cssText = 'background:#ef4444;color:#fff;';
setTimeout(() => {
if (!clearConfirming) return;
clearConfirming = false;
clearBtn.textContent = '전체 삭제';
clearBtn.style.cssText = '';
}, 3000);
return;
}
savePwList([]);
renderList(listEl);
clearConfirming = false;
clearBtn.textContent = '전체 삭제';
clearBtn.style.cssText = '';
});
const body = document.createElement('div');
body.className = 'b64ds-body';
body.appendChild(listEl);
body.appendChild(inputRow);
body.appendChild(clearBtn);
const panel = document.createElement('div');
panel.id = 'b64d-pw-manager';
panel.dataset.theme = getSystemTheme();
panel.addEventListener('click', e => e.stopPropagation());
panel.appendChild(hdr);
panel.appendChild(body);
const bd = document.createElement('div');
bd.id = 'b64d-pw-backdrop';
bd.addEventListener('click', closePm);
document.body.appendChild(bd);
document.body.appendChild(panel);
renderList(listEl);
inp.focus();
}
/* ================================================================
설정 패널 (버튼 식 UI)
================================================================ */
function getSystemTheme() {
// kone.gg 자체 다크모드(Tailwind class 방식) 우선 확인
if (document.documentElement.classList.contains('dark')) return 'dark';
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyThemeToOverlays() {
const theme = getSystemTheme();
const s = document.getElementById('b64d-settings');
if (s) s.dataset.theme = theme;
const p = document.getElementById('b64d-pw-manager');
if (p) p.dataset.theme = theme;
}
window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', applyThemeToOverlays);
// kone.gg가 <html class="dark"> 토글 방식을 사용하므로 class 변화 감지
new MutationObserver(applyThemeToOverlays).observe(
document.documentElement, { attributes: true, attributeFilter: ['class'] }
);
function closeSettingsPanel() {
document.getElementById('b64d-settings')?.remove();
document.getElementById('b64d-settings-backdrop')?.remove();
}
function openSettingsPanel() {
if (document.getElementById('b64d-settings')) { closeSettingsPanel(); return; }
function mkToggle(key, stKey, label) {
const btn = document.createElement('button');
btn.className = `b64ds-toggle${CFG[key] ? ' active' : ''}`;
btn.innerHTML = `<span>${label}</span><span class="b64ds-badge">${CFG[key] ? 'ON' : 'OFF'}</span>`;
btn.addEventListener('click', () => {
CFG[key] = !CFG[key];
GM_setValue(stKey, CFG[key]);
btn.classList.toggle('active', CFG[key]);
btn.querySelector('.b64ds-badge').textContent = CFG[key] ? 'ON' : 'OFF';
if (key !== 'LIVE_APPLY' && CFG.LIVE_APPLY) applyLive();
});
return btn;
}
function mkSep(t) { const d = document.createElement('div'); d.className = 'b64ds-sep'; d.textContent = t; return d; }
function mkAction(t, fn) {
const btn = document.createElement('button');
btn.className = 'b64ds-action'; btn.textContent = t;
btn.addEventListener('click', fn); return btn;
}
// 단축키 행
const hotkeyRow = document.createElement('div');
hotkeyRow.className = 'b64ds-hotkey-row';
const hotkeyLabel = document.createElement('span');
hotkeyLabel.className = 'b64ds-hotkey-label';
hotkeyLabel.textContent = '설정 단축키';
const hotkeyR = document.createElement('div');
hotkeyR.className = 'b64ds-hotkey-r';
const hotkeyKey = document.createElement('span');
hotkeyKey.className = 'b64ds-hotkey-key';
hotkeyKey.textContent = HOTKEY;
const hotkeyBtn = document.createElement('button');
hotkeyBtn.className = 'b64ds-hotkey-btn';
hotkeyBtn.textContent = '변경';
let capturing = false;
let captureListener = null;
hotkeyBtn.addEventListener('click', () => {
if (capturing) return;
capturing = true;
hotkeyBtn.classList.add('capturing');
hotkeyBtn.textContent = '입력…';
captureListener = e => {
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return; // 수식키만 누름: 대기
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
// 취소
} else if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
// 수식키+일반키 조합만 허용
HOTKEY = buildCombo(e);
GM_setValue('hotkey', HOTKEY);
hotkeyKey.textContent = HOTKEY;
} else {
return; // 수식키 없는 단일 키는 무시하고 계속 대기
}
capturing = false;
hotkeyBtn.classList.remove('capturing');
hotkeyBtn.textContent = '변경';
document.removeEventListener('keydown', captureListener, true);
};
document.addEventListener('keydown', captureListener, true);
});
hotkeyR.appendChild(hotkeyKey);
hotkeyR.appendChild(hotkeyBtn);
hotkeyRow.appendChild(hotkeyLabel);
hotkeyRow.appendChild(hotkeyR);
const hdr = document.createElement('div');
hdr.className = 'b64ds-header';
hdr.innerHTML = '<span>KONE + 설정</span>';
const x = document.createElement('button');
x.className = 'b64ds-close'; x.textContent = '✕';
x.addEventListener('click', closeSettingsPanel);
hdr.appendChild(x);
const body = document.createElement('div');
body.className = 'b64ds-body';
function mkNote(t) {
const d = document.createElement('div');
d.style.cssText = 'font-size:11px;color:var(--b64-text2);padding:2px 4px 6px;line-height:1.4;';
d.textContent = t;
return d;
}
function mkTriple(key, stKey, label, opts) {
const row = document.createElement('div');
row.className = 'b64ds-triple-row';
const lbl = document.createElement('span');
lbl.className = 'b64ds-triple-label';
lbl.textContent = label;
const btns = document.createElement('div');
btns.className = 'b64ds-triple-btns';
opts.forEach(({ value, text }) => {
const b = document.createElement('button');
b.className = `b64ds-triple-btn${CFG[key] === value ? ' active' : ''}`;
b.textContent = text;
b.addEventListener('click', () => {
CFG[key] = value;
GM_setValue(stKey, value);
btns.querySelectorAll('.b64ds-triple-btn').forEach(x => x.classList.toggle('active', x === b));
document.querySelectorAll('.b64-link.kp-focused, .b64-product-link.kp-focused')
.forEach(el => el.classList.remove('kp-focused'));
_kpIdx = -1;
});
btns.appendChild(b);
});
row.appendChild(lbl);
row.appendChild(btns);
return row;
}
[
mkSep('적용'),
mkToggle('LIVE_APPLY', 'liveApply', '설정 변경 즉시 적용'),
mkNote('OFF 시 다음 페이지 로드에 반영 · 긴 글에서 렉 방지'),
mkSep('본문'),
mkToggle('CONTENT_DECODE', 'contentDecode', '본문 번역'),
mkToggle('LIST_DECODE', 'listDecode', '목록 페이지 제목 번역'),
mkToggle('DRAG_DECODE', 'dragDecode', '드래그 자동 변환'),
mkToggle('BRAILLE_DECODE', 'brailleDecode', '점자 디코딩'),
mkSep('링크'),
mkToggle('LINK_CARD', 'linkCard', '링크 카드'),
mkToggle('LINK_CHECK', 'linkCheck', '링크 생존 확인'),
mkToggle('DLSITE_PREVIEW', 'dlsitePreview', '카드 호버 미리보기'),
mkToggle('SCROLL_TO_FIRST', 'scrollToFirst', '첫 다운로드 링크로 자동 스크롤'),
mkTriple('NAV_TARGET', 'navTarget', 'w/s 탐색 범위', [
{ value: 'links', text: '다운로드' },
{ value: 'products', text: '작품' },
{ value: 'both', text: '모두' },
]),
mkSep('비번'),
mkToggle('PW_AUTO', 'pwAuto', '비번 자동입력'),
mkAction('🔑 비번 목록 관리', openPwManager),
mkSep('단축키'),
hotkeyRow,
].forEach(el => body.appendChild(el));
const panel = document.createElement('div');
panel.id = 'b64d-settings';
panel.dataset.theme = getSystemTheme();
panel.addEventListener('click', e => e.stopPropagation());
panel.appendChild(hdr);
panel.appendChild(body);
const bd = document.createElement('div');
bd.id = 'b64d-settings-backdrop';
bd.addEventListener('click', closeSettingsPanel);
document.body.appendChild(bd);
document.body.appendChild(panel);
}
function maybeInitPwSetup() {
if (getPwList() !== null) return;
setTimeout(() => {
const ok = window.confirm('🔑 KONE +\n\n비번 자동입력이 처음 실행됩니다.\n지금 비번을 등록하시겠습니까?');
if (!ok) { savePwList([]); return; }
const input = window.prompt('비번 입력 (쉼표로 구분):\n예: pass1, 2024국룰');
if (input) {
const pws = input.split(',').map(s => s.trim()).filter(Boolean);
savePwList(pws);
window.alert(`✅ ${pws.length}개 등록됨.`);
} else { savePwList([]); }
}, 2000);
}
refreshMenu();
maybeInitPwSetup();
// 최초 설치 시 설정 패널 자동 오픈 (kone.gg에서만)
if (!GM_getValue('_b64d_installed', false)) {
GM_setValue('_b64d_installed', true);
if (/kone\.gg/.test(location.hostname)) {
setTimeout(openSettingsPanel, 2000);
}
}
/* ================================================================
CSS
================================================================ */
if (!document.getElementById('b64d-style')) {
const s = document.createElement('style');
s.id = 'b64d-style';
s.textContent = `
/* ── 다운로드 링크 카드 ── */
.b64-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 8px;margin:2px 0;
background:#f4f4f5;border:0.5px solid #d4d4d8;border-radius:8px;text-decoration:none;color:#18181b;
font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;
transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;}
@media(prefers-color-scheme:dark){.b64-link{background:#27272a;border-color:#3f3f46;color:#fafafa;}}
.b64-link:hover{background:#e4e4e7;border-color:#a1a1aa;}
@media(prefers-color-scheme:dark){.b64-link:hover{background:#3f3f46;border-color:#71717a;}}
.b64-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.55;}
.b64-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}
.b64-link.lk-checking .bl-arrow{animation:b64spin .8s linear infinite;display:inline-block;}
@keyframes b64spin{to{transform:rotate(360deg);}}
.b64-link.lk-alive{background:#f0fdf4;border-color:#86efac;color:#14532d;}
@media(prefers-color-scheme:dark){.b64-link.lk-alive{background:#14532d;border-color:#16a34a;color:#bbf7d0;}}
.b64-link.lk-dead{background:#fef2f2;border-color:#fca5a5;color:#7f1d1d;}
@media(prefers-color-scheme:dark){.b64-link.lk-dead{background:#7f1d1d;border-color:#dc2626;color:#fecaca;}}
.b64-link.lk-alive .bl-icon,.b64-link.lk-dead .bl-icon,
.b64-link.lk-alive .bl-arrow,.b64-link.lk-dead .bl-arrow{opacity:.7;}
/* ── 상품 링크 카드 (DLsite) ── */
.b64-product-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 8px;margin:2px 0;
background:#f5f3ff;border:0.5px solid #c4b5fd;border-radius:8px;text-decoration:none;color:#4c1d95;
font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;
transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;}
@media(prefers-color-scheme:dark){.b64-product-link{background:#2e1065;border-color:#7c3aed;color:#ddd6fe;}}
.b64-product-link:hover{background:#ede9fe;border-color:#a78bfa;}
@media(prefers-color-scheme:dark){.b64-product-link:hover{background:#4c1d95;border-color:#8b5cf6;}}
.b64-product-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.6;}
.b64-product-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-product-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}
/* ── Steam 카드 ── */
.b64-product-link.pl-steam{background:#f0f9ff;border-color:#7dd3fc;color:#075985;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-steam{background:#082f49;border-color:#0369a1;color:#bae6fd;}}
.b64-product-link.pl-steam:hover{background:#e0f2fe;border-color:#38bdf8;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-steam:hover{background:#0c4a6e;border-color:#0284c7;}}
/* ── Patreon 카드 (오렌지) ── */
.b64-product-link.pl-patreon{background:#fff7ed;border-color:#fdba74;color:#7c2d12;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-patreon{background:#431407;border-color:#c2410c;color:#fed7aa;}}
.b64-product-link.pl-patreon:hover{background:#ffedd5;border-color:#fb923c;}
@media(prefers-color-scheme:dark){.b64-product-link.pl-patreon:hover{background:#7c2d12;border-color:#ea580c;}}
/* ── 설정 패널 / 비번 모달 ── */
#b64d-settings,#b64d-pw-manager{
--b64-bg:#18181b;--b64-bg2:#27272a;--b64-bg3:#3f3f46;
--b64-text:#fafafa;--b64-text2:#a1a1aa;--b64-sep-c:#71717a;
--b64-on-bg:#0c4a6e;--b64-on-hov:#1a6a8f;--b64-on-text:#e0f2fe;
--b64-input-bg:#27272a;--b64-input-bd:#52525b;--b64-del-c:#ef4444;
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
width:300px;max-height:82vh;overflow-y:auto;border-radius:14px;
background:var(--b64-bg);color:var(--b64-text);
box-shadow:0 16px 48px rgba(0,0,0,.65);z-index:2147483641;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;}
#b64d-settings[data-theme="light"],#b64d-pw-manager[data-theme="light"]{
--b64-bg:#ffffff;--b64-bg2:#f4f4f5;--b64-bg3:#e4e4e7;
--b64-text:#18181b;--b64-text2:#52525b;--b64-sep-c:#71717a;
--b64-on-bg:#0369a1;--b64-on-hov:#0284c7;--b64-on-text:#ffffff;
--b64-input-bg:#ffffff;--b64-input-bd:#d4d4d8;}
#b64d-settings-backdrop,#b64d-pw-backdrop{
position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483640;backdrop-filter:blur(2px);}
#b64d-pw-manager{z-index:2147483642;}
#b64d-pw-backdrop{z-index:2147483641;}
.b64ds-header{
display:flex;align-items:center;justify-content:space-between;
padding:14px 16px;background:var(--b64-bg2);border-radius:14px 14px 0 0;
font-weight:700;font-size:15px;letter-spacing:-.01em;position:sticky;top:0;z-index:1;}
.b64ds-close{background:none;border:none;color:var(--b64-sep-c);cursor:pointer;font-size:18px;padding:0 4px;line-height:1;border-radius:4px;transition:color .15s;}
.b64ds-close:hover{color:var(--b64-text);}
.b64ds-body{padding:12px;display:flex;flex-direction:column;gap:5px;}
.b64ds-sep{font-size:12px;font-weight:700;color:var(--b64-sep-c);letter-spacing:.06em;text-transform:uppercase;padding:10px 4px 3px;}
.b64ds-toggle{
display:flex;align-items:center;justify-content:space-between;
width:100%;padding:11px 14px;border-radius:9px;cursor:pointer;
border:none;background:var(--b64-bg2);color:var(--b64-text2);text-align:left;
font-size:13px;font-family:inherit;transition:background .12s;}
.b64ds-toggle:hover{background:var(--b64-bg3);}
.b64ds-toggle.active{background:var(--b64-on-bg);color:var(--b64-on-text);}
.b64ds-toggle.active:hover{background:var(--b64-on-hov);}
.b64ds-badge{font-size:11px;font-weight:700;padding:2px 9px;border-radius:99px;letter-spacing:.03em;flex-shrink:0;background:rgba(128,128,128,.15);}
.b64ds-toggle.active .b64ds-badge{background:rgba(255,255,255,.2);}
.b64ds-action{
display:flex;align-items:center;gap:8px;
width:100%;padding:11px 14px;border-radius:9px;cursor:pointer;
border:none;background:var(--b64-bg2);color:var(--b64-text2);font-size:13px;
font-family:inherit;text-align:left;transition:background .12s;}
.b64ds-action:hover{background:var(--b64-bg3);}
.b64ds-hotkey-row{
display:flex;align-items:center;justify-content:space-between;gap:8px;
padding:9px 14px;border-radius:9px;background:var(--b64-bg2);}
.b64ds-hotkey-label{font-size:13px;color:var(--b64-text2);}
.b64ds-hotkey-r{display:flex;align-items:center;gap:6px;}
.b64ds-hotkey-key{
font-size:11px;font-family:monospace;padding:3px 9px;border-radius:6px;
background:var(--b64-bg3);color:var(--b64-text);border:1px solid var(--b64-input-bd);}
.b64ds-hotkey-btn{
font-size:11px;padding:3px 9px;border-radius:6px;cursor:pointer;
border:1px solid var(--b64-input-bd);background:var(--b64-bg2);color:var(--b64-text2);
font-family:inherit;transition:background .12s,color .12s;}
.b64ds-hotkey-btn:hover{background:var(--b64-bg3);}
.b64ds-hotkey-btn.capturing{color:var(--b64-on-text);background:var(--b64-on-bg);border-color:transparent;}
.b64dpm-list{display:flex;flex-direction:column;gap:4px;max-height:200px;overflow-y:auto;padding:2px 0;}
.b64dpm-empty{font-size:12px;color:var(--b64-sep-c);padding:8px 2px;}
.b64dpm-item{
display:flex;align-items:center;justify-content:space-between;
padding:9px 12px;border-radius:8px;background:var(--b64-bg2);}
.b64dpm-item span{font-size:13px;font-family:monospace;color:var(--b64-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;margin-right:8px;}
.b64dpm-del{
background:none;border:none;color:var(--b64-sep-c);cursor:pointer;
font-size:16px;padding:0 2px;flex-shrink:0;line-height:1;transition:color .15s;}
.b64dpm-del:hover{color:var(--b64-del-c);}
.b64dpm-input-row{display:flex;gap:6px;margin-top:6px;}
.b64dpm-input{
flex:1;padding:9px 12px;border-radius:8px;font-size:13px;
border:1px solid var(--b64-input-bd);background:var(--b64-input-bg);
color:var(--b64-text);font-family:inherit;outline:none;min-width:0;}
.b64dpm-input:focus{border-color:var(--b64-on-bg);}
.b64dpm-add{
padding:9px 14px;border-radius:8px;cursor:pointer;font-size:13px;
border:none;background:var(--b64-on-bg);color:var(--b64-on-text);
font-family:inherit;transition:background .12s;white-space:nowrap;flex-shrink:0;}
.b64dpm-add:hover{background:var(--b64-on-hov);}
.b64dpm-clear{
padding:9px 12px;border-radius:8px;cursor:pointer;font-size:12px;
border:1px solid var(--b64-del-c);background:transparent;color:var(--b64-del-c);
font-family:inherit;transition:background .12s,color .12s;width:100%;margin-top:4px;text-align:center;}
.b64dpm-clear:hover{background:var(--b64-del-c);color:#fff;}
/* ── 공통 유틸 ── */
#b64d-drag-tooltip{
position:fixed;z-index:2147483647;background:#18181b;color:#fafafa;
font-size:13px;line-height:1.5;padding:8px 12px;border-radius:8px;
max-width:480px;word-break:break-all;pointer-events:auto;
box-shadow:0 4px 16px rgba(0,0,0,.25);
display:flex;flex-direction:column;gap:6px;}
#b64d-drag-tooltip .b64d-drag-text{white-space:pre-wrap;}
#b64d-drag-tooltip .b64d-drag-cards{display:flex;flex-direction:column;gap:4px;}
/* ── 제목 카드 바 ── */
.b64-title-card-bar{margin-top:14px;padding-top:10px;border-top:2px dashed #e4e4e7;display:flex;flex-direction:column;gap:6px;}
@media(prefers-color-scheme:dark){.b64-title-card-bar{border-top-color:#3f3f46;}}
.b64-title-card-label{font-size:11px;color:#71717a;font-weight:600;letter-spacing:.05em;margin-bottom:2px;}
#b64d-dlsite-preview{
position:fixed;z-index:2147483646;background:rgba(0,0,0,.85);
border:1px solid #333;max-width:640px;max-height:640px;overflow:hidden;
display:flex;align-items:center;justify-content:center;border-radius:8px;pointer-events:auto;}
#b64d-dlsite-preview img{max-width:100%;max-height:100%;display:block;}
#b64d-dlsite-preview .dlp-hint{
position:absolute;bottom:6px;right:8px;
font-size:11px;color:rgba(255,255,255,.5);pointer-events:none;}
/* ── 키보드 링크 탐색 포커스 ── */
.b64-link.kp-focused,.b64-product-link.kp-focused{outline:2px solid #3b82f6;outline-offset:2px;box-shadow:0 0 0 4px rgba(59,130,246,.15);}
@media(prefers-color-scheme:dark){.b64-link.kp-focused,.b64-product-link.kp-focused{outline-color:#60a5fa;box-shadow:0 0 0 4px rgba(96,165,250,.2);}}
html.dark .b64-link.kp-focused,html.dark .b64-product-link.kp-focused{outline-color:#60a5fa;box-shadow:0 0 0 4px rgba(96,165,250,.2);}
/* ── 설정: 3-way 탐색 범위 선택 ── */
.b64ds-triple-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 14px;border-radius:9px;background:var(--b64-bg2);}
.b64ds-triple-label{font-size:13px;color:var(--b64-text2);}
.b64ds-triple-btns{display:flex;gap:4px;}
.b64ds-triple-btn{font-size:11px;padding:4px 10px;border-radius:6px;cursor:pointer;border:1px solid var(--b64-input-bd);background:var(--b64-bg3);color:var(--b64-text2);font-family:inherit;transition:background .12s,color .12s;}
.b64ds-triple-btn.active{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
.b64ds-triple-btn:hover:not(.active){background:var(--b64-bg);}
/* ── kone.gg 자체 다크모드 (html.dark 클래스 방식) ── */
html.dark .b64-link{background:#27272a;border-color:#3f3f46;color:#fafafa;}
html.dark .b64-link:hover{background:#3f3f46;border-color:#71717a;}
html.dark .b64-link.lk-alive{background:#14532d;border-color:#16a34a;color:#bbf7d0;}
html.dark .b64-link.lk-dead{background:#7f1d1d;border-color:#dc2626;color:#fecaca;}
html.dark .b64-product-link{background:#2e1065;border-color:#7c3aed;color:#ddd6fe;}
html.dark .b64-product-link:hover{background:#4c1d95;border-color:#8b5cf6;}
html.dark .b64-product-link.pl-steam{background:#082f49;border-color:#0369a1;color:#bae6fd;}
html.dark .b64-product-link.pl-steam:hover{background:#0c4a6e;border-color:#0284c7;}
html.dark .b64-product-link.pl-patreon{background:#431407;border-color:#c2410c;color:#fed7aa;}
html.dark .b64-product-link.pl-patreon:hover{background:#7c2d12;border-color:#ea580c;}
html.dark .b64-title-card-bar{border-top-color:#3f3f46;}
`;
(document.head || document.documentElement).appendChild(s);
}
/* ================================================================
SVG 아이콘
================================================================ */
function svg(path) {
return `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`;
}
const ICO = {
world: svg('<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'),
link: svg('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'),
linkoff: svg('<path d="m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="m5.17 11.75-1.72 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71"/><line x1="2" y1="2" x2="22" y2="22"/>'),
check: svg('<polyline points="20 6 9 17 4 12"/>'),
alert: svg('<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'),
spin: `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.22-8.56"/></svg>`,
tag: svg('<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>'),
gamepad: svg('<line x1="6" y1="11" x2="10" y2="11"/><line x1="8" y1="9" x2="8" y2="13"/><line x1="15" y1="12" x2="15.01" y2="12"/><line x1="18" y1="10" x2="18.01" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 16 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.544-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>'),
patreon: svg('<circle cx="15" cy="9" r="5.5"/><line x1="3" y1="2" x2="3" y2="22"/>'),
};
/* ================================================================
점자 디코딩
================================================================ */
const BRAILLE_ALPHA = {
'⠁':'a','⠃':'b','⠉':'c','⠙':'d','⠑':'e',
'⠋':'f','⠛':'g','⠓':'h','⠊':'i','⠚':'j',
'⠅':'k','⠇':'l','⠍':'m','⠝':'n','⠕':'o',
'⠏':'p','⠟':'q','⠗':'r','⠎':'s','⠞':'t',
'⠥':'u','⠧':'v','⠺':'w','⠭':'x','⠽':'y','⠵':'z',
'⠂':',','⠆':';','⠒':':','⠲':'.','⠖':'!','⠦':'?',
'⠄':"'",'⠤':'-','⠀':' ',
};
const BRAILLE_NUM = {
'⠁':'1','⠃':'2','⠉':'3','⠙':'4','⠑':'5',
'⠋':'6','⠛':'7','⠓':'8','⠊':'9','⠚':'0',
};
function decodeBraille(str) {
let result = '', numMode = false, capNext = false;
for (const ch of str) {
if (ch === '⠼') { numMode = true; continue; }
if (ch === '⠠') { capNext = true; numMode = false; continue; }
if (ch === '⠀') { numMode = false; result += ' '; continue; }
if (numMode && BRAILLE_NUM[ch]) { result += BRAILLE_NUM[ch]; continue; }
numMode = false;
const a = BRAILLE_ALPHA[ch];
if (!a) { result += ch; continue; }
result += capNext ? a.toUpperCase() : a;
capNext = false;
}
return result;
}
/* ================================================================
base64 디코딩
================================================================ */
const DEC_STRICT = new TextDecoder('utf-8', { fatal: true });
const DEC_LENIENT = new TextDecoder('utf-8', { fatal: false });
function looksLikeB64(str) {
return str.length >= MIN_B64 && str.length % 4 !== 1 &&
/^[A-Za-z0-9+/]+={0,2}$/.test(str);
}
function isProbablyText(str) {
if (!str) return false;
let ctrl = 0, repl = 0;
for (const ch of str) {
const c = ch.codePointAt(0);
// C0 제어문자 + DEL + C1 제어문자(0x80-0x9F) — 일반 텍스트에 없음
if (c < 0x09 || (c > 0x0d && c < 0x20) || c === 0x7f || (c >= 0x80 && c <= 0x9f)) ctrl++;
if (c === 0xfffd) repl++;
}
return ctrl / str.length < 0.1 && repl / str.length < 0.15;
}
function decodeB64Once(str) {
if (!looksLikeB64(str)) return null;
try {
let s = str;
const rem = s.length % 4;
if (rem === 2) s += '==';
else if (rem === 3) s += '=';
const bytes = Uint8Array.from(atob(s), c => c.charCodeAt(0));
try { return DEC_STRICT.decode(bytes); }
catch (e) { return DEC_LENIENT.decode(bytes); }
} catch (e) { return null; }
}
function fullyDecode(str) {
let cur = str, best = null;
for (let i = 0; i < MAX_DECODE; i++) {
const d = decodeB64Once(cur);
if (!d || !isProbablyText(d)) break;
best = d; cur = d;
}
return best;
}
/* ================================================================
매칭 패턴 정의
DLsite:
- 라틴: RJ/BJ/VJ/RE/BE/VE + 4~8자리, 대소문자 무관 (i 플래그)
- 한글 IME: 한글 모드에서 rj 를 타이핑하면 r→ㄱ, j→ㅓ → '거'
Rj(shift+r)→ㄲ, j→ㅓ → '꺼'
두 글자 모두 RJ 코드로 정규화
Steam:
- 스팀 (한글)
- Steam / steam (영문 대소문자)
- st / St (약칭, 단어 경계 필요)
================================================================ */
// DLsite 라틴 코드 – 대소문자 무관
const DLSITE_LATIN_RE = /\b((?:RJ|BJ|VJ|RE|BE|VE)\d{4,8})\b/gi;
// DLsite 한글 IME 입력 – 거(rj) / 꺼(Rj, shift)
const DLSITE_KR_RE = /(꺼|거)(\d{4,8})/g;
// Steam – 스팀, Steam, steam, st/St/ST (단어 경계), 구분자 공백·대시 허용
const STEAM_CODE_RE = /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*(\d{4,10})/g;
/* ================================================================
findAllMatches (base64 + 점자 + DLsite 코드 + Steam 코드)
================================================================ */
function findAllMatches(raw) {
const hits = [];
// ── base64 ──
const b64re = new RegExp(`[A-Za-z0-9+/]{${MIN_B64},}={0,2}`, 'g');
let last = 0, m;
while ((m = b64re.exec(raw)) !== null) {
if (m.index < last) continue;
const decoded = fullyDecode(m[0]);
if (!decoded) continue;
hits.push({ index: m.index, length: m[0].length, decoded, type: 'base64' });
last = m.index + m[0].length;
b64re.lastIndex = last;
}
// ── 점자 ──
if (CFG.BRAILLE_DECODE) {
const bre = /[⠀-⣿]{3,}/g;
while ((m = bre.exec(raw)) !== null) {
const decoded = decodeBraille(m[0]);
if (!decoded || decoded === m[0]) continue;
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) hits.push({ index: m.index, length: m[0].length, decoded, type: 'braille' });
}
}
// ── DLsite 코드 (라틴, 대소문자 무관) ──
DLSITE_LATIN_RE.lastIndex = 0;
while ((m = DLSITE_LATIN_RE.exec(raw)) !== null) {
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) {
const code = m[1].toUpperCase();
hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
}
}
// ── DLsite 코드 (한글 IME: 거=rj, 꺼=Rj → RJ 정규화) ──
DLSITE_KR_RE.lastIndex = 0;
while ((m = DLSITE_KR_RE.exec(raw)) !== null) {
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) {
const code = 'RJ' + m[2];
hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
}
}
// ── Steam 코드 ──
STEAM_CODE_RE.lastIndex = 0;
while ((m = STEAM_CODE_RE.exec(raw)) !== null) {
const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
if (!ov) hits.push({ index: m.index, length: m[0].length, decoded: `Steam ${m[1]}`, type: 'steam', appId: m[1] });
}
hits.sort((a, b) => a.index - b.index);
const clean = []; let end = 0;
for (const h of hits) {
if (h.index >= end) { clean.push(h); end = h.index + h.length; }
}
return clean;
}
/* ================================================================
링크 생존 확인
================================================================ */
function setLinkState(cardEl, alive, msg) {
cardEl.classList.remove('lk-checking');
if (alive === true) cardEl.classList.add('lk-alive');
else if (alive === false) cardEl.classList.add('lk-dead');
// alive === null → 회색 (검증 불가) 상태, 클래스 추가 없음
const sub = cardEl.querySelector('.bl-sub');
const wrap = cardEl.querySelector('.bl-icon-wrap');
const arr = cardEl.querySelector('.bl-arrow');
if (sub) sub.textContent = msg;
if (wrap) wrap.innerHTML = alive === true ? ICO.link : alive === false ? ICO.linkoff : ICO.world;
if (arr) arr.innerHTML = alive === true ? ICO.check : alive === false ? ICO.alert : '↗';
}
function beginCheck(cardEl) {
cardEl.classList.add('lk-checking');
const arr = cardEl.querySelector('.bl-arrow');
if (arr) arr.innerHTML = ICO.spin;
}
function checkLinkByContent(url, cardEl, deadPatterns, siteName) {
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'GET', url, timeout: 3000,
onload(res) {
if (res.status >= 400) {
setLinkState(cardEl, false, `${siteName} · 링크 만료 (${res.status})`);
return;
}
const body = (res.responseText || '').toLowerCase();
const dead = deadPatterns.some(p => body.includes(p.toLowerCase()));
setLinkState(cardEl, !dead, dead ? `${siteName} · 링크 만료` : `${siteName} · 링크 정상`);
},
onerror() { setLinkState(cardEl, false, `${siteName} · 연결 실패`); },
ontimeout() { setLinkState(cardEl, false, `${siteName} · 응답 없음`); },
});
}
function checkLinkByHead(url, cardEl) {
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'HEAD', url, timeout: 3000,
onload(res) {
const alive = res.status >= 200 && res.status < 400;
let h = '';
try { h = new URL(url).hostname; } catch(e) {}
setLinkState(cardEl, alive, alive ? `${h} · 링크 정상` : `${h} · 링크 없음 (${res.status})`);
},
onerror() { setLinkState(cardEl, false, '연결 실패'); },
ontimeout() { setLinkState(cardEl, false, '응답 없음'); },
});
}
// kio.ac/kiosk.ac: SPA라서 dead 텍스트가 JS 렌더링됨 → HTTP 상태 + JSON 패턴 병행 체크
function checkKioLink(url, cardEl, siteName) {
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'GET', url, timeout: 3000,
onload(res) {
if (res.status >= 400) {
setLinkState(cardEl, false, `${siteName} · 링크 만료 (${res.status})`);
return;
}
const body = (res.responseText || '').toLowerCase();
const dead = [
'컬렉션을 찾을 수 없음', '만료되었습니다', '찾을 수 없습니다', '존재하지 않',
'"notfound"', '"not_found"', '"not found"', '"status":404',
'"status":"error"', '"error":true', 'collection not found',
].some(p => body.includes(p));
setLinkState(cardEl, !dead, dead ? `${siteName} · 링크 만료` : `${siteName} · 링크 정상`);
},
onerror() { setLinkState(cardEl, false, `${siteName} · 연결 실패`); },
ontimeout() { setLinkState(cardEl, false, `${siteName} · 응답 없음`); },
});
}
// mega.nz: CS API — folder/file 링크 구분하여 각각 적절한 커맨드 사용
function checkMegaLink(url, cardEl) {
const folderM = url.match(/mega\.nz\/folder\/([A-Za-z0-9_-]+)/);
const fileM = url.match(/mega\.nz\/file\/([A-Za-z0-9_-]+)/);
const m = folderM || fileM;
if (!m) { checkLinkByHead(url, cardEl); return; }
const handle = m[1];
const isFolder = !!folderM;
// folder: ?n=HANDLE + {"a":"f","c":1} / file: body에 핸들 포함 {"a":"g","p":HANDLE}
const apiUrl = isFolder
? `https://g.api.mega.co.nz/cs?id=0&app=webclient&n=${handle}`
: 'https://g.api.mega.co.nz/cs?id=0&app=webclient';
const data = isFolder
? JSON.stringify([{ a: 'f', c: 1 }])
: JSON.stringify([{ a: 'g', p: handle, ssl: 0 }]);
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'POST', url: apiUrl, data,
headers: {
'Content-Type': 'application/json',
'Origin': 'https://mega.nz',
'Referer': 'https://mega.nz/',
},
timeout: 3000,
onload(res) {
try {
const d = JSON.parse(res.responseText);
// 에러: [-9], [-2] 등 음수 배열 또는 단독 음수, 또는 {e: -N}
const code = Array.isArray(d) ? d[0] : (typeof d === 'number' ? d : d?.e);
const dead = typeof code === 'number' && code < 0;
setLinkState(cardEl, !dead, dead ? 'mega.nz · 링크 만료' : 'mega.nz · 링크 정상');
} catch(e) { setLinkState(cardEl, true, 'mega.nz · 링크 정상'); }
},
onerror() { setLinkState(cardEl, false, 'mega.nz · 연결 실패'); },
ontimeout() { setLinkState(cardEl, false, 'mega.nz · 응답 없음'); },
});
}
// gofile.io: guest 토큰 발급 후 contents API 조회
function checkGofileLink(url, cardEl) {
const m = url.match(/gofile\.io\/d\/([^/?#]+)/);
if (!m) { checkLinkByHead(url, cardEl); return; }
const contentId = m[1];
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.gofile.io/accounts',
headers: { 'Content-Type': 'application/json' },
data: '{}',
timeout: 1500,
onload(r1) {
let token = '';
try { token = JSON.parse(r1.responseText)?.data?.token || ''; } catch(e) {}
if (!token) { checkLinkByHead(url, cardEl); return; }
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.gofile.io/contents/${contentId}?token=${token}&wt=4fd6sg89d7s6&cache=true`,
timeout: 1500,
onload(r2) {
try {
const d = JSON.parse(r2.responseText);
const dead = d.status !== 'ok';
setLinkState(cardEl, !dead, dead ? 'gofile.io · 링크 만료' : 'gofile.io · 링크 정상');
} catch(e) { setLinkState(cardEl, true, 'gofile.io · 링크 정상'); }
},
onerror() { setLinkState(cardEl, false, 'gofile.io · 연결 실패'); },
ontimeout() { setLinkState(cardEl, false, 'gofile.io · 응답 없음'); },
});
},
onerror() { checkLinkByHead(url, cardEl); },
ontimeout() { checkLinkByHead(url, cardEl); },
});
}
// mypikpak.com: Drive API 우선, 판단 불가 시 DEAD_PATTERNS 콘텐츠 폴백
function checkPikpakLink(url, cardEl) {
const m = url.match(/mypikpak\.com\/s\/([^/?#]+)/);
if (!m) { checkLinkByHead(url, cardEl); return; }
const shareId = m[1];
const patterns = CFG.DEAD_PATTERNS['mypikpak.com'] || [];
beginCheck(cardEl);
GM_xmlhttpRequest({
method: 'GET',
url: `https://api-drive.mypikpak.com/drive/v1/share?share_id=${shareId}&thumbnail_size=SIZE_MEDIUM&pass_code=`,
timeout: 3000,
onload(res) {
// 인증 오류(401/403)는 "만료"가 아니라 "접근 불가" → 폴백으로
if (res.status >= 400 && res.status !== 401 && res.status !== 403) {
setLinkState(cardEl, false, 'mypikpak · 링크 만료'); return;
}
let apiDead = null; // null = API로 판단 불가
try {
const d = JSON.parse(res.responseText);
if (typeof d.code === 'number' && d.code !== 0) {
apiDead = true;
} else if (d.share_status) {
const okSet = new Set(['OK', 'ACTIVE', 'NORMAL']);
apiDead = !okSet.has(d.share_status);
} else {
apiDead = false; // code===0, share_status 없음 → 정상
}
} catch(e) { /* JSON 파싱 실패 → 폴백 */ }
if (apiDead === true) { setLinkState(cardEl, false, 'mypikpak · 링크 만료'); return; }
if (apiDead === false) { setLinkState(cardEl, true, 'mypikpak · 링크 정상'); return; }
// API 판단 불가 → 페이지 콘텐츠 패턴 + HTTP 상태 폴백
checkLinkByContent(url, cardEl, patterns, 'mypikpak');
},
onerror() { checkLinkByContent(url, cardEl, patterns, 'mypikpak'); },
ontimeout() { setLinkState(cardEl, false, 'mypikpak · 응답 없음'); },
});
}
// transfer.it: SPA + MEGA API 헤더 제약으로 만료 감지 불가 → 회색 표시
function checkTransferLink(url, cardEl) {
setLinkState(cardEl, null, 'transfer.it · 만료 검증 불가');
}
function checkLink(url, cardEl) {
let h = '';
try { h = new URL(url).hostname; } catch(e) { return checkLinkByHead(url, cardEl); }
if (h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac')
return checkKioLink(url, cardEl, h);
if (h === 'mega.nz') return checkMegaLink(url, cardEl);
if (h === 'gofile.io') return checkGofileLink(url, cardEl);
if (h === 'mypikpak.com') return checkPikpakLink(url, cardEl);
if (h === 'transfer.it') return checkTransferLink(url, cardEl);
const patterns = CFG.DEAD_PATTERNS[h] || CFG.DEAD_PATTERNS[h.replace(/^www\./, '')];
if (patterns) checkLinkByContent(url, cardEl, patterns, h);
else checkLinkByHead(url, cardEl);
}
/* ================================================================
유틸
================================================================ */
function esc(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
/* ================================================================
DLsite 상품 카드
- 직접 RJ 코드 또는 DLsite URL에서 추출된 코드 모두 사용
================================================================ */
function makeDlsiteCard(code) {
const prefix = code.slice(0, 2).toUpperCase();
const sectionMap = { RJ: 'maniax', BJ: 'bl', VJ: 'soft', RE: 'maniax', BE: 'bl', VE: 'soft' };
const section = sectionMap[prefix] || 'maniax';
const url = `https://www.dlsite.com/${section}/work/=/product_id/${code}.html`;
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.className = 'b64-product-link';
a.innerHTML = `<span class="bl-icon-wrap">${ICO.tag}</span>
<span class="bl-text">
<span class="bl-title">DLsite · ${esc(code)}</span>
<span class="bl-sub">dlsite.com · 호버 시 미리보기</span>
</span><span class="bl-arrow">↗</span>`;
if (CFG.DLSITE_PREVIEW) {
a.dataset._dlpHooked = '1';
a.addEventListener('mouseenter', dlpShow);
}
return a;
}
/* ================================================================
Steam 상품 카드
================================================================ */
function makeSteamCard(appId) {
const url = `https://store.steampowered.com/app/${appId}/`;
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.className = 'b64-product-link pl-steam';
a.innerHTML = `<span class="bl-icon-wrap">${ICO.gamepad}</span>
<span class="bl-text">
<span class="bl-title">Steam · ${esc(appId)}</span>
<span class="bl-sub">store.steampowered.com · 호버 시 미리보기</span>
</span><span class="bl-arrow">↗</span>`;
// 게임 이름 비동기 취득
GM_xmlhttpRequest({
method: 'GET',
url: `https://store.steampowered.com/api/appdetails?appids=${appId}&filters=basic&l=korean`,
timeout: 6000,
onload(res) {
try {
const data = JSON.parse(res.responseText);
const info = data[appId];
if (info?.success && info.data?.name) {
const titleEl = a.querySelector('.bl-title');
const subEl = a.querySelector('.bl-sub');
if (titleEl) titleEl.textContent = `Steam · ${info.data.name}`;
if (subEl) subEl.textContent = `App #${appId} · 호버 시 미리보기`;
}
} catch(e) {}
},
});
if (CFG.DLSITE_PREVIEW) {
a.dataset._dlpHooked = '1';
a.addEventListener('mouseenter', dlpShow);
}
return a;
}
/* ================================================================
Patreon 상품 카드
================================================================ */
function makePatreonCard(url) {
const m = url.match(/patreon\.com\/(?:c\/)?([^/?#]+)/i);
const creator = m ? m[1] : '';
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.className = 'b64-product-link pl-patreon';
a.innerHTML = `<span class="bl-icon-wrap">${ICO.patreon}</span>
<span class="bl-text">
<span class="bl-title">Patreon${creator ? ' · ' + esc(creator) : ''}</span>
<span class="bl-sub">patreon.com</span>
</span><span class="bl-arrow">↗</span>`;
return a;
}
/* ================================================================
다운로드 링크 카드
- DLsite/Steam/Patreon URL은 LINK_CARD 설정 시 상품 카드로 라우팅
================================================================ */
function makeLinkCard(rawUrl) {
let url = /^https?:\/\//i.test(rawUrl) ? rawUrl : 'https://' + rawUrl;
let h = '';
try { h = new URL(url).hostname; } catch(e) { h = url; }
// kone.gg 내부 링크 → 카드 없이 일반 밑줄 링크
if (/kone\.gg$/.test(h)) {
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.textContent = rawUrl;
a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
return a;
}
if (CFG.LINK_CARD) {
// DLsite URL → 상품 카드
if (h.includes('dlsite.com')) {
const idMatch = url.match(/product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i);
if (idMatch) return makeDlsiteCard(idMatch[1].toUpperCase());
// product_id 없는 DLsite URL → 기본 상품 카드
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
a.className = 'b64-product-link';
a.innerHTML = `<span class="bl-icon-wrap">${ICO.tag}</span>
<span class="bl-text"><span class="bl-title">DLsite</span><span class="bl-sub">dlsite.com</span></span>
<span class="bl-arrow">↗</span>`;
if (CFG.DLSITE_PREVIEW) { a.dataset._dlpHooked = '1'; a.addEventListener('mouseenter', dlpShow); }
return a;
}
// Steam URL → 상품 카드
if (h === 'store.steampowered.com') {
const appMatch = url.match(/\/app\/(\d+)/);
if (appMatch) return makeSteamCard(appMatch[1]);
}
// Patreon URL → 상품 카드 (경로 있는 경우만)
if (h === 'patreon.com' || h === 'www.patreon.com') {
try { if (new URL(url).pathname.length > 1) return makePatreonCard(url); } catch(e) {}
}
}
// 일반 카드
const isKio = h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac';
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute(DONE_ATTR, '');
if (CFG.LINK_CARD) {
a.className = 'b64-link';
a.innerHTML = `<span class="bl-icon-wrap">${isKio ? ICO.link : ICO.world}</span>
<span class="bl-text"><span class="bl-title">${esc(rawUrl)}</span><span class="bl-sub">${esc(h)}</span></span>
<span class="bl-arrow">↗</span>`;
} else {
a.textContent = rawUrl;
a.style.cssText = 'color:#1a73e8;text-decoration:underline;word-break:break-all;';
}
if (CFG.LINK_CHECK) setTimeout(() => checkLink(url, a), 0);
return a;
}
/* ================================================================
디코딩 텍스트 → DOM 노드
URL / DLsite 코드 / Steam 코드를 별도 regex로 처리 (i 플래그 분리)
================================================================ */
function buildNodes(text) {
// 각 타입을 별도 regex로 수집 후 위치 기준 정렬
const matches = [];
let m;
// URL (대소문자 무관)
const urlRe = /((?:https?:\/\/|www\.)[^\s<>"'()]+)/gi;
urlRe.lastIndex = 0;
while ((m = urlRe.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'url', raw: m[0] });
// DLsite 코드 (라틴, 대소문자 무관)
DLSITE_LATIN_RE.lastIndex = 0;
while ((m = DLSITE_LATIN_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: m[1].toUpperCase() });
// DLsite 코드 (한글 IME)
DLSITE_KR_RE.lastIndex = 0;
while ((m = DLSITE_KR_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: 'RJ' + m[2] });
// Steam 코드
STEAM_CODE_RE.lastIndex = 0;
while ((m = STEAM_CODE_RE.exec(text)) !== null)
matches.push({ index: m.index, end: m.index + m[0].length, type: 'steam', appId: m[1] });
// 위치순 정렬 + 중첩 제거
matches.sort((a, b) => a.index - b.index);
const clean = []; let end = 0;
for (const mt of matches) {
if (mt.index >= end) { clean.push(mt); end = mt.end; }
}
const nodes = []; let last = 0;
for (const mt of clean) {
if (mt.index > last) nodes.push(document.createTextNode(text.slice(last, mt.index)));
if (mt.type === 'dlsite') {
nodes.push(CFG.LINK_CARD ? makeDlsiteCard(mt.code) : document.createTextNode(mt.code));
} else if (mt.type === 'steam') {
nodes.push(CFG.LINK_CARD ? makeSteamCard(mt.appId) : document.createTextNode(`Steam ${mt.appId}`));
} else {
// URL: 후행 구두점 제거
let url = mt.raw;
const trail = (url.match(/[.,;:!?)\]}'"]+$/) || [''])[0];
if (trail) url = url.slice(0, -trail.length);
nodes.push(makeLinkCard(url));
if (trail) nodes.push(document.createTextNode(trail));
}
last = mt.end;
}
if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
return nodes;
}
/* ================================================================
본문 텍스트 노드 처리
중복 감지:
- processedRaws: Map<key, WeakRef<wrap>>
- 같은 내용이 또 들어올 때, 기존 wrap이 살아있고 같은 부모이면
Svelte 재삽입으로 판단 → 숨김.
- 기존 wrap이 없거나 다른 부모이면 정상 중복 → 재디코딩.
================================================================ */
const processedRaws = new Map(); // key → WeakRef<wrap>
const processedListRaws = new Map(); // key → WeakRef<decodedSpan> (목록 노드)
const _cardMap = new WeakMap(); // 원본 <a> → 생성된 카드 (Svelte 재렌더링 후 카드 재생성 판단용)
function processContentNode(node) {
if (!node.parentNode) return;
const raw = node.nodeValue;
if (!raw || !raw.trim()) return;
let anc = node.parentNode;
while (anc) {
if (anc.nodeType === Node.ELEMENT_NODE) {
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
if (anc.nodeName === 'A') return;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
}
anc = anc.parentNode;
}
const key = raw.trim();
if (processedRaws.has(key)) {
const existingWrap = processedRaws.get(key)?.deref();
if (existingWrap && document.contains(existingWrap)) {
// decoded wrap이 문서에 살아있음 → 이 위치는 숨김
// (같은 부모 = Svelte/React 재삽입, 다른 부모 = kone.gg 중복 렌더링 - 모두 숨김)
const hide = document.createElement('span');
hide.setAttribute(DONE_ATTR, '');
hide.style.cssText = 'display:none!important;';
hide.textContent = raw;
node.parentNode.replaceChild(hide, node);
return;
}
// existingWrap이 문서에 없음(분리된 DOM, React 전체 교체 등) → 재디코딩
processedRaws.delete(key);
}
const hits = findAllMatches(raw);
if (!hits.length) return;
const frag = document.createDocumentFragment();
let last = 0;
for (const hit of hits) {
const { index, length, type } = hit;
if (index > last) frag.appendChild(document.createTextNode(raw.slice(last, index)));
if (type === 'dlsite') {
frag.appendChild(CFG.LINK_CARD ? makeDlsiteCard(hit.code) : document.createTextNode(hit.decoded));
} else if (type === 'steam') {
frag.appendChild(CFG.LINK_CARD ? makeSteamCard(hit.appId) : document.createTextNode(hit.decoded));
} else {
buildNodes(hit.decoded).forEach(n => frag.appendChild(n));
}
last = index + length;
}
if (last < raw.length) frag.appendChild(document.createTextNode(raw.slice(last)));
const wrap = document.createElement('span');
wrap.setAttribute(DONE_ATTR, '');
wrap.setAttribute(RAW_ATTR, raw);
wrap.style.cssText = 'all:unset;display:contents;';
wrap.appendChild(frag);
processedRaws.set(key, new WeakRef(wrap));
node.parentNode.replaceChild(wrap, node);
}
/* ================================================================
목록 텍스트 노드 처리
================================================================ */
function processListNode(node) {
const raw = node.nodeValue;
if (!raw || !raw.trim()) return;
const p = node.parentNode;
if (!p) return;
if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute(LTDONE_ATTR)) return;
let anc = p;
while (anc) {
if (anc.nodeType === Node.ELEMENT_NODE) {
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
}
anc = anc.parentNode;
}
const key = raw.trim();
if (processedListRaws.has(key)) {
const existingSpan = processedListRaws.get(key)?.deref();
const isSvelte = existingSpan &&
document.contains(existingSpan) &&
existingSpan.parentNode === p;
if (isSvelte) {
const hide = document.createElement('span');
hide.setAttribute(LTDONE_ATTR, '');
hide.style.cssText = 'display:none!important;';
hide.textContent = raw;
p.replaceChild(hide, node);
return;
}
processedListRaws.delete(key);
}
const hits = findAllMatches(raw);
if (!hits.length) return;
// 목록 페이지: RJ/Steam 코드는 인라인 카드로 만들지 않음 (썸네일이 대신 표시)
// 텍스트만 변환 (base64 / 점자)
let result = '', last = 0;
for (const { index, length, decoded } of hits) {
result += raw.slice(last, index) + decoded;
last = index + length;
}
result += raw.slice(last);
if (result === raw) return;
const hideWrap = document.createElement('span');
hideWrap.setAttribute(LTDONE_ATTR, 'src');
hideWrap.style.cssText = 'display:none!important;';
hideWrap.textContent = raw;
const decodedSpan = document.createElement('span');
decodedSpan.setAttribute(LTDONE_ATTR, '');
decodedSpan.style.cssText = 'all:unset;';
decodedSpan.textContent = result;
processedListRaws.set(key, new WeakRef(decodedSpan));
p.insertBefore(hideWrap, node);
p.insertBefore(decodedSpan, node);
p.removeChild(node);
}
/* ================================================================
공통 walker
================================================================ */
function walkAndProcess(root, handler) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const p = node.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
const tag = p.nodeName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA') return NodeFilter.FILTER_REJECT;
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
let anc = p;
while (anc && anc !== root) {
if (anc.nodeType === Node.ELEMENT_NODE) {
// contenteditable="true" 영역(Froala 에디터 등) 내부는 절대 처리하지 않음
if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT;
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
}
anc = anc.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const nodes = []; let n;
while ((n = walker.nextNode())) nodes.push(n);
nodes.forEach(handler);
}
/* ================================================================
드래그 자동 변환
================================================================ */
let dragTooltip = null;
function removeDragTooltip() { if (dragTooltip) { dragTooltip.remove(); dragTooltip = null; } }
document.addEventListener('mouseup', (e) => {
if (!CFG.DRAG_DECODE) return;
if (isWritePage()) return;
// 툴팁 안을 클릭한 경우 닫지 않음 (카드 링크 클릭 보호)
if (dragTooltip && dragTooltip.contains(e.target)) return;
removeDragTooltip();
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
const text = sel.toString().trim();
if (!text) return;
const hits = findAllMatches(text);
if (!hits.length) return;
// 디코딩 결과 텍스트 재조립
let result = '', last = 0;
for (const { index, length, decoded } of hits) { result += text.slice(last, index) + decoded; last = index + length; }
result += text.slice(last);
// 상품 코드(DLsite/Steam) 추출
const productHits = hits.filter(h => h.type === 'dlsite' || h.type === 'steam');
const hasNewText = result !== text;
if (!hasNewText && !productHits.length) return;
// ── 본문 인-플레이스 교체 우선 시도 ──
// 선택 범위가 단일 미처리 텍스트 노드 안에 있으면 바로 본문에 적용
if (sel.rangeCount) {
const r = sel.getRangeAt(0);
const cn = r.startContainer;
if (cn.nodeType === Node.TEXT_NODE &&
cn === r.endContainer &&
document.body.contains(cn) &&
!hasProcessedAncestor(cn)) {
processContentNode(cn);
return; // 본문 적용 완료 → 팝업 불필요
}
}
// ── 인-플레이스 불가 시 팝업 표시 ──
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
dragTooltip = document.createElement('div');
dragTooltip.id = 'b64d-drag-tooltip';
// 텍스트 변환 결과
if (hasNewText) {
const textEl = document.createElement('div');
textEl.className = 'b64d-drag-text';
textEl.textContent = result;
dragTooltip.appendChild(textEl);
}
// 상품 카드
if (productHits.length) {
const seen = new Set();
const cardsWrap = document.createElement('div');
cardsWrap.className = 'b64d-drag-cards';
for (const hit of productHits) {
const id = hit.type === 'dlsite' ? hit.code : hit.appId;
if (seen.has(id)) continue;
seen.add(id);
cardsWrap.appendChild(
hit.type === 'dlsite' ? makeDlsiteCard(hit.code) : makeSteamCard(hit.appId)
);
}
dragTooltip.appendChild(cardsWrap);
}
dragTooltip.style.top = `${rect.bottom + window.scrollY + 6}px`;
dragTooltip.style.left = `${rect.left + window.scrollX}px`;
document.body.appendChild(dragTooltip);
});
document.addEventListener('mousedown', (e) => {
if (dragTooltip && dragTooltip.contains(e.target)) return;
removeDragTooltip();
});
document.addEventListener('keydown', removeDragTooltip);
/* ================================================================
미리보기 팝업 (DLsite 및 Steam 공통)
키보드 a/d 또는 ← → 로 이미지 전환, 휠 스크롤 가능
================================================================ */
const dlp = { el: null, images: [], idx: 0, showing: false, srcWatcher: null };
function dlpUpdateImage() {
if (!dlp.el || !dlp.images.length) return;
dlp.el.innerHTML = '';
const img = document.createElement('img');
img.src = dlp.images[dlp.idx];
img.referrerPolicy = 'no-referrer';
const hint = document.createElement('div');
hint.className = 'dlp-hint';
hint.textContent = `${dlp.idx + 1} / ${dlp.images.length} ←→ / a·d / 휠`;
dlp.el.appendChild(img);
dlp.el.appendChild(hint);
}
function dlpMove(e) {
if (!dlp.el) return;
const pw = dlp.el.offsetWidth || 50;
const ph = dlp.el.offsetHeight || 50;
const x = (e.clientX + 16 + pw > window.innerWidth) ? Math.max(0, e.clientX - pw - 16) : e.clientX + 16;
const y = (e.clientY + 16 + ph > window.innerHeight) ? Math.max(0, e.clientY - ph - 16) : e.clientY + 16;
dlp.el.style.left = `${x}px`;
dlp.el.style.top = `${y}px`;
}
function dlpHide() {
if (dlp.srcWatcher) { dlp.srcWatcher.disconnect(); dlp.srcWatcher = null; }
if (dlp.el) { dlp.el.remove(); dlp.el = null; }
dlp.showing = false; dlp.images = []; dlp.idx = 0;
document.removeEventListener('mousemove', dlpMove);
document.removeEventListener('keydown', dlpKey);
}
function dlpKey(e) {
if (!dlp.showing || dlp.images.length <= 1) return;
if (e.key === 'ArrowLeft' || e.key === 'a') { e.preventDefault(); dlp.idx = (dlp.idx - 1 + dlp.images.length) % dlp.images.length; dlpUpdateImage(); }
if (e.key === 'ArrowRight' || e.key === 'd') { e.preventDefault(); dlp.idx = (dlp.idx + 1) % dlp.images.length; dlpUpdateImage(); }
}
function dlpShow(e) {
if (!CFG.DLSITE_PREVIEW || dlp.showing) return;
dlp.showing = true;
dlp.el = document.createElement('div');
dlp.el.id = 'b64d-dlsite-preview';
dlp.el.textContent = '로딩 중...';
dlp.el.style.color = '#fff';
document.body.appendChild(dlp.el);
dlpMove(e);
document.addEventListener('mousemove', dlpMove);
document.addEventListener('keydown', dlpKey);
dlp.el.addEventListener('wheel', we => {
we.preventDefault();
if (dlp.images.length <= 1) return;
dlp.idx = we.deltaY > 0 ? (dlp.idx+1)%dlp.images.length : (dlp.idx-1+dlp.images.length)%dlp.images.length;
dlpUpdateImage();
}, { passive: false });
const href = e.currentTarget.href;
const steamMatch = href && href.match(/store\.steampowered\.com\/app\/(\d+)/);
if (steamMatch) {
// ── Steam: API로 스크린샷 가져오기 ──
GM_xmlhttpRequest({
method: 'GET',
url: `https://store.steampowered.com/api/appdetails?appids=${steamMatch[1]}&filters=screenshots`,
timeout: 3000,
onload(res) {
if (!dlp.el) return;
try {
const data = JSON.parse(res.responseText);
const info = data[steamMatch[1]];
if (info?.success && info.data?.screenshots?.length) {
dlp.images = info.data.screenshots.map(s => s.path_full);
dlp.idx = 0;
dlpUpdateImage();
} else {
dlp.el.textContent = '스크린샷 없음';
}
} catch(err) {
if (dlp.el) dlp.el.textContent = '미리보기 로드 실패';
}
},
onerror() { if (dlp.el) dlp.el.textContent = '연결 실패'; },
ontimeout() { if (dlp.el) dlp.el.textContent = '응답 없음'; },
});
} else {
// ── DLsite: 페이지 HTML 파싱 ──
GM_xmlhttpRequest({
method: 'GET', url: href,
headers: { 'User-Agent': navigator.userAgent, 'Referer': 'https://www.dlsite.com/' },
onload(res) {
if (!dlp.el) return;
const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
const imgs = [];
for (const sel of ['.product-slider-data > div[data-src]', '.main-image-slider img', '.product-slider img', 'img[src*="img.dlsite.com"]']) {
doc.querySelectorAll(sel).forEach(el => {
const src = el.dataset.src || el.src || '';
if (src && !src.includes('data:') && !src.includes('/resize/') && !imgs.includes(src)) imgs.push(src);
});
if (imgs.length) break;
}
dlp.images = imgs; dlp.idx = 0;
if (imgs.length) dlpUpdateImage();
else if (dlp.el) { dlp.el.textContent = '이미지를 찾을 수 없습니다.'; dlp.el.style.color = '#fff'; }
},
onerror() { if (dlp.el) { dlp.el.textContent = '미리보기 로드 실패'; dlp.el.style.color = '#fff'; } },
});
}
const srcEl = e.currentTarget;
srcEl.addEventListener('mouseleave', dlpHide, { once: true });
// srcEl이 DOM에서 제거될 경우(OG 프리뷰 → 카드 교체 등) 즉시 숨김
const watchParent = srcEl.parentNode || document.body;
dlp.srcWatcher = new MutationObserver(() => {
if (!document.contains(srcEl)) dlpHide();
});
dlp.srcWatcher.observe(watchParent, { childList: true, subtree: true });
}
/* ================================================================
하이퍼링크 → 상품 카드 변환
href 또는 링크 텍스트에 DLsite/Steam 정보가 있는 <a>를 카드로 교체.
교체 후 preview 훅도 자동으로 달린다(makeDlsiteCard/makeSteamCard 내부 처리).
================================================================ */
function convertProductLinks(contentRoots) {
if (!CFG.LINK_CARD) return;
if (!contentRoots.size) return; // CONTENT_SELECTORS 미매칭 = kone.gg 본문 페이지 아님
// contentRoots 내 링크만 처리 (OG 프리뷰 등 외부 중복 방지, 동일 ID 중복 카드 방지)
const seen = new Set();
contentRoots.forEach(root => {
root.querySelectorAll('a').forEach(a => {
if (a.hasAttribute(LTDONE_ATTR)) return;
if (a.hasAttribute(DONE_ATTR)) {
// 카드 원본으로 처리된 링크: 연결된 카드가 DOM에서 사라졌으면 재처리
const prevCard = _cardMap.get(a);
if (prevCard && !document.contains(prevCard)) {
// Svelte 등이 카드를 제거했음 → DONE_ATTR 해제 후 재생성
a.removeAttribute(DONE_ATTR);
a.style.removeProperty('display');
_cardMap.delete(a);
} else {
return;
}
}
if (hasProcessedAncestor(a)) return;
if (!a.parentNode) return;
// 자식 요소가 있는 <a>: kone.gg OG 프리뷰 카드(not-prose + h-24)는 숨김, 그 외 스킵
if (a.firstElementChild) {
if (a.classList.contains('not-prose') && a.classList.contains('h-24')) {
a.setAttribute(DONE_ATTR, '');
a.style.cssText += ';display:none!important;';
}
return;
}
const href = a.href || '';
// kone.gg 내부 링크 → 절대 카드로 변환하지 않음
try { if (/kone\.gg$/.test(new URL(href).hostname)) return; } catch(e) {}
let card = null;
let seenKey = null;
// ── href 기준 ──
if (href.includes('dlsite.com')) {
// www.dlsite.com: /product_id/RJxxx, ch.dlsite.com: /VJxxx 또는 /product/VJxxx
const idMatch = href.match(/product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i)
|| href.match(/[/=]((?:RJ|BJ|VJ|RE|BE|VE)\d{4,8})\b/i);
if (idMatch) { seenKey = 'dl:' + idMatch[1].toUpperCase(); card = seen.has(seenKey) ? null : makeDlsiteCard(idMatch[1].toUpperCase()); }
} else if (href.includes('store.steampowered.com/app/')) {
const appMatch = href.match(/\/app\/(\d+)/);
if (appMatch) { seenKey = 'st:' + appMatch[1]; card = seen.has(seenKey) ? null : makeSteamCard(appMatch[1]); }
} else if (href.includes('patreon.com')) {
try {
const u = new URL(href);
if ((u.hostname === 'patreon.com' || u.hostname === 'www.patreon.com') && u.pathname.length > 1) {
seenKey = 'pt:' + href.split(/[?#]/)[0];
card = seen.has(seenKey) ? null : makePatreonCard(href);
}
} catch(e) {}
}
// ── href 미매칭 시 링크 텍스트에서 코드 탐색 ──
if (!card && !seenKey) {
const t = a.textContent.trim();
DLSITE_LATIN_RE.lastIndex = 0; const dlLatin = DLSITE_LATIN_RE.exec(t);
DLSITE_KR_RE.lastIndex = 0; const dlKr = DLSITE_KR_RE.exec(t);
STEAM_CODE_RE.lastIndex = 0; const st = STEAM_CODE_RE.exec(t);
if (dlLatin) { seenKey = 'dl:' + dlLatin[1].toUpperCase(); card = seen.has(seenKey) ? null : makeDlsiteCard(dlLatin[1].toUpperCase()); }
else if (dlKr) { seenKey = 'dl:RJ' + dlKr[2]; card = seen.has(seenKey) ? null : makeDlsiteCard('RJ' + dlKr[2]); }
else if (st) { seenKey = 'st:' + st[1]; card = seen.has(seenKey) ? null : makeSteamCard(st[1]); }
}
// ── 알려진 다운로드/파일 공유 사이트 → 일반 링크 카드 ──
if (!card && !seenKey && href) {
try {
const u = new URL(href);
const dlHost = u.hostname;
const base = dlHost.replace(/^www\./, '');
// 경로가 없는 사이트 루트 URL (예: https://transfer.it/) → 카드 생성 안 함
if (u.pathname.length <= 1) return;
if (CFG.PW_SITES[dlHost] || CFG.PW_SITES[base] ||
CFG.DEAD_PATTERNS[dlHost] || CFG.DEAD_PATTERNS[base]) {
seenKey = 'lk:' + href.split(/[?#]/)[0];
card = seen.has(seenKey) ? null : makeLinkCard(href);
}
} catch(e) {}
}
if (card) {
if (seenKey) seen.add(seenKey);
card.setAttribute(ORIG_ATTR, a.outerHTML);
a.setAttribute(DONE_ATTR, '');
a.style.cssText += ';display:none!important;';
a.parentNode.insertBefore(card, a);
_cardMap.set(a, card); // 카드 제거 감지용 (Svelte 재렌더링 대응)
} else if (seenKey && seen.has(seenKey)) {
// 같은 flush 내 동일 URL 중복 → 숨김
a.setAttribute(DONE_ATTR, '');
a.style.cssText += ';display:none!important;';
}
});
});
// preview 훅: 카드로 교체되지 않은 DLsite/Steam 링크에도 hover 달기
if (CFG.DLSITE_PREVIEW) {
const previewSel = 'a[href*="dlsite.com"], a[href*="ch.dlsite.com"], a[href*="store.steampowered.com/app/"]';
contentRoots.forEach(root => {
root.querySelectorAll(previewSel).forEach(a => {
if (a.dataset._dlpHooked) return;
if (a.firstElementChild) return; // OG 프리뷰 등 블록형 링크 제외
a.dataset._dlpHooked = '1';
a.addEventListener('mouseenter', dlpShow);
});
});
}
}
/* ================================================================
비번 자동입력 공통 유틸
================================================================ */
function normText(t) { return String(t||'').replace(/\s+/g,' ').trim(); }
function isVisible(el) {
if (!el) return false;
const s = window.getComputedStyle(el);
const r = el.getBoundingClientRect();
return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0;
}
function setNativeValue(el, value) {
const proto = Object.getPrototypeOf(el);
const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
if (desc?.set) desc.set.call(el, value); else el.value = value;
if (el._valueTracker) el._valueTracker.setValue('');
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function getInputEl(sel) {
return [...document.querySelectorAll(sel)].find(isVisible) || null;
}
function getScope(input) {
if (!input) return document.body;
return input.closest('[role="dialog"]') || input.closest('form') ||
input.closest('[class*="modal"]') || input.parentElement || document.body;
}
function findPwError(input, patterns) {
const scope = getScope(input);
for (const el of scope.querySelectorAll('div,p,span,strong,em,small,li')) {
if (!isVisible(el)) continue;
const t = normText(el.textContent);
if (t && t.length < 120 && patterns.some(p => p.test(t))) return t;
}
return '';
}
/* ================================================================
비번 자동입력
================================================================ */
const siteCfg = CFG.PW_SITES[host];
if (siteCfg && CFG.PW_AUTO) {
if (siteCfg.mode === 'kio') {
const KIO = {
phase: 'idle', lastInputEl: null,
lastSubmitAt: 0, lastAttemptAt: 0,
queue: [], idx: 0, tried: new Set(), active: null,
lastAdvKey: '', lastErrText: '',
GRACE: 3000, FALLBACK: 2000, RETRY_MS: 50, RETRY_MAX: 8,
};
function kioGetBtn(scope) {
return [...(scope||document).querySelectorAll('button')]
.find(b => isVisible(b) && normText(b.textContent) === '확인') || null;
}
function kioResetForNewModal(input) {
if (KIO.lastInputEl === input) return;
const lastAt = Math.max(KIO.lastAttemptAt, KIO.lastSubmitAt);
if (KIO.active && ['attempting','submitted'].includes(KIO.phase) &&
lastAt > 0 && Date.now() - lastAt <= KIO.GRACE) {
KIO.lastInputEl = input; return;
}
KIO.lastInputEl = input; KIO.phase = 'idle';
KIO.lastSubmitAt = 0; KIO.lastAttemptAt = 0;
KIO.queue = (getPwList() || []).filter(Boolean).map(v => ({ value: v }));
KIO.idx = 0; KIO.tried = new Set(); KIO.active = null;
KIO.lastAdvKey = ''; KIO.lastErrText = '';
}
function kioClickWhenReady(expected, onSuccess) {
let retries = 0;
const attempt = () => {
const input = getInputEl(siteCfg.inputSel); if (!input) return;
const btn = kioGetBtn(getScope(input)); if (!btn) return;
if (normText(input.value) !== normText(expected)) return;
if (!btn.disabled) { btn.click(); KIO.lastSubmitAt = Date.now(); onSuccess(); return; }
if (retries < KIO.RETRY_MAX) { retries++; setTimeout(attempt, KIO.RETRY_MS); }
else { KIO.phase = 'submitted'; KIO.lastSubmitAt = Date.now(); }
};
queueMicrotask(attempt);
}
async function kioTryNext(input) {
while (KIO.idx < KIO.queue.length) {
const { value } = KIO.queue[KIO.idx];
if (!value || KIO.tried.has(value)) { KIO.idx++; continue; }
KIO.tried.add(value);
KIO.active = { value };
KIO.lastAdvKey = ''; KIO.lastErrText = '';
KIO.lastAttemptAt = Date.now();
KIO.phase = 'attempting';
setNativeValue(input, value);
kioClickWhenReady(value, () => { KIO.phase = 'submitted'; });
return;
}
KIO.phase = 'done'; KIO.active = null;
}
async function kioTick() {
const input = getInputEl(siteCfg.inputSel); if (!input) return;
if (!kioGetBtn(getScope(input))) return;
kioResetForNewModal(input);
if (['attempting','submitted'].includes(KIO.phase) && KIO.active && !normText(input.value)) {
setNativeValue(input, KIO.active.value);
KIO.phase = 'attempting'; KIO.lastAttemptAt = Date.now();
kioClickWhenReady(KIO.active.value, () => { KIO.phase = 'submitted'; KIO.lastSubmitAt = Date.now(); });
return;
}
const err = findPwError(input, siteCfg.errorPat);
const key = KIO.active ? `${KIO.idx}:${err}` : '';
if (err && err !== KIO.lastErrText) KIO.lastErrText = err;
if (err && KIO.phase === 'submitted' && KIO.active &&
normText(input.value) === normText(KIO.active.value) &&
key !== KIO.lastAdvKey) {
KIO.lastAdvKey = key; KIO.idx++;
await kioTryNext(input); return;
}
if (KIO.phase === 'idle') { await kioTryNext(input); return; }
if (KIO.phase === 'attempting' && Date.now() - KIO.lastAttemptAt >= KIO.FALLBACK) { KIO.idx++; await kioTryNext(input); return; }
if (KIO.phase === 'submitted' && Date.now() - KIO.lastSubmitAt >= KIO.FALLBACK) { KIO.idx++; await kioTryNext(input); }
}
let kioTickBusy = false;
window.setInterval(async () => {
if (kioTickBusy) return;
kioTickBusy = true;
try { await kioTick(); } finally { kioTickBusy = false; }
}, 100);
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
Object.assign(KIO, { phase:'idle', lastInputEl:null, lastSubmitAt:0, lastAttemptAt:0,
queue:[], idx:0, tried:new Set(), active:null, lastAdvKey:'', lastErrText:'' });
kioTickBusy = false;
});
document.addEventListener('visibilitychange', () => { if (!document.hidden) kioTick().catch(()=>{}); });
setTimeout(() => kioTick().catch(()=>{}), 0);
}
if (siteCfg.mode === 'generic') {
async function genericTryPw() {
const pws = getPwList() || [];
for (const pw of pws) {
if (!pw) continue;
const input = getInputEl(siteCfg.inputSel);
if (!input) break;
setNativeValue(input, pw);
// React가 input 이벤트를 처리해 버튼을 활성화할 때까지 대기 (최대 btnDelay ms)
const btnWait = siteCfg.btnDelay || 200;
const btnDeadline = Date.now() + btnWait;
let btn = null;
while (Date.now() < btnDeadline) {
btn = document.querySelector(siteCfg.btnSel) ||
[...document.querySelectorAll('button')].find(
b => isVisible(b) && (normText(b.textContent) === '확인' || normText(b.textContent) === 'OK')
);
if (btn && !btn.disabled) break;
await new Promise(r => setTimeout(r, 50));
}
if (btn && !btn.disabled) {
btn.click();
} else {
// 버튼이 disabled이거나 없으면 Enter 키로 폼 제출 시도
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }));
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true }));
}
await new Promise(r => setTimeout(r, 1000));
if (siteCfg.successSel && document.querySelector(siteCfg.successSel)) break;
if (findPwError(input, siteCfg.errorPat)) continue;
}
}
const gObs = new MutationObserver(() => {
const input = getInputEl(siteCfg.inputSel);
if (input && !input.dataset._gTried) { input.dataset._gTried = '1'; setTimeout(genericTryPw, 300); }
});
gObs.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
const i = getInputEl(siteCfg.inputSel);
if (i && !i.dataset._gTried) { i.dataset._gTried = '1'; genericTryPw(); }
}, siteCfg.triggerDelay || 1500);
}
if (siteCfg.mode === 'transfer') {
let transferDone = false;
async function transferTryPw() {
if (transferDone) return;
const pws = getPwList() || [];
for (const pw of pws) {
if (!pw) continue;
const input = getInputEl(siteCfg.inputSel);
if (!input) break;
setNativeValue(input, pw);
await new Promise(r => setTimeout(r, 150));
const btn = document.querySelector(siteCfg.btnSel);
if (btn) btn.click();
await new Promise(r => setTimeout(r, 1200));
if (siteCfg.successSel && document.querySelector(siteCfg.successSel)) { transferDone = true; break; }
}
}
const transferObs = new MutationObserver(() => {
const input = getInputEl(siteCfg.inputSel);
if (input && !input.dataset._tTried) { input.dataset._tTried = '1'; setTimeout(transferTryPw, 400); }
});
transferObs.observe(document.documentElement, { childList: true, subtree: true });
if (siteCfg.dlBtnSel) {
setTimeout(() => {
const dlBtn = document.querySelector(siteCfg.dlBtnSel);
if (dlBtn && isVisible(dlBtn)) dlBtn.click();
}, siteCfg.triggerDelay || 1500);
}
}
if (siteCfg.mode === 'gdrive') {
const parts = location.pathname.split('/');
if (parts[1] === 'file' && parts[2] === 'd' && parts[3]) {
location.href = `https://drive.usercontent.google.com/download?id=${parts[3]}&export=download&authuser=0`;
}
if (parts[1] === 'drive' && parts[2] === 'folders') {
setTimeout(() => {
const btn = document.querySelector('[aria-label="모두 다운로드"], [data-tooltip="모두 다운로드"]');
if (btn) btn.click();
}, 2500);
}
}
if (siteCfg.mode === 'gdrive-dl') {
setTimeout(() => {
const btn = document.querySelector(siteCfg.dlBtnSel);
if (btn) btn.click();
}, 500);
}
}
/* ================================================================
flush / observe
================================================================ */
let pending = false, mo = null, _scrolledOnce = false, _kpIdx = -1, _wasWritePage = false, _urlFlushTimer = null;
function isOurNode(node) {
let n = node;
while (n) {
if (n.nodeType === Node.ELEMENT_NODE &&
(n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR))) return true;
n = n.parentNode;
}
return false;
}
function getContentRoots() {
// 글 작성 페이지면 아무것도 처리 안 함
if (isWritePage()) return new Set();
// 우선순위 그룹: 상위 그룹에서 1개라도 매칭되면 하위 그룹은 건너뜀.
// kone.gg 가 #post-article 과 .article-body 를 별개 요소로 렌더링할 때
// 글 내용이 2배로 보이는 현상을 방지한다.
const PRIORITY_GROUPS = [
['#post-article', '#post-comment'],
['.article-body', 'div.fr-view.article-content', 'body div.article-body > div.fr-view'],
['p.text-sm.whitespace-pre-wrap', 'div.text-sm.whitespace-pre-wrap'],
];
let candidates = new Set();
for (const group of PRIORITY_GROUPS) {
for (const sel of group) {
document.querySelectorAll(sel).forEach(el => {
// contenteditable="true" 조상 → 에디터 내부 → 제외
let anc = el;
while (anc) {
if (anc.contentEditable === 'true') return;
anc = anc.parentElement;
}
// 요소 내부에 Froala 에디터 잔존 → write→read 전환 중 → 제외
if (el.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) return;
candidates.add(el);
});
}
if (candidates.size > 0) break; // 이 그룹에서 매칭됐으면 낮은 우선순위 건너뜀
}
// 중첩 제거: 다른 루트의 자손인 요소는 제외
const result = new Set();
for (const el of candidates) {
let dominated = false;
for (const other of candidates) {
if (other !== el && other.contains(el)) { dominated = true; break; }
}
if (!dominated) result.add(el);
}
return result;
}
function flushListTitles(contentRoots) {
if (!CFG.LIST_DECODE || !document.body) return;
CFG.LIST_SELECTORS.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
let anc = el;
while (anc) { if (contentRoots.has(anc)) return; anc = anc.parentElement; }
walkAndProcess(el, processListNode);
});
});
const gw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const p = node.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
const tag = p.nodeName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
let anc = p;
while (anc) {
if (anc.nodeType === Node.ELEMENT_NODE) {
if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT; // Froala 에디터 내부 제외
if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
if (contentRoots.has(anc)) return NodeFilter.FILTER_REJECT;
const tn = anc.nodeName;
if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
}
anc = anc.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const b64TestRe = new RegExp(`[A-Za-z0-9+/]{${MIN_B64},}={0,2}`);
const listNodes = []; let ln;
while ((ln = gw.nextNode())) {
const v = ln.nodeValue;
if (b64TestRe.test(v) ||
/[⠀-⣿]{3,}/.test(v) ||
/\b(?:RJ|BJ|VJ|RE|BE|VE)\d{4,8}\b/i.test(v) ||
/[꺼거]\d{4,8}/.test(v) ||
/(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*\d{4,10}/.test(v))
listNodes.push(ln);
}
listNodes.forEach(processListNode);
}
/* ================================================================
제목 카드 삽입
CONTENT_SELECTORS 본문 영역이 감지될 때, 같은 컨테이너 내
h1/h2/h3 에서 RJ·Steam 코드를 찾아 본문 하단에 카드 바 추가
================================================================ */
function injectTitleCards(contentRoots) {
if (!CFG.LINK_CARD) return;
// CONTENT_SELECTORS가 매칭된 경우에만 실행 (목록 페이지 제외)
const roots = [...contentRoots].filter(r => !r.hasAttribute(TITLE_CARD_ATTR));
if (!roots.length) return;
// content root 밖의 heading 요소 수집
const allHeadings = [...document.querySelectorAll('h1, h2, h3')].filter(h => {
for (const r of contentRoots) if (r.contains(h)) return false;
return true;
});
for (const root of roots) {
root.setAttribute(TITLE_CARD_ATTR, '1');
if (!allHeadings.length) continue;
// root의 조상을 거슬러 올라가며 heading을 포함하는 가장 가까운 컨테이너 탐색
let scoped = allHeadings;
let anc = root.parentElement;
while (anc && anc !== document.body) {
const found = allHeadings.filter(h => anc.contains(h));
if (found.length) { scoped = found; break; }
anc = anc.parentElement;
}
// heading 텍스트에서 DLsite/Steam 코드 추출
const seen = new Set();
const cards = [];
for (const h of scoped) {
const hits = findAllMatches(h.textContent)
.filter(hit => hit.type === 'dlsite' || hit.type === 'steam');
for (const hit of hits) {
const id = hit.type === 'dlsite' ? hit.code : hit.appId;
if (seen.has(id)) continue;
seen.add(id);
cards.push(
hit.type === 'dlsite' ? makeDlsiteCard(hit.code) : makeSteamCard(hit.appId)
);
}
}
if (!cards.length) continue;
// 카드 바 생성 후 본문 하단에 삽입
const bar = document.createElement('div');
bar.setAttribute(DONE_ATTR, '');
bar.className = 'b64-title-card-bar';
const label = document.createElement('div');
label.className = 'b64-title-card-label';
label.textContent = '📌 제목에서 발견된 작품';
bar.appendChild(label);
cards.forEach(c => bar.appendChild(c));
root.appendChild(bar);
}
}
/* ── 카드 키보드 탐색 (w/s, ↑/↓) ── */
function kpGetLinks() {
if (CFG.NAV_TARGET === 'products') return Array.from(document.querySelectorAll('.b64-product-link'));
if (CFG.NAV_TARGET === 'both') return Array.from(document.querySelectorAll('.b64-link, .b64-product-link'));
return Array.from(document.querySelectorAll('.b64-link'));
}
function kpNavigate(dir) {
const links = kpGetLinks();
if (!links.length) return false;
let next;
if (_kpIdx < 0) {
next = dir > 0 ? 0 : links.length - 1;
} else {
next = _kpIdx + dir;
if (next < 0 || next >= links.length) return false; // 경계에서 정지 (순환 없음)
}
links.forEach(l => l.classList.remove('kp-focused'));
_kpIdx = next;
links[_kpIdx].classList.add('kp-focused');
links[_kpIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
}
// capture:true — 버블링 단계에서 사이트가 이벤트를 막아도 동작하도록
window.addEventListener('keydown', e => {
const tg = e.target;
if (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA' || tg.isContentEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key === 's' || e.key === 'ArrowDown') { if (kpNavigate(1)) e.preventDefault(); }
else if (e.key === 'w' || e.key === 'ArrowUp') { if (kpNavigate(-1)) e.preventDefault(); }
else if (e.key === 'Enter' && _kpIdx >= 0) {
const links = kpGetLinks();
if (links[_kpIdx]) { links[_kpIdx].click(); e.preventDefault(); }
}
}, { capture: true, passive: false });
const WRITE_PAGE_RE = /kone\.gg\/s\/[^/]+\/write(\/|$)/;
function isWritePage(href = location.href) {
return WRITE_PAGE_RE.test(href);
}
// kone.gg OG 프리뷰 카드(not-prose h-24)를 CSS로 숨김
// inline style은 Svelte 재렌더링에 사라지지만 <head> CSS 규칙은 살아남음
function injectOGHideStyle() {
if (!document.head || document.getElementById('b64d-og-hide')) return;
const s = document.createElement('style');
s.id = 'b64d-og-hide';
s.textContent = 'a.not-prose.h-24.overflow-hidden{display:none!important}';
document.head.appendChild(s);
}
function flush() {
pending = false;
if (mo) mo.disconnect();
if (!document.body) { observe(); return; }
injectOGHideStyle();
// 글 작성 페이지에서는 모든 처리 비활성화 (복호화 무한 증식 방지)
if (isWritePage()) { _wasWritePage = true; return; }
const contentRoots = getContentRoots();
// write 페이지에서 돌아온 직후: Froala 에디터 DOM이 아직 남아있으면 재대기
// contenteditable 속성이 먼저 제거되는 경우 대비 → Froala 전용 클래스로 체크
if (_wasWritePage) {
if (document.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) {
pending = true;
setTimeout(flush, 300);
return;
}
_wasWritePage = false;
}
if (CFG.CONTENT_DECODE) contentRoots.forEach(el => walkAndProcess(el, processContentNode));
flushListTitles(contentRoots);
injectTitleCards(contentRoots);
convertProductLinks(contentRoots);
observe();
// 첫 번째 다운로드 링크 카드로 스크롤 (페이지당 1회, 최초 복호화 시)
if (CFG.SCROLL_TO_FIRST && !_scrolledOnce) {
const firstLink = document.querySelector('.b64-link');
if (firstLink) {
_scrolledOnce = true;
setTimeout(() => firstLink.scrollIntoView({ behavior: 'smooth', block: 'center' }), 150);
}
}
}
function applyLive() {
// 1. convertProductLinks 교체 카드(a[data-b64d-orig]) → 원본 <a> 복원
document.querySelectorAll(`[${ORIG_ATTR}]`).forEach(card => {
const origHtml = card.getAttribute(ORIG_ATTR);
if (!card.parentNode) return;
const tmp = document.createElement('div');
tmp.innerHTML = origHtml;
const origEl = tmp.firstChild;
if (origEl) card.parentNode.replaceChild(origEl, card);
});
// 2. processContentNode wrap(span[data-b64d-raw]) → 원본 텍스트 복원
document.querySelectorAll(`span[${RAW_ATTR}]`).forEach(el => {
const raw = el.getAttribute(RAW_ATTR);
if (el.parentNode) el.parentNode.replaceChild(document.createTextNode(raw), el);
});
// 3. processListNode 결과 복원: span[data-b64lt="src"] + 바로 뒤 decoded span 제거
document.querySelectorAll(`span[${LTDONE_ATTR}="src"]`).forEach(hideWrap => {
const parent = hideWrap.parentNode;
if (!parent) return;
const next = hideWrap.nextSibling;
parent.insertBefore(document.createTextNode(hideWrap.textContent), hideWrap);
parent.removeChild(hideWrap);
if (next && next.nodeType === Node.ELEMENT_NODE &&
next.hasAttribute(LTDONE_ATTR) && next.getAttribute(LTDONE_ATTR) !== 'src') {
parent.removeChild(next);
}
});
// 4. 제목 카드 바 제거 + TITLE_CARD_ATTR 초기화
document.querySelectorAll('.b64-title-card-bar').forEach(el => el.remove());
document.querySelectorAll(`[${TITLE_CARD_ATTR}]`).forEach(el => el.removeAttribute(TITLE_CARD_ATTR));
processedRaws.clear();
processedListRaws.clear();
schedule();
}
function schedule(delayMs) {
if (pending) return;
pending = true;
if (delayMs > 0) setTimeout(flush, delayMs);
else queueMicrotask(flush); // 일반 mutation: 페인트 전 즉시 실행 → 깜빡임 제거
}
function observe() {
if (!mo) {
mo = new MutationObserver(mutations => {
for (const mu of mutations)
for (const added of mu.addedNodes) {
if (isOurNode(added)) continue;
if (added.nodeType === Node.ELEMENT_NODE &&
(added.hasAttribute(DONE_ATTR) || added.hasAttribute(LTDONE_ATTR))) continue;
schedule(); return;
}
});
}
const root = document.body || document.documentElement;
if (root) mo.observe(root, { childList: true, subtree: true });
}
window.addEventListener('pageshow', e => {
if (e.persisted) { processedRaws.clear(); processedListRaws.clear(); schedule(); }
});
let _lastUrl = location.href;
function onUrlChange() {
const cur = location.href;
if (cur !== _lastUrl) {
const prev = _lastUrl;
_lastUrl = cur;
// write 페이지에서 떠나는 경우 표시 (flush() 실행 여부와 무관하게 URL로 직접 판단)
if (isWritePage(prev)) _wasWritePage = true;
// 이전 URL 변경 타이머 취소 후 반드시 재시작 (pending=true 상태에서도 동작해야 함)
if (_urlFlushTimer) { clearTimeout(_urlFlushTimer); _urlFlushTimer = null; }
pending = false;
if (mo) mo.disconnect();
processedRaws.clear();
processedListRaws.clear();
_scrolledOnce = false;
_kpIdx = -1;
document.querySelectorAll('.b64-link.kp-focused, .b64-product-link.kp-focused').forEach(el => el.classList.remove('kp-focused'));
dlpHide();
pending = true;
_urlFlushTimer = setTimeout(() => { _urlFlushTimer = null; pending = false; flush(); }, 500);
}
}
window.addEventListener('popstate', onUrlChange);
['pushState', 'replaceState'].forEach(method => {
const orig = history[method];
history[method] = function (...args) { const r = orig.apply(this, args); onUrlChange(); return r; };
});
// 탭 포커스 이탈(다른 탭 클릭·새 탭 열기) 또는 새로고침 시 미리보기 강제 숨김
window.addEventListener('blur', dlpHide);
window.addEventListener('beforeunload', dlpHide);
flush();
observe();
}); // domReady end
})();