Download comics from readallcomics.com as CBZ files
// ==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);
}
})();