Files
MonkeyTokiDownloadScript/Old_Scripts/ntk01Downloader.user.js
2026-06-13 18:33:22 +09:00

1055 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name Ntoki Downloader
// @namespace https://github.com/crossSiteKikyo/tokiDownloader
// @version 0.2.0
// @description 뉴토끼(ntk01.com, sbxh*.com) 웹툰/만화 다운로더 (팝업 방식, JavaScript 렌더링 필요)
// @author hehaho
// @match https://ntk01.com/*
// @match https://www.ntk01.com/*
// @match https://sbxh1.com/*
// @match https://sbxh2.com/*
// @match https://sbxh3.com/*
// @match https://sbxh4.com/*
// @match https://sbxh5.com/*
// @icon https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @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 currentURL = document.URL;
log(`🚀 Ntoki Downloader v0.2.0 시작됨`);
log(`📍 URL: ${currentURL}`);
log(`📍 호스트: ${new URL(currentURL).hostname}`);
// 알려진 웹툰 이미지 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, currentURL).href;
const hostname = new URL(absoluteUrl).hostname;
const pageHost = new URL(currentURL).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 => {
// 배너만 필터: 가로가 세로의 2배 이상(전형적인 배너) 또는 높이 200px 미만
const valid = !(bitmap.width > bitmap.height * 2 || bitmap.height < 200);
bitmap.close();
return valid;
}).catch(() => true);
};
// 이미지 로딩을 기다리고 lazy load 트리거
const waitForImagesToLoad = (targetDocument, timeoutMs = 20000) => {
return new Promise(resolve => {
const container = targetDocument.querySelector('.vw-imgs, .vw-images, #bo_v_con, .view-content, #toon_img, .board-view') || targetDocument.body;
const allImages = container.querySelectorAll('img');
const baseUrl = targetDocument.URL;
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 });
}
});
});
};
// 페이지 타입 감지
const pathMatch = currentURL.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/);
const isEpisodePage = pathMatch && pathMatch[2] ? true : false;
const isListPage = pathMatch && !pathMatch[2] ? true : false;
// 파일 다운로드 헬퍼
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 = () => { return; // 비활성화
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);
};
// XML 특수문자 이스케이프
const escapeXml = (str) => {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
// comicinfo.xml 생성
const generateComicInfoXml = (title, seriesTitle = '', episodeNumber = '', writer = '', imageCount) => {
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>
<Writer>${escapeXml(writer)}</Writer>
<PageCount>${imageCount}</PageCount>
<LanguageISO>ko</LanguageISO>
<Format>Manga</Format>
</ComicInfo>`;
return comicInfo;
};
// JSON-LD에서 정보 추출 (말줄임표 없는 완전한 제목)
const getJsonLdData = () => {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of scripts) {
try {
const data = JSON.parse(script.textContent);
if (data) return data;
} catch (e) {}
}
return null;
};
// DOM에서 정보 추출
const getSeriesTitle = () => {
// JSON-LD에서 먼저 시도
const jsonLd = getJsonLdData();
if (jsonLd) {
// ComicIssue 타입인 경우 isPartOf.name 사용
if (jsonLd.isPartOf && jsonLd.isPartOf.name) {
return jsonLd.isPartOf.name;
}
// Book 타입인 경우 name 사용
if (jsonLd['@type'] === 'Book' && jsonLd.name) {
return jsonLd.name;
}
}
// DOM에서 fallback
const workEl = document.querySelector('.vw-work');
if (workEl) return workEl.textContent.trim();
const heroTitle = document.querySelector('.hero-v2-title');
if (heroTitle) return heroTitle.textContent.trim();
return '';
};
const getEpisodeTitle = () => {
// JSON-LD에서 먼저 시도 - name이 headline보다 완전한 제목임
const jsonLd = getJsonLdData();
if (jsonLd && (jsonLd['@type'] === 'ComicIssue' || jsonLd['@type'] === 'ComicStory')) {
if (jsonLd.name) return jsonLd.name;
}
// DOM에서 fallback
const epEl = document.querySelector('.vw-ep');
if (!epEl) return '';
const span = epEl.querySelector('span');
return span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
};
const getEpisodeNumber = () => {
const epEl = document.querySelector('.vw-ep');
if (!epEl) return '';
const strong = epEl.querySelector('strong');
return strong ? strong.textContent.trim() : '';
};
// 이미지 URL 추출 공통 함수
const extractImageUrls = (container = document, baseUrl) => {
const imageUrls = [];
const seen = new Set();
const resolveUrl = (url) => {
if (!url) return '';
try {
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('//')) return new URL(url, 'https:').href;
if (url.startsWith('/')) return new URL(url, baseUrl || document.URL).href;
return new URL(url, baseUrl || document.URL).href;
} catch { return ''; }
};
const addUrl = (url) => {
if (!url || seen.has(url)) return;
if (url.includes('logo') || url.includes('banner') || url.includes('icon') || url.includes('avatar')) return;
if (!isAllowedImageHost(url)) return;
seen.add(url);
imageUrls.push(url);
};
// 방법 1: .vw-imgs 내의 img 태그
const imgContainer = container.querySelector('.vw-imgs') || container;
const imgElements = imgContainer.querySelectorAll('img');
imgElements.forEach(img => {
const sources = [
img.src,
img.currentSrc,
img.dataset.src,
img.dataset.original,
img.dataset.lazySrc,
img.dataset.lazy,
img.getAttribute('data-src'),
img.getAttribute('data-original'),
img.getAttribute('srcset')?.split(' ')[0],
img.getAttribute('data-srcset')?.split(' ')[0],
];
sources.forEach(s => { const u = resolveUrl(s); if (u) addUrl(u); });
});
// 방법 2: preload 링크
if (imageUrls.length === 0) {
const preloadLinks = container.querySelectorAll('link[rel="preload"][as="image"]');
preloadLinks.forEach(link => {
const u = resolveUrl(link.getAttribute('href'));
if (u) addUrl(u);
});
}
// 방법 3: picture > source srcset
if (imageUrls.length === 0) {
const sources = container.querySelectorAll('picture source[srcset], source[data-srcset]');
sources.forEach(src => {
const srcset = src.getAttribute('srcset') || src.dataset.srcset;
if (srcset) {
srcset.split(',').forEach(s => {
const u = resolveUrl(s.trim().split(' ')[0]);
if (u) addUrl(u);
});
}
});
}
return imageUrls;
};
// 현재 에피소드 이미지 다운로드
const downloadImages = async () => {
// 페이지 이미지 로딩 완료 대기
await waitForImagesToLoad(document);
const imageUrls = extractImageUrls();
if (imageUrls.length === 0) {
log('❌ 다운로드할 이미지를 찾을 수 없음');
const allImgs = document.querySelectorAll('.vw-imgs img');
log('.vw-imgs img 개수:', allImgs.length);
allImgs.forEach((img, i) => {
log(`img[${i}] src=${img.src || '(empty)'}, data-src=${img.dataset?.src || '(none)'}, complete=${img.complete}, naturalWidth=${img.naturalWidth}`);
});
const preloadLinks = document.querySelectorAll('link[rel="preload"][as="image"]');
log('preload links:', preloadLinks.length);
preloadLinks.forEach((link, i) => {
log(`link[${i}] href=${link.getAttribute('href')}`);
});
log('👉 다운로드 로그를 저장합니다.');
try { downloadLogFile(); } catch (_) {}
alert(`❌ 다운로드할 이미지를 찾을 수 없습니다.\n🔍 저장된 로그 파일(Download_xxxxxx.log)을 확인하세요.`);
return;
}
let title = getEpisodeTitle() || document.title.split('|')[0].trim() || 'webtoon_download';
title = title.replace(/[/\\:*?"<>|]/g, '_');
let episodeNum = getEpisodeNumber();
log(`발견된 이미지: ${imageUrls.length}개, 제목: ${title}`);
const zip = new JSZip();
let imageCount = 0;
let failedCount = 0;
for (let i = 0; i < imageUrls.length; i++) {
const imageUrl = imageUrls[i];
log(`다운로드 중... ${i + 1}/${imageUrls.length}`);
try {
const blob = await downloadImageBlob(imageUrl);
if (blob && blob.size > 0) {
const filename = `${String(imageCount).padStart(4, '0')}_page_${i + 1}.jpg`;
zip.file(filename, blob);
imageCount++;
log(`✓ 이미지 다운로드 완료: ${i + 1}/${imageUrls.length}`);
} else {
failedCount++;
log(`⚠ 이미지 다운로드 실패 (${i + 1}번째): 빈 응답`);
}
} catch (error) {
log(`⚠ 이미지 다운로드 실패 (${i + 1}번째):`, error);
failedCount++;
}
}
if (imageCount > 0) {
const seriesTitle = getSeriesTitle();
const comicInfoXml = generateComicInfoXml(title, seriesTitle, episodeNum, '', imageCount);
zip.file('ComicInfo.xml', comicInfoXml);
const blob = await zip.generateAsync({ type: 'blob' });
downloadZip(blob, `${title}.zip`);
downloadLogFile();
alert(`${imageCount}개 이미지 + ComicInfo.xml 다운로드 완료\n(실패: ${failedCount}개)\n\n파일명: ${title}.zip`);
} else {
alert(`❌ 다운로드 가능한 이미지 없음\n발견: ${imageUrls.length}개, 성공: ${imageCount}개, 실패: ${failedCount}`);
}
};
// sleep 헬퍼
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 팝업 창으로 에피소드 페이지를 렌더링 후 이미지 URL 추출 (JavaScript 실행 + 스크롤 필요)
const downloadEpisodeImagesViaPopup = async (episodeUrl, episodeTitle) => {
return new Promise((resolve) => {
log(`🔄 팝업 열기: ${episodeTitle}`);
const popup = window.open(episodeUrl, 'episodeViewer', 'width=800,height=600,left=-9999,top=-9999');
if (!popup) {
log(`⚠️ 팝업 차단됨, HTTP 방식 fallback: ${episodeTitle}`);
fetchHtmlAndResolve(episodeUrl, episodeTitle, resolve);
return;
}
let resolved = false;
const MAX_WAIT = 60000;
const done = (result) => {
if (resolved) return;
resolved = true;
// 팝업은 닫지 않고 result에 closePopup 전달 (호출자가 다운로드 완료 후 닫음)
result.closePopup = closePopup;
resolve(result);
};
let closePopup = () => { try { popup.close(); } catch (_) {} };
// 1단계: .vw-imgs가 나타날 때까지 대기 (최대 30초)
const waitForViewer = () => {
let waited = 0;
let viewerTimer = null;
const pollTimer = setInterval(() => {
waited += 500;
try {
if (popup.closed) {
clearInterval(pollTimer);
log(`⚠️ 팝업이 일찍 닫힘: ${episodeTitle}`);
done({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
return;
}
const doc = popup.document;
const popupUrl = popup.location ? popup.location.href : '';
// about:blank면 아직 로딩 전 -> 계속 대기
if (!popupUrl || popupUrl === 'about:blank') return;
const vwImgs = doc.querySelector('.vw-imgs');
if (vwImgs) {
clearInterval(pollTimer);
if (viewerTimer) clearTimeout(viewerTimer);
log(`✅ .vw-imgs 발견, 스크롤 시작: ${episodeTitle}`);
scrollToLoadImages(doc, done);
return;
}
// 25초 이상 지나도 vw-imgs 없으면 HTTP fallback
if (waited >= 25000) {
clearInterval(pollTimer);
if (viewerTimer) clearTimeout(viewerTimer);
log(`⚠️ .vw-imgs 없음 (${waited}ms 대기): ${episodeTitle}`);
try { popup.close(); } catch (_) {}
fetchHtmlAndResolve(episodeUrl, episodeTitle, done);
return;
}
} catch (e) {
// 아직 cross-origin 상태 (초기 로딩 중) - 무시
}
}, 500);
viewerTimer = setTimeout(() => {
clearInterval(pollTimer);
if (!resolved) {
log(`⏰ 뷰어 대기 타임아웃 (30s): ${episodeTitle}`);
try { popup.close(); } catch (_) {}
fetchHtmlAndResolve(episodeUrl, episodeTitle, done);
}
}, 30000);
};
// 2단계: 모든 이미지 eager 로딩 설정 후 천천히 스크롤
const scrollToLoadImages = (doc, callback) => {
const win = popup;
let lastScroll = -1;
let scrollCount = 0;
let goingDown = true;
const SCROLL_STEP = 700;
const SCROLL_INTERVAL = 600;
// 모든 이미지를 eager로 설정하고 data-src → src 복사
const allImgs = doc.querySelectorAll('.vw-imgs img, .vw-images img, [class*="viewer"] img, [class*="comic"] img');
let imgCount = allImgs.length;
log(`📸 .vw-imgs 내 img 태그: ${imgCount}`);
allImgs.forEach(img => {
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, episodeUrl).href; } catch {}
}
});
});
const scrollTimer = setInterval(() => {
try {
if (popup.closed) {
clearInterval(scrollTimer);
callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
return;
}
if (goingDown) {
win.scrollBy(0, SCROLL_STEP);
} else {
win.scrollBy(0, -SCROLL_STEP * 2);
}
scrollCount++;
const curScroll = win.scrollY || win.pageYOffset || 0;
const maxScroll = Math.max(
doc.documentElement.scrollHeight - win.innerHeight,
doc.body.scrollHeight - win.innerHeight,
0
);
if (goingDown && (curScroll >= maxScroll || curScroll === lastScroll || scrollCount > 80)) {
// 바닥 도달 → 위로 스크롤 시작
goingDown = false;
lastScroll = curScroll;
log(`📜 바닥 도달, 위로 스크롤 (${scrollCount}회): ${episodeTitle}`);
} else if (!goingDown && curScroll <= 0) {
// 맨 위 도달 → 완료
clearInterval(scrollTimer);
log(`📜 왕복 스크롤 완료 (${scrollCount}회): ${episodeTitle}`);
log(`⏳ 5초간 이미지 로딩 대기...`);
setTimeout(() => {
waitForImagesToLoad(doc).then(() => {
extractFromPopup(doc, callback);
}).catch(() => {
extractFromPopup(doc, callback);
});
}, 5000);
return;
}
lastScroll = curScroll;
} catch (e) {
if (scrollCount > 160) {
clearInterval(scrollTimer);
extractFromPopup(doc, callback);
}
}
}, SCROLL_INTERVAL);
setTimeout(() => {
clearInterval(scrollTimer);
if (!resolved) {
log(`⏰ 스크롤 타임아웃: ${episodeTitle}`);
extractFromPopup(doc, callback);
}
}, 180000);
};
// 3단계: 팝업 DOM에서 이미지 URL 추출 + 누락 페이지 보강
const extractFromPopup = async (doc, callback) => {
try {
log(`🔍 팝업 DOM 분석: ${episodeTitle}`);
const vwImgs = doc.querySelector('.vw-imgs');
const imgEls = vwImgs ? vwImgs.querySelectorAll('img') : [];
log(`.vw-imgs 존재: ${!!vwImgs}, img 개수: ${imgEls.length}`);
let imageUrls = extractImageUrls(doc, episodeUrl);
log(`${imageUrls.length}개 이미지 URL 추출 (팝업)`);
// pNNN 패턴이 있으면 누락 페이지 HEAD 요청으로 찾기
if (imageUrls.some(u => /\/p\d{3}\./.test(u))) {
imageUrls = await fillMissingPages(imageUrls, episodeUrl);
}
let chapterTitle = episodeTitle;
if (imageUrls.length > 0) {
log(`호스트 목록: ${[...new Set(imageUrls.map(u => new URL(u).hostname))].join(', ')}`);
}
try {
const epEl = doc.querySelector('.vw-ep');
if (epEl) {
const strong = epEl.querySelector('strong');
const span = epEl.querySelector('span');
const episodeNum = strong ? strong.textContent.trim() : '';
const episodeSubtitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
chapterTitle = episodeNum ? `${episodeNum} - ${episodeSubtitle}` : episodeSubtitle;
}
} catch (e) {}
callback({
success: imageUrls.length > 0,
imageUrls, imageCount: imageUrls.length, chapterTitle
});
} catch (error) {
log(`❌ 팝업 추출 실패: ${episodeTitle}`, error);
callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
}
};
// 시작
waitForViewer();
});
};
// HTTP fetch fallback (메타데이터 + 가능한 이미지)
const fetchHtmlAndResolve = async (episodeUrl, episodeTitle, resolve) => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
const response = await fetch(episodeUrl, {
credentials: 'include',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Referer': document.URL,
'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) {
resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
return;
}
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const imageUrls = extractImageUrls(doc, episodeUrl);
let chapterTitle = episodeTitle;
if (imageUrls.length > 0) {
log(`✓ [${episodeTitle}] ${imageUrls.length}개 이미지 URL 추출 (HTTP fallback)`);
} else {
log(`⚠️ 이미지 없음 (HTTP fallback): ${episodeTitle}`);
}
resolve({
success: imageUrls.length > 0,
imageUrls: imageUrls,
imageCount: imageUrls.length,
chapterTitle: chapterTitle
});
} catch (error) {
log(`❌ HTTP fallback 실패: ${episodeTitle}`, error);
resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
}
};
// URL로부터 이미지 다운로드
const downloadImageBlob = (url) => {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': new URL(document.URL).origin + '/'
},
onload: (response) => {
try {
const responseText = response.responseHeaders || '';
const contentType = (responseText.match(/content-type:\s*([^\r\n;]+)/i) || [])[1] || '';
if (contentType && !contentType.startsWith('image/')) {
log(`⚠️ 이미지가 아닌 응답 스킵:`, contentType);
resolve(null);
return;
}
const blob = new Blob([response.response], { type: 'image/jpeg' });
isComicPageBlob(blob).then(valid => {
if (valid) {
resolve(blob);
} else {
log(`⚠️ 광고 배너로 의심됨: ${url}`);
resolve(null);
}
});
} catch (error) {
log(`⚠️ 이미지 Blob 변환 실패:`, error);
resolve(null);
}
},
onerror: (error) => {
log(`⚠️ 이미지 다운로드 실패: ${url}`);
resolve(null);
}
});
});
};
// 누락 페이지 URL 자동 생성 (pNNN 패턴, HEAD 불필요)
const fillMissingPages = async (imageUrls, episodeUrl) => {
const pUrls = imageUrls.filter(u => /\/p\d{3}\./.test(u));
if (pUrls.length === 0) return imageUrls;
const baseMatch = episodeUrl.match(/\/manhwa\/([^/]+)\/(\d+)/);
if (!baseMatch) return imageUrls;
let maxPage = 0;
const extMap = new Map();
for (const url of pUrls) {
const m = url.match(/\/p(\d{3})\.(jpg|png|webp)/);
if (m) {
const num = parseInt(m[1], 10);
maxPage = Math.max(maxPage, num);
extMap.set(url, m[2]);
}
}
log(`🔍 누락 페이지 확인: 최대 p${String(maxPage).padStart(3,'0')}까지 있음, ${maxPage}번 이후 확장자 패턴 분석 중...`);
// 마지막 3개 이미지의 확장자 패턴으로 다음 확장자 추론
const sortedPages = [...pUrls]
.map(u => { const m = u.match(/\/p(\d{3})\.(jpg|png|webp)/); return m ? {url: u, num: parseInt(m[1],10), ext: m[2]} : null; })
.filter(Boolean)
.sort((a,b) => a.num - b.num);
const lastFew = sortedPages.slice(-5);
log(` 마지막 ${lastFew.length}개 패턴: ${lastFew.map(p => `p${p.num}.${p.ext}`).join(', ')}`);
const results = [...imageUrls];
const seen = new Set(imageUrls);
for (let page = maxPage + 1; page <= maxPage + 10; page++) {
const pageStr = String(page).padStart(3, '0');
// 이전 페이지들의 확장자 패턴 확인
const prevExts = [];
for (let p = Math.max(1, page - 5); p < page && p <= maxPage; p++) {
const prevUrl = imageUrls.find(u => u.includes(`/p${String(p).padStart(3,'0')}.`));
if (prevUrl) {
const m = prevUrl.match(/\.(jpg|png|webp)$/);
if (m) prevExts.push(m[1]);
}
}
// 가장 자주 나온 확장자 선호
const extCount = {};
let bestExt = '.jpg';
for (const e of prevExts) { extCount[e] = (extCount[e]||0)+1; }
const entries = Object.entries(extCount);
if (entries.length > 0) {
bestExt = entries.sort((a,b) => b[1] - a[1])[0][0];
}
const url = `https://i.toonflix.app/manhwa/${baseMatch[1]}/${baseMatch[2]}/p${pageStr}.${bestExt}`;
if (!seen.has(url)) {
log(` 생성: p${pageStr}.${bestExt} (패턴 기반)`);
results.push(url);
seen.add(url);
}
}
const added = results.length - imageUrls.length;
if (added > 0) log(`${added}개 페이지 URL 추가 생성 (총 ${results.length}개)`);
else log(` 추가 생성 없음`);
return results;
};
// 에피소드 목록 페이지에서 링크 수집
const getEpisodeList = () => {
const episodeElements = document.querySelectorAll('a.ep-row-v2-link');
if (episodeElements.length === 0) return null;
const seriesTitle = getSeriesTitle();
const writerFromPage = document.querySelector('.hero-v2-author');
const seriesWriter = writerFromPage ? writerFromPage.textContent.trim() : '';
const episodeLinks = Array.from(episodeElements).map((a) => {
const titleElement = a.querySelector('.ep-row-v2-title strong');
const title = titleElement ? titleElement.textContent.trim() : a.textContent.trim();
const noElement = a.querySelector('.ep-row-v2-no');
const episodeNum = noElement ? noElement.textContent.trim() : '';
return { url: a.href, title, episodeNum };
});
episodeLinks.reverse();
return { episodeLinks, seriesTitle, seriesWriter };
};
// 에피소드 목록을 순차 다운로드 (공통 코어)
const downloadEpisodeList = async (episodeLinks, seriesTitle, seriesWriter) => {
let downloadedCount = 0;
let failedCount = 0;
const MAX_RETRIES = 2;
let failedEpisodes = [];
const downloadSingleEpisode = async (episode, retryCount = 0) => {
let result;
try {
log(`⏳ [${retryCount > 0 ? `재시도 ${retryCount}/${MAX_RETRIES}` : ''}] 다운로드 중: ${episode.title}`);
result = await downloadEpisodeImagesViaPopup(episode.url, episode.title);
if (result.success && result.imageUrls && result.imageUrls.length > 0) {
const zip = new JSZip();
let downloadedImages = 0;
for (let j = 0; j < result.imageUrls.length; j++) {
const imageUrl = result.imageUrls[j];
log(` 📥 이미지 ${j + 1}/${result.imageUrls.length} 다운로드 중...`);
const blob = await downloadImageBlob(imageUrl);
if (blob) {
const filename = `${String(j).padStart(4, '0')}_page_${j + 1}.jpg`;
zip.file(filename, blob);
downloadedImages++;
}
}
if (downloadedImages > 0) {
let chapterTitle = result.chapterTitle || episode.title;
const cleanTitle = chapterTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100);
let epNumber = episode.episodeNum;
if (!epNumber) {
const numMatch = episode.title.match(/^(\d+)/);
epNumber = numMatch ? numMatch[1] : '0';
}
const comicInfoXml = generateComicInfoXml(
cleanTitle,
seriesTitle,
epNumber,
seriesWriter,
downloadedImages
);
zip.file('ComicInfo.xml', comicInfoXml);
const blob = await zip.generateAsync({ type: 'blob' });
downloadZip(blob, `${cleanTitle}.zip`);
log(`${cleanTitle} (${downloadedImages}/${result.imageCount}개 이미지) 다운로드 완료`);
if (result.closePopup) result.closePopup();
return true;
}
}
if (result && result.closePopup) result.closePopup();
return false;
} catch (error) {
log(`❌ 에피소드 처리 실패: ${episode.title}`, error);
if (result && result.closePopup) result.closePopup();
return false;
}
};
// 1차 다운로드
for (let i = 0; i < episodeLinks.length; i++) {
const episode = episodeLinks[i];
const success = await downloadSingleEpisode(episode);
if (success) {
downloadedCount++;
} else {
failedCount++;
failedEpisodes.push(episode);
}
await new Promise(resolve => setTimeout(resolve, 20000));
}
// 실패한 에피소드 재시도
for (let retry = 0; retry < MAX_RETRIES && failedEpisodes.length > 0; retry++) {
log(`🔄 재시도 ${retry + 1}/${MAX_RETRIES}: ${failedEpisodes.length}개 에피소드`);
const stillFailed = [];
for (const episode of failedEpisodes) {
const success = await downloadSingleEpisode(episode, retry + 1);
if (success) {
downloadedCount++;
failedCount--;
} else {
stillFailed.push(episode);
}
await new Promise(resolve => setTimeout(resolve, 30000));
}
failedEpisodes = stillFailed;
if (failedEpisodes.length === 0) break;
}
// 사용자 재시도 루프
while (failedEpisodes.length > 0) {
const result = confirm(`🎉 1차 완료!\n\n✅ 다운로드: ${downloadedCount}\n⚠️ 실패: ${failedEpisodes.length}\n📊 총 에피소드: ${episodeLinks.length}\n\n실패한 에피소드를 다시 시도할까요?`);
if (!result) break;
const stillFailed = [];
for (const episode of failedEpisodes) {
log(`🔄 사용자 재시도: ${episode.title}`);
const success = await downloadSingleEpisode(episode, '재시도');
if (success) {
downloadedCount++;
failedCount--;
} else {
stillFailed.push(episode);
}
await new Promise(resolve => setTimeout(resolve, 30000));
}
failedEpisodes = stillFailed;
}
log(`✅ 최종 완료: ${downloadedCount}개 성공, ${failedEpisodes.length}개 실패 (총 ${episodeLinks.length}개)`);
alert(`🎉 최종 완료!\n\n✅ 다운로드: ${downloadedCount}\n⚠️ 실패: ${failedEpisodes.length}\n📊 총 에피소드: ${episodeLinks.length}`);
};
// 전체 에피소드 다운로드
const downloadAllEpisodesAsZips = async () => {
const list = getEpisodeList();
if (!list) {
log(`❌ 에피소드 링크를 찾을 수 없음 (선택자: a.ep-row-v2-link)`);
const allLinks = document.querySelectorAll('a[href*="/manhwa/"]');
log(`/manhwa/ 링크 개수: ${allLinks.length}`);
allLinks.forEach((a, i) => {
if (i < 10) log(` 링크[${i}] class="${a.className}" href="${a.href}"`);
});
alert(`❌ 다운로드할 에피소드를 찾을 수 없습니다.`);
return;
}
log(`📊 ${list.episodeLinks.length}개의 에피소드 발견 (첫화순으로 정렬)`);
log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
await downloadEpisodeList(list.episodeLinks, list.seriesTitle, list.seriesWriter);
};
// 에피소드 번호 추출 헬퍼 (숫자만)
const parseEpisodeNumber = (ep) => {
const digits = ep.episodeNum.replace(/[^\d]/g, '');
if (digits) return parseInt(digits, 10);
const m = ep.title.match(/(\d+)/);
return m ? parseInt(m[1], 10) : null;
};
// N화 이후 다운로드
const downloadEpisodesFromInput = async () => {
const list = getEpisodeList();
if (!list) {
alert(`❌ 에피소드 목록을 찾을 수 없습니다.`);
return;
}
const input = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.\n(예: 10 → 10화부터 마지막까지)`, '');
if (input === null) return;
const startNum = parseInt(input.replace(/[^\d]/g, ''), 10);
if (isNaN(startNum) || startNum < 1) {
alert(`올바른 숫자를 입력하세요.`);
return;
}
const filtered = list.episodeLinks.filter(ep => {
const n = parseEpisodeNumber(ep);
return n !== null && n >= startNum;
});
if (filtered.length === 0) {
alert(`${startNum}화 이후의 에피소드가 없습니다.`);
return;
}
log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화 이후 ${filtered.length}개 에피소드`);
log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter);
};
// 에피소드 범위 다운로드
const downloadEpisodesRangeInput = async () => {
const list = getEpisodeList();
if (!list) {
alert(`❌ 에피소드 목록을 찾을 수 없습니다.`);
return;
}
const startInput = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.`, '');
if (startInput === null) return;
const startNum = parseInt(startInput.replace(/[^\d]/g, ''), 10);
if (isNaN(startNum) || startNum < 1) {
alert(`올바른 숫자를 입력하세요.`);
return;
}
const endInput = prompt(`다운로드할 마지막 에피소드 번호를 입력하세요.`, '');
if (endInput === null) return;
const endNum = parseInt(endInput.replace(/[^\d]/g, ''), 10);
if (isNaN(endNum) || endNum < startNum) {
alert(`마지막 번호는 ${startNum} 이상이어야 합니다.`);
return;
}
const filtered = list.episodeLinks.filter(ep => {
const n = parseEpisodeNumber(ep);
return n !== null && n >= startNum && n <= endNum;
});
if (filtered.length === 0) {
alert(`${startNum}화~${endNum}화 범위의 에피소드가 없습니다.`);
return;
}
log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화~${endNum}화 범위 ${filtered.length}개 에피소드`);
log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter);
};
// 메뉴 명령 ID 관리 (중복 등록 방지)
let menuCmdIds = [];
// 안전한 메뉴 콜백 래퍼: 모든 에러를 LOG로 기록하고 로그 파일 다운로드
const safeCallback = (fn) => async (...args) => {
try {
await fn(...args);
} catch (e) {
log('❌ 치명적 오류:', e);
log('Stack:', e instanceof Error ? e.stack : '');
log('로그를 저장합니다.');
try { downloadLogFile(); } catch (_) {}
}
};
// 메뉴 명령 등록 함수
const registerMenuCommands = () => {
// 이전 명령 해제
menuCmdIds.forEach(id => GM_unregisterMenuCommand(id));
menuCmdIds = [];
const currentUrl = document.URL;
const hostname = new URL(currentUrl).hostname;
const pathMatch = currentUrl.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/);
const isEp = pathMatch && pathMatch[2];
const isList = pathMatch && !pathMatch[2];
log(`📍 현재 URL: ${currentUrl}`);
log(`📍 호스트: ${hostname}, 목록: ${isList}, 에피소드: ${isEp}`);
if (isEp) {
menuCmdIds.push(GM_registerMenuCommand('📥 현재 에피소드 다운로드', safeCallback(downloadImages)));
log('✅ 에피소드 페이지 메뉴 등록 완료');
} else if (isList) {
menuCmdIds.push(GM_registerMenuCommand('📥 전체 에피소드 다운로드', safeCallback(downloadAllEpisodesAsZips)));
menuCmdIds.push(GM_registerMenuCommand('📥 N화 이후 다운로드', safeCallback(downloadEpisodesFromInput)));
menuCmdIds.push(GM_registerMenuCommand('📥 범위 다운로드', safeCallback(downloadEpisodesRangeInput)));
log('✅ 목록 페이지 메뉴 등록 완료 (3개)');
} else {
log('⏸️ 현재 페이지는 다운로드 대상이 아닙니다. 로그 저장 메뉴만 표시됩니다.');
}
// 항상 로그 저장 메뉴 등록
menuCmdIds.push(GM_registerMenuCommand('📋 로그 저장', () => downloadLogFile()));
};
// 초기 메뉴 등록
registerMenuCommands();
// SPA 네비게이션 감지 (Next.js)
const startNavObserver = () => {
const observer = new MutationObserver(() => {
registerMenuCommands();
});
observer.observe(document.body, { childList: true, subtree: true });
};
// 히스토리 API 변경 감지
const origPushState = history.pushState;
const origReplaceState = history.replaceState;
history.pushState = function() {
origPushState.apply(this, arguments);
setTimeout(registerMenuCommands, 300);
};
history.replaceState = function() {
origReplaceState.apply(this, arguments);
setTimeout(registerMenuCommands, 300);
};
window.addEventListener('popstate', () => setTimeout(registerMenuCommands, 300));
startNavObserver();
log(`Ntoki Downloader v0.2.0 loaded`);
log(`URL: ${document.URL}`);
log(`User-Agent: ${navigator.userAgent}`);
log(`지원 도메인: sbxh1/2/3.com, ntk01.com`);
log('팝업 방식 (JavaScript 렌더링) + 오류 시 로그 자동 저장');
const jszipCheck = typeof JSZip !== 'undefined';
log(`JSZip 라이브러리: ${jszipCheck ? '✅ 로드됨' : '❌ 로드 안 됨'}`);
if (!jszipCheck) {
log('⚠️ JSZip이 로드되지 않았습니다. CDN 차단 또는 네트워크 문제일 수 있습니다.');
}
})();