Initial commit - tokiDownloader.user.js
This commit is contained in:
515
Old_Scripts/tokiDownloader.user copy.js
Normal file
515
Old_Scripts/tokiDownloader.user copy.js
Normal file
@@ -0,0 +1,515 @@
|
||||
// ==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 += ` <Page Image="${i}" Type="Story"/>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const comicInfo = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<Title>${title}</Title>
|
||||
<Series>${series}</Series>
|
||||
<Number>${number}</Number>
|
||||
<Count>-1</Count>
|
||||
<Volume>-1</Volume>
|
||||
<Summary></Summary>
|
||||
<Notes></Notes>
|
||||
<Year>-1</Year>
|
||||
<Month>-1</Month>
|
||||
<Writer>${writer}</Writer>
|
||||
<Penciller></Penciller>
|
||||
<Inker></Inker>
|
||||
<Colorist></Colorist>
|
||||
<Letterer></Letterer>
|
||||
<CoverArtist></CoverArtist>
|
||||
<Editor></Editor>
|
||||
<Publisher></Publisher>
|
||||
<Imprint></Imprint>
|
||||
<Genre></Genre>
|
||||
<Web></Web>
|
||||
<PageCount>${pageCount}</PageCount>
|
||||
<LanguageISO>ko</LanguageISO>
|
||||
<Format></Format>
|
||||
<BlackAndWhite>Unknown</BlackAndWhite>
|
||||
<Manga>Yes</Manga>
|
||||
${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}
|
||||
</ComicInfo>`;
|
||||
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(/<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에 추가
|
||||
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(/<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'));
|
||||
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...
|
||||
})();
|
||||
Reference in New Issue
Block a user