656 lines
26 KiB
JavaScript
656 lines
26 KiB
JavaScript
// ==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 ? '목록 페이지' : '기타'})`);
|
|
})();
|