Initial commit - tokiDownloader.user.js
This commit is contained in:
655
Old_Scripts/ntk01Downloader.user copy.js
Normal file
655
Old_Scripts/ntk01Downloader.user copy.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 현재 페이지에서 시리즈 제목 추출
|
||||
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 ? '목록 페이지' : '기타'})`);
|
||||
})();
|
||||
Reference in New Issue
Block a user