// ==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 += ` \n`; } } let genreStr = tags.length > 0 ? tags.join(', ') : ''; return ` ${title} ${series} ${number} -1 -1 ${summary} -1 -1 ${writer} ${pageCount} ${genreStr} ko Yes ${pagesXml ? ` \n${pagesXml} \n` : ''}`; }; // 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()); })();