// ==UserScript== // @name ntk01 Downloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.0.1 // @description ntk01.com 웹툰/만화 다운로더 // @author hehaho // @match https://ntk01.com/* // @match https://www.ntk01.com/* // @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad // @grant GM_registerMenuCommand // @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 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); } }; // 글로벌 이미지 저장소 (네트워크 모니터링용) window.capturedImageUrls = []; window.capturedImageBlobs = []; // fetch 인터셉트 (이미지 Blob 직접 캡처) const originalFetch = window.fetch; window.fetch = function(...args) { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('.jpeg')) && !url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('thumb'); if (isImageUrl && !window.capturedImageUrls.includes(url)) { window.capturedImageUrls.push(url); } const result = originalFetch.apply(this, args); // Blob 응답 캡처 (Canvas Tainted 우회) if (isImageUrl && result instanceof Promise) { result.then(response => { if (response.ok && (response.headers.get('content-type')?.includes('image') || url.match(/\.(jpg|jpeg|png|webp)$/i))) { const clonedResponse = response.clone(); clonedResponse.blob().then(blob => { if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) { window.capturedImageBlobs.push(blob); } }).catch(() => {}); } return response; }).catch(() => {}); } return result; }; // XMLHttpRequest 인터셉트 (arraybuffer 응답 캡처) const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, ...rest) { this._captureUrl = url; return originalOpen.apply(this, [method, url, ...rest]); }; XMLHttpRequest.prototype.send = function(...args) { const xhr = this; const originalOnreadystatechange = xhr.onreadystatechange; xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { const url = xhr._captureUrl; const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('.jpeg')) && !url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('thumb'); if (isImageUrl && xhr.responseType === 'arraybuffer') { try { const blob = new Blob([xhr.response], { type: 'image/jpeg' }); if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) { window.capturedImageBlobs.push(blob); } } catch (e) {} } } if (originalOnreadystatechange) { return originalOnreadystatechange.apply(this, arguments); } }; return originalSend.apply(this, args); }; // 현재 URL 체크 const currentURL = document.URL; // ntk01이 아니면 스크립트 실행 안 함 if (!currentURL.includes('ntk01.com')) { return; } // 페이지 타입 감지 const pagePathParts = currentURL.split('/').filter(p => p); // URL을 더 정확히 분석 (https://ntk01.com/manhwa/11017/111796 형식) const pathMatch = currentURL.match(/\/manhwa\/(\d+)(?:\/(\d+))?/); const isEpisodePage = pathMatch && pathMatch[2] ? true : false; // 에피소드 번호가 있으면 에피소드 페이지 const isListPage = pathMatch && !pathMatch[2] ? true : false; // 에피소드 번호가 없으면 목록 페이지 // 도메인 추출 const getProtocolDomain = () => { return currentURL.match(/^https?:\/\/[^:/?]+/)[0]; }; // 상대 URL을 절대 URL로 변환 const resolveUrl = (url) => { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://')) { return url; } const protocolDomain = getProtocolDomain(); if (url.startsWith('/')) { return protocolDomain + url; } // 상대주소 처리 const currentPageUrl = currentURL.split('?')[0]; const currentPath = currentPageUrl.substring(protocolDomain.length); const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/') + 1); return protocolDomain + currentDir + url; }; // 이미지 수집 함수 (에피소드 읽기 페이지) const collectImagesFromEpisodePage = () => { // ntk01.com 에피소드 페이지의 이미지 구조에 맞춘 선택자 const imgElements = document.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img'); imgElements.forEach(img => { const src = img.src || img.dataset.src || img.getAttribute('src'); if (src) { const absoluteUrl = resolveUrl(src); if (absoluteUrl && !window.capturedImageUrls.includes(absoluteUrl)) { window.capturedImageUrls.push(absoluteUrl); } } }); }; // 에피소드 페이지에서 이미지 추출 const extractImagesFromEpisodeHtml = (html) => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const images = []; const imgElements = doc.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img'); imgElements.forEach(img => { const src = img.src || img.dataset.src || img.getAttribute('src'); if (src) { const absoluteUrl = resolveUrl(src); if (absoluteUrl && !images.includes(absoluteUrl)) { images.push(absoluteUrl); } } }); return images; }; // 이미지 수집 함수 (목록 페이지) const collectImagesFromListPage = () => { // 목록 페이지의 썸네일/장편 const imgElements = document.querySelectorAll('.list-item img, .episode img, .thumbnail img, [class*="thumb"] img'); imgElements.forEach(img => { const src = img.src || img.dataset.src || img.getAttribute('src'); if (src && !src.includes('logo') && !src.includes('banner') && !src.includes('icon')) { const absoluteUrl = resolveUrl(src); if (absoluteUrl && !window.capturedImageUrls.includes(absoluteUrl)) { window.capturedImageUrls.push(absoluteUrl); } } }); }; // 이미지 수집 함수 (선택) const collectImagesFromDOM = () => { if (isEpisodePage) { collectImagesFromEpisodePage(); } else if (isListPage) { collectImagesFromListPage(); } }; // comicinfo.xml 생성 함수 const generateComicInfoXml = (title, seriesTitle = '', episodeNumber = '', writer = '', imageCount) => { const now = new Date(); const dateStr = now.toISOString().split('T')[0]; const comicInfo = ` ${escapeXml(title)} ${escapeXml(seriesTitle)} ${escapeXml(episodeNumber)} ${escapeXml(writer)} ${imageCount} ko Manga No Unknown `; return comicInfo; }; // XML 특수문자 이스케이프 함수 const escapeXml = (str) => { if (!str) return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; // 현재 페이지에서 시리즈 제목 추출 const getSeriesTitle = () => { const workEl = document.querySelector('.vw-work'); return workEl ? workEl.textContent.trim() : ''; }; // 현재 페이지에서 에피소드 제목 추출 const getEpisodeTitle = () => { const epEl = document.querySelector('.vw-ep'); if (!epEl) return ''; const span = epEl.querySelector('span'); const episodeTitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : ''; return episodeTitle; }; const GetEpisodeNumberFromTitle = (title) => { const epEl = document.querySelector('.vw-ep'); if (!epEl) return ''; const strong = epEl.querySelector('strong'); const episodeNum = strong ? strong.textContent.trim() : ''; return episodeNum; }; // HTML에서 시리즈 제목 추출 const getSeriesTitleFromHtml = (html) => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const workEl = doc.querySelector('.vw-work'); return workEl ? workEl.textContent.trim() : ''; }; // HTML에서 에피소드 제목 추출 const getEpisodeTitleFromHtml = (html) => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const epEl = doc.querySelector('.vw-ep'); if (!epEl) return ''; const span = epEl.querySelector('span'); const episodeTitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : ''; return episodeTitle; }; // HTML에서 에피소드 번호 추출 const getEpisodeNumFromHtml = (html) => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const epEl = doc.querySelector('.vw-ep'); if (!epEl) return ''; const strong = epEl.querySelector('strong'); const episodeNum = strong ? strong.textContent.trim() : ''; return episodeNum; }; // HTML에서 작가 정보 추출 const getWriterFromHtml = (html) => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // 방법 1: .vw-info 또는 유사 정보 영역에서 찾기 let writerEl = doc.querySelector('[class*="info"] span:contains("작가")') || doc.querySelector('.vw-info'); // 방법 2: "작가"라는 텍스트 다음에 오는 요소 찾기 if (!writerEl) { const allSpans = doc.querySelectorAll('span, div, p'); for (let el of allSpans) { if (el.textContent && el.textContent.includes('작가')) { const match = el.textContent.match(/작가[\s:]+([^\n,]+)/); if (match && match[1]) { return match[1].trim(); } } } } // 방법 3: data-author 속성 확인 const authorAttr = doc.querySelector('[data-author]'); if (authorAttr) { return authorAttr.getAttribute('data-author').trim(); } // 방법 4: 작가 메타데이터 태그 확인 const metaAuthor = doc.querySelector('meta[name="author"]'); if (metaAuthor) { return metaAuthor.getAttribute('content').trim(); } return ''; }; // 페이지 로드 완료 후 이미지 수집 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', collectImagesFromDOM); } else { collectImagesFromDOM(); } // MutationObserver를 통한 동적 이미지 감지 const observer = new MutationObserver(() => { collectImagesFromDOM(); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); // Canvas를 사용해 DOM의 img 태그에서 직접 blob 추출 (가장 안정적) const extractImageBlobFromImg = (imgElement) => { return new Promise((resolve, reject) => { try { // 이미지가 실제로 로드되었는지 확인 if (!imgElement.complete || imgElement.naturalWidth === 0) { // 아직 로드 중이면 onload 대기 const tempImg = new Image(); tempImg.crossOrigin = 'anonymous'; tempImg.onload = () => { try { const canvas = document.createElement('canvas'); canvas.width = tempImg.naturalWidth; canvas.height = tempImg.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(tempImg, 0, 0); canvas.toBlob(blob => { if (blob && blob.size > 0) { resolve(blob); } else { reject(new Error('Canvas toBlob failed')); } }, 'image/jpeg', 0.95); } catch (e) { reject(e); } }; tempImg.onerror = () => reject(new Error('Image load failed')); tempImg.src = imgElement.src || imgElement.dataset.src; } else { // 이미 로드됨 const canvas = document.createElement('canvas'); canvas.width = imgElement.naturalWidth || imgElement.width; canvas.height = imgElement.naturalHeight || imgElement.height; const ctx = canvas.getContext('2d'); ctx.drawImage(imgElement, 0, 0); canvas.toBlob(blob => { if (blob && blob.size > 0) { resolve(blob); } else { reject(new Error('Canvas toBlob failed')); } }, 'image/jpeg', 0.95); } } catch (error) { reject(error); } }); }; // GM_xmlhttpRequest를 이용한 이미지 다운로드 (Canvas 실패 시 대체) const downloadImageViaGM = (url) => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: (response) => { if (response.status === 200 && response.response) { resolve(response.response); } else { reject(new Error(`HTTP ${response.status}`)); } }, onerror: (error) => { reject(new Error(`Network error: ${error}`)); } }); }); }; // 다운로드 함수 (개별 에피소드) const downloadImages = async () => { // DOM에서 직접 img 태그 수집 (가장 확실한 방법) const imgElements = document.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img'); if (imgElements.length === 0) { alert('다운로드할 이미지를 찾을 수 없습니다.'); return; } // 제목 추출 let title = getEpisodeTitle() || document.title.split('|')[0].trim() || 'ntk01_download'; title = title.replace(/[/\\:*?"<>|]/g, '_'); console.log(`발견된 이미지: ${imgElements.length}개`); const zip = new JSZip(); let imageCount = 0; let failedCount = 0; // 각 img 태그에서 Canvas로 blob 추출 for (let i = 0; i < imgElements.length; i++) { const imgElement = imgElements[i]; try { const blob = await extractImageBlobFromImg(imgElement); if (blob && blob.size > 0) { const filename = `${String(imageCount).padStart(4, '0')}_page_${i + 1}.jpg`; zip.file(filename, blob); imageCount++; } } catch (error) { console.warn(`이미지 추출 실패 (${i + 1}번째):`, error); failedCount++; } } // ZIP 생성 및 다운로드 if (imageCount > 0) { const seriesTitle = getSeriesTitle(); const writer = getWriterFromHtml(document.documentElement.outerHTML); const comicInfoXml = generateComicInfoXml(title, imageCount, seriesTitle, '', writer); zip.file('ComicInfo.xml', comicInfoXml); const blob = await zip.generateAsync({ type: 'blob' }); downloadZip(blob, `${title}.zip`); alert(`✓ ${imageCount}개 이미지 + ComicInfo.xml 다운로드 완료\n(실패: ${failedCount}개)`); } else { alert(`❌ 다운로드 가능한 이미지 없음 (실패: ${failedCount}개)`); } }; // 모든 에피소드를 각각 ZIP으로 다운로드 (목록 페이지용) const downloadAllEpisodesAsZips = async () => { // 에피소드 링크 찾기 const episodeElements = document.querySelectorAll('a.ep-row-v2-link'); if (episodeElements.length === 0) { alert('다운로드할 에피소드를 찾을 수 없습니다.'); return; } // 목록 페이지에서 시리즈 정보 추출 (모든 챕터에서 공통) const seriesTitle = getSeriesTitle(); const writerFromPage = document.querySelector('.hero-v2-author'); const seriesWriter = writerFromPage ? writerFromPage.textContent.trim() : ''; const episodeLinks = Array.from(episodeElements).map((a, index) => { const titleElement = a.querySelector('.ep-row-v2-title strong'); const title = titleElement ? titleElement.textContent.trim() : a.textContent.trim(); return { url: a.href, title, orderNumber: index + 1 }; }); console.log(`${episodeLinks.length}개의 에피소드 발견`); console.log(`시리즈: ${seriesTitle}, 작가: ${seriesWriter}`); let downloadedCount = 0; let failedCount = 0; for (let i = 0; i < episodeLinks.length; i++) { const episode = episodeLinks[i]; try { console.log(`[${i + 1}/${episodeLinks.length}] 다운로드 중: ${episode.title}`); // iframe에 에피소드 로드 (실제 렌더링 필요) const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = episode.url; document.body.appendChild(iframe); // iframe 로드 대기 await new Promise((resolve) => { const checkLoad = () => { try { if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') { resolve(); } else { setTimeout(checkLoad, 100); } } catch (e) { // CORS 오류 발생 - iframe 방식 사용 불가 resolve(); } }; setTimeout(checkLoad, 500); }); let imageCount = 0; let imgFailedCount = 0; let chapterTitle = episode.title; try { // iframe 내 이미지 추출 시도 const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; const imgElements = iframeDoc.querySelectorAll('.vw-imgs img'); // iframe에서 챕터 제목 추출 (더 정확함) const iframeEpisodeTitle = iframeDoc.querySelector('.vw-ep'); if (iframeEpisodeTitle) { const strong = iframeEpisodeTitle.querySelector('strong'); const span = iframeEpisodeTitle.querySelector('span'); const episodeNum = strong ? strong.textContent.trim() : ''; const episodeSubtitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : ''; chapterTitle = episodeNum ? `${episodeNum} - ${episodeSubtitle}` : episodeSubtitle; } if (imgElements.length > 0) { const zip = new JSZip(); for (let j = 0; j < imgElements.length; j++) { try { const blob = await extractImageBlobFromImg(imgElements[j]); if (blob && blob.size > 0) { const filename = `${String(imageCount).padStart(4, '0')}_page_${j + 1}.jpg`; zip.file(filename, blob); imageCount++; } } catch (error) { imgFailedCount++; } } if (imageCount > 0) { // 파일명: "01 - 챕터제목.zip" 형식 const cleanTitle = chapterTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 50); const filename = `${cleanTitle}.zip`; // comicinfo.xml 생성 (인덱스 번호, 시리즈, 작가 포함) const comicInfoXml = generateComicInfoXml( cleanTitle, // title imageCount, // pageCount seriesTitle, // series episode.orderNumber.toString(), // number (인덱스) seriesWriter // writer ); zip.file('ComicInfo.xml', comicInfoXml); const blob = await zip.generateAsync({ type: 'blob' }); downloadZip(blob, filename); downloadedCount++; console.log(`✓ ${filename} (${imageCount}개 이미지)`); } } } catch (error) { console.warn(`iframe 접근 실패:`, error); } iframe.remove(); if (imageCount === 0) { failedCount++; } // 레이트 제한 await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { console.error(`에피소드 처리 실패: ${episode.title}`, error); failedCount++; } } alert(`완료!\n다운로드: ${downloadedCount}개\n실패: ${failedCount}개`); }; // 메뉴 명령 등록 (페이지 타입에 따라 다른 기능) if (isEpisodePage) { GM_registerMenuCommand('📥 현재 에피소드 다운로드', downloadImages); } else if (isListPage) { GM_registerMenuCommand('📥 전체 에피소드 다운로드', downloadAllEpisodesAsZips); } }); console.log(`ntk01 Downloader 스크립트 로드 완료 (${isEpisodePage ? '에피소드 페이지' : isListPage ? '목록 페이지' : '기타'})`); })();