// ==UserScript== // @name Ntoki Downloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.2.0 // @description 뉴토끼(ntk01.com, sbxh*.com) 웹툰/만화 다운로더 (팝업 방식, JavaScript 렌더링 필요) // @author hehaho // @match https://ntk01.com/* // @match https://www.ntk01.com/* // @match https://sbxh1.com/* // @match https://sbxh2.com/* // @match https://sbxh3.com/* // @match https://sbxh4.com/* // @match https://sbxh5.com/* // @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'; const LOG = []; const log = (...args) => { const msg = args.map(a => { if (a instanceof Error) return a.message; if (typeof a === 'object' && a !== null) { try { return JSON.stringify(a); } catch(e) { return String(a); } } return String(a); }).join(' '); LOG.push(`[${new Date().toISOString().slice(11,19)}] ${msg}`); }; const currentURL = document.URL; log(`🚀 Ntoki Downloader v0.2.0 시작됨`); log(`📍 URL: ${currentURL}`); log(`📍 호스트: ${new URL(currentURL).hostname}`); // 알려진 웹툰 이미지 CDN 도메인 (화이트리스트) const ALLOWED_IMAGE_DOMAINS = ['i.toonflix.app', 'img.ntk01.com', '11toon8.com', 'sbxh1.com', 'sbxh2.com', 'sbxh3.com']; const isAllowedImageHost = (url) => { try { const absoluteUrl = url.startsWith('http://') || url.startsWith('https://') ? url : new URL(url, currentURL).href; const hostname = new URL(absoluteUrl).hostname; const pageHost = new URL(currentURL).hostname; if (hostname === pageHost || hostname.endsWith('.' + pageHost)) return true; return ALLOWED_IMAGE_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d)); } catch(e) { return false; } }; // 이미지 해상도 기반 검증 (만화 페이지는 세로>가로, 최소 500px) const isComicPageBlob = (blob) => { return createImageBitmap(blob).then(bitmap => { // 배너만 필터: 가로가 세로의 2배 이상(전형적인 배너) 또는 높이 200px 미만 const valid = !(bitmap.width > bitmap.height * 2 || bitmap.height < 200); bitmap.close(); return valid; }).catch(() => true); }; // 이미지 로딩을 기다리고 lazy load 트리거 const waitForImagesToLoad = (targetDocument, timeoutMs = 20000) => { return new Promise(resolve => { const container = targetDocument.querySelector('.vw-imgs, .vw-images, #bo_v_con, .view-content, #toon_img, .board-view') || targetDocument.body; const allImages = container.querySelectorAll('img'); const baseUrl = targetDocument.URL; allImages.forEach(img => { if (img.getAttribute('loading') === 'lazy') { img.setAttribute('loading', 'eager'); } ['data-src', 'data-original', 'data-lazy-src', 'data-lazy'].forEach(attr => { const val = img.getAttribute(attr); if (val && !img.src) { try { img.src = new URL(val, baseUrl).href; } catch {} } }); }); const loading = Array.from(allImages).filter(img => !img.complete || img.naturalWidth === 0); if (loading.length === 0) { resolve(); return; } let remaining = loading.length; const timeout = setTimeout(resolve, timeoutMs); loading.forEach(img => { const done = () => { remaining--; if (remaining <= 0) { clearTimeout(timeout); resolve(); } }; if (img.complete && img.naturalWidth > 0) done(); else { img.addEventListener('load', done, { once: true }); img.addEventListener('error', done, { once: true }); } }); }); }; // 페이지 타입 감지 const pathMatch = currentURL.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/); const isEpisodePage = pathMatch && pathMatch[2] ? true : false; const isListPage = pathMatch && !pathMatch[2] ? true : false; // 파일 다운로드 헬퍼 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); } }; const downloadLogFile = () => { return; // 비활성화 const now = new Date(); const time = String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0') + String(now.getSeconds()).padStart(2,'0'); const content = LOG.join('\n'); if (!content) return; const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `Download_${time}.log`; a.click(); URL.revokeObjectURL(url); }; // XML 특수문자 이스케이프 const escapeXml = (str) => { if (!str) return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; // comicinfo.xml 생성 const generateComicInfoXml = (title, seriesTitle = '', episodeNumber = '', writer = '', imageCount) => { const comicInfo = ` ${escapeXml(title)} ${escapeXml(seriesTitle)} ${escapeXml(episodeNumber)} ${escapeXml(writer)} ${imageCount} ko Manga `; return comicInfo; }; // JSON-LD에서 정보 추출 (말줄임표 없는 완전한 제목) const getJsonLdData = () => { const scripts = document.querySelectorAll('script[type="application/ld+json"]'); for (const script of scripts) { try { const data = JSON.parse(script.textContent); if (data) return data; } catch (e) {} } return null; }; // DOM에서 정보 추출 const getSeriesTitle = () => { // JSON-LD에서 먼저 시도 const jsonLd = getJsonLdData(); if (jsonLd) { // ComicIssue 타입인 경우 isPartOf.name 사용 if (jsonLd.isPartOf && jsonLd.isPartOf.name) { return jsonLd.isPartOf.name; } // Book 타입인 경우 name 사용 if (jsonLd['@type'] === 'Book' && jsonLd.name) { return jsonLd.name; } } // DOM에서 fallback const workEl = document.querySelector('.vw-work'); if (workEl) return workEl.textContent.trim(); const heroTitle = document.querySelector('.hero-v2-title'); if (heroTitle) return heroTitle.textContent.trim(); return ''; }; const getEpisodeTitle = () => { // JSON-LD에서 먼저 시도 - name이 headline보다 완전한 제목임 const jsonLd = getJsonLdData(); if (jsonLd && (jsonLd['@type'] === 'ComicIssue' || jsonLd['@type'] === 'ComicStory')) { if (jsonLd.name) return jsonLd.name; } // DOM에서 fallback const epEl = document.querySelector('.vw-ep'); if (!epEl) return ''; const span = epEl.querySelector('span'); return span ? span.textContent.trim().replace(/^[\s\-]+/, '') : ''; }; const getEpisodeNumber = () => { const epEl = document.querySelector('.vw-ep'); if (!epEl) return ''; const strong = epEl.querySelector('strong'); return strong ? strong.textContent.trim() : ''; }; // 이미지 URL 추출 공통 함수 const extractImageUrls = (container = document, baseUrl) => { const imageUrls = []; const seen = new Set(); const resolveUrl = (url) => { if (!url) return ''; try { if (url.startsWith('http://') || url.startsWith('https://')) return url; if (url.startsWith('//')) return new URL(url, 'https:').href; if (url.startsWith('/')) return new URL(url, baseUrl || document.URL).href; return new URL(url, baseUrl || document.URL).href; } catch { return ''; } }; const addUrl = (url) => { if (!url || seen.has(url)) return; if (url.includes('logo') || url.includes('banner') || url.includes('icon') || url.includes('avatar')) return; if (!isAllowedImageHost(url)) return; seen.add(url); imageUrls.push(url); }; // 방법 1: .vw-imgs 내의 img 태그 const imgContainer = container.querySelector('.vw-imgs') || container; const imgElements = imgContainer.querySelectorAll('img'); imgElements.forEach(img => { const sources = [ img.src, img.currentSrc, img.dataset.src, img.dataset.original, img.dataset.lazySrc, img.dataset.lazy, img.getAttribute('data-src'), img.getAttribute('data-original'), img.getAttribute('srcset')?.split(' ')[0], img.getAttribute('data-srcset')?.split(' ')[0], ]; sources.forEach(s => { const u = resolveUrl(s); if (u) addUrl(u); }); }); // 방법 2: preload 링크 if (imageUrls.length === 0) { const preloadLinks = container.querySelectorAll('link[rel="preload"][as="image"]'); preloadLinks.forEach(link => { const u = resolveUrl(link.getAttribute('href')); if (u) addUrl(u); }); } // 방법 3: picture > source srcset if (imageUrls.length === 0) { const sources = container.querySelectorAll('picture source[srcset], source[data-srcset]'); sources.forEach(src => { const srcset = src.getAttribute('srcset') || src.dataset.srcset; if (srcset) { srcset.split(',').forEach(s => { const u = resolveUrl(s.trim().split(' ')[0]); if (u) addUrl(u); }); } }); } return imageUrls; }; // 현재 에피소드 이미지 다운로드 const downloadImages = async () => { // 페이지 이미지 로딩 완료 대기 await waitForImagesToLoad(document); const imageUrls = extractImageUrls(); if (imageUrls.length === 0) { log('❌ 다운로드할 이미지를 찾을 수 없음'); const allImgs = document.querySelectorAll('.vw-imgs img'); log('.vw-imgs img 개수:', allImgs.length); allImgs.forEach((img, i) => { log(`img[${i}] src=${img.src || '(empty)'}, data-src=${img.dataset?.src || '(none)'}, complete=${img.complete}, naturalWidth=${img.naturalWidth}`); }); const preloadLinks = document.querySelectorAll('link[rel="preload"][as="image"]'); log('preload links:', preloadLinks.length); preloadLinks.forEach((link, i) => { log(`link[${i}] href=${link.getAttribute('href')}`); }); log('👉 다운로드 로그를 저장합니다.'); try { downloadLogFile(); } catch (_) {} alert(`❌ 다운로드할 이미지를 찾을 수 없습니다.\n🔍 저장된 로그 파일(Download_xxxxxx.log)을 확인하세요.`); return; } let title = getEpisodeTitle() || document.title.split('|')[0].trim() || 'webtoon_download'; title = title.replace(/[/\\:*?"<>|]/g, '_'); let episodeNum = getEpisodeNumber(); log(`발견된 이미지: ${imageUrls.length}개, 제목: ${title}`); const zip = new JSZip(); let imageCount = 0; let failedCount = 0; for (let i = 0; i < imageUrls.length; i++) { const imageUrl = imageUrls[i]; log(`다운로드 중... ${i + 1}/${imageUrls.length}`); try { const blob = await downloadImageBlob(imageUrl); if (blob && blob.size > 0) { const filename = `${String(imageCount).padStart(4, '0')}_page_${i + 1}.jpg`; zip.file(filename, blob); imageCount++; log(`✓ 이미지 다운로드 완료: ${i + 1}/${imageUrls.length}`); } else { failedCount++; log(`⚠ 이미지 다운로드 실패 (${i + 1}번째): 빈 응답`); } } catch (error) { log(`⚠ 이미지 다운로드 실패 (${i + 1}번째):`, error); failedCount++; } } if (imageCount > 0) { const seriesTitle = getSeriesTitle(); const comicInfoXml = generateComicInfoXml(title, seriesTitle, episodeNum, '', imageCount); zip.file('ComicInfo.xml', comicInfoXml); const blob = await zip.generateAsync({ type: 'blob' }); downloadZip(blob, `${title}.zip`); downloadLogFile(); alert(`✓ ${imageCount}개 이미지 + ComicInfo.xml 다운로드 완료\n(실패: ${failedCount}개)\n\n파일명: ${title}.zip`); } else { alert(`❌ 다운로드 가능한 이미지 없음\n발견: ${imageUrls.length}개, 성공: ${imageCount}개, 실패: ${failedCount}개`); } }; // sleep 헬퍼 const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // 팝업 창으로 에피소드 페이지를 렌더링 후 이미지 URL 추출 (JavaScript 실행 + 스크롤 필요) const downloadEpisodeImagesViaPopup = async (episodeUrl, episodeTitle) => { return new Promise((resolve) => { log(`🔄 팝업 열기: ${episodeTitle}`); const popup = window.open(episodeUrl, 'episodeViewer', 'width=800,height=600,left=-9999,top=-9999'); if (!popup) { log(`⚠️ 팝업 차단됨, HTTP 방식 fallback: ${episodeTitle}`); fetchHtmlAndResolve(episodeUrl, episodeTitle, resolve); return; } let resolved = false; const MAX_WAIT = 60000; const done = (result) => { if (resolved) return; resolved = true; // 팝업은 닫지 않고 result에 closePopup 전달 (호출자가 다운로드 완료 후 닫음) result.closePopup = closePopup; resolve(result); }; let closePopup = () => { try { popup.close(); } catch (_) {} }; // 1단계: .vw-imgs가 나타날 때까지 대기 (최대 30초) const waitForViewer = () => { let waited = 0; let viewerTimer = null; const pollTimer = setInterval(() => { waited += 500; try { if (popup.closed) { clearInterval(pollTimer); log(`⚠️ 팝업이 일찍 닫힘: ${episodeTitle}`); done({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle }); return; } const doc = popup.document; const popupUrl = popup.location ? popup.location.href : ''; // about:blank면 아직 로딩 전 -> 계속 대기 if (!popupUrl || popupUrl === 'about:blank') return; const vwImgs = doc.querySelector('.vw-imgs'); if (vwImgs) { clearInterval(pollTimer); if (viewerTimer) clearTimeout(viewerTimer); log(`✅ .vw-imgs 발견, 스크롤 시작: ${episodeTitle}`); scrollToLoadImages(doc, done); return; } // 25초 이상 지나도 vw-imgs 없으면 HTTP fallback if (waited >= 25000) { clearInterval(pollTimer); if (viewerTimer) clearTimeout(viewerTimer); log(`⚠️ .vw-imgs 없음 (${waited}ms 대기): ${episodeTitle}`); try { popup.close(); } catch (_) {} fetchHtmlAndResolve(episodeUrl, episodeTitle, done); return; } } catch (e) { // 아직 cross-origin 상태 (초기 로딩 중) - 무시 } }, 500); viewerTimer = setTimeout(() => { clearInterval(pollTimer); if (!resolved) { log(`⏰ 뷰어 대기 타임아웃 (30s): ${episodeTitle}`); try { popup.close(); } catch (_) {} fetchHtmlAndResolve(episodeUrl, episodeTitle, done); } }, 30000); }; // 2단계: 모든 이미지 eager 로딩 설정 후 천천히 스크롤 const scrollToLoadImages = (doc, callback) => { const win = popup; let lastScroll = -1; let scrollCount = 0; let goingDown = true; const SCROLL_STEP = 700; const SCROLL_INTERVAL = 600; // 모든 이미지를 eager로 설정하고 data-src → src 복사 const allImgs = doc.querySelectorAll('.vw-imgs img, .vw-images img, [class*="viewer"] img, [class*="comic"] img'); let imgCount = allImgs.length; log(`📸 .vw-imgs 내 img 태그: ${imgCount}개`); allImgs.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) { try { img.src = new URL(val, episodeUrl).href; } catch {} } }); }); const scrollTimer = setInterval(() => { try { if (popup.closed) { clearInterval(scrollTimer); callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle }); return; } if (goingDown) { win.scrollBy(0, SCROLL_STEP); } else { win.scrollBy(0, -SCROLL_STEP * 2); } scrollCount++; const curScroll = win.scrollY || win.pageYOffset || 0; const maxScroll = Math.max( doc.documentElement.scrollHeight - win.innerHeight, doc.body.scrollHeight - win.innerHeight, 0 ); if (goingDown && (curScroll >= maxScroll || curScroll === lastScroll || scrollCount > 80)) { // 바닥 도달 → 위로 스크롤 시작 goingDown = false; lastScroll = curScroll; log(`📜 바닥 도달, 위로 스크롤 (${scrollCount}회): ${episodeTitle}`); } else if (!goingDown && curScroll <= 0) { // 맨 위 도달 → 완료 clearInterval(scrollTimer); log(`📜 왕복 스크롤 완료 (${scrollCount}회): ${episodeTitle}`); log(`⏳ 5초간 이미지 로딩 대기...`); setTimeout(() => { waitForImagesToLoad(doc).then(() => { extractFromPopup(doc, callback); }).catch(() => { extractFromPopup(doc, callback); }); }, 5000); return; } lastScroll = curScroll; } catch (e) { if (scrollCount > 160) { clearInterval(scrollTimer); extractFromPopup(doc, callback); } } }, SCROLL_INTERVAL); setTimeout(() => { clearInterval(scrollTimer); if (!resolved) { log(`⏰ 스크롤 타임아웃: ${episodeTitle}`); extractFromPopup(doc, callback); } }, 180000); }; // 3단계: 팝업 DOM에서 이미지 URL 추출 + 누락 페이지 보강 const extractFromPopup = async (doc, callback) => { try { log(`🔍 팝업 DOM 분석: ${episodeTitle}`); const vwImgs = doc.querySelector('.vw-imgs'); const imgEls = vwImgs ? vwImgs.querySelectorAll('img') : []; log(`.vw-imgs 존재: ${!!vwImgs}, img 개수: ${imgEls.length}`); let imageUrls = extractImageUrls(doc, episodeUrl); log(`✓ ${imageUrls.length}개 이미지 URL 추출 (팝업)`); // pNNN 패턴이 있으면 누락 페이지 HEAD 요청으로 찾기 if (imageUrls.some(u => /\/p\d{3}\./.test(u))) { imageUrls = await fillMissingPages(imageUrls, episodeUrl); } let chapterTitle = episodeTitle; if (imageUrls.length > 0) { log(`호스트 목록: ${[...new Set(imageUrls.map(u => new URL(u).hostname))].join(', ')}`); } try { const epEl = doc.querySelector('.vw-ep'); if (epEl) { const strong = epEl.querySelector('strong'); const span = epEl.querySelector('span'); const episodeNum = strong ? strong.textContent.trim() : ''; const episodeSubtitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : ''; chapterTitle = episodeNum ? `${episodeNum} - ${episodeSubtitle}` : episodeSubtitle; } } catch (e) {} callback({ success: imageUrls.length > 0, imageUrls, imageCount: imageUrls.length, chapterTitle }); } catch (error) { log(`❌ 팝업 추출 실패: ${episodeTitle}`, error); callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle }); } }; // 시작 waitForViewer(); }); }; // HTTP fetch fallback (메타데이터 + 가능한 이미지) const fetchHtmlAndResolve = async (episodeUrl, episodeTitle, resolve) => { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); const response = await fetch(episodeUrl, { credentials: 'include', headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Referer': document.URL, 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7' } }); clearTimeout(timeoutId); const html = await response.text(); if (!html || html.length < 500) { resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle }); return; } const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const imageUrls = extractImageUrls(doc, episodeUrl); let chapterTitle = episodeTitle; if (imageUrls.length > 0) { log(`✓ [${episodeTitle}] ${imageUrls.length}개 이미지 URL 추출 (HTTP fallback)`); } else { log(`⚠️ 이미지 없음 (HTTP fallback): ${episodeTitle}`); } resolve({ success: imageUrls.length > 0, imageUrls: imageUrls, imageCount: imageUrls.length, chapterTitle: chapterTitle }); } catch (error) { log(`❌ HTTP fallback 실패: ${episodeTitle}`, error); resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle }); } }; // URL로부터 이미지 다운로드 const downloadImageBlob = (url) => { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': new URL(document.URL).origin + '/' }, onload: (response) => { try { const responseText = response.responseHeaders || ''; const contentType = (responseText.match(/content-type:\s*([^\r\n;]+)/i) || [])[1] || ''; if (contentType && !contentType.startsWith('image/')) { log(`⚠️ 이미지가 아닌 응답 스킵:`, contentType); resolve(null); return; } const blob = new Blob([response.response], { type: 'image/jpeg' }); isComicPageBlob(blob).then(valid => { if (valid) { resolve(blob); } else { log(`⚠️ 광고 배너로 의심됨: ${url}`); resolve(null); } }); } catch (error) { log(`⚠️ 이미지 Blob 변환 실패:`, error); resolve(null); } }, onerror: (error) => { log(`⚠️ 이미지 다운로드 실패: ${url}`); resolve(null); } }); }); }; // 누락 페이지 URL 자동 생성 (pNNN 패턴, HEAD 불필요) const fillMissingPages = async (imageUrls, episodeUrl) => { const pUrls = imageUrls.filter(u => /\/p\d{3}\./.test(u)); if (pUrls.length === 0) return imageUrls; const baseMatch = episodeUrl.match(/\/manhwa\/([^/]+)\/(\d+)/); if (!baseMatch) return imageUrls; let maxPage = 0; const extMap = new Map(); for (const url of pUrls) { const m = url.match(/\/p(\d{3})\.(jpg|png|webp)/); if (m) { const num = parseInt(m[1], 10); maxPage = Math.max(maxPage, num); extMap.set(url, m[2]); } } log(`🔍 누락 페이지 확인: 최대 p${String(maxPage).padStart(3,'0')}까지 있음, ${maxPage}번 이후 확장자 패턴 분석 중...`); // 마지막 3개 이미지의 확장자 패턴으로 다음 확장자 추론 const sortedPages = [...pUrls] .map(u => { const m = u.match(/\/p(\d{3})\.(jpg|png|webp)/); return m ? {url: u, num: parseInt(m[1],10), ext: m[2]} : null; }) .filter(Boolean) .sort((a,b) => a.num - b.num); const lastFew = sortedPages.slice(-5); log(` 마지막 ${lastFew.length}개 패턴: ${lastFew.map(p => `p${p.num}.${p.ext}`).join(', ')}`); const results = [...imageUrls]; const seen = new Set(imageUrls); for (let page = maxPage + 1; page <= maxPage + 10; page++) { const pageStr = String(page).padStart(3, '0'); // 이전 페이지들의 확장자 패턴 확인 const prevExts = []; for (let p = Math.max(1, page - 5); p < page && p <= maxPage; p++) { const prevUrl = imageUrls.find(u => u.includes(`/p${String(p).padStart(3,'0')}.`)); if (prevUrl) { const m = prevUrl.match(/\.(jpg|png|webp)$/); if (m) prevExts.push(m[1]); } } // 가장 자주 나온 확장자 선호 const extCount = {}; let bestExt = '.jpg'; for (const e of prevExts) { extCount[e] = (extCount[e]||0)+1; } const entries = Object.entries(extCount); if (entries.length > 0) { bestExt = entries.sort((a,b) => b[1] - a[1])[0][0]; } const url = `https://i.toonflix.app/manhwa/${baseMatch[1]}/${baseMatch[2]}/p${pageStr}.${bestExt}`; if (!seen.has(url)) { log(` ➕ 생성: p${pageStr}.${bestExt} (패턴 기반)`); results.push(url); seen.add(url); } } const added = results.length - imageUrls.length; if (added > 0) log(`✅ ${added}개 페이지 URL 추가 생성 (총 ${results.length}개)`); else log(` 추가 생성 없음`); return results; }; // 에피소드 목록 페이지에서 링크 수집 const getEpisodeList = () => { const episodeElements = document.querySelectorAll('a.ep-row-v2-link'); if (episodeElements.length === 0) return null; const seriesTitle = getSeriesTitle(); const writerFromPage = document.querySelector('.hero-v2-author'); const seriesWriter = writerFromPage ? writerFromPage.textContent.trim() : ''; const episodeLinks = Array.from(episodeElements).map((a) => { const titleElement = a.querySelector('.ep-row-v2-title strong'); const title = titleElement ? titleElement.textContent.trim() : a.textContent.trim(); const noElement = a.querySelector('.ep-row-v2-no'); const episodeNum = noElement ? noElement.textContent.trim() : ''; return { url: a.href, title, episodeNum }; }); episodeLinks.reverse(); return { episodeLinks, seriesTitle, seriesWriter }; }; // 에피소드 목록을 순차 다운로드 (공통 코어) const downloadEpisodeList = async (episodeLinks, seriesTitle, seriesWriter) => { let downloadedCount = 0; let failedCount = 0; const MAX_RETRIES = 2; let failedEpisodes = []; const downloadSingleEpisode = async (episode, retryCount = 0) => { let result; try { log(`⏳ [${retryCount > 0 ? `재시도 ${retryCount}/${MAX_RETRIES}` : ''}] 다운로드 중: ${episode.title}`); result = await downloadEpisodeImagesViaPopup(episode.url, episode.title); if (result.success && result.imageUrls && result.imageUrls.length > 0) { const zip = new JSZip(); let downloadedImages = 0; for (let j = 0; j < result.imageUrls.length; j++) { const imageUrl = result.imageUrls[j]; log(` 📥 이미지 ${j + 1}/${result.imageUrls.length} 다운로드 중...`); const blob = await downloadImageBlob(imageUrl); if (blob) { const filename = `${String(j).padStart(4, '0')}_page_${j + 1}.jpg`; zip.file(filename, blob); downloadedImages++; } } if (downloadedImages > 0) { let chapterTitle = result.chapterTitle || episode.title; const cleanTitle = chapterTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100); let epNumber = episode.episodeNum; if (!epNumber) { const numMatch = episode.title.match(/^(\d+)/); epNumber = numMatch ? numMatch[1] : '0'; } const comicInfoXml = generateComicInfoXml( cleanTitle, seriesTitle, epNumber, seriesWriter, downloadedImages ); zip.file('ComicInfo.xml', comicInfoXml); const blob = await zip.generateAsync({ type: 'blob' }); downloadZip(blob, `${cleanTitle}.zip`); log(`✅ ${cleanTitle} (${downloadedImages}/${result.imageCount}개 이미지) 다운로드 완료`); if (result.closePopup) result.closePopup(); return true; } } if (result && result.closePopup) result.closePopup(); return false; } catch (error) { log(`❌ 에피소드 처리 실패: ${episode.title}`, error); if (result && result.closePopup) result.closePopup(); return false; } }; // 1차 다운로드 for (let i = 0; i < episodeLinks.length; i++) { const episode = episodeLinks[i]; const success = await downloadSingleEpisode(episode); if (success) { downloadedCount++; } else { failedCount++; failedEpisodes.push(episode); } await new Promise(resolve => setTimeout(resolve, 20000)); } // 실패한 에피소드 재시도 for (let retry = 0; retry < MAX_RETRIES && failedEpisodes.length > 0; retry++) { log(`🔄 재시도 ${retry + 1}/${MAX_RETRIES}: ${failedEpisodes.length}개 에피소드`); const stillFailed = []; for (const episode of failedEpisodes) { const success = await downloadSingleEpisode(episode, retry + 1); if (success) { downloadedCount++; failedCount--; } else { stillFailed.push(episode); } await new Promise(resolve => setTimeout(resolve, 30000)); } failedEpisodes = stillFailed; if (failedEpisodes.length === 0) break; } // 사용자 재시도 루프 while (failedEpisodes.length > 0) { const result = confirm(`🎉 1차 완료!\n\n✅ 다운로드: ${downloadedCount}개\n⚠️ 실패: ${failedEpisodes.length}개\n📊 총 에피소드: ${episodeLinks.length}개\n\n실패한 에피소드를 다시 시도할까요?`); if (!result) break; const stillFailed = []; for (const episode of failedEpisodes) { log(`🔄 사용자 재시도: ${episode.title}`); const success = await downloadSingleEpisode(episode, '재시도'); if (success) { downloadedCount++; failedCount--; } else { stillFailed.push(episode); } await new Promise(resolve => setTimeout(resolve, 30000)); } failedEpisodes = stillFailed; } log(`✅ 최종 완료: ${downloadedCount}개 성공, ${failedEpisodes.length}개 실패 (총 ${episodeLinks.length}개)`); alert(`🎉 최종 완료!\n\n✅ 다운로드: ${downloadedCount}개\n⚠️ 실패: ${failedEpisodes.length}개\n📊 총 에피소드: ${episodeLinks.length}개`); }; // 전체 에피소드 다운로드 const downloadAllEpisodesAsZips = async () => { const list = getEpisodeList(); if (!list) { log(`❌ 에피소드 링크를 찾을 수 없음 (선택자: a.ep-row-v2-link)`); const allLinks = document.querySelectorAll('a[href*="/manhwa/"]'); log(`/manhwa/ 링크 개수: ${allLinks.length}`); allLinks.forEach((a, i) => { if (i < 10) log(` 링크[${i}] class="${a.className}" href="${a.href}"`); }); alert(`❌ 다운로드할 에피소드를 찾을 수 없습니다.`); return; } log(`📊 ${list.episodeLinks.length}개의 에피소드 발견 (첫화순으로 정렬)`); log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`); await downloadEpisodeList(list.episodeLinks, list.seriesTitle, list.seriesWriter); }; // 에피소드 번호 추출 헬퍼 (숫자만) const parseEpisodeNumber = (ep) => { const digits = ep.episodeNum.replace(/[^\d]/g, ''); if (digits) return parseInt(digits, 10); const m = ep.title.match(/(\d+)/); return m ? parseInt(m[1], 10) : null; }; // N화 이후 다운로드 const downloadEpisodesFromInput = async () => { const list = getEpisodeList(); if (!list) { alert(`❌ 에피소드 목록을 찾을 수 없습니다.`); return; } const input = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.\n(예: 10 → 10화부터 마지막까지)`, ''); if (input === null) return; const startNum = parseInt(input.replace(/[^\d]/g, ''), 10); if (isNaN(startNum) || startNum < 1) { alert(`올바른 숫자를 입력하세요.`); return; } const filtered = list.episodeLinks.filter(ep => { const n = parseEpisodeNumber(ep); return n !== null && n >= startNum; }); if (filtered.length === 0) { alert(`${startNum}화 이후의 에피소드가 없습니다.`); return; } log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화 이후 ${filtered.length}개 에피소드`); log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`); await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter); }; // 에피소드 범위 다운로드 const downloadEpisodesRangeInput = async () => { const list = getEpisodeList(); if (!list) { alert(`❌ 에피소드 목록을 찾을 수 없습니다.`); return; } const startInput = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.`, ''); if (startInput === null) return; const startNum = parseInt(startInput.replace(/[^\d]/g, ''), 10); if (isNaN(startNum) || startNum < 1) { alert(`올바른 숫자를 입력하세요.`); return; } const endInput = prompt(`다운로드할 마지막 에피소드 번호를 입력하세요.`, ''); if (endInput === null) return; const endNum = parseInt(endInput.replace(/[^\d]/g, ''), 10); if (isNaN(endNum) || endNum < startNum) { alert(`마지막 번호는 ${startNum} 이상이어야 합니다.`); return; } const filtered = list.episodeLinks.filter(ep => { const n = parseEpisodeNumber(ep); return n !== null && n >= startNum && n <= endNum; }); if (filtered.length === 0) { alert(`${startNum}화~${endNum}화 범위의 에피소드가 없습니다.`); return; } log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화~${endNum}화 범위 ${filtered.length}개 에피소드`); log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`); await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter); }; // 메뉴 명령 ID 관리 (중복 등록 방지) let menuCmdIds = []; // 안전한 메뉴 콜백 래퍼: 모든 에러를 LOG로 기록하고 로그 파일 다운로드 const safeCallback = (fn) => async (...args) => { try { await fn(...args); } catch (e) { log('❌ 치명적 오류:', e); log('Stack:', e instanceof Error ? e.stack : ''); log('로그를 저장합니다.'); try { downloadLogFile(); } catch (_) {} } }; // 메뉴 명령 등록 함수 const registerMenuCommands = () => { // 이전 명령 해제 menuCmdIds.forEach(id => GM_unregisterMenuCommand(id)); menuCmdIds = []; const currentUrl = document.URL; const hostname = new URL(currentUrl).hostname; const pathMatch = currentUrl.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/); const isEp = pathMatch && pathMatch[2]; const isList = pathMatch && !pathMatch[2]; log(`📍 현재 URL: ${currentUrl}`); log(`📍 호스트: ${hostname}, 목록: ${isList}, 에피소드: ${isEp}`); if (isEp) { menuCmdIds.push(GM_registerMenuCommand('📥 현재 에피소드 다운로드', safeCallback(downloadImages))); log('✅ 에피소드 페이지 메뉴 등록 완료'); } else if (isList) { menuCmdIds.push(GM_registerMenuCommand('📥 전체 에피소드 다운로드', safeCallback(downloadAllEpisodesAsZips))); menuCmdIds.push(GM_registerMenuCommand('📥 N화 이후 다운로드', safeCallback(downloadEpisodesFromInput))); menuCmdIds.push(GM_registerMenuCommand('📥 범위 다운로드', safeCallback(downloadEpisodesRangeInput))); log('✅ 목록 페이지 메뉴 등록 완료 (3개)'); } else { log('⏸️ 현재 페이지는 다운로드 대상이 아닙니다. 로그 저장 메뉴만 표시됩니다.'); } // 항상 로그 저장 메뉴 등록 menuCmdIds.push(GM_registerMenuCommand('📋 로그 저장', () => downloadLogFile())); }; // 초기 메뉴 등록 registerMenuCommands(); // SPA 네비게이션 감지 (Next.js) const startNavObserver = () => { const observer = new MutationObserver(() => { registerMenuCommands(); }); observer.observe(document.body, { childList: true, subtree: true }); }; // 히스토리 API 변경 감지 const origPushState = history.pushState; const origReplaceState = history.replaceState; history.pushState = function() { origPushState.apply(this, arguments); setTimeout(registerMenuCommands, 300); }; history.replaceState = function() { origReplaceState.apply(this, arguments); setTimeout(registerMenuCommands, 300); }; window.addEventListener('popstate', () => setTimeout(registerMenuCommands, 300)); startNavObserver(); log(`Ntoki Downloader v0.2.0 loaded`); log(`URL: ${document.URL}`); log(`User-Agent: ${navigator.userAgent}`); log(`지원 도메인: sbxh1/2/3.com, ntk01.com`); log('팝업 방식 (JavaScript 렌더링) + 오류 시 로그 자동 저장'); const jszipCheck = typeof JSZip !== 'undefined'; log(`JSZip 라이브러리: ${jszipCheck ? '✅ 로드됨' : '❌ 로드 안 됨'}`); if (!jszipCheck) { log('⚠️ JSZip이 로드되지 않았습니다. CDN 차단 또는 네트워크 문제일 수 있습니다.'); } })();