// ==UserScript==
// @name sbxh6Downloader
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
// @version 0.2.0
// @description sbxh6.com (뉴토끼) 만화 다운로더
// @author hehaho
// @match https://sbxh*.com/manhwa/*
// @match https://sbxh*.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() {
const metaEl = document.querySelector('div.vw-bot-meta strong');
if (metaEl) return metaEl.textContent.trim();
const titleEl = document.querySelector('h1');
if (titleEl && !titleEl.closest('.hero-v2')) {
return titleEl.textContent.trim();
}
const ogTitle = document.querySelector('meta[property="og:title"]');
if (ogTitle) {
const content = ogTitle.getAttribute('content') || '';
return content.split('|')[0].trim() || content;
}
return document.title.split('|')[0].trim() || '제목없음';
}
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, seriesName, episodeTitle, episodeNumber) {
const zip = new JSZip();
const folderName = `${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, seriesName, '', episodeNumber, total, total
);
folder.file('ComicInfo.xml', comicInfoXml);
const zipBlob = await zip.generateAsync({ type: 'blob' });
const safeName = `${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 seriesName = getSeriesName();
const episodeTitle = getEpisodeTitle();
log(`시리즈: ${seriesName}, 에피소드: ${episodeTitle}, 이미지: ${imageUrls.length}개`);
await downloadImagesAsZip(imageUrls, seriesName, episodeTitle, '1');
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, 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 seriesName = getSeriesName();
startQueue(episodes, seriesName);
}
// 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 seriesName = getSeriesName();
startQueue(filtered, seriesName);
}
// 범위 다운로드
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 seriesName = getSeriesName();
startQueue(filtered, seriesName);
}
// 자동 큐 처리: 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 seriesName = sessionStorage.getItem(SERIES_KEY) || getSeriesName();
const episodeTitle = getEpisodeTitle() || queue[idx].title;
await downloadImagesAsZip(imageUrls, seriesName, episodeTitle, 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();
}
})();