// ==UserScript== // @name sbxh6Downloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.1.1 // @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() 즉시 콜백 실행 // --------------------------------------------------------------- // document.hidden 강제 false (배경 탭에서도 페이지가 활성화된 것으로 인식) try { Object.defineProperty(document, 'hidden', { get: () => false, configurable: true }); Object.defineProperty(document, 'visibilityState', { get: () => 'visible', configurable: true }); } 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 (_) {} // 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 = () => { // 사이트가 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; } const waitForViewerReady = async (timeout = 12000) => { const viewerSelectors = [ '.vw-imgs', '.vw-imgs--double', '.vw-imgs--single', '.webtoon-page', '.manhwa-page', '[class*="vw-imgs"]', '.viewer', '[role="main"]' ]; const start = Date.now(); while (Date.now() - start < timeout) { for (const selector of viewerSelectors) { const el = document.querySelector(selector); if (el) return el; } const imgs = Array.from(document.querySelectorAll('img')).filter(img => img.naturalWidth >= 120 && img.naturalHeight >= 120); if (imgs.length > 0) return document; await sleep(300); } return document; }; function getEpisodeImages(container) { const root = container || document; // 만화/웹툰 뷰어 영역에서만 이미지 수집 const viewerSelectors = [ '.vw-imgs', '.vw-imgs--double', '.vw-imgs--single', '.webtoon-page', '.manhwa-page', '[class*="vw-imgs"]', '.viewer' ]; let imgs = []; for (const selector of viewerSelectors) { try { const containers = root.querySelectorAll(selector); if (containers.length > 0) { containers.forEach(containerEl => { imgs.push(...Array.from(containerEl.querySelectorAll('img'))); }); if (imgs.length > 0) break; } } catch (_) {} } // 찾지 못했으면 전체 img에서 필터링 if (imgs.length === 0) { imgs = Array.from(root.querySelectorAll('img')).filter(img => { const rect = img.getBoundingClientRect(); return rect.height > 50 && rect.width > 50; }); } const urls = []; const seen = new Set(); imgs.forEach(img => { const src = img.src || img.getAttribute('src') || img.getAttribute('data-src') || ''; if (src && src.startsWith('http') && !seen.has(src)) { const lowerSrc = src.toLowerCase(); if (!lowerSrc.includes('avatar') && !lowerSrc.includes('logo') && !lowerSrc.includes('icon') && !lowerSrc.includes('banner') && !lowerSrc.includes('nav') && !lowerSrc.includes('header') && !lowerSrc.includes('footer') && !lowerSrc.includes('.svg') && !lowerSrc.includes('placeholder')) { 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 () => { const viewer = document.querySelector('.vw-imgs, [class*="vw-imgs"], .viewer') || document.body; forceLoadImages(viewer); let prevHeight = 0; let stuckCount = 0; let maxAttempts = 100; // 더 많은 시도 let attemptCount = 0; while (stuckCount < 10 && attemptCount < maxAttempts) { attemptCount++; const maxScroll = Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, viewer.scrollHeight, 0 ); log(`[${attemptCount}] 높이: ${maxScroll}px (이전: ${prevHeight}px)`); if (maxScroll === prevHeight) { stuckCount++; log(`정체 카운트: ${stuckCount}/10`); } else { stuckCount = 0; log(`새 콘텐츠 감지!`); } prevHeight = maxScroll; // 작은 step으로 천천히 스크롤 (모든 이미지 트리거) const step = Math.max(50, Math.min(100, maxScroll / 20)); // 50-100px 범위로 조정 let currentPos = window.scrollY; while (currentPos < maxScroll) { window.scrollTo(0, currentPos); await sleep(50); // 더 빠른 반응 currentPos += step; } // 정확히 끝까지 window.scrollTo(0, maxScroll); await sleep(150); // 중간중간 강제 로딩 forceLoadImages(viewer); await sleep(400); } // 최종 확인: 절대 끝까지 const finalHeight = Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, viewer.scrollHeight ); log(`최종 높이: ${finalHeight}px - 끝까지 스크롤`); window.scrollTo(0, finalHeight); await sleep(250); forceLoadImages(viewer); await sleep(250); window.scrollTo(0, finalHeight); await sleep(375); log('스크롤 완료'); }; // 헬퍼: 시리즈명에서 작가 분리 ("시리즈 - 작가" 형식 → {series, writer}) function extractSeriesAndWriter(fullSeriesName) { const parts = fullSeriesName.split(' - '); if (parts.length >= 2) { return { series: parts[0].trim(), writer: parts[1].trim() }; } return { series: fullSeriesName, writer: '' }; } // 공통: 이미지 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); } // ComicInfo: 시리즈와 작가 분리, Title은 "시리즈명 + 에피소드제목"으로 조합 const { series: cleanSeries, writer } = extractSeriesAndWriter(seriesName); const fullTitle = `${cleanSeries} ${episodeTitle}`; const comicInfo = generateComicInfo(fullTitle, cleanSeries, writer, episodeNumber, imageUrls.length, imageUrls.length); zip.file('ComicInfo.xml', comicInfo); // ZIP 파일명은 원본 그대로 유지 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 () => { const viewer = await waitForViewerReady(); let urls = getEpisodeImages(viewer); log(`초기 이미지 감지: ${urls.length}개`); if (urls.length === 0) { log('이미지 없음 - 0.3초 대기 후 재시도...'); for (let i = 0; i < 10; i++) { await sleep(300); urls = getEpisodeImages(viewer); if (urls.length > 0) { log(`${i+1}번 시도 후 ${urls.length}개 감지`); break; } } } // 실제 만화 이미지가 충분하지 않으면 lazy loading 트리거 const hasLargeImages = urls.some(src => src.match(/\.(jpe?g|png|webp|gif)(\?|$)/i)); if (urls.length <= 8 || !hasLargeImages) { log(`lazy loading 트리거 (${urls.length}개 → 전체)...`); await triggerLazyLoading(); urls = getEpisodeImages(viewer); log(`스크롤 후 감지: ${urls.length}개`); } 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); } } // 목록 페이지에서 에피소드 목록 추출 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(7000); 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 { 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(); } })();