Initial commit - tokiDownloader.user.js

This commit is contained in:
user01
2026-06-13 18:33:22 +09:00
commit 528cf73a34
385 changed files with 10847 additions and 0 deletions

View File

@@ -0,0 +1,568 @@
// ==UserScript==
// @name 11toonDownloader
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
// @version 0.0.6
// @description 일일툰 전용 다운로더
// @author hehaho
// @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
// ==/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);
}
};
// 글로벌 이미지 저장소 (네트워크 모니터링용)
window.capturedImageUrls = [];
window.capturedImageBlobs = [];
// fetch 인터셉트 (이미지 Blob 직접 캡처)
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
!url.includes('logo') && !url.includes('banner') && !url.includes('icon');
if (isImageUrl && !window.capturedImageUrls.includes(url)) {
window.capturedImageUrls.push(url);
}
const result = originalFetch.apply(this, args);
// Blob 응답 캡처 (Canvas Tainted 우회)
if (isImageUrl && result instanceof Promise) {
result.then(response => {
if (response.ok && (response.headers.get('content-type')?.includes('image') || url.match(/\.(jpg|jpeg|png|webp)$/i))) {
const clonedResponse = response.clone();
clonedResponse.blob().then(blob => {
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
window.capturedImageBlobs.push(blob);
}
}).catch(() => {});
}
return response;
}).catch(() => {});
}
return result;
};
// XMLHttpRequest 인터셉트 (arraybuffer 응답 캡처)
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._captureUrl = url;
return originalOpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function(...args) {
const xhr = this;
const originalOnreadystatechange = xhr.onreadystatechange;
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
const url = xhr._captureUrl;
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
!url.includes('logo') && !url.includes('banner') && !url.includes('icon');
if (isImageUrl && xhr.response instanceof ArrayBuffer) {
try {
const blob = new Blob([xhr.response], { type: xhr.getResponseHeader('content-type') || 'image/jpeg' });
if (!window.capturedImageBlobs.some(b => b.size === blob.size)) {
window.capturedImageBlobs.push(blob);
}
} catch(e) {}
}
}
if (originalOnreadystatechange) {
return originalOnreadystatechange.apply(this, arguments);
}
};
return originalSend.apply(this, args);
};
let site = '';
let protocolDomain = '';
let isListPage = false;
// 현재 url 체크
const currentURL = document.URL;
if (currentURL.includes('bo_table=toons')) {
if (currentURL.includes('wr_id=')) {
site = "일일툰";
} else {
site = "일일툰_목록";
isListPage = true;
}
protocolDomain = currentURL.match(/^https?:\/\/[^:/?]+/)[0];
} else {
return;
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const 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`;
}
}
return `<?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>
<PageCount>${pageCount}</PageCount>
<LanguageISO>ko</LanguageISO>
<Manga>Yes</Manga>
${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}</ComicInfo>`;
};
// DOM에서 이미지 URL 추출 헬퍼 함수
const extractImagesFromDom = (targetDocument, protocolDomain) => {
let imageUrls = [];
// 1. img 태그 탐색
const contentDiv = targetDocument.querySelector('#bo_v_con') || targetDocument.querySelector('.view-content') || targetDocument.querySelector('#toon_img') || targetDocument.querySelector('.board-view');
let imgElements = Array.from(targetDocument.querySelectorAll('#bo_v_con img, .view-content img, .view-wrap img, .toon-img img, .item-image img, .board-view img, #toon_img img'));
if (contentDiv && imgElements.length === 0) {
imgElements = Array.from(contentDiv.querySelectorAll('img'));
}
imgElements.forEach(img => {
let src = img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
for (let attr of img.attributes) {
if (attr.name.startsWith('data-') && (attr.value.includes('http') || attr.value.includes('/data/'))) {
src = attr.value;
}
}
if (src && !src.includes('gif') && !src.includes('logo') && !src.includes('banner')) {
if (src.startsWith('//')) src = 'https:' + src;
else if (src.startsWith('/')) src = protocolDomain + src;
imageUrls.push(src);
}
});
// 2. css background-image 탐색
const divElements = Array.from(targetDocument.querySelectorAll('#bo_v_con div, .view-content div, .toon-img div'));
divElements.forEach(div => {
const bg = div.style.backgroundImage || window.getComputedStyle(div).backgroundImage;
if (bg && bg !== 'none') {
const match = bg.match(/url\(["']?(.*?)["']?\)/);
if (match && match[1] && !match[1].includes('gif')) {
let src = match[1];
if (src.startsWith('//')) src = 'https:' + src;
else if (src.startsWith('/')) src = protocolDomain + src;
imageUrls.push(src);
}
}
});
// 3. Canvas 탐색
if (imageUrls.length === 0) {
const canvasElements = Array.from(targetDocument.querySelectorAll('canvas'));
canvasElements.forEach(canvas => {
try {
const dataUrl = canvas.toDataURL('image/jpeg');
if (dataUrl && dataUrl.length > 1000) {
imageUrls.push(dataUrl);
}
} catch(e) {}
});
}
// 4. HTML 원문 정규식 탐색 (Fallback)
if (imageUrls.length === 0) {
const html = contentDiv ? contentDiv.innerHTML : targetDocument.documentElement.innerHTML;
const matches1 = html.match(/https?:\/\/[^"'\s>]*\.(jpg|jpeg|png|webp)[^"'\s>]*/gi) || [];
const matches2 = html.match(/(?:\/data\/file\/[a-zA-Z0-9_/-]+|img[0-9]*\.[a-zA-Z0-9-]+\.[a-zA-Z]+\/data\/file\/[a-zA-Z0-9_/-]+)\.(jpg|jpeg|png|webp)/gi) || [];
const matches3 = html.match(/\/toki[^"'\s>]+\.(jpg|jpeg|png|webp)/gi) || [];
const matches4 = html.match(/(?:data-src|data-original)=["']([^"']+)["']/gi) || [];
let allMatches = [...matches1, ...matches2, ...matches3];
matches4.forEach(m => {
const urlMatch = m.match(/=["']([^"']+)["']/);
if (urlMatch && urlMatch[1] && !urlMatch[1].includes('gif') && !urlMatch[1].includes('logo') && !urlMatch[1].includes('banner')) {
allMatches.push(urlMatch[1]);
}
});
if (allMatches.length > 0) {
imageUrls = imageUrls.concat(allMatches.map(url => {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('/')) return protocolDomain + url;
if (!url.startsWith('http')) return protocolDomain + '/' + url;
return url;
}));
}
}
// 중복 제거 및 최종 필터링
imageUrls = [...new Set(imageUrls)].filter(url => {
if (url.startsWith('data:image')) return true;
return !url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('no_img') && !url.includes('profile') && !url.includes('blank') && url.length > 25;
});
return imageUrls;
};
// 이미지 다운로드 및 압축 헬퍼 함수
const processAndDownloadZip = async (title, imageUrls, imageBlobs) => {
const zip = new JSZip();
// 1. Blob 사용 시 (가장 우선)
if (imageBlobs && imageBlobs.length > 0) {
console.log(`[다운로드] Blob 이미지 ${imageBlobs.length}개 사용`);
imageBlobs.forEach((blob, i) => {
const filename = `image${String(i).padStart(4, '0')}.jpg`;
zip.file(filename, blob);
});
const comicInfoContent = generateComicInfo(title, title, '', '1', imageBlobs.length, imageBlobs.length);
zip.file('ComicInfo.xml', comicInfoContent);
const content = await zip.generateAsync({ type: "blob" });
downloadZip(content, `${title}.zip`);
console.log(`[완료] ${title} 다운로드 완료`);
return;
}
// 2. URL 사용 시
if (!imageUrls || imageUrls.length === 0) {
console.warn(`[스킵] ${title} - 이미지를 찾을 수 없습니다.`);
return;
}
console.log(`[다운로드] URL 이미지 ${imageUrls.length}개 사용`);
const downloadImageTask = (url, index) => {
return new Promise((resolve) => {
const filename = `image${String(index).padStart(4, '0')}.jpg`;
if (url.startsWith('data:image')) {
try {
const base64Data = url.split(',')[1];
zip.file(filename, base64Data, {base64: true});
} catch(e) {}
resolve();
return;
}
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' });
zip.file(filename, blob);
}
resolve();
},
onerror: () => resolve(),
ontimeout: () => resolve()
});
});
};
// 5개씩 동시 다운로드
let promiseList = [];
for (let j = 0; j < imageUrls.length; j++) {
promiseList.push(downloadImageTask(imageUrls[j], j));
if (promiseList.length >= 5) {
await Promise.all(promiseList);
promiseList = [];
await sleep(500);
}
}
if (promiseList.length > 0) {
await Promise.all(promiseList);
}
const comicInfoContent = generateComicInfo(title, title, '', '1', imageUrls.length, imageUrls.length);
zip.file('ComicInfo.xml', comicInfoContent);
const content = await zip.generateAsync({ type: "blob" });
downloadZip(content, `${title}.zip`);
console.log(`[완료] ${title} 다운로드 완료`);
};
// 에피소드 하나 다운로드 처리 (목록에서 연속 다운로드 시 사용)
async function downloadEpisode(episodeTitle, url) {
return new Promise(async (resolve) => {
try {
console.log(`[진행 중] ${episodeTitle}`);
const iframe = document.createElement('iframe');
iframe.style.border = '5px solid red';
document.body.appendChild(iframe);
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
const script = iframeDoc.createElement('script');
script.type = 'text/javascript';
script.innerHTML = `
window.capturedImageUrls = [];
window.capturedImageBlobs = [];
const isTargetImage = (u) => u && typeof u === 'string' &&
(u.includes('.jpg') || u.includes('.png') || u.includes('.webp') || u.includes('/image') || u.includes('/photo')) &&
!u.includes('logo') && !u.includes('banner') && !u.includes('icon');
const origFetch = window.fetch;
window.fetch = function(...args) {
const u = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (isTargetImage(u)) {
if (!window.capturedImageUrls.includes(u)) window.capturedImageUrls.push(u);
const result = origFetch.apply(this, args);
if (result instanceof Promise) {
result.then(response => {
if (response.ok) {
response.clone().blob().then(blob => {
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
window.capturedImageBlobs.push(blob);
}
}).catch(() => {});
}
return response;
}).catch(() => {});
}
return result;
}
return origFetch.apply(this, args);
};
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, u, ...rest) {
this._captureUrl = u;
return origOpen.apply(this, [method, u, ...rest]);
};
XMLHttpRequest.prototype.send = function(...args) {
const xhr = this;
const origOnreadystatechange = xhr.onreadystatechange;
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
const u = xhr._captureUrl;
if (isTargetImage(u) && xhr.response instanceof ArrayBuffer) {
try {
const blob = new Blob([xhr.response], { type: xhr.getResponseHeader('content-type') || 'image/jpeg' });
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
window.capturedImageBlobs.push(blob);
}
} catch(e) {}
}
}
if (origOnreadystatechange) return origOnreadystatechange.apply(this, arguments);
};
return origSend.apply(this, args);
};
`;
iframeDoc.head.appendChild(script);
}
} catch(e) {}
await new Promise((res) => {
iframe.addEventListener('load', () => res(), { once: true });
iframe.src = url;
});
await sleep(3000);
const iframeWindow = iframe.contentWindow;
const iframeDocument = iframeWindow.document;
let lastScroll = -1;
let scrollAttempts = 0;
while (scrollAttempts < 50) {
iframeWindow.scrollBy(0, window.innerHeight || 800);
await sleep(300);
if (iframeWindow.scrollY === lastScroll || iframeWindow.scrollY + iframeWindow.innerHeight >= iframeDocument.documentElement.scrollHeight) {
break;
}
lastScroll = iframeWindow.scrollY;
scrollAttempts++;
}
await sleep(1500);
let imageBlobs = [];
if (iframeWindow && iframeWindow.capturedImageBlobs && iframeWindow.capturedImageBlobs.length > 0) {
imageBlobs = [...iframeWindow.capturedImageBlobs];
} else if (window.capturedImageBlobs && window.capturedImageBlobs.length > 0) {
imageBlobs = [...window.capturedImageBlobs];
}
let imageUrls = [];
if (imageBlobs.length === 0) {
imageUrls = extractImagesFromDom(iframeDocument, protocolDomain);
if (imageUrls.length === 0) {
if (iframeWindow && iframeWindow.capturedImageUrls && iframeWindow.capturedImageUrls.length > 0) {
imageUrls = [...iframeWindow.capturedImageUrls];
} else if (window.capturedImageUrls && window.capturedImageUrls.length > 0) {
imageUrls = [...window.capturedImageUrls];
}
}
}
iframe.remove();
await processAndDownloadZip(episodeTitle, imageUrls, imageBlobs);
resolve();
} catch (err) {
console.error(`[오류] ${episodeTitle}:`, err);
resolve();
}
});
}
async function tokiDownload() {
try {
if (isListPage) {
console.clear();
console.log('목록 페이지 처리 시작');
const episodeList = document.querySelector('ul.episode-list') || document.querySelector('.list-body') || document.querySelector('.board-list') || document.querySelector('.list-container') || document.querySelector('#bo_list');
if (!episodeList) {
alert('에피소드 목록을 찾을 수 없습니다.');
return;
}
const items = Array.from(episodeList.querySelectorAll('li, tr, .list-item, .item, .list-row'));
const episodes = [];
items.forEach((item) => {
const button = item.querySelector('button[onclick*="location.href"]');
const link = item.querySelector('a[href*="wr_id="]');
if (button) {
const onclick = button.getAttribute('onclick') || '';
const match = onclick.match(/location\.href=['"]([^'"]+)['"]/);
if (match && match[1]) {
const absoluteUrl = new URL(match[1], window.location.href).href;
const episodeTitle = item.innerText.split('\n')[0].trim();
if (episodeTitle && absoluteUrl) {
episodes.push({ title: episodeTitle, url: absoluteUrl });
}
}
} else if (link && link.href) {
let clone = link.cloneNode(true);
clone.querySelectorAll('span, img, i, b, strong').forEach(s => s.remove());
let episodeTitle = clone.innerText.trim();
if (!episodeTitle) episodeTitle = link.innerText.trim();
if (episodeTitle && link.href) {
episodes.push({ title: episodeTitle, url: link.href });
}
}
});
const uniqueEpisodes = [];
const urlSet = new Set();
episodes.forEach(ep => {
const wrIdMatch = ep.url.match(/wr_id=([0-9]+)/);
const uniqueKey = wrIdMatch ? wrIdMatch[1] : ep.url;
if (!urlSet.has(uniqueKey)) {
urlSet.add(uniqueKey);
uniqueEpisodes.push(ep);
}
});
uniqueEpisodes.reverse();
if (uniqueEpisodes.length === 0) {
alert('에피소드 링크를 추출할 수 없습니다. (구조가 변경되었을 수 있습니다.)');
return;
}
for (let i = 0; i < uniqueEpisodes.length; i++) {
await downloadEpisode(uniqueEpisodes[i].title, uniqueEpisodes[i].url);
await sleep(3000);
}
alert('모든 에피소드 다운로드가 완료되었습니다.');
} else {
const titleElement = document.querySelector('h1') || document.querySelector('.view-title');
const title = titleElement ? titleElement.innerText.trim() : '제목없음';
console.clear();
console.log(`단일 다운로드 시작: ${title}`);
let imageBlobs = window.capturedImageBlobs || [];
let imageUrls = [];
if (imageBlobs.length === 0) {
imageUrls = extractImagesFromDom(document, protocolDomain);
if (imageUrls.length === 0) {
imageUrls = window.capturedImageUrls || [];
}
}
if (imageBlobs.length === 0 && (!imageUrls || imageUrls.length === 0)) {
alert('이미지를 감지하지 못했습니다. 페이지가 완전히 로드되지 않았을 수 있습니다.');
return;
}
await processAndDownloadZip(title, imageUrls, imageBlobs);
}
} catch (error) {
alert(`오류발생: \n` + error);
console.error(error);
}
}
if (isListPage) {
GM_registerMenuCommand('[일일툰] 전체 에피소드 다운로드', () => tokiDownload());
} else {
GM_registerMenuCommand('[일일툰] 현재 에피소드 다운로드', () => tokiDownload());
}
})();

View File

@@ -0,0 +1,877 @@
// ==UserScript==
// @name ManatokiDownloader
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
// @version 0.0.8
// @description 마나토끼/뉴토끼 전용 다운로더
// @author hehaho
// @match https://*.com/manhwa/*
// @match https://*.com/webtoon/*
// @match https://*.com/comic/*
// @match https://*.net/comic/*
// @match https://*.net/manhwa/*
// @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
// ==/UserScript==
(function () {
'use strict';
const LOG = [];
const log = (...args) => {
const msg = args.map(a => {
if (a instanceof Error) return a.message;
if (typeof a === 'object' && a !== null) {
try { return JSON.stringify(a); } catch(e) { return String(a); }
}
return String(a);
}).join(' ');
LOG.push(`[${new Date().toISOString().slice(11,19)}] ${msg}`);
};
// 파일 다운로드 헬퍼
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);
}
};
const downloadLogFile = () => {
const now = new Date();
const time = String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0') + String(now.getSeconds()).padStart(2,'0');
const content = LOG.join('\n');
if (!content) return;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Download_${time}.log`;
a.click();
URL.revokeObjectURL(url);
};
// 글로벌 이미지 저장소 (네트워크 모니터링용)
window.capturedImageUrls = [];
window.capturedImageBlobs = [];
// fetch 인터셉트 (이미지 Blob 직접 캡처)
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
const isImageUrl = url && (url.includes('.jpg') || url.includes('.jpeg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && isAllowedImageHost(url);
if (isImageUrl && !window.capturedImageUrls.includes(url)) {
window.capturedImageUrls.push(url);
}
const result = originalFetch.apply(this, args);
// Blob 응답 캡처 (Canvas Tainted 우회)
if (isImageUrl && result instanceof Promise) {
result.then(response => {
if (response.ok && (response.headers.get('content-type')?.includes('image') || url.match(/\.(jpg|jpeg|png|webp)$/i))) {
const clonedResponse = response.clone();
clonedResponse.blob().then(blob => {
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
isComicPageBlob(blob).then(valid => {
if (valid) window.capturedImageBlobs.push(blob);
});
}
}).catch(() => {});
}
return response;
}).catch(() => {});
}
return result;
};
// XMLHttpRequest 인터셉트 (arraybuffer 응답 캡처)
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._captureUrl = url;
return originalOpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function(...args) {
const xhr = this;
const originalOnreadystatechange = xhr.onreadystatechange;
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
const url = xhr._captureUrl;
const isImageUrl = url && (url.includes('.jpg') || url.includes('.jpeg') || url.includes('.png') || url.includes('.webp') || url.includes('/image') || url.includes('/photo')) &&
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && isAllowedImageHost(url);
if (isImageUrl && xhr.response instanceof ArrayBuffer) {
try {
const blob = new Blob([xhr.response], { type: xhr.getResponseHeader('content-type') || 'image/jpeg' });
if (!window.capturedImageBlobs.some(b => b.size === blob.size)) {
isComicPageBlob(blob).then(valid => {
if (valid) window.capturedImageBlobs.push(blob);
});
}
} catch(e) {}
}
}
if (originalOnreadystatechange) {
return originalOnreadystatechange.apply(this, arguments);
}
};
return originalSend.apply(this, args);
};
// 알려진 웹툰 이미지 CDN 도메인 (화이트리스트)
const ALLOWED_IMAGE_DOMAINS = ['i.toonflix.app', 'img.ntk01.com', '11toon8.com', 'sbxh1.com', 'sbxh2.com', 'sbxh3.com'];
const isAllowedImageHost = (url) => {
try {
const absoluteUrl = url.startsWith('http://') || url.startsWith('https://')
? url
: new URL(url, protocolDomain || document.URL).href;
const hostname = new URL(absoluteUrl).hostname;
if (protocolDomain) {
const pageHost = new URL(protocolDomain).hostname;
if (hostname === pageHost || hostname.endsWith('.' + pageHost)) return true;
}
return ALLOWED_IMAGE_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
} catch(e) {
return false;
}
};
// 이미지 해상도 기반 검증 (만화 페이지는 세로>가로, 최소 500px)
const isComicPageBlob = (blob) => {
return createImageBitmap(blob).then(bitmap => {
const valid = bitmap.height > bitmap.width && bitmap.height > 500;
bitmap.close();
return valid;
}).catch(() => true);
};
// 이미지 로딩을 기다리고 lazy load 트리거
const waitForImagesToLoad = (targetDocument, timeoutMs = 20000) => {
return new Promise(resolve => {
const container = targetDocument.querySelector('#bo_v_con, .view-content, #toon_img, .board-view, .vw-imgs') || targetDocument.body;
const baseUrl = targetDocument.URL;
const allImages = container.querySelectorAll('img');
allImages.forEach(img => {
if (img.getAttribute('loading') === 'lazy') {
img.setAttribute('loading', 'eager');
}
['data-src', 'data-original', 'data-lazy-src', 'data-lazy'].forEach(attr => {
const val = img.getAttribute(attr);
if (val && !img.src) {
try {
img.src = new URL(val, baseUrl).href;
} catch {}
}
});
});
// 로딩되지 않은 이미지 수집
const loading = Array.from(allImages).filter(img => !img.complete || img.naturalWidth === 0);
if (loading.length === 0) { resolve(); return; }
let remaining = loading.length;
const timeout = setTimeout(resolve, timeoutMs);
loading.forEach(img => {
const done = () => {
remaining--;
if (remaining <= 0) { clearTimeout(timeout); resolve(); }
};
if (img.complete && img.naturalWidth > 0) done();
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
});
};
let site = '';
let protocolDomain = '';
let isListPage = false;
// 현재 url 체크
const currentURL = document.URL;
if (currentURL.includes('/manhwa/') || currentURL.includes('/webtoon/') || currentURL.includes('/comic/')) {
const pathParts = new URL(currentURL).pathname.split('/').filter(p => p);
if (pathParts.length <= 2) {
site = "마나토끼_목록";
isListPage = true;
} else {
site = "마나토끼";
isListPage = false;
}
protocolDomain = currentURL.match(/^https?:\/\/[^:/?]+/)[0];
} else {
return;
}
// 메타데이터 추출 함수 (목록 페이지용)
const extractListMetadata = () => {
let title = '';
let writer = '';
let summary = '';
let tags = [];
// Title
const titleSpan = document.querySelector('.view-content b');
if (titleSpan) {
title = titleSpan.innerText.trim();
} else {
const h1 = document.querySelector('h1');
if (h1) title = h1.innerText.split('>')[0].trim();
}
// Writer, Tags, Summary
const viewContents = document.querySelectorAll('.view-content');
viewContents.forEach(el => {
const text = el.innerText;
if (text.includes('작가')) {
const a = el.querySelector('a');
if (a) writer = a.innerText.trim();
} else if (text.includes('분류')) {
const tagLinks = el.querySelectorAll('a');
tagLinks.forEach(a => tags.push(a.innerText.trim()));
} else if (text.includes('줄거리')) {
summary = text.replace(/.*줄거리\s*:/, '').trim();
}
});
return { title, writer, summary, tags };
};
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const generateComicInfo = (title, series, writer, number, pageCount, imageCount = 0, summary = '', tags = []) => {
let pagesXml = '';
if (imageCount > 0) {
for (let i = 0; i < imageCount; i++) {
pagesXml += ` <Page Image="${i}" Type="Story"/>\n`;
}
}
let genreStr = tags.length > 0 ? tags.join(', ') : '';
return `<?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}</Summary>
<Notes></Notes>
<Year>-1</Year>
<Month>-1</Month>
<Writer>${writer}</Writer>
<PageCount>${pageCount}</PageCount>
<Genre>${genreStr}</Genre>
<LanguageISO>ko</LanguageISO>
<Manga>Yes</Manga>
${pagesXml ? ` <Pages>\n${pagesXml} </Pages>\n` : ''}</ComicInfo>`;
};
// DOM에서 이미지 URL 추출 헬퍼 함수
const extractImagesFromDom = (targetDocument, protocolDomain) => {
let imageUrls = [];
// 1. img 태그 탐색
const contentDiv = targetDocument.querySelector('#bo_v_con') || targetDocument.querySelector('.view-content') || targetDocument.querySelector('#toon_img') || targetDocument.querySelector('.board-view');
let imgElements = Array.from(targetDocument.querySelectorAll('#bo_v_con img, .view-content img, .view-wrap img, .toon-img img, .item-image img, .board-view img, #toon_img img, .view-padding img'));
if (contentDiv && imgElements.length === 0) {
imgElements = Array.from(contentDiv.querySelectorAll('img'));
}
// 마나토끼 등 lazyload 이미지 처리
imgElements.forEach(img => {
if (img.dataset.src) img.src = img.dataset.src;
if (img.dataset.original) img.src = img.dataset.original;
if (img.getAttribute('data-original')) img.src = img.getAttribute('data-original');
});
imgElements.forEach(img => {
let src = img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
for (let attr of img.attributes) {
if (attr.name.startsWith('data-') && (attr.value.includes('http') || attr.value.includes('/data/'))) {
src = attr.value;
}
}
if (src && !src.includes('gif') && !src.includes('logo') && !src.includes('banner')) {
if (src.startsWith('//')) src = 'https:' + src;
else if (src.startsWith('/')) src = protocolDomain + src;
imageUrls.push(src);
}
});
// 2. css background-image 탐색
const divElements = Array.from(targetDocument.querySelectorAll('#bo_v_con div, .view-content div, .toon-img div'));
divElements.forEach(div => {
const bg = div.style.backgroundImage || window.getComputedStyle(div).backgroundImage;
if (bg && bg !== 'none') {
const match = bg.match(/url\(["']?(.*?)["']?\)/);
if (match && match[1] && !match[1].includes('gif')) {
let src = match[1];
if (src.startsWith('//')) src = 'https:' + src;
else if (src.startsWith('/')) src = protocolDomain + src;
imageUrls.push(src);
}
}
});
// 3. Canvas 탐색
if (imageUrls.length === 0) {
const canvasElements = Array.from(targetDocument.querySelectorAll('canvas'));
canvasElements.forEach(canvas => {
try {
const dataUrl = canvas.toDataURL('image/jpeg');
if (dataUrl && dataUrl.length > 1000) {
imageUrls.push(dataUrl);
}
} catch(e) {}
});
}
// 4. HTML 원문 정규식 탐색 (Fallback)
if (imageUrls.length === 0) {
const html = contentDiv ? contentDiv.innerHTML : targetDocument.documentElement.innerHTML;
const matches1 = html.match(/https?:\/\/[^"'\s>]*\.(jpg|jpeg|png|webp)[^"'\s>]*/gi) || [];
const matches2 = html.match(/(?:\/data\/file\/[a-zA-Z0-9_/-]+|img[0-9]*\.[a-zA-Z0-9-]+\.[a-zA-Z]+\/data\/file\/[a-zA-Z0-9_/-]+)\.(jpg|jpeg|png|webp)/gi) || [];
const matches3 = html.match(/\/toki[^"'\s>]+\.(jpg|jpeg|png|webp)/gi) || [];
const matches4 = html.match(/(?:data-src|data-original)=["']([^"']+)["']/gi) || [];
let allMatches = [...matches1, ...matches2, ...matches3];
matches4.forEach(m => {
const urlMatch = m.match(/=["']([^"']+)["']/);
if (urlMatch && urlMatch[1] && !urlMatch[1].includes('gif') && !urlMatch[1].includes('logo') && !urlMatch[1].includes('banner')) {
allMatches.push(urlMatch[1]);
}
});
if (allMatches.length > 0) {
imageUrls = imageUrls.concat(allMatches.map(url => {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('/')) return protocolDomain + url;
if (!url.startsWith('http')) return protocolDomain + '/' + url;
return url;
}));
}
}
// 중복 제거 및 최종 필터링
imageUrls = [...new Set(imageUrls)].filter(url => {
if (url.startsWith('data:image')) return true;
return !url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('no_img') && !url.includes('profile') && !url.includes('blank') && url.length > 25 && isAllowedImageHost(url);
});
return imageUrls;
};
// 이미지 다운로드 및 압축 헬퍼 함수
const processAndDownloadZip = async (title, imageUrls, imageBlobs, metadata = null, index = '1') => {
const zip = new JSZip();
// 1. Blob 사용 시 (가장 우선)
if (imageBlobs && imageBlobs.length > 0) {
log(`[다운로드] Blob 이미지 ${imageBlobs.length}개 사용`);
imageBlobs.forEach((blob, i) => {
const filename = `image${String(i).padStart(4, '0')}.jpg`;
zip.file(filename, blob);
});
const series = metadata && metadata.title ? metadata.title : title;
const writer = metadata && metadata.writer ? metadata.writer : '';
const summary = metadata && metadata.summary ? metadata.summary : '';
const tags = metadata && metadata.tags ? metadata.tags : [];
const comicInfoContent = generateComicInfo(title, series, writer, index, imageBlobs.length, imageBlobs.length, summary, tags);
zip.file('ComicInfo.xml', comicInfoContent);
let safeTitle = title.replace(/[\\/:*?"<>|\n\r]/g, '').trim();
// e.g., '마돈나는 유리 케이스 안에 1화' -> '마돈나는 유리 케이스 안에_1화'
if (safeTitle.match(/\s+[\d\.]+(화|회|권)$/)) {
safeTitle = safeTitle.replace(/\s+([\d\.]+(화|회|권))$/, '_$1');
}
const content = await zip.generateAsync({ type: "blob" });
downloadZip(content, `${safeTitle}.zip`);
downloadLogFile();
log(`[완료] ${safeTitle} 다운로드 완료`);
return;
}
// 2. URL 사용 시
if (!imageUrls || imageUrls.length === 0) {
log(`[스킵] ${title} - 이미지를 찾을 수 없습니다.`);
return;
}
log(`[다운로드] URL 이미지 ${imageUrls.length}개 사용`);
const downloadImageTask = (url, index) => {
return new Promise((resolve) => {
const filename = `image${String(index).padStart(4, '0')}.jpg`;
if (url.startsWith('data:image')) {
try {
const base64Data = url.split(',')[1];
zip.file(filename, base64Data, {base64: true});
} catch(e) {}
resolve();
return;
}
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 headers = response.responseHeaders || '';
const contentType = (headers.match(/content-type:\s*([^\r\n;]+)/i) || [])[1] || '';
if (contentType && !contentType.startsWith('image/')) {
log('[스킵] 이미지가 아닌 응답:', url, contentType);
resolve();
return;
}
const blob = new Blob([response.response], { type: 'image/jpeg' });
isComicPageBlob(blob).then(valid => {
if (valid) {
zip.file(filename, blob);
} else {
log('[스킵] 광고 배너로 의심됨:', url);
}
resolve();
});
} else {
resolve();
}
},
onerror: () => resolve(),
ontimeout: () => resolve()
});
});
};
// 5개씩 동시 다운로드
let promiseList = [];
for (let j = 0; j < imageUrls.length; j++) {
promiseList.push(downloadImageTask(imageUrls[j], j));
if (promiseList.length >= 5) {
await Promise.all(promiseList);
promiseList = [];
await sleep(500);
}
}
if (promiseList.length > 0) {
await Promise.all(promiseList);
}
const series = metadata && metadata.title ? metadata.title : title;
const writer = metadata && metadata.writer ? metadata.writer : '';
const summary = metadata && metadata.summary ? metadata.summary : '';
const tags = metadata && metadata.tags ? metadata.tags : [];
const comicInfoContent = generateComicInfo(title, series, writer, index, imageUrls.length, imageUrls.length, summary, tags);
zip.file('ComicInfo.xml', comicInfoContent);
// title format is expected to be something like '제목 1화' or '제목_1화'
// If it's already '마돈나는 유리 케이스 안에 1화', using it directly is fine.
// We'll replace spaces before the episode number with an underscore if the user strictly wants '제목_1화.zip'
let safeTitle = title.replace(/[\\/:*?"<>|\n\r]/g, '').trim();
// e.g., '마돈나는 유리 케이스 안에 1화' -> '마돈나는 유리 케이스 안에_1화'
if (safeTitle.match(/\s+[\d\.]+(화|회|권)$/)) {
safeTitle = safeTitle.replace(/\s+([\d\.]+(화|회|권))$/, '_$1');
}
const content = await zip.generateAsync({ type: "blob" });
downloadZip(content, `${safeTitle}.zip`);
downloadLogFile();
log(`[완료] ${safeTitle} 다운로드 완료`);
};
// 에피소드 하나 다운로드 처리 (iframe + HTTP fallback)
async function downloadEpisode(episodeTitle, url, metadata = null, index = '1') {
try {
log(`[진행 중] ${episodeTitle}`);
let imageUrls = [];
// 1. iframe 방식 시도
try {
imageUrls = await downloadViaIframe(episodeTitle, url);
if (imageUrls.length > 0) log(`✓ iframe 방식 성공: ${imageUrls.length}`);
} catch (err) {
log(`[iframe 오류] ${episodeTitle}:`, err.message);
}
// 2. HTTP fetch fallback
if (imageUrls.length === 0) {
log(`[HTTP fallback] ${episodeTitle}`);
try {
imageUrls = await fetchHtmlAndExtract(url);
if (imageUrls.length > 0) log(`✓ HTTP fallback 성공: ${imageUrls.length}`);
} catch (err) {
log(`[HTTP fallback 오류] ${episodeTitle}:`, err.message);
}
}
if (imageUrls.length === 0) {
log(`[스킵] ${episodeTitle} - 이미지를 찾을 수 없습니다.`);
return;
}
await processAndDownloadZip(episodeTitle, imageUrls, [], metadata, index);
} catch (err) {
log(`[오류] ${episodeTitle}:`, err);
}
}
async function downloadViaIframe(episodeTitle, url) {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.cssText = 'width:100%;height:90vh;border:2px solid red;border-radius:8px;margin:10px 0';
document.body.appendChild(iframe);
let resolved = false;
const cleanup = () => {
try { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); } catch (_) {}
clearTimeout(timeout);
};
const done = (result) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(result);
};
const timeout = setTimeout(() => {
if (!resolved) { log(`[iframe 타임아웃] ${episodeTitle}`); done([]); }
}, 35000);
iframe.addEventListener('load', () => {
setTimeout(async () => {
try {
const iframeWin = iframe.contentWindow;
const iframeDoc = iframeWin.document;
// fetch 인터셉터 주입 (lazy load 이미지 캡처용)
try {
const script = iframeDoc.createElement('script');
script.textContent = `
window.__capturedUrls = [];
window.__capturedBlobs = [];
const ___origFetch = window.fetch;
window.fetch = function(...args) {
const u = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (u && (u.includes('.jpg') || u.includes('.jpeg') || u.includes('.png') || u.includes('.webp')) && !u.includes('logo') && !u.includes('banner')) {
if (!window.__capturedUrls.includes(u)) window.__capturedUrls.push(u);
}
return ___origFetch.apply(this, args);
};
`;
iframeDoc.body.appendChild(script);
} catch (e) {}
// 스크롤로 lazy load 트리거
let lastScroll = -1;
let scrollAttempts = 0;
while (scrollAttempts < 50) {
iframeWin.scrollBy(0, window.innerHeight || 800);
await sleep(300);
const curScroll = iframeWin.scrollY || 0;
const maxScroll = Math.max(
iframeDoc.documentElement.scrollHeight - iframeWin.innerHeight,
iframeDoc.body.scrollHeight - iframeWin.innerHeight,
0
);
if (curScroll >= maxScroll || curScroll === lastScroll) break;
lastScroll = curScroll;
scrollAttempts++;
}
await waitForImagesToLoad(iframeDoc);
let urls = extractImagesFromDom(iframeDoc, protocolDomain);
if (urls.length === 0 && iframeWin.__capturedUrls && iframeWin.__capturedUrls.length > 0) {
urls = [...iframeWin.__capturedUrls].filter(u => isAllowedImageHost(u));
}
done([...new Set(urls)]);
} catch (err) {
log(`[iframe 처리 오류] ${episodeTitle}:`, err.message);
done([]);
}
}, 2000);
});
iframe.addEventListener('error', () => {
log(`[iframe 로드 오류] ${episodeTitle}`);
done([]);
});
iframe.src = url;
});
}
async function fetchHtmlAndExtract(url) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
const response = await fetch(url, {
credentials: 'include',
signal: controller.signal,
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Referer': protocolDomain,
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7'
}
});
clearTimeout(timeoutId);
const html = await response.text();
if (!html || html.length < 500) return [];
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return extractImagesFromDom(doc, protocolDomain);
}
async function tokiDownload() {
try {
if (isListPage) {
log('=== 목록 페이지 처리 시작 ===');
const metadata = extractListMetadata();
log('추출된 메타데이터:', metadata);
let episodeList = document.querySelector('.serial-list') || document.querySelector('ul.episode-list') || document.querySelector('.list-body') || document.querySelector('.board-list') || document.querySelector('.list-container') || document.querySelector('#bo_list') || document.querySelector('.comic-serial-list') || document.querySelector('.toon-list') || document.querySelector('.episode-wrap');
// Manatoki fallback: If list container is not found, try to find any links that look like episodes
let items = [];
if (episodeList) {
items = Array.from(episodeList.querySelectorAll('li, tr, .list-item, .item, .list-row'));
} else {
// Fallback to all episode-like links on the page if no container is found
const fallbackLinks = Array.from(document.querySelectorAll('a.item-subject, a[href*="/comic/"], a[href*="/webtoon/"], a[href*="/manhwa/"]'));
if (fallbackLinks.length > 0) {
const filteredLinks = fallbackLinks.filter(link => {
const text = (link.innerText || link.textContent || '').replace(/\s+/g, '');
return !text.includes('업데이트') && !text.includes('최신화') && !text.includes('첫화') && !text.includes('첫회') && text.length > 0;
});
items = filteredLinks.map(link => link.closest('li, tr, div') || link);
if (items.length > 0) {
episodeList = true; // Pretend we found the list
}
}
}
if (!episodeList || items.length === 0) {
alert('에피소드 목록을 찾을 수 없습니다.');
return;
}
const episodes = [];
items.forEach((item) => {
const button = item.querySelector ? item.querySelector('button[onclick*="location.href"]') : null;
const link = (item.tagName && item.tagName.toLowerCase() === 'a') ? item : (item.querySelector ? (item.querySelector('a[href*="wr_id="]') || item.querySelector('a.item-subject') || item.querySelector('a[href*="/comic/"]') || item.querySelector('a[href*="/webtoon/"]') || item.querySelector('a[href*="/manhwa/"]')) : null);
if (button) {
const onclick = button.getAttribute('onclick') || '';
const match = onclick.match(/location\.href=['"]([^'"]+)['"]/);
if (match && match[1]) {
const absoluteUrl = new URL(match[1], window.location.href).href;
const episodeTitle = item.innerText.split('\n')[0].trim();
if (episodeTitle && absoluteUrl) {
episodes.push({ title: episodeTitle, url: absoluteUrl });
}
}
} else if (link && link.href) {
let episodeTitle = '';
try {
let clone = link.cloneNode(true);
if (clone.querySelectorAll) {
clone.querySelectorAll('span, img, i, b, strong').forEach(s => s.remove());
}
episodeTitle = (clone.innerText || clone.textContent || '').trim();
} catch (e) {}
if (!episodeTitle) episodeTitle = (link.innerText || link.textContent || '').trim();
// Clean up stray comment counts or numbers attached to title for Manatoki
episodeTitle = episodeTitle.replace(/^[0-9]+\s*/, '').replace(/\s*[0-9]+$/, '').trim();
if (!episodeTitle || episodeTitle === '') {
episodeTitle = (item.innerText || item.textContent || '').trim().split('\n')[0].trim() || '제목없음';
episodeTitle = episodeTitle.replace(/^[0-9]+\s*/, '').replace(/\s*[0-9]+$/, '').trim();
}
if (episodeTitle && link.href) {
episodes.push({ title: episodeTitle, url: link.href });
}
}
});
const uniqueEpisodes = [];
const urlSet = new Set();
episodes.forEach(ep => {
const wrIdMatch = ep.url.match(/wr_id=([0-9]+)/);
// Match /comic/12345 or /manhwa/11017/111796
const manatokiMatch = ep.url.match(/\/(?:comic|webtoon|manhwa)\/(?:[0-9]+\/)?([0-9]+)/);
const uniqueKey = wrIdMatch ? wrIdMatch[1] : (manatokiMatch ? manatokiMatch[1] : ep.url);
if (!urlSet.has(uniqueKey)) {
urlSet.add(uniqueKey);
uniqueEpisodes.push(ep);
}
});
uniqueEpisodes.reverse();
if (uniqueEpisodes.length === 0) {
alert('에피소드 링크를 추출할 수 없습니다. (구조가 변경되었을 수 있습니다.)');
return;
}
for (let i = 0; i < uniqueEpisodes.length; i++) {
// 회차 번호: 오래된 것(i=0)부터 1부터 시작 (reverse를 한 이후이므로)
const episodeIndex = String(i + 1);
await downloadEpisode(uniqueEpisodes[i].title, uniqueEpisodes[i].url, metadata, episodeIndex);
await sleep(3000);
}
alert('모든 에피소드 다운로드가 완료되었습니다.');
} else {
const titleElement = document.querySelector('h1') || document.querySelector('.view-title') || document.querySelector('title');
let title = titleElement ? titleElement.innerText.trim() : '제목없음';
// Manatoki: "아사코 30화 > 마나토끼..." -> "아사코 30화"
if (title.includes('>')) {
title = title.split('>')[0].trim();
}
log(`=== 단일 다운로드 시작: ${title} ===`);
// 단일 페이지에서는 이전 화 버튼 등을 통해 전체 구조를 알기 힘드므로
// 현재 페이지에서 알 수 있는 메타데이터만 구성합니다.
let seriesName = title;
if (title.includes('화')) {
seriesName = title.substring(0, title.lastIndexOf(' '));
}
const metadata = { title: seriesName, writer: '', summary: '', tags: [] };
// 제목에서 숫자를 추출해 회차로 설정해봅니다.
let episodeIndex = '1';
const numMatch = title.match(/(\d+)(화|회)/);
if (numMatch) episodeIndex = numMatch[1];
// lazy load 트리거: 페이지를 스크롤하고 이미지 로딩 대기
let lastScroll = -1;
let scrollAttempts = 0;
while (scrollAttempts < 20) {
window.scrollBy(0, window.innerHeight || 800);
await sleep(300);
if (window.scrollY === lastScroll) break;
lastScroll = window.scrollY;
scrollAttempts++;
}
await waitForImagesToLoad(document);
// 스크롤 원복
window.scrollTo(0, 0);
let imageBlobs = window.capturedImageBlobs || [];
let imageUrls = [];
if (imageBlobs.length === 0) {
imageUrls = extractImagesFromDom(document, protocolDomain);
if (imageUrls.length === 0) {
imageUrls = window.capturedImageUrls || [];
}
}
if (imageBlobs.length === 0 && (!imageUrls || imageUrls.length === 0)) {
// One last fallback to just grab ALL images in the document if view-content fails (for weird DOM structures)
if (imageUrls.length === 0) {
const fallbackImgs = Array.from(document.querySelectorAll('img[src], img[data-src], img[data-original]'));
fallbackImgs.forEach(img => {
let src = img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
if (src && !src.includes('gif') && !src.includes('logo') && !src.includes('banner') && isAllowedImageHost(src)) {
if (src.startsWith('//')) src = 'https:' + src;
else if (src.startsWith('/')) src = protocolDomain + src;
imageUrls.push(src);
}
});
imageUrls = [...new Set(imageUrls)].filter(url => url.length > 25);
}
if (imageUrls.length === 0) {
log('❌ 이미지를 감지하지 못했습니다.');
log(`capturedImageUrls: ${(window.capturedImageUrls || []).length}`);
log(`capturedImageBlobs: ${(window.capturedImageBlobs || []).length}`);
try { downloadLogFile(); } catch (_) {}
alert(`❌ 이미지를 감지하지 못했습니다.\n🔍 저장된 로그 파일을 확인하세요.`);
return;
}
}
await processAndDownloadZip(title, imageUrls, imageBlobs, metadata, episodeIndex);
}
} catch (error) {
alert(`오류발생: \n` + error);
log(error);
}
}
const safeDownload = async () => {
try {
await tokiDownload();
} catch (e) {
log('❌ 치명적 오류:', e);
try { downloadLogFile(); } catch (_) {}
}
};
if (isListPage) {
GM_registerMenuCommand('[마나토끼] 전체 에피소드 다운로드', safeDownload);
} else {
GM_registerMenuCommand('[마나토끼] 현재 에피소드 다운로드', safeDownload);
}
GM_registerMenuCommand('[마나토끼] 로그 저장', () => downloadLogFile());
})();

