// ==UserScript==
// @name tokiDownloader
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
// @version 0.0.5
// @description 일일툰 다운로더
// @author hehaho
// @match https://*.com/webtoon/*
// @match https://*.com/novel/*
// @match https://*.net/comic/*
// @match http://*.cprapid.com:*/bbs/board.php*
// @match https://*.spotv24.com/bbs/board.php*
// @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
// @downloadURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.user.js
// @updateURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.meta.js
// ==/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);
}
};
// 글로벌 이미지 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'; // 예시
let isListPage = false; // 목록 페이지 여부
// 현재 url 체크
const currentURL = document.URL;
if (currentURL.includes('bo_table=toons')) {
// cprapid.com 또는 spotv24.com
if (currentURL.includes('wr_id=')) {
site = "일일툰"; // 개별 에피소드
} else {
site = "일일툰_목록"; // 목록 페이지
isListPage = true;
}
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 = '';
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 {
// 목록 페이지 처리
if (isListPage) {
console.clear();
console.log('목록 페이지 처리 시작');
// 에피소드 목록 추출
const episodeList = document.querySelector('ul.episode-list');
if (!episodeList) {
alert('에피소드 목록을 찾을 수 없습니다.');
return;
}
const items = Array.from(episodeList.querySelectorAll('li'));
console.log(`총 ${items.length}개 에피소드 발견`);
// 각 에피소드의 링크 추출
const episodes = [];
items.forEach((li, index) => {
const button = li.querySelector('button.episode');
if (button) {
const onclick = button.getAttribute('onclick') || '';
const match = onclick.match(/location\.href='([^']+)'/);
if (match && match[1]) {
const relativeUrl = match[1];
const absoluteUrl = new URL(relativeUrl, window.location.href).href;
const episodeTitle = li.innerText.split('\n')[0].trim();
episodes.push({
index,
title: episodeTitle,
url: absoluteUrl
});
}
}
});
console.log(`추출된 링크: ${episodes.length}개`);
if (episodes.length === 0) {
alert('에피소드 링크를 추출할 수 없습니다.');
return;
}
// iframe 생성
const iframe = document.createElement('iframe');
iframe.width = 600;
iframe.height = 600;
document.body.prepend(iframe);
const waitIframeLoad = (url) => {
return new Promise((resolve) => {
iframe.addEventListener('load', () => resolve(), { once: true });
iframe.src = url;
});
};
// 각 에피소드 다운로드
for (let i = 0; i < episodes.length; i++) {
const episode = episodes[i];
console.clear();
console.log(`${i + 1}/${episodes.length} ${episode.title} 처리 중...`);
// iframe에서 페이지 로드
await waitIframeLoad(episode.url);
// 이미지 로드 대기 (canvas 렌더링 시간)
console.log('이미지 로드 대기 중...');
await sleep(3000);
// 스크린샷을 통해 이미지 로드 강제 (canvas 렌더링)
try {
const iframeDoc = iframe.contentWindow.document;
const viewContent = iframeDoc.querySelector('.view-content, [class*="viewer"]');
if (viewContent) {
// 스크롤 트리거 (lazy loading 이미지 로드)
viewContent.scrollTop = 100;
}
} catch (e) {
// iframe CORS 문제 무시
}
// 추가 대기 (네트워크 요청 완료)
await sleep(2000);
// 이미지 URL 수집 (전역 변수에서)
let imageUrls = [];
if (window.capturedImageUrls && window.capturedImageUrls.length > 0) {
imageUrls = window.capturedImageUrls.slice(); // 복사
window.capturedImageUrls = []; // 초기화
}
if (imageUrls.length === 0) {
console.log(`이미지를 찾을 수 없음 - 스킵`);
continue;
}
console.log(`이미지 ${imageUrls.length}개 발견`);
// 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: () => {
console.log(` 이미지 ${index} 오류`);
resolve();
},
ontimeout: () => {
console.log(` 이미지 ${index} 타임아웃`);
resolve();
}
});
});
};
let promiseList = [];
for (let j = 0; j < imageUrls.length; j++) {
promiseList.push(downloadImage(imageUrls[j], j, 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(episode.title, episode.title, '', String(i + 1), imageUrls.length, imageUrls.length);
zip.file('ComicInfo.xml', comicInfoContent);
// ZIP 다운로드
const content = await zip.generateAsync({ type: "blob" });
downloadZip(content, episode.title + '.zip');
console.log(`${i + 1}/${episodes.length} ${episode.title} 완료`);
// 다운로드 간격
await sleep(3000);
}
iframe.remove();
console.log(`모든 에피소드 다운로드 완료`);
return;
}
// 리스트들 가져오기
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(), { once: true });
// iframe.addEventListener('DOMContentLoaded', () => resolve());
iframe.src = url;
});
};
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" });
downloadZip(content, title + ".zip");
console.log(`완료: ${imageUrls.length}개 이미지 다운로드`);
}
console.log(`모든 회차 다운완료`);
} catch (error) {
alert(`tokiDownload 오류발생: ${site}\n${currentURL}\n` + error);
console.error(error);
}
}
// ui 추가
if (isListPage) {
// 목록 페이지용 메뉴
GM_registerMenuCommand('[목록] 전체 다운로드', () => tokiDownload());
} else {
// 개별 페이지용 메뉴 (기존)
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...
})();