// ==UserScript== // @name sbxh6Downloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.1.3 // @description sbxh6.com (뉴토끼) 만화 다운로더 // @author hehaho // @match https://sbxh9.com/manhwa/* // @match https://sbxh9.com/manhwa/*/* // @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_xmlhttpRequest // @grant GM_download // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip-utils/0.1.0/jszip-utils.js // @run-at document-start // @license MIT // ==/UserScript== (function () { 'use strict'; // 페이지가 setTimeout을 오버라이드해도 영향받지 않도록 원본 캡처 const _setTimeout = window.setTimeout.bind(window); const sleep = (ms) => new Promise(r => _setTimeout(r, ms)); // --------------------------------------------------------------- // Phase 1: 문서 표시 상태 강제 + IntersectionObserver 후킹 // --------------------------------------------------------------- // document/visibility 강제 활성화 (페이지 JS가 덮어써도 주기적 복원) const forceVisible = () => { try { Object.defineProperty(document, 'hidden', { get: () => false, configurable: true }); Object.defineProperty(document, 'visibilityState', { get: () => 'visible', configurable: true }); ['webkitHidden', 'mozHidden', 'msHidden'].forEach(p => { try { Object.defineProperty(document, p, { get: () => false, configurable: true }); } catch (_) {} }); } catch (_) {} try { const proto = Document.prototype; if (proto) { Object.defineProperty(proto, 'hidden', { get: () => false, configurable: true }); Object.defineProperty(proto, 'visibilityState', { get: () => 'visible', configurable: true }); } } catch (_) {} try { document.hasFocus = () => true; } catch (_) {} }; forceVisible(); (function refreshVisible() { forceVisible(); _setTimeout(refreshVisible, 500); })(); // IntersectionObserver 후킹: observe() 즉시 콜백 실행 const OrigIO = window.IntersectionObserver; if (OrigIO) { const HookedIO = function (callback, options) { const instance = new OrigIO(callback, options); const origObserve = instance.observe.bind(instance); instance.observe = function (target) { origObserve(target); _setTimeout(() => { try { callback([{ target: target, isIntersecting: true, intersectionRatio: 1, boundingClientRect: target.getBoundingClientRect(), intersectionRect: target.getBoundingClientRect(), rootBounds: null, time: performance.now() }], instance); } catch (_) { } }, 50); }; return instance; }; HookedIO.prototype = OrigIO.prototype; window.IntersectionObserver = HookedIO; } // --------------------------------------------------------------- // Phase 2: DOM 로딩 후 나머지 로직 // --------------------------------------------------------------- const main = () => { // --------------------------------------------------------------- // 로깅 시스템 (sessionStorage 기반, 페이지 이동 시에도 유지) // --------------------------------------------------------------- const LOG_KEY2 = 'sbxh6_log'; const getLog = () => { try { return JSON.parse(sessionStorage.getItem(LOG_KEY2) || '[]'); } catch (_) { return []; } }; const appendLog = (...args) => { const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '); const entry = `[${new Date().toISOString().slice(11,23)}] ${msg}`; try { console.log(...args); } catch (_) {} try { const buf = getLog(); buf.push(entry); if (buf.length > 500) buf.splice(0, buf.length - 500); sessionStorage.setItem(LOG_KEY2, JSON.stringify(buf)); } catch (_) {} }; const downloadLog = () => { const buf = getLog(); if (buf.length === 0) { alert('로그가 없습니다.'); return; } const text = buf.join('\n'); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `download_log_${Date.now()}.txt`; a.click(); URL.revokeObjectURL(url); }; const log = appendLog; // 메뉴 명령 ID 관리 (중복 등록 방지 → 배지 숫자 증가 방지) let menuCmdIds = []; const registerMenu = () => { menuCmdIds.forEach(id => { try { GM_unregisterMenuCommand(id); } catch (_) {} }); menuCmdIds = []; const { isListPage, isEpisodePage } = getListPageInfo(); if (isListPage) { menuCmdIds.push(GM_registerMenuCommand('[목록] 전체 에피소드 다운로드', () => downloadAllEpisodes())); menuCmdIds.push(GM_registerMenuCommand('[목록] n화 이후 다운로드', () => downloadAfterEpisode())); menuCmdIds.push(GM_registerMenuCommand('[목록] 범위 다운로드', () => downloadRangeEpisodes())); menuCmdIds.push(GM_registerMenuCommand('[목록] 로그 다운로드', () => downloadLog())); } else if (isEpisodePage) { menuCmdIds.push(GM_registerMenuCommand('현재 에피소드 다운로드', () => downloadCurrentEpisode())); menuCmdIds.push(GM_registerMenuCommand('로그 다운로드', () => downloadLog())); } }; const downloadZip = (blob, filename) => { const url = URL.createObjectURL(blob); if (typeof GM_download !== "undefined") { GM_download({ url: url, name: filename, onload: () => URL.revokeObjectURL(url), onerror: () => { const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } }); } else { const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } }; // 자동 연속 다운로드 큐 (sessionStorage 기반) const QUEUE_KEY = 'sbxh6_q'; const IDX_KEY = 'sbxh6_i'; const SERIES_KEY = 'sbxh6_s'; const LIST_KEY = 'sbxh6_list'; const getQueue = () => { try { return JSON.parse(sessionStorage.getItem(QUEUE_KEY) || 'null'); } catch (_) { return null; } }; const getQueueIndex = () => { try { return parseInt(sessionStorage.getItem(IDX_KEY) || '0', 10); } catch (_) { return 0; } }; const cleanupQueue = () => { sessionStorage.removeItem(QUEUE_KEY); sessionStorage.removeItem(IDX_KEY); sessionStorage.removeItem(SERIES_KEY); sessionStorage.removeItem(LIST_KEY); }; function generateComicInfo(title, series, writer, number, pageCount, imageCount = 0) { let pagesXml = ''; if (imageCount > 0) { for (let i = 0; i < imageCount; i++) { pagesXml += ` \n`; } } const comicInfo = ` ${title} ${series} ${number} -1 -1 -1 -1 ${writer} ${pageCount} ko Unknown Yes ${pagesXml ? ` \n${pagesXml} \n` : ''} `; return comicInfo; } function getEpisodeImages(container) { const root = container || document; const imgs = root.querySelectorAll( '.vw-imgs img, .vw-imgs--double img, .vw-imgs--single img, ' + '.webtoon-page img, .manhwa-page img, ' + '[class*="vw-imgs"] img' ); const urls = []; const seen = new Set(); imgs.forEach(img => { const src = img.src || img.getAttribute('src') || ''; if (src && src.startsWith('http') && !seen.has(src)) { seen.add(src); urls.push(src); } }); if (urls.length < 5) { log(`getEpisodeImages: img=${imgs.length} urls=${urls.length} (소수만 발견)`); } return urls; } function getSeriesName() { let seriesName = '제목없음'; let writer = ''; const metaEl = document.querySelector('div.vw-bot-meta strong'); let rawTitle = ''; if (metaEl) { rawTitle = metaEl.textContent.trim(); } else { const ogTitle = document.querySelector('meta[property="og:title"]'); rawTitle = (ogTitle ? ogTitle.getAttribute('content') || '' : document.title).split('|')[0].trim(); } const parts = rawTitle.split(/\s-\s/); seriesName = parts[0] || rawTitle; if (parts.length > 1) writer = parts[1]; return { seriesName, writer }; } function getEpisodeTitle() { const metaEl = document.querySelector('div.vw-bot-meta span'); if (metaEl) return metaEl.textContent.trim(); const titleEl = document.querySelector('h1'); if (titleEl && !titleEl.closest('.hero-v2')) { return titleEl.textContent.trim(); } return document.title.split('|')[0].trim() || '제목없음'; } function getListPageInfo() { const currentURL = window.location.href; const pathParts = window.location.pathname.split('/').filter(Boolean); const isListPage = pathParts.length === 2; const isEpisodePage = pathParts.length >= 3; return { isListPage, isEpisodePage }; } // 모든 img의 data-* 속성을 src로 강제 복사 (알려진 속성명 우선, 이후 모든 data-* 검사) const forceLoadImages = (root) => { const container = root || document; container.querySelectorAll('img').forEach(img => { img.setAttribute('loading', 'eager'); // 우선 알려진 속성명 ['data-src', 'data-original', 'data-lazy-src', 'data-lazy', 'data-srcset'].forEach(attr => { const val = img.getAttribute(attr); if (val && !img.src) img.src = val; }); // 그래도 src가 없으면 모든 data-* 속성 중 http로 시작하는 값 사용 if (!img.src || !img.src.startsWith('http')) { for (const attr of img.attributes) { if (attr.name.startsWith('data-') && attr.value && attr.value.startsWith('http')) { img.src = attr.value; break; } } } }); }; // 강제 scroll 이벤트 디스패치 const fireScroll = () => { window.dispatchEvent(new Event('scroll', { bubbles: true })); window.dispatchEvent(new UIEvent('scroll', { detail: 0 })); }; // 디버그: .vw-imgs 영역 HTML 구조 로그 const debugDumpViewer = (label) => { try { const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"]'); if (!viewer) { log(`${label}: viewer 없음`); return; } const children = viewer.children; let info = `${label}: childCount=${children.length}`; for (let i = 0; i < Math.min(children.length, 5); i++) { const c = children[i]; info += ` | [${i}] <${c.tagName}> class="${c.className}" style="${c.getAttribute('style') || ''}"`; if (c.tagName === 'IMG') { info += ` src="${c.src || '(none)'}" data-src="${c.getAttribute('data-src') || ''}"`; } } log(info); } catch (_) {} }; // lazy loading 트리거: data-src 복사 + loading=eager + 스크롤 const triggerLazyLoading = async (doc, win) => { const d = doc || document; const w = win || window; log(`triggerLazyLoading: scrollY=${w.scrollY}, hidden=${document.hidden}, innerH=${window.innerHeight}`); debugDumpViewer('triggerLazyLoading 시작'); const viewer = d.querySelector('.vw-imgs, [class*="vw-imgs"]') || d; forceLoadImages(viewer); let prevHeight = 0; let stuckCount = 0; while (stuckCount < 3) { const maxScroll = Math.max( d.documentElement.scrollHeight, d.body.scrollHeight, 0 ); log(`triggerLazyLoading: scroll=${maxScroll}, stuck=${stuckCount}, imgs=${viewer.querySelectorAll('img').length}`); if (maxScroll === prevHeight) { stuckCount++; } else { stuckCount = 0; } prevHeight = maxScroll; const step = Math.min(300, maxScroll); for (let s = w.scrollY; s <= maxScroll; s += step) { w.scrollTo(0, Math.min(s, maxScroll)); fireScroll(); _setTimeout(fireScroll, 10); await sleep(300); } w.scrollTo(0, maxScroll); fireScroll(); forceLoadImages(viewer); await sleep(1500); } forceLoadImages(viewer); await sleep(2000); w.scrollTo(0, 0); fireScroll(); debugDumpViewer('triggerLazyLoading 완료'); log(`triggerLazyLoading: 완료, 최종 imgs=${viewer.querySelectorAll('img').length}`); await sleep(1000); }; // viewer 내 이미지 슬롯 전체 개수 (img + 미로딩 placeholder div) const countTotalImgSlots = (root) => { const container = root || document; const viewers = container.querySelectorAll( '.vw-imgs, .vw-imgs--double, .vw-imgs--single, ' + '.webtoon-page, .manhwa-page, [class*="vw-imgs"]' ); if (viewers.length === 0) return 0; let total = 0; viewers.forEach(v => { total += v.querySelectorAll('img').length; total += v.querySelectorAll( 'div[style*="aspect-ratio"], ' + 'div[class*="placeholder"], ' + 'div[class*="lazy"], ' + 'div.img-item, ' + 'div[class*="img-wrap"]' ).length; }); return total; }; // 첫 번째 img의 URL 패턴으로 모든 이미지 URL 생성 (lazy 로딩 우회) const generateEpisodeUrls = () => { try { const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"]'); if (!viewer) { log('generateUrls: viewer 없음'); return null; } const totalSlots = countTotalImgSlots(); if (totalSlots === 0) { log('generateUrls: totalSlots=0'); return null; } const firstImg = viewer.querySelector('img'); if (!firstImg) { log('generateUrls: img 없음'); return null; } const src = firstImg.src || firstImg.getAttribute('src') || ''; if (!src || !src.startsWith('http')) { log('generateUrls: 유효한 src 없음'); return null; } // URL 패턴: /p001.jpg → base 제거 const baseUrl = src.replace(/\/p\d+\.[a-z]+(?:\?.*)?$/i, '/'); const extMatch = src.match(/\.([a-z]+)(?:\?.*)?$/i); const ext = extMatch ? extMatch[1] : 'jpg'; const numMatch = src.match(/p(\d+)\.[a-z]+/i); const numDigits = numMatch ? numMatch[1].length : 3; const urls = []; for (let i = 1; i <= totalSlots; i++) { urls.push(`${baseUrl}p${String(i).padStart(numDigits, '0')}.${ext}`); } log(`generateUrls: ${baseUrl} → ${urls.length}개 생성`); return urls; } catch (e) { log('generateUrls 오류:', e); return null; } }; // 공통: 현재 페이지에서 이미지 획득 + lazy 로딩 트리거 + URL 생성 fallback const getImagesWithLazyLoad = async () => { let urls = getEpisodeImages(); if (urls.length === 0) { for (let i = 0; i < 10; i++) { await sleep(500); urls = getEpisodeImages(); if (urls.length > 0) break; } } const prevLen = urls.length; debugDumpViewer('getImagesWithLazyLoad 시작'); // 우선 페이지 끝까지 fast scroll 1회 const maxScroll = Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, 0 ); const hasContent = maxScroll > window.innerHeight * 1.2; if (hasContent) { log(`getImagesWithLazyLoad: scrollable(${maxScroll}px) → fast scroll`); window.scrollTo(0, maxScroll); fireScroll(); _setTimeout(fireScroll, 10); await sleep(2000); forceLoadImages(); urls = getEpisodeImages(); log(`getImagesWithLazyLoad: fast scroll 후 ${urls.length}개`); } // 부족하면 step-by-step lazy 로딩 (최대 3회) for (let attempt = 0; attempt < 3; attempt++) { const totalSlots = countTotalImgSlots(); const stillNeed = ( (totalSlots > 0 && urls.length < totalSlots) || urls.length <= 3 || (prevLen > 0 && urls.length < prevLen * 0.5) ); log(`getImagesWithLazyLoad: loaded=${urls.length}, totalSlots=${totalSlots}, need=${stillNeed} (attempt ${attempt + 1})`); if (!stillNeed) break; log(`getImagesWithLazyLoad: ${urls.length}개 → step scroll (attempt ${attempt + 1})`); await triggerLazyLoading(); const newUrls = getEpisodeImages(); if (newUrls.length <= urls.length) break; urls = newUrls; } // 모든 data-* 속성 강제 검사 forceLoadImages(); urls = getEpisodeImages(); // 3개 이하면 HTML 덤프 if (urls.length <= 3) debugDumpViewer('getImagesWithLazyLoad 최종'); // 그래도 부족하면 URL 패턴으로 직접 생성 (fallback) const totalSlots = countTotalImgSlots(); if (urls.length < totalSlots && totalSlots > 3) { const generated = generateEpisodeUrls(); if (generated && generated.length > urls.length) { log(`getImagesWithLazyLoad: URL 생성 fallback → ${generated.length}개 (원래 ${urls.length}개)`); urls = generated; } } log(`getImagesWithLazyLoad: 최종 ${urls.length}개`); return urls; }; async function downloadImagesAsZip(imageUrls, seriesInfo, episodeTitle, episodeNumber) { const zip = new JSZip(); const folderName = `${seriesInfo.seriesName}_${episodeNumber}`.replace(/[<>:"/\\|?*]/g, '_').slice(0, 200); const folder = zip.folder(folderName); const fetchImage = (url) => new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', headers: { 'Referer': window.location.origin + '/' }, onload: (res) => { if (res.status >= 200 && res.status < 300) { resolve(res.response); } else { reject(new Error(`HTTP ${res.status}: ${url}`)); } }, onerror: (err) => reject(new Error(`GM_xhr 실패: ${url}`)) }); }); const total = imageUrls.length; for (let i = 0; i < total; i++) { const url = imageUrls[i]; const ext = url.match(/\.(jpe?g|png|webp|gif|avif)/i)?.[1] || 'jpg'; const filename = `p${String(i + 1).padStart(3, '0')}.${ext}`; try { const blob = await fetchImage(url); folder.file(filename, blob, { binary: true }); } catch (e) { log(`downloadImagesAsZip: ${filename} 실패 - ${e.message}`); } } const comicInfoXml = generateComicInfo( episodeTitle, seriesInfo.seriesName, seriesInfo.writer, episodeNumber, total, total ); folder.file('ComicInfo.xml', comicInfoXml); const zipBlob = await zip.generateAsync({ type: 'blob' }); const safeName = `${seriesInfo.seriesName}_${episodeNumber}.zip`.replace(/[<>:"/\\|?*]/g, '_'); downloadZip(zipBlob, safeName); log(`downloadImagesAsZip: ${safeName} 저장 완료 (${total}개 중 ${folder.files ? Object.keys(folder.files).length - 1 : '?'}개 성공)`); } async function downloadCurrentEpisode() { try { log('에피소드 다운로드 시작...'); const imageUrls = await getImagesWithLazyLoad(); if (imageUrls.length === 0) { alert('이미지를 찾을 수 없습니다.'); return; } const seriesInfo = getSeriesName(); const episodeTitleText = getEpisodeTitle(); const episodeTitle = `${seriesInfo.seriesName} ${episodeTitleText}`; const episodeNumber = episodeTitleText.match(/\d+/)?.[0] || '1'; log(`시리즈: ${seriesInfo.seriesName}, 작가: ${seriesInfo.writer}, 에피소드: ${episodeTitle}, 이미지: ${imageUrls.length}개`); await downloadImagesAsZip(imageUrls, seriesInfo, episodeTitle, episodeNumber); log('완료'); } catch (error) { alert('다운로드 중 오류 발생:\n' + error); log(error); } } // 목록 페이지에서 에피소드 목록 추출 const getEpisodesFromList = () => { const episodeList = document.querySelector('ul.ep-list-v2'); if (!episodeList) return null; const episodes = []; episodeList.querySelectorAll('li.ep-row-v2').forEach((li, index) => { const link = li.querySelector('a.ep-row-v2-link'); if (link) { const href = link.getAttribute('href'); const absoluteUrl = href.startsWith('http') ? href : new URL(href, window.location.href).href; const epNoEl = li.querySelector('.ep-row-v2-no'); const epTitleEl = li.querySelector('.ep-row-v2-title strong'); episodes.push({ index, number: epNoEl ? epNoEl.textContent.trim() : String(index + 1), title: epTitleEl ? epTitleEl.textContent.trim() : String(index + 1), url: absoluteUrl }); } }); return episodes.length > 0 ? episodes : null; }; // 큐 저장 후 첫 화로 이동 const startQueue = (episodes, seriesName) => { log(`${episodes.length}개 에피소드를 큐에 저장, 첫 화로 이동합니다...`); sessionStorage.setItem(QUEUE_KEY, JSON.stringify(episodes)); sessionStorage.setItem(SERIES_KEY, JSON.stringify(seriesName)); sessionStorage.setItem(IDX_KEY, '0'); sessionStorage.setItem(LIST_KEY, window.location.href); window.location.href = episodes[0].url; }; async function downloadAllEpisodes() { log('목록 페이지 처리 시작'); const episodes = getEpisodesFromList(); if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; } const seriesInfo = getSeriesName(); startQueue(episodes, seriesInfo); } // n화 이후 다운로드 async function downloadAfterEpisode() { const episodes = getEpisodesFromList(); if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; } const input = prompt('어느 에피소드부터 다운로드할까요?\n(번호 입력, 예: 50)'); if (!input) return; const threshold = parseFloat(input.trim()); if (isNaN(threshold)) { alert('올바른 숫자를 입력하세요.'); return; } const filtered = episodes .filter(ep => parseFloat(ep.number) >= threshold) .sort((a, b) => parseFloat(a.number) - parseFloat(b.number)); if (filtered.length === 0) { alert('조건에 맞는 에피소드가 없습니다.'); return; } const seriesInfo = getSeriesName(); startQueue(filtered, seriesInfo); } // 범위 다운로드 async function downloadRangeEpisodes() { const episodes = getEpisodesFromList(); if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; } const input = prompt('범위를 입력하세요.\n(예: 30-50 또는 50-끝)'); if (!input) return; const match = input.trim().match(/^(\d+(?:\.\d+)?)\s*-\s*(?:(\d+(?:\.\d+)?)|(끝|end))?$/); if (!match) { alert('올바른 형식이 아닙니다. 예: 30-50'); return; } const start = parseFloat(match[1]); const end = match[2] !== undefined ? parseFloat(match[2]) : Infinity; if (isNaN(start)) { alert('올바른 숫자를 입력하세요.'); return; } const filtered = episodes .filter(ep => { const num = parseFloat(ep.number); return num >= start && num <= end; }) .sort((a, b) => parseFloat(a.number) - parseFloat(b.number)); if (filtered.length === 0) { alert('조건에 맞는 에피소드가 없습니다.'); return; } const seriesInfo = getSeriesName(); startQueue(filtered, seriesInfo); } // 자동 큐 처리: sessionStorage에 큐가 있으면 현재 화면을 다운로드 후 다음으로 이동 const processAutoQueue = async () => { const queue = getQueue(); if (!queue) return; const { isEpisodePage } = getListPageInfo(); if (!isEpisodePage) return; const idx = getQueueIndex(); if (idx >= queue.length) { cleanupQueue(); return; } log(`자동 다운로드: ${idx + 1}/${queue.length} - ${queue[idx].title}`); await sleep(5000); try { const imageUrls = await getImagesWithLazyLoad(); log(`자동 다운로드: ${idx + 1}/${queue.length} - ${queue[idx].title}, 이미지 ${imageUrls.length}개`); if (imageUrls.length > 0) { const seriesInfoStr = sessionStorage.getItem(SERIES_KEY); const seriesInfo = seriesInfoStr ? JSON.parse(seriesInfoStr) : getSeriesName(); const episodeTitleText = getEpisodeTitle() || queue[idx].title; const fullEpisodeTitle = `${seriesInfo.seriesName} ${episodeTitleText}`; await downloadImagesAsZip(imageUrls, seriesInfo, fullEpisodeTitle, queue[idx].number); } else { log(`이미지 없음 - 스킵`); } } catch (e) { log('자동 다운로드 오류:', e); } const next = idx + 1; sessionStorage.setItem(IDX_KEY, String(next)); if (next < queue.length) { log(`${next + 1}/${queue.length}번째로 이동...`); await sleep(2000); window.location.href = queue[next].url; } else { const listUrl = sessionStorage.getItem(LIST_KEY); cleanupQueue(); log('모든 에피소드 다운로드 완료!'); if (listUrl) { log('목록 페이지로 복귀합니다...'); await sleep(2000); window.location.href = listUrl; } } }; registerMenu(); // 자동 다운로드 큐 확인 (목록→에피소드 이동 시) _setTimeout(processAutoQueue, 1000); // Next.js SPA 네비게이션 감지: URL 변경 시 메뉴 재등록 const origPushState = history.pushState; const origReplaceState = history.replaceState; history.pushState = function () { origPushState.apply(this, arguments); _setTimeout(registerMenu, 500); }; history.replaceState = function () { origReplaceState.apply(this, arguments); _setTimeout(registerMenu, 500); }; window.addEventListener('popstate', () => _setTimeout(registerMenu, 500)); log('sbxh6Downloader loaded'); }; // DOM 로딩 완료 후 main() 실행 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', main); } else { main(); } })();