// ==UserScript== // @name 11toonDownloader // @namespace https://github.com/crossSiteKikyo/tokiDownloader // @version 0.0.6 // @description 일일툰 전용 다운로더 // @author hehaho // @match http://*.cprapid.com:*/bbs/board.php* // @match https://*.spotv24.com/bbs/board.php* // @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('/image') || url.includes('/photo')) && !url.includes('logo') && !url.includes('banner') && !url.includes('icon'); 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('/image') || url.includes('/photo')) && !url.includes('logo') && !url.includes('banner') && !url.includes('icon'); 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)) { window.capturedImageBlobs.push(blob); } } catch(e) {} } } if (originalOnreadystatechange) { return originalOnreadystatechange.apply(this, arguments); } }; return originalSend.apply(this, args); }; let site = ''; let protocolDomain = ''; let isListPage = false; // 현재 url 체크 const currentURL = document.URL; if (currentURL.includes('bo_table=toons')) { if (currentURL.includes('wr_id=')) { site = "일일툰"; } else { site = "일일툰_목록"; isListPage = true; } protocolDomain = currentURL.match(/^https?:\/\/[^:/?]+/)[0]; } else { return; } const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const generateComicInfo = (title, series, writer, number, pageCount, imageCount = 0) => { let pagesXml = ''; if (imageCount > 0) { for (let i = 0; i < imageCount; i++) { pagesXml += ` \n`; } } return ` ${title} ${series} ${number} -1 -1 -1 -1 ${writer} ${pageCount} 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')); if (contentDiv && imgElements.length === 0) { imgElements = Array.from(contentDiv.querySelectorAll('img')); } 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; }); return imageUrls; }; // 이미지 다운로드 및 압축 헬퍼 함수 const processAndDownloadZip = async (title, imageUrls, imageBlobs) => { const zip = new JSZip(); // 1. Blob 사용 시 (가장 우선) if (imageBlobs && imageBlobs.length > 0) { console.log(`[다운로드] Blob 이미지 ${imageBlobs.length}개 사용`); imageBlobs.forEach((blob, i) => { const filename = `image${String(i).padStart(4, '0')}.jpg`; zip.file(filename, blob); }); const comicInfoContent = generateComicInfo(title, title, '', '1', imageBlobs.length, imageBlobs.length); zip.file('ComicInfo.xml', comicInfoContent); const content = await zip.generateAsync({ type: "blob" }); downloadZip(content, `${title}.zip`); console.log(`[완료] ${title} 다운로드 완료`); return; } // 2. URL 사용 시 if (!imageUrls || imageUrls.length === 0) { console.warn(`[스킵] ${title} - 이미지를 찾을 수 없습니다.`); return; } console.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 blob = new Blob([response.response], { type: 'image/jpeg' }); zip.file(filename, blob); } 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 comicInfoContent = generateComicInfo(title, title, '', '1', imageUrls.length, imageUrls.length); zip.file('ComicInfo.xml', comicInfoContent); const content = await zip.generateAsync({ type: "blob" }); downloadZip(content, `${title}.zip`); console.log(`[완료] ${title} 다운로드 완료`); }; // 에피소드 하나 다운로드 처리 (목록에서 연속 다운로드 시 사용) async function downloadEpisode(episodeTitle, url) { return new Promise(async (resolve) => { try { console.log(`[진행 중] ${episodeTitle}`); const iframe = document.createElement('iframe'); iframe.style.border = '5px solid red'; document.body.appendChild(iframe); try { const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; if (iframeDoc) { const script = iframeDoc.createElement('script'); script.type = 'text/javascript'; script.innerHTML = ` window.capturedImageUrls = []; window.capturedImageBlobs = []; const isTargetImage = (u) => u && typeof u === 'string' && (u.includes('.jpg') || u.includes('.png') || u.includes('.webp') || u.includes('/image') || u.includes('/photo')) && !u.includes('logo') && !u.includes('banner') && !u.includes('icon'); const origFetch = window.fetch; window.fetch = function(...args) { const u = typeof args[0] === 'string' ? args[0] : args[0]?.url; if (isTargetImage(u)) { if (!window.capturedImageUrls.includes(u)) window.capturedImageUrls.push(u); const result = origFetch.apply(this, args); if (result instanceof Promise) { result.then(response => { if (response.ok) { response.clone().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; } return origFetch.apply(this, args); }; const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, u, ...rest) { this._captureUrl = u; return origOpen.apply(this, [method, u, ...rest]); }; XMLHttpRequest.prototype.send = function(...args) { const xhr = this; const origOnreadystatechange = xhr.onreadystatechange; xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { const u = xhr._captureUrl; if (isTargetImage(u) && xhr.response instanceof ArrayBuffer) { try { const blob = new Blob([xhr.response], { type: xhr.getResponseHeader('content-type') || 'image/jpeg' }); if (blob.size > 100 && !window.capturedImageBlobs.some(b => b.size === blob.size)) { window.capturedImageBlobs.push(blob); } } catch(e) {} } } if (origOnreadystatechange) return origOnreadystatechange.apply(this, arguments); }; return origSend.apply(this, args); }; `; iframeDoc.head.appendChild(script); } } catch(e) {} await new Promise((res) => { iframe.addEventListener('load', () => res(), { once: true }); iframe.src = url; }); await sleep(3000); const iframeWindow = iframe.contentWindow; const iframeDocument = iframeWindow.document; let lastScroll = -1; let scrollAttempts = 0; while (scrollAttempts < 50) { iframeWindow.scrollBy(0, window.innerHeight || 800); await sleep(300); if (iframeWindow.scrollY === lastScroll || iframeWindow.scrollY + iframeWindow.innerHeight >= iframeDocument.documentElement.scrollHeight) { break; } lastScroll = iframeWindow.scrollY; scrollAttempts++; } await sleep(1500); let imageBlobs = []; if (iframeWindow && iframeWindow.capturedImageBlobs && iframeWindow.capturedImageBlobs.length > 0) { imageBlobs = [...iframeWindow.capturedImageBlobs]; } else if (window.capturedImageBlobs && window.capturedImageBlobs.length > 0) { imageBlobs = [...window.capturedImageBlobs]; } let imageUrls = []; if (imageBlobs.length === 0) { imageUrls = extractImagesFromDom(iframeDocument, protocolDomain); if (imageUrls.length === 0) { if (iframeWindow && iframeWindow.capturedImageUrls && iframeWindow.capturedImageUrls.length > 0) { imageUrls = [...iframeWindow.capturedImageUrls]; } else if (window.capturedImageUrls && window.capturedImageUrls.length > 0) { imageUrls = [...window.capturedImageUrls]; } } } iframe.remove(); await processAndDownloadZip(episodeTitle, imageUrls, imageBlobs); resolve(); } catch (err) { console.error(`[오류] ${episodeTitle}:`, err); resolve(); } }); } async function tokiDownload() { try { if (isListPage) { console.clear(); console.log('목록 페이지 처리 시작'); const episodeList = document.querySelector('ul.episode-list') || document.querySelector('.list-body') || document.querySelector('.board-list') || document.querySelector('.list-container') || document.querySelector('#bo_list'); if (!episodeList) { alert('에피소드 목록을 찾을 수 없습니다.'); return; } const items = Array.from(episodeList.querySelectorAll('li, tr, .list-item, .item, .list-row')); const episodes = []; items.forEach((item) => { const button = item.querySelector('button[onclick*="location.href"]'); const link = item.querySelector('a[href*="wr_id="]'); 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 clone = link.cloneNode(true); clone.querySelectorAll('span, img, i, b, strong').forEach(s => s.remove()); let episodeTitle = clone.innerText.trim(); if (!episodeTitle) episodeTitle = link.innerText.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]+)/); const uniqueKey = wrIdMatch ? wrIdMatch[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++) { await downloadEpisode(uniqueEpisodes[i].title, uniqueEpisodes[i].url); await sleep(3000); } alert('모든 에피소드 다운로드가 완료되었습니다.'); } else { const titleElement = document.querySelector('h1') || document.querySelector('.view-title'); const title = titleElement ? titleElement.innerText.trim() : '제목없음'; console.clear(); console.log(`단일 다운로드 시작: ${title}`); 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)) { alert('이미지를 감지하지 못했습니다. 페이지가 완전히 로드되지 않았을 수 있습니다.'); return; } await processAndDownloadZip(title, imageUrls, imageBlobs); } } catch (error) { alert(`오류발생: \n` + error); console.error(error); } } if (isListPage) { GM_registerMenuCommand('[일일툰] 전체 에피소드 다운로드', () => tokiDownload()); } else { GM_registerMenuCommand('[일일툰] 현재 에피소드 다운로드', () => tokiDownload()); } })();