diff --git a/tokimonkeyDownader.js b/tokimonkeyDownader.js index af11b84..6160408 100644 --- a/tokimonkeyDownader.js +++ b/tokimonkeyDownader.js @@ -1,13 +1,14 @@ // ==UserScript== // @name sbxh6Downloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader -// @version 0.0.1 +// @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 @@ -19,6 +20,28 @@ (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") { @@ -47,6 +70,23 @@ 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) { @@ -87,12 +127,20 @@ ${pagesXml ? ` \n${pagesXml} \n` : ''} return comicInfo; } - function getEpisodeImages() { - const imgs = document.querySelectorAll('div.vw-imgs img, div.vw-imgs--single img, div.vw-imgs--double img'); + 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 => { - if (img.src && img.src.startsWith('http') && !urls.includes(img.src)) { - urls.push(img.src); + const src = img.src || img.getAttribute('src') || ''; + if (src && src.startsWith('http') && !seen.has(src)) { + seen.add(src); + urls.push(src); } }); return urls; @@ -137,285 +185,213 @@ ${pagesXml ? ` \n${pagesXml} \n` : ''} 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 { - console.clear(); - console.log('에피소드 다운로드 시작...'); - - let imageUrls = getEpisodeImages(); - if (imageUrls.length === 0) { - console.log('이미지 로드 대기 중...'); - for (let i = 0; i < 10; i++) { - await sleep(500); - imageUrls = getEpisodeImages(); - if (imageUrls.length > 0) break; - } - } + log('에피소드 다운로드 시작...'); + const imageUrls = await getImagesWithLazyLoad(); if (imageUrls.length === 0) { - alert('이미지를 찾을 수 없습니다. 페이지가 완전히 로드되었는지 확인해주세요.'); + alert('이미지를 찾을 수 없습니다.'); return; } const seriesName = getSeriesName(); const episodeTitle = getEpisodeTitle(); - console.log(`시리즈: ${seriesName}`); - console.log(`에피소드: ${episodeTitle}`); - console.log(`이미지: ${imageUrls.length}개 발견`); + log(`시리즈: ${seriesName}, 에피소드: ${episodeTitle}, 이미지: ${imageUrls.length}개`); - const zip = new JSZip(); - const protocolDomain = window.location.origin; - - const downloadImage = (url, index, total) => { - return new Promise((resolve) => { - GM_xmlhttpRequest({ - method: 'GET', - url: url, - responseType: 'arraybuffer', - headers: { - 'Referer': protocolDomain, - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - }, - onload: (response) => { - if (response.status === 200) { - const ext = url.split('.').pop().split('?')[0] || 'jpg'; - const blob = new Blob([response.response], { type: 'image/' + ext }); - const filename = `image${String(index).padStart(4, '0')}.${ext}`; - zip.file(filename, blob); - console.log(`${index + 1}/${total} 다운로드 완료`); - } else { - console.log(`이미지 ${index} 실패 (${response.status})`); - } - resolve(); - }, - onerror: () => { - console.log(`이미지 ${index} 오류`); - resolve(); - }, - ontimeout: () => { - console.log(`이미지 ${index} 타임아웃`); - resolve(); - } - }); - }); - }; - - let promiseList = []; - for (let i = 0; i < imageUrls.length; i++) { - promiseList.push(downloadImage(imageUrls[i], i, imageUrls.length)); - if (promiseList.length >= 5) { - await Promise.all(promiseList); - promiseList = []; - await sleep(1000); - } - } - if (promiseList.length > 0) { - await Promise.all(promiseList); - } - - const extMatch = imageUrls[0].match(/\.(jpg|jpeg|png|webp)/i); - const ext = extMatch ? extMatch[1].toLowerCase() : 'jpg'; - const extCounts = {}; - imageUrls.forEach(url => { - const e = url.match(/\.(jpg|jpeg|png|webp)/i); - if (e) extCounts[e[1].toLowerCase()] = (extCounts[e[1].toLowerCase()] || 0) + 1; - }); - const primaryExt = Object.keys(extCounts).sort((a, b) => extCounts[b] - extCounts[a])[0] || 'jpg'; - - const comicInfoContent = generateComicInfo(episodeTitle, seriesName, '', '1', imageUrls.length, imageUrls.length); - zip.file('ComicInfo.xml', comicInfoContent); - - const content = await zip.generateAsync({ type: "blob" }); - downloadZip(content, `${seriesName} - ${episodeTitle}.zip`); - - console.log(`완료: ${imageUrls.length}개 이미지`); + await downloadImagesAsZip(imageUrls, seriesName, episodeTitle, '1'); + log('완료'); } catch (error) { alert('다운로드 중 오류 발생:\n' + error); - console.error(error); + log(error); } } async function downloadAllEpisodes() { - try { - console.clear(); - console.log('목록 페이지 처리 시작'); + log('목록 페이지 처리 시작'); - const episodeList = document.querySelector('ul.ep-list-v2'); - if (!episodeList) { - alert('에피소드 목록을 찾을 수 없습니다.'); - return; - } + const episodeList = document.querySelector('ul.ep-list-v2'); + if (!episodeList) { alert('에피소드 목록을 찾을 수 없습니다.'); return; } - const items = episodeList.querySelectorAll('li.ep-row-v2'); - console.log(`총 ${items.length}개 에피소드 발견`); - - const episodes = []; - items.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'); - const epNumber = epNoEl ? epNoEl.textContent.trim() : String(index + 1); - const epTitle = epTitleEl ? epTitleEl.textContent.trim() : epNumber; - - episodes.push({ - index, - number: epNumber, - title: epTitle, - url: absoluteUrl - }); - } - }); - - console.log(`추출된 링크: ${episodes.length}개`); - - if (episodes.length === 0) { - alert('에피소드 링크를 추출할 수 없습니다.'); - return; - } - - const seriesName = getSeriesName(); - console.log(`시리즈: ${seriesName}`); - - const iframe = document.createElement('iframe'); - iframe.width = 600; - iframe.height = 600; - iframe.style.position = 'fixed'; - iframe.style.left = '-9999px'; - iframe.style.top = '0'; - document.body.appendChild(iframe); - - const waitIframeLoad = (url) => { - return new Promise((resolve) => { - iframe.addEventListener('load', () => resolve(), { once: true }); - iframe.src = url; + 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 }); - }; - - const getImagesInIframe = () => { - try { - const iframeDoc = iframe.contentWindow.document; - const imgs = iframeDoc.querySelectorAll('div.vw-imgs img, div.vw-imgs--single img'); - const urls = []; - imgs.forEach(img => { - if (img.src && img.src.startsWith('http') && !urls.includes(img.src)) { - urls.push(img.src); - } - }); - return urls; - } catch (e) { - console.log('iframe 접근 오류:', e); - return []; - } - }; - - for (let i = 0; i < episodes.length; i++) { - const episode = episodes[i]; - console.clear(); - console.log(`${i + 1}/${episodes.length} ${episode.title} 처리 중...`); - - await waitIframeLoad(episode.url); - console.log('페이지 로드 완료, 이미지 대기 중...'); - await sleep(3000); - - try { - const iframeDoc = iframe.contentWindow.document; - const viewContent = iframeDoc.querySelector('.vw-imgs, .vw-imgs--single, [class*="vw-imgs"]'); - if (viewContent) { - viewContent.scrollTop = 100; - } - } catch (e) {} - - await sleep(2000); - - let imageUrls = getImagesInIframe(); - - if (imageUrls.length === 0) { - console.log(`이미지를 찾을 수 없음 - 스킵`); - continue; - } - - console.log(`이미지 ${imageUrls.length}개 발견`); - - const zip = new JSZip(); - const protocolDomain = window.location.origin; - - const downloadImage = (url, index, total) => { - return new Promise((resolve) => { - GM_xmlhttpRequest({ - method: 'GET', - url: url, - responseType: 'arraybuffer', - headers: { - 'Referer': protocolDomain, - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - }, - onload: (response) => { - if (response.status === 200) { - const ext = url.split('.').pop().split('?')[0] || 'jpg'; - const blob = new Blob([response.response], { type: 'image/' + ext }); - const filename = `image${String(index).padStart(4, '0')}.${ext}`; - zip.file(filename, blob); - console.log(` ${index + 1}/${total} 이미지 다운로드`); - } else { - console.log(` 이미지 ${index} 실패 (${response.status})`); - } - resolve(); - }, - onerror: () => { - console.log(` 이미지 ${index} 오류`); - resolve(); - }, - ontimeout: () => { - console.log(` 이미지 ${index} 타임아웃`); - resolve(); - } - }); - }); - }; - - let promiseList = []; - for (let j = 0; j < imageUrls.length; j++) { - promiseList.push(downloadImage(imageUrls[j], j, imageUrls.length)); - if (promiseList.length >= 5) { - await Promise.all(promiseList); - promiseList = []; - await sleep(1000); - } - } - if (promiseList.length > 0) { - await Promise.all(promiseList); - } - - const comicInfoContent = generateComicInfo(episode.title, seriesName, '', String(i + 1), imageUrls.length, imageUrls.length); - zip.file('ComicInfo.xml', comicInfoContent); - - const content = await zip.generateAsync({ type: "blob" }); - downloadZip(content, `${seriesName} - ${episode.title}.zip`); - - console.log(`${i + 1}/${episodes.length} ${episode.title} 완료`); - await sleep(3000); } + }); - iframe.remove(); - console.log('모든 에피소드 다운로드 완료'); - } catch (error) { - alert('목록 다운로드 중 오류:\n' + error); - console.error(error); + 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 { isListPage, isEpisodePage } = getListPageInfo(); + const next = idx + 1; + sessionStorage.setItem(IDX_KEY, String(next)); - if (isListPage) { - GM_registerMenuCommand('[목록] 전체 에피소드 다운로드', () => downloadAllEpisodes()); - console.log('sbxh6Downloader: 목록 페이지 감지됨. Tampermonkey 메뉴에서 실행하세요.'); - } else if (isEpisodePage) { - GM_registerMenuCommand('현재 에피소드 다운로드', () => downloadCurrentEpisode()); - console.log('sbxh6Downloader: 에피소드 페이지 감지됨. Tampermonkey 메뉴에서 실행하세요.'); - } else { - console.log('sbxh6Downloader: 알 수 없는 페이지 타입'); - } + 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'); })();