您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
북토끼, 뉴토끼, 마나토끼 다운로더
// ==UserScript== // @name tokiDownloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.0.3 // @description 북토끼, 뉴토끼, 마나토끼 다운로더 // @author hehaho // @match https://*.com/webtoon/* // @match https://*.com/novel/* // @match https://*.net/comic/* // @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad // @grant GM_registerMenuCommand // @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'; 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 { // 다운 페이지가 아니라면 리턴. @match가 정규표현식이 아니기 때문에 정확한 host를 매치 할 수 없기 때문. return; } function sleep(ms) { return new Promise(resolve => { setTimeout(() => resolve(), ms); }) } async function tokiDownload(startIndex, lastIndex) { try { // JSZip 생성 const zip = new JSZip(); // 리스트들 가져오기 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; } } // 제목 가져오기 - 처음 제목과 마지막 제목을 제목에 넣는다. // const contentName = document.querySelector('.page-title .page-desc').innerText; const firstName = list[0].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim(); const lastName = list.at(-1).querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim(); const rootFolder = `${site} ${firstName} ~ ${lastName}`; // 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 == "북토끼") { for (let i = 0; i < list.length; i++) { console.clear(); console.log(`${i + 1}/${list.length} 진행중`); const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0'); const fileName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/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.file(`${num} ${fileName}.txt`, fileContent); // 이미지가 있다면 폴더를 만들어 그 안에 데이터를 넣는다. - 아직 이미지인 소설을 못찾음 // zip.folder(`${num} ${fileName}`).file(`${num} ${fileName}.txt`, fileContent); } } // 뉴토끼 또는 마나토끼 else if (site == "뉴토끼" || site == "마나토끼") { // 이미지를 fetch하고 zip에 추가하는 Promise const fetchAndAddToZip = (src, num, folderName, j, extension, listLen) => { return new Promise((resolve) => { fetch(src).then(response => { response.blob().then(blob => { zip.folder(`${num} ${folderName}`).file(`${folderName} image${j.toString().padStart(4, '0')}${extension}`, blob); console.log(`${j + 1}/${listLen}진행완료`); resolve(); }) }) }) }; for (let i = 0; i < list.length; i++) { const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0'); const folderName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/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')); // 화면에 보이지 않는 이미지라면 리스트에서 iframe제거 for (let j = 0; j < imgLists.length;) { if (imgLists[j].checkVisibility() === false) imgLists.splice(j, 1); else j++; } console.log(`이미지 ${imgLists.length}개 감지`); let promiseList = []; for (let j = 0; j < imgLists.length; j++) { // data-l44925d0f9f="src"같이 속성을 부여해놓고 스크롤 해야 src가 바뀌는 방식이다. // src를 직접 가져오면 loading.gif를 가져온다. // protocolDomain으로 바꿈으로서 CORS 해결 let src = imgLists[j].outerHTML; try { // src가 https://가 없을 때도 있어서 \/data[^"]+로 감지해야함. src = `${protocolDomain}${src.match(/\/data[^"]+/)[0]}`; // 가끔 확장자가 없는 이미지가 있는데, 해당 이미지는 다운받지 않음. const extension = src.match(/\.[a-zA-Z]+$/)[0]; promiseList.push(fetchAndAddToZip(src, num, folderName, j, extension, imgLists.length)); } catch(error) { console.log(error); } } await Promise.all(promiseList); console.log(`${i + 1}/${list.length} ${folderName} 완료`); } } // iframe제거 iframe.remove(); // 파일 생성후 다운로드 console.log(`다운로드중입니다... 잠시 기다려주세요`); const content = await zip.generateAsync({ type: "blob" }); const link = document.createElement("a"); link.href = URL.createObjectURL(content); link.download = rootFolder; // ZIP 파일 이름 지정 link.click(); URL.revokeObjectURL(link.href); // 메모리 해제 link.remove(); 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... })();