View File

@@ -0,0 +1,655 @@
// ==UserScript==
// @name ntk01 Downloader
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
// @version 0.0.1
// @description ntk01.com 웹툰/만화 다운로더
// @author hehaho
// @match https://ntk01.com/*
// @match https://www.ntk01.com/*
// @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
// ==/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);
}
};
// 글로벌 이미지 저장소 (네트워크 모니터링용)
window.capturedImageUrls = [];
window.capturedImageBlobs = [];
// fetch 인터셉트 (이미지 Blob 직접 캡처)
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('.jpeg')) &&
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('thumb');
if (isImageUrl && !window.capturedImageUrls.includes(url)) {
window.capturedImageUrls.push(url);
}
const result = originalFetch.apply(this, args);
// Blob 응답 캡처 (Canvas Tainted 우회)
if (isImageUrl && result instanceof Promise) {
result.then(response => {
if (response.ok && (response.headers.get('content-type')?.includes('image') || url.match(/\.(jpg|jpeg|png|webp)$/i))) {
const clonedResponse = response.clone();
clonedResponse.blob().then(blob => {
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
window.capturedImageBlobs.push(blob);
}
}).catch(() => {});
}
return response;
}).catch(() => {});
}
return result;
};
// XMLHttpRequest 인터셉트 (arraybuffer 응답 캡처)
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._captureUrl = url;
return originalOpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function(...args) {
const xhr = this;
const originalOnreadystatechange = xhr.onreadystatechange;
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
const url = xhr._captureUrl;
const isImageUrl = url && (url.includes('.jpg') || url.includes('.png') || url.includes('.webp') || url.includes('.jpeg')) &&
!url.includes('logo') && !url.includes('banner') && !url.includes('icon') && !url.includes('thumb');
if (isImageUrl && xhr.responseType === 'arraybuffer') {
try {
const blob = new Blob([xhr.response], { type: 'image/jpeg' });
if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) {
window.capturedImageBlobs.push(blob);
}
} catch (e) {}
}
}
if (originalOnreadystatechange) {
return originalOnreadystatechange.apply(this, arguments);
}
};
return originalSend.apply(this, args);
};
// 현재 URL 체크
const currentURL = document.URL;
// ntk01이 아니면 스크립트 실행 안 함
if (!currentURL.includes('ntk01.com')) {
return;
}
// 페이지 타입 감지
const pagePathParts = currentURL.split('/').filter(p => p);
// URL을 더 정확히 분석 (https://ntk01.com/manhwa/11017/111796 형식)
const pathMatch = currentURL.match(/\/manhwa\/(\d+)(?:\/(\d+))?/);
const isEpisodePage = pathMatch && pathMatch[2] ? true : false; // 에피소드 번호가 있으면 에피소드 페이지
const isListPage = pathMatch && !pathMatch[2] ? true : false; // 에피소드 번호가 없으면 목록 페이지
// 도메인 추출
const getProtocolDomain = () => {
return currentURL.match(/^https?:\/\/[^:/?]+/)[0];
};
// 상대 URL을 절대 URL로 변환
const resolveUrl = (url) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
const protocolDomain = getProtocolDomain();
if (url.startsWith('/')) {
return protocolDomain + url;
}
// 상대주소 처리
const currentPageUrl = currentURL.split('?')[0];
const currentPath = currentPageUrl.substring(protocolDomain.length);
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/') + 1);
return protocolDomain + currentDir + url;
};
// 이미지 수집 함수 (에피소드 읽기 페이지)
const collectImagesFromEpisodePage = () => {
// ntk01.com 에피소드 페이지의 이미지 구조에 맞춘 선택자
const imgElements = document.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img');
imgElements.forEach(img => {
const src = img.src || img.dataset.src || img.getAttribute('src');
if (src) {
const absoluteUrl = resolveUrl(src);
if (absoluteUrl && !window.capturedImageUrls.includes(absoluteUrl)) {
window.capturedImageUrls.push(absoluteUrl);
}
}
});
};
// 에피소드 페이지에서 이미지 추출
const extractImagesFromEpisodeHtml = (html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const images = [];
const imgElements = doc.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img');
imgElements.forEach(img => {
const src = img.src || img.dataset.src || img.getAttribute('src');
if (src) {
const absoluteUrl = resolveUrl(src);
if (absoluteUrl && !images.includes(absoluteUrl)) {
images.push(absoluteUrl);
}
}
});
return images;
};
// 이미지 수집 함수 (목록 페이지)
const collectImagesFromListPage = () => {
// 목록 페이지의 썸네일/장편
const imgElements = document.querySelectorAll('.list-item img, .episode img, .thumbnail img, [class*="thumb"] img');
imgElements.forEach(img => {
const src = img.src || img.dataset.src || img.getAttribute('src');
if (src && !src.includes('logo') && !src.includes('banner') && !src.includes('icon')) {
const absoluteUrl = resolveUrl(src);
if (absoluteUrl && !window.capturedImageUrls.includes(absoluteUrl)) {
window.capturedImageUrls.push(absoluteUrl);
}
}
});
};
// 이미지 수집 함수 (선택)
const collectImagesFromDOM = () => {
if (isEpisodePage) {
collectImagesFromEpisodePage();
} else if (isListPage) {
collectImagesFromListPage();
}
};
// comicinfo.xml 생성 함수
const generateComicInfoXml = (title, seriesTitle = '', episodeNumber = '', writer = '', imageCount) => {
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const comicInfo = `<?xml version="1.0" encoding="utf-8"?>
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Title>${escapeXml(title)}</Title>
<Series>${escapeXml(seriesTitle)}</Series>
<Number>${escapeXml(episodeNumber)}</Number>
<Count></Count>
<Volume></Volume>
<AlternateSeries></AlternateSeries>
<AlternateNumber></AlternateNumber>
<AlternateCount></AlternateCount>
<Summary></Summary>
<Notes></Notes>
<Year></Year>
<Month></Month>
<Day></Day>
<Writer>${escapeXml(writer)}</Writer>
<Penciller></Penciller>
<Inker></Inker>
<Colorist></Colorist>
<Letterer></Letterer>
<CoverArtist></CoverArtist>
<Publisher></Publisher>
<Imprint></Imprint>
<Genre></Genre>
<Tags></Tags>
<Web></Web>
<PageCount>${imageCount}</PageCount>
<LanguageISO>ko</LanguageISO>
<Format>Manga</Format>
<BlackAndWhite>No</BlackAndWhite>
<Manga></Manga>
<Characters></Characters>
<Teams></Teams>
<Locations></Locations>
<ScanInformation></ScanInformation>
<StoryArc></StoryArc>
<SeriesGroup></SeriesGroup>
<AgeRating>Unknown</AgeRating>
<CommunityRating></CommunityRating>
<MainCharacterOrTeam></MainCharacterOrTeam>
<Review></Review>
</ComicInfo>`;
return comicInfo;
};
// XML 특수문자 이스케이프 함수
const escapeXml = (str) => {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
// 현재 페이지에서 시리즈 제목 추출
const getSeriesTitle = () => {
const workEl = document.querySelector('.vw-work');
return workEl ? workEl.textContent.trim() : '';
};
// 현재 페이지에서 에피소드 제목 추출
const getEpisodeTitle = () => {
const epEl = document.querySelector('.vw-ep');
if (!epEl) return '';
const span = epEl.querySelector('span');
const episodeTitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
return episodeTitle;
};
const GetEpisodeNumberFromTitle = (title) => {
const epEl = document.querySelector('.vw-ep');
if (!epEl) return '';
const strong = epEl.querySelector('strong');
const episodeNum = strong ? strong.textContent.trim() : '';
return episodeNum;
};
// HTML에서 시리즈 제목 추출
const getSeriesTitleFromHtml = (html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const workEl = doc.querySelector('.vw-work');
return workEl ? workEl.textContent.trim() : '';
};
// HTML에서 에피소드 제목 추출
const getEpisodeTitleFromHtml = (html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const epEl = doc.querySelector('.vw-ep');
if (!epEl) return '';
const span = epEl.querySelector('span');
const episodeTitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
return episodeTitle;
};
// HTML에서 에피소드 번호 추출
const getEpisodeNumFromHtml = (html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const epEl = doc.querySelector('.vw-ep');
if (!epEl) return '';
const strong = epEl.querySelector('strong');
const episodeNum = strong ? strong.textContent.trim() : '';
return episodeNum;
};
// HTML에서 작가 정보 추출
const getWriterFromHtml = (html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 방법 1: .vw-info 또는 유사 정보 영역에서 찾기
let writerEl = doc.querySelector('[class*="info"] span:contains("작가")') ||
doc.querySelector('.vw-info');
// 방법 2: "작가"라는 텍스트 다음에 오는 요소 찾기
if (!writerEl) {
const allSpans = doc.querySelectorAll('span, div, p');
for (let el of allSpans) {
if (el.textContent && el.textContent.includes('작가')) {
const match = el.textContent.match(/작가[\s:]+([^\n,]+)/);
if (match && match[1]) {
return match[1].trim();
}
}
}
}
// 방법 3: data-author 속성 확인
const authorAttr = doc.querySelector('[data-author]');
if (authorAttr) {
return authorAttr.getAttribute('data-author').trim();
}
// 방법 4: 작가 메타데이터 태그 확인
const metaAuthor = doc.querySelector('meta[name="author"]');
if (metaAuthor) {
return metaAuthor.getAttribute('content').trim();
}
return '';
};
// 페이지 로드 완료 후 이미지 수집
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', collectImagesFromDOM);
} else {
collectImagesFromDOM();
}
// MutationObserver를 통한 동적 이미지 감지
const observer = new MutationObserver(() => {
collectImagesFromDOM();
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
// Canvas를 사용해 DOM의 img 태그에서 직접 blob 추출 (가장 안정적)
const extractImageBlobFromImg = (imgElement) => {
return new Promise((resolve, reject) => {
try {
// 이미지가 실제로 로드되었는지 확인
if (!imgElement.complete || imgElement.naturalWidth === 0) {
// 아직 로드 중이면 onload 대기
const tempImg = new Image();
tempImg.crossOrigin = 'anonymous';
tempImg.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = tempImg.naturalWidth;
canvas.height = tempImg.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(tempImg, 0, 0);
canvas.toBlob(blob => {
if (blob && blob.size > 0) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed'));
}
}, 'image/jpeg', 0.95);
} catch (e) {
reject(e);
}
};
tempImg.onerror = () => reject(new Error('Image load failed'));
tempImg.src = imgElement.src || imgElement.dataset.src;
} else {
// 이미 로드됨
const canvas = document.createElement('canvas');
canvas.width = imgElement.naturalWidth || imgElement.width;
canvas.height = imgElement.naturalHeight || imgElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgElement, 0, 0);
canvas.toBlob(blob => {
if (blob && blob.size > 0) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed'));
}
}, 'image/jpeg', 0.95);
}
} catch (error) {
reject(error);
}
});
};
// GM_xmlhttpRequest를 이용한 이미지 다운로드 (Canvas 실패 시 대체)
const downloadImageViaGM = (url) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: (response) => {
if (response.status === 200 && response.response) {
resolve(response.response);
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: (error) => {
reject(new Error(`Network error: ${error}`));
}
});
});
};
// 다운로드 함수 (개별 에피소드)
const downloadImages = async () => {
// DOM에서 직접 img 태그 수집 (가장 확실한 방법)
const imgElements = document.querySelectorAll('.vw-imgs img, .comic-viewer img, [class*="viewer"] img, .content img, .page img');
if (imgElements.length === 0) {
alert('다운로드할 이미지를 찾을 수 없습니다.');
return;
}
// 제목 추출
let title = getEpisodeTitle() || document.title.split('|')[0].trim() || 'ntk01_download';
title = title.replace(/[/\\:*?"<>|]/g, '_');
console.log(`발견된 이미지: ${imgElements.length}`);
const zip = new JSZip();
let imageCount = 0;
let failedCount = 0;
// 각 img 태그에서 Canvas로 blob 추출
for (let i = 0; i < imgElements.length; i++) {
const imgElement = imgElements[i];
try {
const blob = await extractImageBlobFromImg(imgElement);
if (blob && blob.size > 0) {
const filename = `${String(imageCount).padStart(4, '0')}_page_${i + 1}.jpg`;
zip.file(filename, blob);
imageCount++;
}
} catch (error) {
console.warn(`이미지 추출 실패 (${i + 1}번째):`, error);
failedCount++;
}
}
// ZIP 생성 및 다운로드
if (imageCount > 0) {
const seriesTitle = getSeriesTitle();
const writer = getWriterFromHtml(document.documentElement.outerHTML);
const comicInfoXml = generateComicInfoXml(title, imageCount, seriesTitle, '', writer);
zip.file('ComicInfo.xml', comicInfoXml);
const blob = await zip.generateAsync({ type: 'blob' });
downloadZip(blob, `${title}.zip`);
alert(`${imageCount}개 이미지 + ComicInfo.xml 다운로드 완료\n(실패: ${failedCount}개)`);
} else {
alert(`❌ 다운로드 가능한 이미지 없음 (실패: ${failedCount}개)`);
}
};
// 모든 에피소드를 각각 ZIP으로 다운로드 (목록 페이지용)
const downloadAllEpisodesAsZips = async () => {
// 에피소드 링크 찾기
const episodeElements = document.querySelectorAll('a.ep-row-v2-link');
if (episodeElements.length === 0) {
alert('다운로드할 에피소드를 찾을 수 없습니다.');
return;
}
// 목록 페이지에서 시리즈 정보 추출 (모든 챕터에서 공통)
const seriesTitle = getSeriesTitle();
const writerFromPage = document.querySelector('.hero-v2-author');
const seriesWriter = writerFromPage ? writerFromPage.textContent.trim() : '';
const episodeLinks = Array.from(episodeElements).map((a, index) => {
const titleElement = a.querySelector('.ep-row-v2-title strong');
const title = titleElement ? titleElement.textContent.trim() : a.textContent.trim();
return { url: a.href, title, orderNumber: index + 1 };
});
console.log(`${episodeLinks.length}개의 에피소드 발견`);
console.log(`시리즈: ${seriesTitle}, 작가: ${seriesWriter}`);
let downloadedCount = 0;
let failedCount = 0;
for (let i = 0; i < episodeLinks.length; i++) {
const episode = episodeLinks[i];
try {
console.log(`[${i + 1}/${episodeLinks.length}] 다운로드 중: ${episode.title}`);
// iframe에 에피소드 로드 (실제 렌더링 필요)
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = episode.url;
document.body.appendChild(iframe);
// iframe 로드 대기
await new Promise((resolve) => {
const checkLoad = () => {
try {
if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
resolve();
} else {
setTimeout(checkLoad, 100);
}
} catch (e) {
// CORS 오류 발생 - iframe 방식 사용 불가
resolve();
}
};
setTimeout(checkLoad, 500);
});
let imageCount = 0;
let imgFailedCount = 0;
let chapterTitle = episode.title;
try {
// iframe 내 이미지 추출 시도
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const imgElements = iframeDoc.querySelectorAll('.vw-imgs img');
// iframe에서 챕터 제목 추출 (더 정확함)
const iframeEpisodeTitle = iframeDoc.querySelector('.vw-ep');
if (iframeEpisodeTitle) {
const strong = iframeEpisodeTitle.querySelector('strong');
const span = iframeEpisodeTitle.querySelector('span');
const episodeNum = strong ? strong.textContent.trim() : '';
const episodeSubtitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
chapterTitle = episodeNum ? `${episodeNum} - ${episodeSubtitle}` : episodeSubtitle;
}
if (imgElements.length > 0) {
const zip = new JSZip();
for (let j = 0; j < imgElements.length; j++) {
try {
const blob = await extractImageBlobFromImg(imgElements[j]);
if (blob && blob.size > 0) {
const filename = `${String(imageCount).padStart(4, '0')}_page_${j + 1}.jpg`;
zip.file(filename, blob);
imageCount++;
}
} catch (error) {
imgFailedCount++;
}
}
if (imageCount > 0) {
// 파일명: "01 - 챕터제목.zip" 형식
const cleanTitle = chapterTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 50);
const filename = `${cleanTitle}.zip`;
// comicinfo.xml 생성 (인덱스 번호, 시리즈, 작가 포함)
const comicInfoXml = generateComicInfoXml(
cleanTitle, // title
imageCount, // pageCount
seriesTitle, // series
episode.orderNumber.toString(), // number (인덱스)
seriesWriter // writer
);
zip.file('ComicInfo.xml', comicInfoXml);
const blob = await zip.generateAsync({ type: 'blob' });
downloadZip(blob, filename);
downloadedCount++;
console.log(`${filename} (${imageCount}개 이미지)`);
}
}
} catch (error) {
console.warn(`iframe 접근 실패:`, error);
}
iframe.remove();
if (imageCount === 0) {
failedCount++;
}
// 레이트 제한
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error(`에피소드 처리 실패: ${episode.title}`, error);
failedCount++;
}
}
alert(`완료!\n다운로드: ${downloadedCount}\n실패: ${failedCount}`);
};
// 메뉴 명령 등록 (페이지 타입에 따라 다른 기능)
if (isEpisodePage) {
GM_registerMenuCommand('📥 현재 에피소드 다운로드', downloadImages);
} else if (isListPage) {
GM_registerMenuCommand('📥 전체 에피소드 다운로드', downloadAllEpisodesAsZips);
}
});
console.log(`ntk01 Downloader 스크립트 로드 완료 (${isEpisodePage ? '에피소드 페이지' : isListPage ? '목록 페이지' : '기타'})`);
})();

File diff suppressed because it is too large Load Diff

View 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...
})();

View File

@@ -0,0 +1,206 @@
// ==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
// @downloadURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.user.js
// @updateURL https://update.sleazyfork.org/scripts/531932/tokiDownloader.meta.js
// ==/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} 완료`);
// 너무 급하게 다운받다 블록당하는거 방지 30초
await sleep(30000);
}
}
// 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...
})();