// ==UserScript== // @name tokiDownloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.0.4 // @description 북토끼, 뉴토끼, 마나토끼, 일일툰 다운로더 // @author hehaho // @match https://*.com/webtoon/* // @match https://*.com/novel/* // @match https://*.net/comic/* // @match http://*.cprapid.com:*/bbs/board.php* // @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @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 // @downloadURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.user.js // @updateURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.meta.js // ==/UserScript== (function () { 'use strict'; // 글로벌 이미지 URL 저장소 (네트워크 모니터링용) window.capturedImageUrls = []; // fetch 인터셉트 (모든 페이지에서 실행) const originalFetch = window.fetch; window.fetch = function(...args) { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; if (url && (url.includes('pl3040.com') || url.includes('www.pl3040.com')) && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp'))) { if (!window.capturedImageUrls.includes(url)) { window.capturedImageUrls.push(url); } } return originalFetch.apply(this, args); }; // XMLHttpRequest 인터셉트 const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url, ...rest) { if (url && (url.includes('pl3040.com') || url.includes('www.pl3040.com')) && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp'))) { if (!window.capturedImageUrls.includes(url)) { window.capturedImageUrls.push(url); } } return originalOpen.apply(this, [method, url, ...rest]); }; let site = '뉴토끼'; // 예시 let protocolDomain = 'https://newtoki350.com'; // 예시 // 현재 url 체크 const currentURL = document.URL; if (currentURL.match(/^https:\/\/booktoki[0-9]+.com\/novel\/[0-9]+/)) { site = "북토끼"; protocolDomain = currentURL.match(/^https:\/\/booktoki[0-9]+.com/)[0]; } else if (currentURL.match(/^https:\/\/newtoki[0-9]+.com\/webtoon\/[0-9]+/)) { site = "뉴토끼"; protocolDomain = currentURL.match(/^https:\/\/newtoki[0-9]+.com/)[0]; } else if (currentURL.match(/^https:\/\/manatoki[0-9]+.net\/comic\/[0-9]+/)) { site = "마나토끼"; protocolDomain = currentURL.match(/^https:\/\/manatoki[0-9]+.net/)[0]; } else if (currentURL.match(/\/bbs\/board\.php.*bo_table=toons.*wr_id=/)) { site = "일일툰"; protocolDomain = currentURL.match(/^https?:\/\/[^:]+/)[0]; } else { // 다운 페이지가 아니라면 리턴. @match가 정규표현식이 아니기 때문에 정확한 host를 매치 할 수 없기 때문. return; } function sleep(ms) { return new Promise(resolve => { setTimeout(() => resolve(), ms); }) } function extractComicMetadata() { let seriesName = ''; let writerName = ''; // 마나토끼, 뉴토끼에서 시리즈 이름과 작가 추출 if (site === "마나토끼" || site === "뉴토끼") { // 시리즈 이름 추출 (페이지 타이틀에서) const pageTitle = document.querySelector('h1'); if (pageTitle) { const titleText = pageTitle.innerText.trim(); seriesName = titleText.split('>')[0].trim().replace(/\s*\([^)]*\)$/, ''); } // 작가 추출 (작가 라벨 다음의 링크) const viewContents = document.querySelectorAll('.view-content'); for (let elem of viewContents) { if (elem.innerText.includes('작가')) { const writerLink = elem.querySelector('a'); if (writerLink) { writerName = writerLink.innerText.trim(); } break; } } } return { seriesName, writerName }; } 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; } async function tokiDownload(startIndex, lastIndex) { try { // 리스트들 가져오기 let list = Array.from(document.querySelector('.list-body').querySelectorAll('li')).reverse(); // 리스트 필터 // startIndex가 있다면 if (startIndex) { while (true) { let num = parseInt(list[0].querySelector('.wr-num').innerText); if (num < startIndex) list.shift(); else break; } } // lastIndex가 있다면 if (lastIndex) { while (true) { let num = parseInt(list.at(-1).querySelector('.wr-num').innerText); if (lastIndex < num) list.pop(); else break; } } // iframe생성 const iframe = document.createElement('iframe'); iframe.width = 600; iframe.height = 600; document.querySelector('.content').prepend(iframe); const waitIframeLoad = (url) => { return new Promise((resolve) => { iframe.addEventListener('load', () => resolve()); // iframe.addEventListener('DOMContentLoaded', () => resolve()); iframe.src = url; }); }; // 북토끼 - 현재 페이지의 리스트들을 다운한다. if (site == "북토끼") { const contentName = document.querySelector('.page-title .page-desc').innerText; for (let i = 0; i < list.length; i++) { console.clear(); console.log(`${i + 1}/${list.length} 진행중`); // 각 회차별로 개별 ZIP 생성 const zip = new JSZip(); const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0'); const fileName = list[i].querySelector('a').innerHTML.replace(//g, '').trim(); const src = list[i].querySelector('a').href; await waitIframeLoad(src); await sleep(1000); const iframeDocument = iframe.contentWindow.document; // 소설 텍스트 추출 const fileContent = iframeDocument.querySelector('#novel_content').innerText; // 텍스트 파일을 ZIP에 추가 zip.file(`${num} ${fileName}.txt`, fileContent); // comicinfo.xml 생성 및 추가 const comicInfoContent = generateComicInfo(fileName, '북토끼', '', num, 1); zip.file('ComicInfo.xml', comicInfoContent); // ZIP 생성 및 다운로드 const content = await zip.generateAsync({ type: "blob" }); const link = document.createElement("a"); link.href = URL.createObjectURL(content); link.download = `${num} ${fileName}`; link.click(); URL.revokeObjectURL(link.href); console.log(`${i + 1}/${list.length} ${fileName} 다운완료`); // 너무 급하게 다운받다 블록당하는거 방지 30초 await sleep(30000); } } // 뉴토끼 또는 마나토끼 else if (site == "뉴토끼" || site == "마나토끼") { // 리스트 페이지에서 시리즈 이름과 작가 추출 const metadata = extractComicMetadata(); const seriesName = metadata.seriesName; const writerName = metadata.writerName; // 이미지를 fetch하고 zip에 추가하는 Promise (GM_xmlhttpRequest로 CORS 우회) const fetchAndAddToZip = (src, folderName, j, extension, listLen, zipObj) => { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: src, 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 blob = new Blob([response.response], { type: 'image/jpeg' }); zipObj.file(`${folderName} image${j.toString().padStart(4, '0')}${extension}`, blob); console.log(`${j + 1}/${listLen}진행완료`); } else { console.log(`이미지 다운로드 실패 (${response.status}): ${src}`); } resolve(); }, onerror: (error) => { console.log(`이미지 다운로드 오류: ${src}`); resolve(); }, ontimeout: (error) => { console.log(`이미지 다운로드 타임아웃: ${src}`); resolve(); } }); }) }; for (let i = 0; i < list.length; i++) { // 각 회차별로 개별 ZIP 생성 const zip = new JSZip(); const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0'); const folderName = list[i].querySelector('a').innerHTML.replace(//g, '').trim(); const src = list[i].querySelector('a').href; console.clear(); console.log(`${i + 1}/${list.length} ${folderName} 진행중`); await waitIframeLoad(src); await sleep(1000); const iframeDocument = iframe.contentWindow.document; // 이미지 추출 // view-padding의 div의 img. let imgLists = Array.from(iframeDocument.querySelectorAll('.view-padding div img')); console.log(`이미지 ${imgLists.length}개 감지`); // GIF 로딩 이미지가 실제 이미지로 변경될 때까지 대기하는 함수 const waitForImageToLoad = async (imgElement, timeoutMs = 8000) => { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { let currentSrc = ''; // 1. src 속성 확인 if (imgElement.src && !imgElement.src.includes('gif') && !imgElement.src.includes('loading')) { currentSrc = imgElement.src; } // 2. src가 gif/loading이면 data-* 속성 확인 if (!currentSrc) { for (let attr of imgElement.attributes) { if (attr.name.startsWith('data-') && attr.value.includes('/')) { const value = attr.value.toLowerCase(); if (!value.includes('gif') && !value.includes('loading')) { currentSrc = attr.value; break; } } } } if (currentSrc) { return currentSrc; } await sleep(100); } return ''; }; // 모든 이미지를 스크롤하여 실제 src가 로드되도록 강제 for (let j = 0; j < imgLists.length; j++) { imgLists[j].scrollIntoView({ behavior: 'auto', block: 'center' }); await sleep(100); } let promiseList = []; for (let j = 0; j < imgLists.length; j++) { // data-l44925d0f9f="src"같이 속성을 부여해놓고 스크롤 해야 src가 바뀌는 방식이다. // src를 직접 가져오면 loading.gif를 가져온다. // protocolDomain으로 바꿈으로서 CORS 해결 let src = ''; try { const imgElement = imgLists[j]; // GIF가 아닌 실제 이미지가 로드될 때까지 대기 (최대 8초) src = await waitForImageToLoad(imgElement); // 여전히 src를 못 찾았으면 outerHTML에서 추출 시도 if (!src) { const htmlStr = imgElement.outerHTML; const srcMatch = htmlStr.match(/src="([^"]+)"/); if (srcMatch && !srcMatch[1].includes('gif') && !srcMatch[1].includes('loading')) { src = srcMatch[1]; } else { const dataMatch = htmlStr.match(/\/data[^"\s]+/); if (dataMatch && !dataMatch[0].includes('gif')) { src = dataMatch[0]; } } } if (!src) { console.log(`이미지 ${j}의 경로를 찾을 수 없음`); continue; } // src가 이미 절대 URL이면 그대로 사용, 상대경로면 protocolDomain 추가 if (!src.startsWith('http')) { src = `${protocolDomain}${src}`; } // 가끔 확장자가 없는 이미지가 있는데, 확장자가 없으면 기본값(.jpg) 사용 const extensionMatch = src.match(/\.[a-zA-Z0-9]+$/); const extension = extensionMatch ? extensionMatch[0] : '.jpg'; promiseList.push(fetchAndAddToZip(src, folderName, j, extension, imgLists.length, zip)); } catch(error) { console.log(`이미지 ${j} 처리 중 오류:`, error); } } await Promise.all(promiseList); // comicinfo.xml 생성 및 추가 (시리즈명과 작가 정보 포함) const comicInfoContent = generateComicInfo(folderName, seriesName, writerName, num, imgLists.length, imgLists.length); zip.file('ComicInfo.xml', comicInfoContent); // ZIP 생성 및 다운로드 const content = await zip.generateAsync({ type: "blob" }); const link = document.createElement("a"); link.href = URL.createObjectURL(content); link.download = `${num} ${folderName}`; link.click(); URL.revokeObjectURL(link.href); console.log(`${i + 1}/${list.length} ${folderName} 완료`); // 너무 급하게 다운받다 블록당하는거 방지 30초 await sleep(30000); } } // 일일툰 else if (site === "일일툰") { // URL에서 시리즈 ID와 에피소드 ID 추출 const urlParams = new URLSearchParams(window.location.search); const seriesId = urlParams.get('is') || ''; const episodeId = urlParams.get('wr_id') || ''; if (!seriesId || !episodeId) { alert('시리즈 ID 또는 에피소드 ID를 찾을 수 없습니다.'); return; } // 제목 추출 const titleElement = document.querySelector('h1'); const title = titleElement ? titleElement.innerText.trim() : '제목없음'; // 전역 변수에서 이미 캡처된 이미지 URL 가져오기 let imageUrls = window.capturedImageUrls || []; console.clear(); console.log(`일일툰 다운로드 시작`); console.log(`현재까지 감지된 이미지: ${imageUrls.length}개`); // 이미지가 없으면 추가 대기 if (imageUrls.length === 0) { console.log('이미지 로드 대기 중...'); for (let i = 0; i < 10; i++) { await sleep(500); imageUrls = window.capturedImageUrls || []; console.log(`대기 ${i+1}: ${imageUrls.length}개`); if (imageUrls.length > 0) break; } } if (imageUrls.length === 0) { alert('이미지 URL을 감지하지 못했습니다. 페이지가 완전히 로드되지 않았을 수 있습니다.'); return; } // ZIP 생성 const zip = new JSZip(); // 이미지 다운로드 함수 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 blob = new Blob([response.response], { type: 'image/jpeg' }); const filename = `image${String(index).padStart(4, '0')}.jpg`; zip.file(filename, blob); console.log(`${index + 1}/${total} 다운로드 완료`); } else { console.log(`이미지 ${index} 다운로드 실패 (${response.status})`); } resolve(); }, onerror: (error) => { console.log(`이미지 ${index} 다운로드 오류:`, error); 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); } // comicinfo.xml 생성 및 추가 const comicInfoContent = generateComicInfo(title, title, '', '1', imageUrls.length, imageUrls.length); zip.file('ComicInfo.xml', comicInfoContent); // ZIP 생성 및 다운로드 const content = await zip.generateAsync({ type: "blob" }); const link = document.createElement("a"); link.href = URL.createObjectURL(content); link.download = title; link.click(); URL.revokeObjectURL(link.href); console.log(`완료: ${imageUrls.length}개 이미지 다운로드`); } console.log(`모든 회차 다운완료`); } catch (error) { alert(`tokiDownload 오류발생: ${site}\n${currentURL}\n` + error); console.error(error); } } // ui 추가 GM_registerMenuCommand('전체 다운로드', () => tokiDownload()); GM_registerMenuCommand('N번째 회차부터', () => { const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1); tokiDownload(startPageInput); }); GM_registerMenuCommand('N번째 회차부터 N번째 까지', () => { const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1); const endPageInput = prompt('몇번째 회차까지 저장할까요?', 2); tokiDownload(startPageInput, endPageInput); }); // Your code here... })();