/*
* Comix Downloader — manual fallback
*
* Temporary workaround for the rare scrambled-image bug that the browser
* extension can't currently handle (see issue #2). The browser extension
* loads the chapter in a hidden background tab, which throttles the site's
* own canvas renderer and prevents it from drawing the unscrambled image
* for the few pages comix.to actually scrambles. This script does the same
* job — but from the user's *foreground* tab where rendering works.
*
* Usage (paste-once):
* 1. Open the affected chapter on comix.to.
* 2. Open DevTools (F12) → Console tab.
* 3. Paste the bootloader from the README and press Enter.
* 4. Don't switch tabs. Watch the green-bordered panel in the top-right.
* 5. When it says "✓ Saved", check your Downloads folder.
*
* Or paste this whole file directly into the console if the bootloader is
* blocked by the site's CSP.
*/
(async () => {
// 1. Pull JSZip from a CDN (the page doesn't have it on its own)
if (typeof JSZip === 'undefined') {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js';
s.onload = res;
s.onerror = () => rej(new Error('JSZip CDN load failed — site CSP may block jsdelivr'));
document.head.appendChild(s);
});
}
// 2. Floating progress UI
document.getElementById('cdl-fb')?.remove();
const ui = document.createElement('div');
ui.id = 'cdl-fb';
ui.style.cssText = 'position:fixed;top:20px;right:20px;width:360px;background:#0a0a14;color:#e8ecf0;padding:14px;border:2px solid #4ade80;border-radius:10px;z-index:2147483647;font:13px -apple-system,BlinkMacSystemFont,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,0.6)';
ui.innerHTML = '
Comix Downloader — manual fallback
' +
'
Starting…
' +
'
' +
'
' +
'
0 / ?
' +
'';
document.body.appendChild(ui);
const msg = (t) => ui.querySelector('#cdl-msg').textContent = t;
const bar = (n, d) => {
ui.querySelector('#cdl-bar').style.width = (d ? (n / d * 100).toFixed(1) : 0) + '%';
ui.querySelector('#cdl-counter').textContent = n + ' / ' + (d || '?');
};
let cancelled = false;
ui.querySelector('#cdl-cancel').onclick = () => { cancelled = true; msg('Cancelling…'); };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Helper — decode a data URL ("data:image/X;base64,...") into bytes
const decodeDataUrl = (dataUrl) => {
const m = dataUrl.match(/^data:([^;,]+)(?:;base64)?,(.*)$/);
if (!m) throw new Error('Invalid data URL');
const binary = atob(m[2]);
const buf = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
const ext = (m[1].split('/')[1] || 'png').toLowerCase();
return { buf, ext };
};
// Helper — pull canvas pixels reliably. Re-queries each attempt because
// React re-renders blow away the canvas DOM node and reset dimensions.
// Returns { buf, ext } or null if the canvas can't be captured (tainted,
// unrendered, etc.) — caller falls back to fetching the raw URL.
const captureCanvas = async (pageEl) => {
const MAX_ATTEMPTS = 80; // ~8s of polling
let lastCenterAlpha = 0;
let stableCount = 0;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const canvas = pageEl.querySelector('canvas');
if (!canvas || canvas.width === 0 || canvas.height === 0) {
// Likely React just cleaned it up — wait and retry
await sleep(100);
continue;
}
let alpha;
try {
const ctx = canvas.getContext('2d');
alpha = ctx.getImageData(canvas.width >> 1, canvas.height >> 1, 1, 1).data[3];
} catch (_) {
// Tainted canvas — can't read at all, bail to URL fetch
return null;
}
if (alpha > 0) {
// Require two consecutive non-empty reads so we don't grab mid-frame
if (lastCenterAlpha > 0) stableCount++;
if (stableCount >= 1) {
try {
const dataUrl = canvas.toDataURL('image/webp', 0.95);
const final = dataUrl.startsWith('data:image/webp')
? dataUrl
: canvas.toDataURL('image/png');
return decodeDataUrl(final);
} catch (_) {
return null;
}
}
} else {
stableCount = 0;
}
lastCenterAlpha = alpha;
await sleep(100);
}
return null;
};
// Helper — fall back to fetching the CDN URL directly (will be mosaic if scrambled)
const fetchRaw = async (url) => {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
const ext = (url.match(/\.([a-z0-9]+)$/i) || [, 'webp'])[1];
return { buf: new Uint8Array(await res.arrayBuffer()), ext };
};
try {
// 3. Filename derivation
const mangaTitle =
document.querySelector('.rpage-header__title')?.textContent?.trim() ||
document.title.replace(/\s*[-|].*$/, '').trim() || 'manga';
const slug = mangaTitle.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
const chMatch = location.pathname.match(/\/\d+-chapter-([\w.-]+)/i);
const zipName = (slug || 'manga') + '-Ch' + (chMatch ? chMatch[1] : 'unknown') + '.zip';
// 4. Find pages
const initialPages = [...document.querySelectorAll('.rpage-page')]
.sort((a, b) => (+a.dataset.page) - (+b.dataset.page));
if (initialPages.length === 0) throw new Error('No .rpage-page elements found. Make sure you are on a comix.to chapter reader page.');
// 5. SINGLE PASS — scroll to each page in order, capture immediately while it's still in view
// (avoids React's cleanup wiping the canvas between scroll and capture).
msg('Capturing ' + initialPages.length + ' pages…');
const zip = new JSZip();
let captured = 0;
let mosaicFallbacks = 0;
for (let i = 0; i < initialPages.length; i++) {
if (cancelled) return msg('Cancelled.');
// Re-query (React may have re-keyed the DOM nodes)
const allNow = [...document.querySelectorAll('.rpage-page')]
.sort((a, b) => (+a.dataset.page) - (+b.dataset.page));
const el = allNow[i] || initialPages[i];
const dataPage = +el.dataset.page;
const paddedIdx = String(dataPage).padStart(3, '0');
el.scrollIntoView({ block: 'center', behavior: 'auto' });
// Snapshot the URL from the img early — React may unmount it later
const initialImgSrc = (() => {
const img = el.querySelector('img');
return img?.currentSrc || img?.src || null;
})();
// Always try to capture canvas first (works for scrambled pages).
// captureCanvas re-queries on every attempt so DOM changes are tolerated.
let item = await captureCanvas(el);
// If no canvas (non-scrambled page) or canvas read failed, fall back to URL fetch
if (!item) {
// Re-look for the URL one more time, then use the snapshot
const img = el.querySelector('img');
const url = (img?.currentSrc || img?.src) || initialImgSrc;
if (url) {
try {
item = await fetchRaw(url);
if (el.querySelector('canvas')) mosaicFallbacks++; // had canvas but couldn't read it → likely mosaic in output
} catch (err) {
console.warn('[ComixDL fallback] page ' + dataPage + ' fetch failed:', err);
zip.file(paddedIdx + '_ERROR.txt',
'Page ' + dataPage + ' could not be captured.\n' +
'Error: ' + err.message + '\nURL: ' + url);
}
} else {
console.warn('[ComixDL fallback] page ' + dataPage + ' has no img and no readable canvas');
zip.file(paddedIdx + '_ERROR.txt',
'Page ' + dataPage + ' had no image URL and no readable canvas.\n' +
'React likely had not rendered this page yet — try scrolling slower or running again.');
}
}
if (item) {
zip.file(paddedIdx + '.' + item.ext, item.buf);
captured++;
}
bar(i + 1, initialPages.length);
}
if (cancelled) return msg('Cancelled.');
// 6. Pack + download
msg('Building ZIP…');
const blob = await zip.generateAsync({ type: 'blob', compression: 'STORE' });
const objUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objUrl;
a.download = zipName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(objUrl), 60_000);
const sizeMB = (blob.size / 1048576).toFixed(1);
let summary = '✓ Saved as ' + zipName + ' (' + captured + '/' + initialPages.length + ' pages, ' + sizeMB + ' MB)';
if (mosaicFallbacks > 0) summary += '. ⚠ ' + mosaicFallbacks + ' page(s) may still be mosaic.';
msg(summary);
ui.querySelector('#cdl-cancel').textContent = 'Close';
ui.querySelector('#cdl-cancel').onclick = () => ui.remove();
} catch (err) {
msg('✗ Failed: ' + err.message);
console.error(err);
}
})();