ComicScraper

Download comics from readallcomics.com as CBZ files

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name        ComicScraper
// @namespace   Violentmonkey Scripts
// @icon        https://readallcomics.com/favicon.ico
// @version     1.1.0
// @match       https://readallcomics.com/*
// @grant       GM_xmlhttpRequest
// @author      Retr0Hac
// @license MIT
// @description Download comics from readallcomics.com as CBZ files
// ==/UserScript==

(function () {
    'use strict';

    if (!window.location.pathname.startsWith('/category/')) return;

    const listStories = document.getElementsByClassName('list-story');
    if (listStories.length < 2) return;

    // Parse series name and year from category slug, e.g. "batman-2016" → "Batman", "2016"
    const categorySlug = window.location.pathname.replace('/category/', '').replace(/\/$/, '');
    const slugYearMatch = categorySlug.match(/-(\d{4})$/);
    const slugYear = slugYearMatch ? slugYearMatch[1] : null;
    const nameSlug = slugYear ? categorySlug.slice(0, -(1 + slugYear.length)) : categorySlug;
    const seriesName = nameSlug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');

    function issueFilename(rawName, idx) {
        // Extract issue number: handles "001", "#1", "#001", "1"
        const numMatch = rawName.match(/#?\s*0*(\d+)/);
        const num = numMatch ? String(parseInt(numMatch[1], 10)).padStart(3, '0') : String(idx).padStart(3, '0');
        // Year: prefer slug, fall back to "(YYYY)" in link text
        const year = slugYear || ((rawName.match(/\((\d{4})\)/) || [])[1] || null);
        const base = year ? `${seriesName} (${year}) ${num}` : `${seriesName} ${num}`;
        return base.replace(/[/\\:*?"<>|]/g, '_');
    }

    const issueItems = Array.from(listStories[1].children);
    const issues = issueItems.map(item => ({
        url: item.children[0].href,
        name: item.children[0].textContent.trim(),
    })).reverse();
    issues.forEach((issue, i) => { issue.filename = issueFilename(issue.name, i + 1); });

    if (issues.length === 0) return;
    console.log(`ComicScraper: found ${issues.length} issues, series="${seriesName}", year=${slugYear}`);

    // --- ZIP builder (no external dependency) ---

    function makeCrc32Table() {
        const t = new Uint32Array(256);
        for (let i = 0; i < 256; i++) {
            let c = i;
            for (let j = 0; j < 8; j++) c = (c & 1) ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
            t[i] = c;
        }
        return t;
    }
    const CRC_TABLE = makeCrc32Table();

    function crc32(data) {
        let c = 0xFFFFFFFF;
        for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xFF] ^ (c >>> 8);
        return (c ^ 0xFFFFFFFF) >>> 0;
    }

    // entries: [{name: string, data: Uint8Array}]
    function buildZip(entries) {
        const enc = new TextEncoder();
        const locals = [];
        const central = [];
        let offset = 0;

        for (const { name, data } of entries) {
            const nb = enc.encode(name);
            const crc = crc32(data);
            const sz = data.length;

            const lh = new Uint8Array(30 + nb.length);
            const lv = new DataView(lh.buffer);
            lv.setUint32(0, 0x04034b50, true);
            lv.setUint16(4, 20, true);
            lv.setUint32(14, crc, true);
            lv.setUint32(18, sz, true);
            lv.setUint32(22, sz, true);
            lv.setUint16(26, nb.length, true);
            lh.set(nb, 30);

            const cd = new Uint8Array(46 + nb.length);
            const cv = new DataView(cd.buffer);
            cv.setUint32(0, 0x02014b50, true);
            cv.setUint16(4, 20, true);
            cv.setUint16(6, 20, true);
            cv.setUint32(16, crc, true);
            cv.setUint32(20, sz, true);
            cv.setUint32(24, sz, true);
            cv.setUint16(28, nb.length, true);
            cv.setUint32(42, offset, true);
            cd.set(nb, 46);

            locals.push(lh, data);
            central.push(cd);
            offset += lh.length + sz;
        }

        const cdOffset = offset;
        const cdSize = central.reduce((s, c) => s + c.length, 0);

        const eocd = new Uint8Array(22);
        const ev = new DataView(eocd.buffer);
        ev.setUint32(0, 0x06054b50, true);
        ev.setUint16(8, entries.length, true);
        ev.setUint16(10, entries.length, true);
        ev.setUint32(12, cdSize, true);
        ev.setUint32(16, cdOffset, true);

        const all = [...locals, ...central, eocd];
        const total = all.reduce((s, a) => s + a.length, 0);
        const out = new Uint8Array(total);
        let pos = 0;
        for (const chunk of all) { out.set(chunk, pos); pos += chunk.length; }
        return out;
    }

    // --- Helpers ---

    function parseRange(str, max) {
        const indices = new Set();
        for (const part of str.split(',').map(s => s.trim())) {
            if (!part) continue;
            const [rawA, rawB] = part.split('-');
            const a = parseInt(rawA, 10), b = rawB !== undefined ? parseInt(rawB, 10) : a;
            for (let i = a; i <= Math.min(b, max); i++) if (i >= 1) indices.add(i - 1);
        }
        return [...indices].sort((a, b) => a - b);
    }

    function fetchPage(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url,
                onload: r => resolve(new DOMParser().parseFromString(r.responseText, 'text/html')),
                onerror: reject,
            });
        });
    }

    function fetchBinary(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url,
                responseType: 'arraybuffer',
                headers: { Referer: 'https://readallcomics.com/' },
                onload: r => resolve(r.response),
                onerror: reject,
            });
        });
    }

    function extractImageUrls(doc) {
        for (const sel of [
            '.reading-content img',
            '#spliced-pages img',
            '.spliced img',
            '.chapter-content img',
            '.post-inner img',
            '.entry-content img',
            'article img',
            'img',
        ]) {
            const imgs = [...doc.querySelectorAll(sel)];
            if (imgs.length === 0) continue;
            const urls = imgs
                .map(img =>
                    img.getAttribute('data-src') ||
                    img.getAttribute('data-lazy-src') ||
                    img.getAttribute('data-original') ||
                    img.src
                )
                .filter(src => src && /\.(jpe?g|png|webp|gif)/i.test(src) && !/logo|icon|avatar|ad[_-]/i.test(src));
            console.log(`ComicScraper [${sel}]: ${urls.length} images`, urls.slice(0, 3));
            if (urls.length > 0) return urls;
        }
        return [];
    }

    function ext(url) {
        return (url.match(/\.(jpe?g|png|webp|gif)/i) || ['', 'jpg'])[1].replace('jpeg', 'jpg');
    }

    // --- Download one issue as CBZ ---

    async function downloadIssue(issue, setStatus) {
        setStatus(`Fetching: ${issue.name}`);
        let doc;
        try {
            doc = await fetchPage(issue.url);
        } catch {
            setStatus(`ERROR: could not fetch ${issue.name}`);
            return;
        }

        const urls = extractImageUrls(doc);
        if (urls.length === 0) {
            setStatus(`ERROR: no images found in ${issue.name}`);
            return;
        }

        const entries = [];
        for (let i = 0; i < urls.length; i++) {
            setStatus(`${issue.name}: page ${i + 1} / ${urls.length}`);
            try {
                const buf = await fetchBinary(urls[i]);
                if (buf && buf.byteLength > 0) {
                    entries.push({ name: `${String(i + 1).padStart(3, '0')}.${ext(urls[i])}`, data: new Uint8Array(buf) });
                } else {
                    console.warn('ComicScraper: empty response for', urls[i]);
                }
            } catch (e) {
                console.warn('ComicScraper: failed to fetch', urls[i], e);
            }
        }

        if (entries.length === 0) {
            setStatus(`ERROR: no pages saved for ${issue.name}`);
            return;
        }

        setStatus(`Packaging: ${issue.name} (${entries.length} pages)…`);
        await new Promise(r => setTimeout(r, 0)); // let status render

        const zipBytes = buildZip(entries);
        const blob = new Blob([zipBytes], { type: 'application/zip' });

        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = `${issue.filename}.cbz`;
        a.style.display = 'none';
        document.body.appendChild(a);
        a.click();
        setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 1000);

        setStatus(`Done: ${issue.name}`);
    }

    // --- UI ---

    const BLUE = '#2b6cb0';

    const ui = document.createElement('div');
    ui.style.cssText = 'display:flex;align-items:center;gap:8px;margin:10px 0;flex-wrap:wrap';

    const label = document.createElement('span');
    label.textContent = `Issues (1–${issues.length}):`;
    label.style.fontWeight = 'bold';

    const input = document.createElement('input');
    input.type = 'text';
    input.placeholder = `e.g. 1-${issues.length}`;
    input.style.cssText = 'padding:4px 8px;width:130px;border:1px solid #ccc;border-radius:3px;font-size:0.9em';

    const btn = document.createElement('button');
    btn.textContent = 'Download CBZ';
    btn.style.cssText = `padding:4px 14px;cursor:pointer;background:${BLUE};color:#fff;border:none;border-radius:3px;font-size:0.9em`;

    const testBtn = document.createElement('button');
    testBtn.textContent = 'Test #1';
    testBtn.title = 'Fetch issue #1 and log image URLs to console';
    testBtn.style.cssText = 'padding:4px 10px;cursor:pointer;background:#718096;color:#fff;border:none;border-radius:3px;font-size:0.9em';

    const status = document.createElement('span');
    status.style.cssText = 'font-style:italic;color:#555;font-size:0.85em';

    const setStatus = text => { status.textContent = text; };

    testBtn.addEventListener('click', async () => {
        testBtn.disabled = true;
        setStatus('Testing issue #1…');
        try {
            const doc = await fetchPage(issues[0].url);
            const urls = extractImageUrls(doc);
            setStatus(`Test: found ${urls.length} images — check console`);
            console.log('ComicScraper TEST urls:', urls);
        } catch (e) {
            setStatus('Test failed — check console');
            console.error('ComicScraper TEST error:', e);
        }
        testBtn.disabled = false;
    });

    btn.addEventListener('click', async () => {
        const rangeStr = input.value.trim() || `1-${issues.length}`;
        const indices = parseRange(rangeStr, issues.length);
        if (indices.length === 0) { setStatus('Invalid range.'); return; }

        btn.disabled = true;
        btn.style.opacity = '0.6';
        for (const idx of indices) {
            await downloadIssue(issues[idx], setStatus);
        }
        setStatus(`Finished — ${indices.length} issue(s) downloaded.`);
        btn.disabled = false;
        btn.style.opacity = '1';
    });

    ui.append(label, input, btn, testBtn, status);

    const shareBtn = document.querySelector('.a2a_dd.addtoany_share_save.addtoany_share');
    if (shareBtn) {
        shareBtn.replaceWith(ui);
    } else {
        listStories[1].after(ui);
    }
})();