Greasy Fork is available in English.

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