// ==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-end // @license MIT // ==/UserScript== (function () { 'use strict'; // 사이트가 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())); } 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 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); }; 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 }; } // lazy loading 트리거: data-src 복사 + loading=eager + 스크롤 const triggerLazyLoading = async () => { const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"]') || document; const imgs = viewer.querySelectorAll('img'); imgs.forEach(img => { img.setAttribute('loading', 'eager'); ['data-src', 'data-original', 'data-lazy-src', 'data-lazy'].forEach(attr => { const val = img.getAttribute(attr); if (val && !img.src) img.src = val; }); }); // 동적 스크롤: 새 콘텐츠가 계속 로드되면 scrollHeight도 계속 증가 let prevHeight = 0; let stuckCount = 0; while (stuckCount < 3) { const maxScroll = Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, 0 ); if (maxScroll === prevHeight) { stuckCount++; } else { stuckCount = 0; } prevHeight = maxScroll; // 현재 위치에서 끝까지 step 단위로 스크롤 const step = Math.min(500, maxScroll); for (let s = window.scrollY; s <= maxScroll; s += step) { window.scrollTo(0, Math.min(s, maxScroll)); await sleep(80); } window.scrollTo(0, maxScroll); await sleep(1000); } // 끝까지 다 내려간 상태에서 여유 대기 await sleep(2000); window.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}개)`); }; // 공통: 현재 페이지에서 이미지 획득 + lazy 로딩 트리거 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; } } if (urls.length <= 3) { log(`lazy loading 트리거 (${urls.length}개 → 전체)...`); await triggerLazyLoading(); urls = getEpisodeImages(); } return urls; }; 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); } } async function downloadAllEpisodes() { log('목록 페이지 처리 시작'); const episodeList = document.querySelector('ul.ep-list-v2'); if (!episodeList) { alert('에피소드 목록을 찾을 수 없습니다.'); return; } 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 }); } }); if (episodes.length === 0) { alert('에피소드 링크를 추출할 수 없습니다.'); return; } const seriesName = getSeriesName(); log(`${episodes.length}개 에피소드를 큐에 저장, 첫 화로 이동합니다...`); // 큐 저장 후 첫 번째 에피소드로 이동 sessionStorage.setItem(QUEUE_KEY, JSON.stringify(episodes)); sessionStorage.setItem(SERIES_KEY, seriesName); sessionStorage.setItem(IDX_KEY, '0'); window.location.href = episodes[0].url; } // 자동 큐 처리: 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(2000); try { const imageUrls = await getImagesWithLazyLoad(); 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 { cleanupQueue(); log('모든 에피소드 다운로드 완료!'); } }; 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'); })();