ComicScraper

Download comics from readallcomics.com as CBZ files

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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