877 lines
40 KiB
JavaScript
877 lines
40 KiB
JavaScript
// ==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());
|
|
})(); |