// ==UserScript== // @name sbxh6Downloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.1.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'; // --------------------------------------------------------------- // Phase 1: IntersectionObserver 후킹 (document-start 에서 즉시 실행) // 백그라운드 탭에서 IO가 동작하지 않아도 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 = () => { // 사이트가 console을 모니터링/차단하므로 자체 로거 사용 const log = (...args) => { try { console.log(...args); } catch (_) {} }; // 메뉴 명령 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())); } else if (isEpisodePage) { menuCmdIds.push(GM_registerMenuCommand('현재 에피소드 다운로드', () => downloadCurrentEpisode())); } }; 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); } }; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // 자동 연속 다운로드 큐 (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); } }); 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 }; } // 이미지의 data-* 속성을 src로 강제 복사 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; }); }); }; // lazy loading 트리거: data-src 복사 + loading=eager + 스크롤 const triggerLazyLoading = async (doc, win) => { const d = doc || document; const w = win || window; 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 ); 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)); await sleep(300); } w.scrollTo(0, maxScroll); forceLoadImages(viewer); await sleep(1500); } forceLoadImages(viewer); await sleep(2000); w.scrollTo(0, 0); await sleep(1000); }; // 공통: 이미지 URL 목록을 받아 ZIP 생성 + 다운로드 const downloadImagesAsZip = async (imageUrls, seriesName, episodeTitle, episodeNumber) => { const zip = new JSZip(); const protocolDomain = window.location.origin; for (let i = 0; i < imageUrls.length; i++) { const url = imageUrls[i]; await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', headers: { 'Referer': protocolDomain, 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, onload: (resp) => { if (resp.status === 200) { const ext = url.split('.').pop().split('?')[0] || 'jpg'; zip.file(`image${String(i).padStart(4, '0')}.${ext}`, new Blob([resp.response], { type: 'image/' + ext })); } resolve(); }, onerror: () => resolve(), ontimeout: () => resolve() }); }); if ((i + 1) % 5 === 0) await sleep(1000); } const comicInfo = generateComicInfo(episodeTitle, seriesName, '', episodeNumber, imageUrls.length, imageUrls.length); zip.file('ComicInfo.xml', comicInfo); const blob = await zip.generateAsync({ type: "blob" }); const filename = `${seriesName} - ${episodeTitle}.zip`.replace(/[/\\:*?"<>|]/g, '_'); downloadZip(blob, filename); log(`저장: ${filename} (${imageUrls.length}개)`); }; // 공통: 페이지/iframe에서 이미지 획득 + lazy 로딩 트리거 const getImagesWithLazyLoad = async (doc, win) => { const d = doc || document; const w = win || window; let urls = getEpisodeImages(d); if (urls.length === 0) { for (let i = 0; i < 10; i++) { await sleep(500); urls = getEpisodeImages(d); if (urls.length > 0) break; } } if (urls.length <= 3) { log(`lazy loading 트리거 (${urls.length}개 → 전체)...`); await triggerLazyLoading(d, w); urls = getEpisodeImages(d); } return urls; }; async function downloadCurrentEpisode() { try { log('에피소드 다운로드 시작...'); const imageUrls = await getImagesWithLazyLoad(document, window); 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; }; let downloadFrame = null; // iframe에서 에피소드 처리 const processEpisodeInFrame = async (episodes, index, seriesName) => { if (index >= episodes.length) { log('모든 에피소드 다운로드 완료!'); if (downloadFrame) { downloadFrame.remove(); downloadFrame = null; } return; } const episode = episodes[index]; log(`${index + 1}/${episodes.length} ${episode.title} 로딩 중...`); // iframe 생성 (상단 1/3) if (!downloadFrame) { downloadFrame = document.createElement('iframe'); downloadFrame.style.position = 'fixed'; downloadFrame.style.top = '0'; downloadFrame.style.left = '0'; downloadFrame.style.width = '100%'; downloadFrame.style.height = '33.33vh'; downloadFrame.style.border = 'none'; downloadFrame.style.zIndex = '999999'; document.body.appendChild(downloadFrame); } // 에피소드 로드 downloadFrame.src = episode.url; await new Promise((resolve) => { downloadFrame.addEventListener('load', () => resolve(), { once: true }); }); log(`${episode.title} 로드 완료, 이미지 대기 중...`); await sleep(7000); // iframe에서 이미지 추출 let imageUrls = []; try { const iframeWin = downloadFrame.contentWindow; const iframeDoc = iframeWin.document; imageUrls = await getImagesWithLazyLoad(iframeDoc, iframeWin); } catch (e) { log('iframe 접근 오류, GM_xmlhttpRequest로 대체:', e); // GM_xmlhttpRequest로 페이지 HTML 받아서 이미지 추출 시도 // (fallback - 추후 구현) } if (imageUrls.length > 0) { await downloadImagesAsZip(imageUrls, seriesName, episode.title, episode.number); } else { log('이미지 없음 - 스킵'); } await sleep(2000); processEpisodeInFrame(episodes, index + 1, seriesName); }; const startQueue = (episodes, seriesName) => { log(`${episodes.length}개 에피소드 다운로드 시작 (iframe)...`); processEpisodeInFrame(episodes, 0, seriesName); }; async function downloadAllEpisodes() { log('목록 페이지 처리 시작'); const episodes = getEpisodesFromList(); if (!episodes) { alert('에피소드 목록을 찾을 수 없습니다.'); return; } const seriesName = getSeriesName(); startQueue(episodes, seriesName); } 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(7000); try { const imageUrls = await getImagesWithLazyLoad(document, window); 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(); } })();