tokiDownloader

북토끼, 뉴토끼, 마나토끼 다운로더

질문, 리뷰하거나, 이 스크립트를 신고하세요.
  1. // ==UserScript==
  2. // @name tokiDownloader
  3. // @namespace https://github.com/crossSiteKikyo/tokiDownloader
  4. // @version 0.0.1
  5. // @description 북토끼, 뉴토끼, 마나토끼 다운로더
  6. // @author hehaho
  7. // @match https://*.com/webtoon/*
  8. // @match https://*.com/novel/*
  9. // @match https://*.net/comic/*
  10. // @icon https://i.namu.wiki/i/VLM5tYIVQKb8_ULcWJYsKvbV7swtlZE93vkQQiZei0LiwrbyDQHvSEup8Hnr2tTXAUtBjS0srw1OnSjU540TpAapRswupu3nE_JE_A9d3o1YXX5sqRL-qRyzkjBY6X3ss-gzOVryhlC4YmnhpFLhyQ.webp
  11. // @grant GM_registerMenuCommand
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip-utils/0.1.0/jszip-utils.js
  14. // @run-at document-end
  15. // @license MIT
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21. let site = '뉴토끼'; // 예시
  22. let protocolDomain = 'https://newtoki350.com'; // 예시
  23. // 현재 url 체크
  24. const currentURL = document.URL;
  25. if (currentURL.match(/^https:\/\/booktoki[0-9]+.com\/novel\/[0-9]+/)) {
  26. site = "북토끼"; protocolDomain = currentURL.match(/^https:\/\/booktoki[0-9]+.com/)[0];
  27. }
  28. else if (currentURL.match(/^https:\/\/newtoki[0-9]+.com\/webtoon\/[0-9]+/)) {
  29. site = "뉴토끼"; protocolDomain = currentURL.match(/^https:\/\/newtoki[0-9]+.com/)[0];
  30. }
  31. else if (currentURL.match(/^https:\/\/manatoki[0-9]+.net\/comic\/[0-9]+/)) {
  32. site = "마나토끼"; protocolDomain = currentURL.match(/^https:\/\/manatoki[0-9]+.net/)[0];
  33. }
  34. else {
  35. // 다운 페이지가 아니라면 리턴. @match가 정규표현식이 아니기 때문에 정확한 host를 매치 할 수 없기 때문.
  36. return;
  37. }
  38.  
  39. function sleep(ms) {
  40. return new Promise(resolve => {
  41. setTimeout(() => resolve(), ms);
  42. })
  43. }
  44.  
  45. async function tokiDownload(startIndex, lastIndex) {
  46. try {
  47. // JSZip 생성
  48. const zip = new JSZip();
  49.  
  50. // 리스트들 가져오기
  51. let list = Array.from(document.querySelector('.list-body').querySelectorAll('li')).reverse();
  52.  
  53. // 리스트 필터
  54. // startIndex가 있다면
  55. if (startIndex) {
  56. while (true) {
  57. let num = parseInt(list[0].querySelector('.wr-num').innerText);
  58. if (num < startIndex)
  59. list.shift();
  60. else
  61. break;
  62. }
  63. }
  64. // lastIndex가 있다면
  65. if (lastIndex) {
  66. while (true) {
  67. let num = parseInt(list.at(-1).querySelector('.wr-num').innerText);
  68. if (lastIndex < num)
  69. list.pop();
  70. else
  71. break;
  72. }
  73. }
  74.  
  75. // 제목 가져오기 - 처음 제목과 마지막 제목을 제목에 넣는다.
  76. // const contentName = document.querySelector('.page-title .page-desc').innerText;
  77. const firstName = list[0].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
  78. const lastName = list.at(-1).querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
  79. const rootFolder = `${site} ${firstName} ~ ${lastName}`;
  80.  
  81. // iframe생성
  82. const iframe = document.createElement('iframe');
  83. iframe.width = 600;
  84. iframe.height = 600;
  85. document.querySelector('.content').prepend(iframe);
  86. const waitIframeLoad = (url) => {
  87. return new Promise((resolve) => {
  88. iframe.addEventListener('load', () => resolve());
  89. // iframe.addEventListener('DOMContentLoaded', () => resolve());
  90. iframe.src = url;
  91. });
  92. };
  93.  
  94. // 북토끼 - 현재 페이지의 리스트들을 다운한다.
  95. if (site == "북토끼") {
  96. for (let i = 0; i < list.length; i++) {
  97. console.clear();
  98. console.log(`${i + 1}/${list.length} 진행중`);
  99.  
  100. const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0');
  101. const fileName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
  102. const src = list[i].querySelector('a').href;
  103. await waitIframeLoad(src);
  104. await sleep(1000);
  105. const iframeDocument = iframe.contentWindow.document;
  106. // 소설 텍스트 추출
  107. const fileContent = iframeDocument.querySelector('#novel_content').innerText;
  108. // 이미지가 없다면 폴더를 만들지 않고 텍스트 파일 하나만 만든다.
  109. zip.file(`${num} ${fileName}.txt`, fileContent);
  110. // 이미지가 있다면 폴더를 만들어 그 안에 데이터를 넣는다. - 아직 이미지인 소설을 못찾음
  111. // zip.folder(`${num} ${fileName}`).file(`${num} ${fileName}.txt`, fileContent);
  112. }
  113. }
  114. // 뉴토끼 또는 마나토끼
  115. else if (site == "뉴토끼" || site == "마나토끼") {
  116. // 이미지를 fetch하고 zip에 추가하는 Promise
  117. const fetchAndAddToZip = (src, num, folderName, j, extension, listLen) => {
  118. return new Promise((resolve) => {
  119. fetch(src).then(response => {
  120. response.blob().then(blob => {
  121. zip.folder(`${num} ${folderName}`).file(`${folderName} image${j.toString().padStart(4, '0')}${extension}`, blob);
  122. console.log(`${j + 1}/${listLen}진행완료`);
  123. resolve();
  124. })
  125. })
  126. })
  127. };
  128. for (let i = 0; i < list.length; i++) {
  129. const num = list[i].querySelector('.wr-num').innerText.padStart(4, '0');
  130. const folderName = list[i].querySelector('a').innerHTML.replace(/<span[\s\S]*?\/span>/g, '').trim();
  131. const src = list[i].querySelector('a').href;
  132. console.clear();
  133. console.log(`${i + 1}/${list.length} ${folderName} 진행중`);
  134.  
  135. await waitIframeLoad(src);
  136. await sleep(1000);
  137. const iframeDocument = iframe.contentWindow.document;
  138. // 이미지 추출
  139. // view-padding의 div의 img.
  140. let imgLists = Array.from(iframeDocument.querySelectorAll('.view-padding div img'));
  141. // 화면에 보이지 않는 이미지라면 리스트에서 iframe제거
  142. for (let j = 0; j < imgLists.length;) {
  143. if (imgLists[j].checkVisibility() === false)
  144. imgLists.splice(j, 1);
  145. else
  146. j++;
  147. }
  148. console.log(`이미지 ${imgLists.length}개 감지`);
  149. let promiseList = [];
  150. for (let j = 0; j < imgLists.length; j++) {
  151. // data-l44925d0f9f="src"같이 속성을 부여해놓고 스크롤 해야 src가 바뀌는 방식이다.
  152. // src를 직접 가져오면 loading.gif를 가져온다.
  153. // protocolDomain으로 바꿈으로서 CORS 해결
  154. let src = imgLists[j].outerHTML;
  155. // src가 https://가 없을 때도 있어서 \/data[^"]+로 감지해야함.
  156. src = `${protocolDomain}${src.match(/\/data[^"]+/)[0]}`;
  157. const extension = src.match(/\.[a-zA-Z]+$/)[0];
  158. promiseList.push(fetchAndAddToZip(src, num, folderName, j, extension, imgLists.length));
  159. }
  160. await Promise.all(promiseList);
  161. console.log(`${i + 1}/${list.length} ${folderName} 완료`);
  162. }
  163. }
  164.  
  165. // iframe제거
  166. iframe.remove();
  167.  
  168. // 파일 생성후 다운로드
  169. console.log(`다운로드중입니다... 잠시 기다려주세요`);
  170. const content = await zip.generateAsync({ type: "blob" });
  171. const link = document.createElement("a");
  172. link.href = URL.createObjectURL(content);
  173. link.download = rootFolder; // ZIP 파일 이름 지정
  174. link.click();
  175. URL.revokeObjectURL(link.href); // 메모리 해제
  176. link.remove();
  177. console.log(`다운완료`);
  178. } catch (error) {
  179. alert(`tokiDownload 오류발생: ${site}\n${currentURL}\n` + error);
  180. console.error(error);
  181. }
  182. }
  183.  
  184. // ui 추가
  185. GM_registerMenuCommand('전체 다운로드', () => tokiDownload());
  186. GM_registerMenuCommand('N번째 회차부터', () => {
  187. const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1);
  188. tokiDownload(startPageInput);
  189. });
  190. GM_registerMenuCommand('N번째 회차부터 N번째 까지', () => {
  191. const startPageInput = prompt('몇번째 회차부터 저장할까요?', 1);
  192. const endPageInput = prompt('몇번째 회차까지 저장할까요?', 2);
  193. tokiDownload(startPageInput, endPageInput);
  194. });
  195. // Your code here...
  196. })();