NovelCrow ZIP Downloader

Downloads images from novelcrow.com into a ZIP file (supports lazy loading)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         NovelCrow ZIP Downloader
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Downloads images from novelcrow.com into a ZIP file (supports lazy loading)
// @author       B14CKWXD
// @match        https://novelcrow.com/*
// @grant        GM_xmlhttpRequest
// @connect      novelcrow.com
// @connect      cdnjs.cloudflare.com
// @connect      *
// ==/UserScript==

(function() {
    'use strict';

    let isDownloading = false;
    let JSZipLib = null;

    // --- Load JSZip dynamically ---
    function loadJSZip(callback) {
        if (typeof JSZip !== 'undefined') {
            JSZipLib = JSZip;
            callback();
            return;
        }

        const script = document.createElement('script');
        script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
        script.onload = function() {
            console.log('[NovelCrow] JSZip loaded');
            JSZipLib = JSZip;
            callback();
        };
        script.onerror = function() {
            alert('Failed to load JSZip library.');
        };
        document.head.appendChild(script);
    }

    // --- Add control panel ---
    function addControlPanel() {
        const panel = document.createElement('div');
        panel.id = 'download-panel';
        panel.style.cssText = `
            position: fixed;
            top: 5px;
            right: 5px;
            z-index: 99999;
            padding: 6px;
            background: #1a1a2e;
            color: white;
            border-radius: 5px;
            font-family: Arial, sans-serif;
            min-width: 140px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.5);
            border: 1px solid #4a4a6a;
            font-size: 9px;
        `;

        panel.innerHTML = `
            <h3 style="margin:0 0 6px 0; color:#4CAF50; text-align:center; font-size:10px;">📚 NovelCrow</h3>

            <button id="load-all-btn" style="
                width: 100%;
                padding: 4px 6px;
                margin: 2px 0;
                background: linear-gradient(135deg, #9c27b0, #7b1fa2);
                color: white;
                border: none;
                border-radius: 3px;
                cursor: pointer;
                font-weight: bold;
                font-size: 9px;
            ">🔄 Load All Images</button>

            <button id="download-zip-btn" style="
                width: 100%;
                padding: 4px 6px;
                margin: 2px 0;
                background: linear-gradient(135deg, #4CAF50, #45a049);
                color: white;
                border: none;
                border-radius: 3px;
                cursor: pointer;
                font-weight: bold;
                font-size: 9px;
            ">📦 Download ZIP</button>

            <button id="download-chapter-list-btn" style="
                width: 100%;
                padding: 3px 6px;
                margin: 2px 0;
                background: linear-gradient(135deg, #2196F3, #1976D2);
                color: white;
                border: none;
                border-radius: 3px;
                cursor: pointer;
                font-size: 8px;
            ">📋 All Chapters</button>

            <div id="progress-container" style="margin-top: 6px; display: none;">
                <div style="display:flex; justify-content:space-between; margin-bottom:2px; font-size:8px;">
                    <span id="progress-text">...</span>
                    <span id="progress-percent">0%</span>
                </div>
                <div style="width: 100%; height: 6px; background: #2a2a4a; border-radius: 3px; overflow: hidden;">
                    <div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); transition: width 0.3s; border-radius: 3px;"></div>
                </div>
            </div>

            <div id="status" style="margin-top: 4px; font-size: 7px; max-height: 80px; overflow-y: auto; background: #0d0d1a; padding: 4px; border-radius: 3px; display: none; line-height: 1.3;"></div>
        `;

        document.body.appendChild(panel);

        document.getElementById('load-all-btn').onclick = loadAllLazyImages;
        document.getElementById('download-zip-btn').onclick = startDownload;
        document.getElementById('download-chapter-list-btn').onclick = downloadAllChapters;
    }

    function showProgress() {
        document.getElementById('progress-container').style.display = 'block';
        document.getElementById('status').style.display = 'block';
    }

    function updateProgress(current, total, text) {
        const percent = Math.round((current / total) * 100);
        document.getElementById('progress-bar').style.width = percent + '%';
        document.getElementById('progress-percent').textContent = percent + '%';
        document.getElementById('progress-text').textContent = text || `${current}/${total}`;
    }

    function log(message) {
        const status = document.getElementById('status');
        if (status) {
            status.innerHTML += `<div style="margin:1px 0;">${message}</div>`;
            status.scrollTop = status.scrollHeight;
        }
        console.log('[NovelCrow]', message);
    }

    function clearLog() {
        const status = document.getElementById('status');
        if (status) status.innerHTML = '';
    }

    function setButtonState(btnId, text, color) {
        const btn = document.getElementById(btnId);
        if (btn) {
            btn.textContent = text;
            btn.style.background = color;
        }
    }

    // --- Load all lazy-loaded images ---
    function loadAllLazyImages() {
        setButtonState('load-all-btn', '⏳ Loading...', '#FFC107');
        showProgress();
        clearLog();
        log('🔄 Loading lazy images...');

        let scrollAttempts = 0;
        const maxScrollAttempts = 30;

        function scrollAndWait() {
            window.scrollTo(0, document.body.scrollHeight);
            scrollAttempts++;
            updateProgress(scrollAttempts, maxScrollAttempts, 'Scrolling...');

            if (scrollAttempts < maxScrollAttempts) {
                setTimeout(scrollAndWait, 300);
            } else {
                window.scrollTo(0, 0);

                // Force load lazy images
                const lazyImages = document.querySelectorAll('img[data-src], img[data-lazy-src], img.lazyload, img.lazy');
                log('Found ' + lazyImages.length + ' lazy images');

                lazyImages.forEach((img) => {
                    const src = img.dataset.src || img.dataset.lazySrc;
                    if (src) {
                        img.src = src;
                    }
                    img.classList.remove('lazyload', 'lazy');
                });

                setTimeout(() => {
                    setButtonState('load-all-btn', '✅ Loaded!', '#4CAF50');
                    log('✅ Images loaded!');
                }, 2000);
            }
        }

        scrollAndWait();
    }

    // --- Get chapter title ---
    function getChapterTitle() {
        const selectors = ['h1.entry-title', '.chapter-title', 'h1', '.post-title'];

        for (const selector of selectors) {
            const titleEl = document.querySelector(selector);
            if (titleEl && titleEl.textContent.trim()) {
                return titleEl.textContent.trim()
                    .replace(/[<>:"/\\|?*]/g, '')
                    .replace(/\s+/g, '_')
                    .substring(0, 80);
            }
        }

        const urlParts = window.location.pathname.split('/').filter(Boolean);
        return urlParts[urlParts.length - 1] || 'chapter';
    }

    // --- Get ONLY comic images using specific selectors ---
    function getComicImages() {
        // These selectors specifically target comic/manga images, NOT thumbnails
        const comicSelectors = [
            '.wp-manga-chapter-img',           // Most specific - manga chapter images
            '.reading-content img',            // Reading area images
            '.chapter-content img',            // Chapter content images
            '#readerarea img',                 // Reader area
            '.page-break img'                  // Page break images
        ];

        for (const selector of comicSelectors) {
            const images = document.querySelectorAll(selector);
            if (images.length > 0) {
                console.log('[NovelCrow] Using selector:', selector, 'found:', images.length);
                return Array.from(images);
            }
        }

        return [];
    }

    // --- Download image via canvas (since direct fetch gets 403) ---
    function downloadImageViaCanvas(imgElement, callback) {
        try {
            // Wait for image to be fully loaded
            if (!imgElement.complete || imgElement.naturalWidth === 0) {
                callback(new Error('Image not loaded'), null);
                return;
            }

            const canvas = document.createElement('canvas');
            canvas.width = imgElement.naturalWidth;
            canvas.height = imgElement.naturalHeight;

            const ctx = canvas.getContext('2d');
            ctx.drawImage(imgElement, 0, 0);

            canvas.toBlob(function(blob) {
                if (blob) {
                    const reader = new FileReader();
                    reader.onload = function() {
                        callback(null, reader.result);
                    };
                    reader.onerror = function() {
                        callback(new Error('FileReader error'), null);
                    };
                    reader.readAsArrayBuffer(blob);
                } else {
                    callback(new Error('Canvas toBlob failed'), null);
                }
            }, 'image/png', 1.0);
        } catch (e) {
            console.error('[NovelCrow] Canvas error:', e);
            callback(e, null);
        }
    }

    // --- Create and download ZIP ---
    function createAndDownloadZip(files, zipFilename) {
        log('📦 Creating ZIP...');
        setButtonState('download-zip-btn', '📦 ZIP...', '#9C27B0');

        if (!JSZipLib) {
            log('❌ JSZip not loaded');
            return;
        }

        try {
            const zip = new JSZipLib();

            files.forEach(function(file) {
                zip.file(file.name, file.data);
            });

            zip.generateAsync({
                type: 'blob',
                compression: 'STORE'
            }).then(function(blob) {
                log('✅ ' + (blob.size / 1024 / 1024).toFixed(1) + 'MB');

                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = zipFilename;
                document.body.appendChild(a);
                a.click();

                setTimeout(function() {
                    document.body.removeChild(a);
                    URL.revokeObjectURL(url);
                }, 500);

                log('🎉 Done!');
                setButtonState('download-zip-btn', '✅ Done!', '#4CAF50');
                isDownloading = false;

            }).catch(function(err) {
                log('❌ ZIP error');
                console.error(err);
                isDownloading = false;
            });

        } catch (e) {
            log('❌ Error');
            console.error(e);
            isDownloading = false;
        }
    }

    // --- Main download function (uses canvas directly since fetch gets 403) ---
    function startDownload() {
        if (isDownloading) {
            return;
        }

        if (!JSZipLib) {
            loadJSZip(function() {
                startDownload();
            });
            return;
        }

        isDownloading = true;
        showProgress();
        clearLog();

        const title = getChapterTitle();
        log('📖 ' + title.substring(0, 25) + '...');

        setButtonState('download-zip-btn', '⏳ Scan...', '#FFC107');

        // Get ONLY comic images (not thumbnails)
        const comicImages = getComicImages();

        if (comicImages.length === 0) {
            log('❌ No comic images found');
            log('Try "Load All Images" first');
            setButtonState('download-zip-btn', '❌ None', '#f44336');
            isDownloading = false;
            return;
        }

        log('✅ ' + comicImages.length + ' comic images');

        // Use canvas method directly (since fetch gets 403)
        const files = [];
        let currentIndex = 0;
        let successCount = 0;

        function processNext() {
            if (currentIndex >= comicImages.length) {
                log('📊 ' + successCount + '/' + comicImages.length);

                if (files.length === 0) {
                    log('❌ All failed');
                    setButtonState('download-zip-btn', '❌ Failed', '#f44336');
                    isDownloading = false;
                    return;
                }

                updateProgress(100, 100, 'ZIP...');
                setTimeout(function() {
                    createAndDownloadZip(files, title + '.zip');
                }, 300);
                return;
            }

            const img = comicImages[currentIndex];
            const paddedIndex = String(currentIndex + 1).padStart(4, '0');

            // Try to get original filename from src
            const src = img.src || img.dataset.src || '';
            let filename = src.substring(src.lastIndexOf('/') + 1).split('?')[0];
            if (!filename || filename.length < 3) {
                filename = 'image.png';
            }
            // Ensure it has an extension
            if (!filename.match(/\.(jpg|jpeg|png|webp|gif)$/i)) {
                filename = filename.replace(/\.[^.]+$/, '') + '.png';
            }

            const finalFilename = paddedIndex + '_' + filename;

            updateProgress(currentIndex + 1, comicImages.length, (currentIndex + 1) + '/' + comicImages.length);
            setButtonState('download-zip-btn', '⏳ ' + (currentIndex + 1) + '/' + comicImages.length, '#FFC107');

            downloadImageViaCanvas(img, function(err, data) {
                if (err || !data) {
                    log('❌ ' + (currentIndex + 1));
                } else {
                    log('✅ ' + (currentIndex + 1));
                    files.push({ name: finalFilename, data: data });
                    successCount++;
                }

                currentIndex++;
                setTimeout(processNext, 100);
            });
        }

        processNext();
    }

    // --- Download all chapters ---
    function downloadAllChapters() {
        if (isDownloading) {
            return;
        }

        if (!JSZipLib) {
            loadJSZip(function() {
                downloadAllChapters();
            });
            return;
        }

        showProgress();
        clearLog();
        log('🔍 Finding chapters...');

        const chapterSelectors = [
            '.wp-manga-chapter a',
            'li.wp-manga-chapter a',
            '.listing-chapters_wrap a',
            '.chapters-list a',
            'ul.main li a',
            '.eplister a',
            '#chapterlist a'
        ];

        let chapterLinks = [];

        for (const selector of chapterSelectors) {
            const links = document.querySelectorAll(selector);
            if (links.length > 0) {
                log('Found via: ' + selector);
                links.forEach(link => {
                    if (link.href && link.href.includes('novelcrow.com')) {
                        chapterLinks.push({
                            url: link.href,
                            title: link.textContent.trim().substring(0, 50)
                        });
                    }
                });
                break;
            }
        }

        // Remove duplicates
        const uniqueChapters = [];
        const seenUrls = new Set();
        chapterLinks.forEach(ch => {
            if (!seenUrls.has(ch.url)) {
                seenUrls.add(ch.url);
                uniqueChapters.push(ch);
            }
        });

        if (uniqueChapters.length === 0) {
            log('❌ No chapters found');
            return;
        }

        log('✅ ' + uniqueChapters.length + ' chapters');

        if (!confirm('Found ' + uniqueChapters.length + ' chapters.\n\nDownload all?')) {
            return;
        }

        isDownloading = true;
        processChapterList(uniqueChapters, 0);
    }

    function processChapterList(chapters, index) {
        if (index >= chapters.length) {
            setButtonState('download-chapter-list-btn', '✅ Done!', '#4CAF50');
            log('🎉 All done!');
            isDownloading = false;
            return;
        }

        const chapter = chapters[index];

        log('[' + (index + 1) + '/' + chapters.length + '] ' + chapter.title.substring(0, 15) + '...');
        updateProgress(index + 1, chapters.length, (index + 1) + '/' + chapters.length);
        setButtonState('download-chapter-list-btn', '⏳ ' + (index + 1) + '/' + chapters.length, '#FFC107');

        // Open chapter in hidden iframe to load images, then capture via canvas
        processChapterViaIframe(chapter.url, chapter.title, function() {
            setTimeout(function() {
                processChapterList(chapters, index + 1);
            }, 3000);
        });
    }

    // --- Process chapter by loading in iframe ---
    function processChapterViaIframe(url, chapterTitle, callback) {
        const cleanTitle = chapterTitle
            .replace(/[<>:"/\\|?*]/g, '')
            .replace(/\s+/g, '_')
            .substring(0, 80);

        // Create hidden iframe
        const iframe = document.createElement('iframe');
        iframe.style.cssText = 'position:fixed; top:-9999px; left:-9999px; width:1px; height:1px; opacity:0;';
        document.body.appendChild(iframe);

        iframe.onload = function() {
            // Wait for images to load
            setTimeout(function() {
                try {
                    const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;

                    // Get comic images from iframe
                    const comicSelectors = [
                        '.wp-manga-chapter-img',
                        '.reading-content img',
                        '.page-break img'
                    ];

                    let images = [];
                    for (const selector of comicSelectors) {
                        images = iframeDoc.querySelectorAll(selector);
                        if (images.length > 0) break;
                    }

                    log('  Found ' + images.length + ' images');

                    if (images.length === 0) {
                        document.body.removeChild(iframe);
                        callback();
                        return;
                    }

                    // Download via canvas
                    const files = [];
                    let imgIndex = 0;

                    function downloadNextFromIframe() {
                        if (imgIndex >= images.length) {
                            // Create ZIP
                            if (files.length > 0) {
                                const zip = new JSZipLib();
                                files.forEach(f => zip.file(f.name, f.data));

                                zip.generateAsync({ type: 'blob', compression: 'STORE' })
                                    .then(function(blob) {
                                        const downloadUrl = URL.createObjectURL(blob);
                                        const a = document.createElement('a');
                                        a.href = downloadUrl;
                                        a.download = cleanTitle + '.zip';
                                        document.body.appendChild(a);
                                        a.click();
                                        setTimeout(function() {
                                            document.body.removeChild(a);
                                            URL.revokeObjectURL(downloadUrl);
                                        }, 500);
                                        log('  ✅ ' + cleanTitle.substring(0, 15) + '.zip');
                                        document.body.removeChild(iframe);
                                        callback();
                                    })
                                    .catch(function() {
                                        log('  ❌ ZIP fail');
                                        document.body.removeChild(iframe);
                                        callback();
                                    });
                            } else {
                                document.body.removeChild(iframe);
                                callback();
                            }
                            return;
                        }

                        const img = images[imgIndex];
                        const paddedIdx = String(imgIndex + 1).padStart(4, '0');
                        const filename = paddedIdx + '_image.png';

                        downloadImageViaCanvas(img, function(err, data) {
                            if (!err && data) {
                                files.push({ name: filename, data: data });
                            }
                            imgIndex++;
                            setTimeout(downloadNextFromIframe, 100);
                        });
                    }

                    downloadNextFromIframe();

                } catch (e) {
                    console.error('[NovelCrow] Iframe error:', e);
                    log('  ❌ Access error');
                    document.body.removeChild(iframe);
                    callback();
                }
            }, 5000); // Wait 5 seconds for images to load
        };

        iframe.onerror = function() {
            log('  ❌ Load fail');
            document.body.removeChild(iframe);
            callback();
        };

        iframe.src = url;
    }

    // --- Initialize ---
    function init() {
        loadJSZip(function() {
            addControlPanel();
            log('✅ Ready!');
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();