ComicScraper

Download comics from readallcomics.com as CBZ files

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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);
    }
})();