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,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 ? '목록 페이지' : '기타'})`);
})();