// ==UserScript==
// @name Shonen Jump+ Downloader (unscramble)
// @namespace https://shonenjumpplus.com/
// @version 2.0.0
// @description Download your legally-purchased Shonen Jump+ magazine/episode/volume pages as unscrambled JPEGs, written one-by-one to a folder you choose. For personal accessibility use only.
// @author you
// @match https://shonenjumpplus.com/magazine/*
// @match https://shonenjumpplus.com/episode/*
// @match https://shonenjumpplus.com/volume/*
// @match https://shonenjumpplus.com/viewer/*
// @grant GM_download
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const CONFIG = {
// The scramble is a 4x4 grid transpose. The inner rect is the largest
// multiple of (DIV*MULT) that fits inside the image; the right/bottom
// margins outside that rect are NOT scrambled and stay in place.
DIV: 4,
MULT: 8,
// Only download pages whose `type` is in this list. 'main' = actual
// manga/magazine content pages; skips ad thumbnails and link images.
PAGE_TYPES: new Set(['main']),
// Concurrency for image fetches. SJ+'s CDN (CloudFront/S3) handles this
// fine; keep modest to be polite.
CONCURRENCY: 6,
JPEG_QUALITY: 0.92,
// Where the page list lives on SJ+ pages.
EPISODE_JSON_ID: 'episode-json',
// Delay between individual <a download> fallback saves, in ms. Browsers
// block rapid-fire downloads as "abuse"; this stays under the limit.
FALLBACK_DELAY_MS: 350,
};
const HAS_FS_ACCESS = typeof window.showDirectoryPicker === 'function';
// ---------------------------------------------------------------------------
// Tiny helpers
// ---------------------------------------------------------------------------
const $ = (sel, root = document) => root.querySelector(sel);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
function log(...args) { console.log('[SJP-DL]', ...args); }
// Read the embedded readableProduct JSON. SJ+ renders the full page list
// into <script id="episode-json" type="text/json" data-value="..."> on
// /magazine/*, /episode/*, and /volume/* routes, so we do NOT need to flip
// through pages or wait for lazy-load.
function waitForEpisodeJson(timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const t0 = Date.now();
const tick = setInterval(() => {
const el = document.getElementById(CONFIG.EPISODE_JSON_ID);
if (el && el.getAttribute('data-value')) {
clearInterval(tick);
resolve(el);
} else if (Date.now() - t0 > timeoutMs) {
clearInterval(tick);
reject(new Error(`Timed out waiting for #${CONFIG.EPISODE_JSON_ID}. Are you on a purchased/viewable page?`));
}
}, 200);
});
}
async function getPages() {
const el = await waitForEpisodeJson();
const raw = el.getAttribute('data-value');
if (!raw) throw new Error('#episode-json has empty data-value');
const data = JSON.parse(raw);
const rp = data && data.readableProduct;
if (!rp) throw new Error('readableProduct missing from JSON');
const pages = rp && rp.pageStructure && rp.pageStructure.pages;
if (!Array.isArray(pages)) throw new Error('pageStructure.pages is not an array');
const title = (rp.title || 'shonenjumpplus').trim();
const id = rp.id || 'unknown';
const dir = sanitizeFilename(`${title} (${id})`);
const picked = pages
.map((p, i) => ({ page: p, origIndex: i }))
.filter(({ page }) => page && page.src && CONFIG.PAGE_TYPES.has(page.type));
return { title, id, dir, pages: picked };
}
function sanitizeFilename(s) {
return s
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
}
function pad(n, total) {
return String(n).padStart(String(total).length, '0');
}
// ---------------------------------------------------------------------------
// Unscramble: 4x4 grid transpose. Resolution-independent.
// ---------------------------------------------------------------------------
function unscrambleToCanvas(img) {
const w = img.naturalWidth;
const h = img.naturalHeight;
if (!w || !h) throw new Error('image has zero dimensions');
const div = CONFIG.DIV;
const mult = CONFIG.MULT;
const colWidth = Math.floor(w / (div * mult)) * mult;
const rowHeight = Math.floor(h / (div * mult)) * mult;
if (colWidth <= 0 || rowHeight <= 0) throw new Error(`cell size <= 0 for ${w}x${h}`);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
// Copy through the unscrambled margins so the canvas starts as a
// faithful copy of the source (right strip + bottom strip stay put).
ctx.drawImage(img, 0, 0, w, h, 0, 0, w, h);
// Transpose the 4x4 inner grid: cell (col,row) -> (row,col).
for (let col = 0; col < div; col++) {
for (let row = 0; row < div; row++) {
ctx.drawImage(
img,
col * colWidth, row * rowHeight, colWidth, rowHeight,
row * colWidth, col * rowHeight, colWidth, rowHeight
);
}
}
return canvas;
}
function canvasToBlob(canvas, quality = CONFIG.JPEG_QUALITY) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('canvas.toBlob returned null (tainted canvas?)'))),
'image/jpeg',
quality
);
});
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.decoding = 'async';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`failed to load image: ${url}`));
img.onabort = () => reject(new Error(`image load aborted: ${url}`));
img.src = url + (url.includes('?') ? '&_=' : '?_=') + Date.now();
});
}
// ---------------------------------------------------------------------------
// Concurrency-limited map with per-item side effects
// ---------------------------------------------------------------------------
async function runPool(items, worker, concurrency, onProgress) {
const results = new Array(items.length);
let cursor = 0;
let done = 0;
async function next() {
while (cursor < items.length) {
const idx = cursor++;
try {
results[idx] = { ok: true, value: await worker(items[idx], idx) };
} catch (e) {
results[idx] = { ok: false, error: e };
}
done++;
if (onProgress) onProgress(done, items.length, results[idx], items[idx]);
}
}
const workers = Array.from({ length: clamp(concurrency, 1, items.length) }, () => next());
await Promise.all(workers);
return results;
}
// ---------------------------------------------------------------------------
// Sinks: where each unscrambled blob goes.
// ---------------------------------------------------------------------------
// Preferred: File System Access API. User picks a folder once; each blob is
// written immediately as a real file on disk. Progress is durable — a tab
// crash keeps everything already written. No giant in-memory blob.
class DirectorySink {
constructor(dirHandle, subfolderName) {
this.dirHandle = dirHandle;
this.subfolderName = subfolderName;
this.subHandle = null;
}
async init() {
// Create (or open) the named subfolder inside the chosen folder so
// each magazine/episode lands in its own directory.
this.subHandle = await this.dirHandle.getDirectoryHandle(
this.subfolderName, { create: true }
);
}
async write(filename, blob) {
const fh = await this.subHandle.getFileHandle(filename, { create: true });
const w = await fh.createWritable();
await w.write(blob);
await w.close();
}
}
// Fallback: trigger a browser download per page via <a download>. Slower
// (rate-limited) and the browser may prompt once for "allow multiple
// downloads". Used only when FSA API is unavailable.
class DownloadSink {
constructor(subfolderName) {
this.subfolderName = subfolderName; // used as filename prefix
}
async init() {}
async write(filename, blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Prefix with subfolder name so loose files are grouped in Downloads.
a.download = `${this.subfolderName} - ${filename}`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
await sleep(CONFIG.FALLBACK_DELAY_MS);
}
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
function injectStyles() {
if (document.getElementById('sjp-dl-style')) return;
const css = `
.sjp-dl-panel {
position: fixed; top: 16px; right: 16px; width: 340px; max-height: 85vh;
overflow-y: auto; z-index: 2147483646;
background: #1f1f24; color: #e8e8ea; border: 1px solid #3a3a44;
border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,0.45);
font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
padding: 14px 14px 12px;
}
.sjp-dl-panel h3 { margin: 0 0 6px; font-size: 14px; font-weight: 600; color: #fff; }
.sjp-dl-panel .sub { font-size: 11px; color: #9a9aa4; margin-bottom: 10px; word-break: break-word; }
.sjp-dl-panel button {
border: 0; border-radius: 6px; padding: 8px 10px; font-size: 13px;
font-weight: 600; cursor: pointer; color: #fff; background: #3b82f6;
transition: background .15s, opacity .15s;
}
.sjp-dl-panel button:hover:not(:disabled) { background: #2563eb; }
.sjp-dl-panel button:disabled { opacity: .5; cursor: not-allowed; }
.sjp-dl-panel button.secondary { background: #4b5563; }
.sjp-dl-panel button.secondary:hover:not(:disabled) { background: #374151; }
.sjp-dl-panel .row { display: flex; gap: 8px; margin-bottom: 10px; }
.sjp-dl-panel .row > button { flex: 1; }
.sjp-dl-bar { height: 8px; background: #2a2a32; border-radius: 4px; overflow: hidden; margin: 6px 0 4px; }
.sjp-dl-bar > div { height: 100%; width: 0%; background: linear-gradient(90deg,#3b82f6,#60a5fa); transition: width .2s; }
.sjp-dl-status { font-size: 11px; color: #9a9aa4; min-height: 16px; word-break: break-word; }
.sjp-dl-log { font-size: 11px; color: #8a8a94; max-height: 120px; overflow-y: auto; margin-top: 6px;
background: #18181c; border-radius: 6px; padding: 6px 8px; white-space: pre-wrap; }
.sjp-dl-log .err { color: #f87171; }
.sjp-dl-log .ok { color: #4ade80; }
.sjp-dl-close { position: absolute; top: 8px; right: 10px; background: transparent; color: #9a9aa4;
border: 0; font-size: 16px; cursor: pointer; padding: 2px 6px; }
.sjp-dl-close:hover { color: #fff; background: transparent; }
.sjp-dl-panel .hint { font-size: 10px; color: #6b6b75; margin-top: 8px; line-height: 1.4; }
`;
const style = document.createElement('style');
style.id = 'sjp-dl-style';
style.textContent = css;
document.head.appendChild(style);
}
function buildPanel({ title, pageCount, onStart, onCancel }) {
const root = document.createElement('div');
root.className = 'sjp-dl-panel';
const sinkHint = HAS_FS_ACCESS
? 'You\'ll pick a folder once; each page is written there immediately as 001.jpg, 002.jpg, ... inside a subfolder named after the title. Zip it yourself when done.'
: 'Your browser lacks the File System Access API, so pages will be saved as individual downloads (browser may ask once to allow multiple). Zip them yourself when done.';
root.innerHTML = `
<button class="sjp-dl-close" title="Close">×</button>
<h3>SJ+ Downloader</h3>
<div class="sub">${title.replace(/</g, '<')}<br>${pageCount} main page(s) found</div>
<div class="row">
<button class="primary">Download pages</button>
<button class="secondary stop" disabled>Stop</button>
</div>
<div class="sjp-dl-bar"><div></div></div>
<div class="sjp-dl-status">Ready.</div>
<div class="sjp-dl-log"></div>
<div class="hint">${sinkHint}</div>
`;
document.body.appendChild(root);
const bar = $('.sjp-dl-bar > div', root);
const status = $('.sjp-dl-status', root);
const logBox = $('.sjp-dl-log', root);
const startBtn = $('button.primary', root);
const stopBtn = $('button.stop', root);
$('.sjp-dl-close', root).onclick = () => {
if (confirm('Close the downloader panel? In-progress download will be cancelled (already-written files stay).')) {
onCancel();
root.remove();
}
};
const api = {
setProgress(done, total, pct) {
bar.style.width = `${pct.toFixed(1)}%`;
status.textContent = `${done} / ${total} (${pct.toFixed(0)}%)`;
},
setStatus(msg) { status.textContent = msg; },
log(msg, kind) {
const line = document.createElement('div');
if (kind === 'err') line.className = 'err';
if (kind === 'ok') line.className = 'ok';
line.textContent = msg;
logBox.appendChild(line);
logBox.scrollTop = logBox.scrollHeight;
},
setBusy(busy) {
startBtn.disabled = busy;
stopBtn.disabled = !busy;
},
done() {
startBtn.disabled = false;
stopBtn.disabled = true;
},
remove() { root.remove(); },
};
startBtn.onclick = () => onStart(api);
stopBtn.onclick = () => onCancel();
return api;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
let cancelFlag = { cancelled: false };
async function pickSink(dir) {
if (HAS_FS_ACCESS) {
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
const sink = new DirectorySink(handle, dir);
await sink.init();
return sink;
}
const sink = new DownloadSink(dir);
await sink.init();
return sink;
}
async function startDownload(pages, dir, api) {
cancelFlag = { cancelled: false };
api.setBusy(true);
let sink;
try {
api.setStatus('Pick an output folder...');
sink = await pickSink(dir);
} catch (e) {
api.setStatus('Cancelled folder pick.');
api.log(`No folder chosen: ${e.message}`, 'err');
api.done();
return;
}
api.setStatus(`Downloading ${pages.length} pages, ${CONFIG.CONCURRENCY} at a time...`);
api.log(`Output folder: ${dir}/`);
api.log(`Fetching ${pages.length} pages...`);
// With the DownloadSink we must serialize writes (one <a> at a time),
// so drop concurrency to 1 in that case to avoid interleaved prompts.
const concurrency = (sink instanceof DownloadSink) ? 1 : CONFIG.CONCURRENCY;
if (sink instanceof DownloadSink) {
api.log('Fallback mode: saving one page at a time (slower).', 'err');
}
let okCount = 0;
let errCount = 0;
await runPool(
pages,
async ({ page }, i) => {
if (cancelFlag.cancelled) throw new Error('cancelled');
const img = await loadImage(page.src);
if (cancelFlag.cancelled) throw new Error('cancelled');
const canvas = unscrambleToCanvas(img);
const blob = await canvasToBlob(canvas);
const filename = `${pad(i + 1, pages.length)}.jpg`;
await sink.write(filename, blob);
okCount++;
},
concurrency,
(done, total, result) => {
const pct = (done / total) * 100;
api.setProgress(done, total, pct);
if (!result.ok) {
errCount++;
api.log(`Page ${pad(done, total)}: ERROR ${result.error && result.error.message}`, 'err');
} else if (done % 10 === 0 || done === total) {
api.log(`Page ${pad(done, total)} ok`);
}
}
);
if (cancelFlag.cancelled) {
api.setStatus('Stopped.');
api.log(`Stopped. ${okCount} page(s) already saved to disk — re-run to resume.`, 'err');
api.done();
return;
}
api.setProgress(pages.length, pages.length, 100);
api.setStatus(`Done. ${okCount} pages saved to ${dir}/`);
api.log(`Finished: ${okCount} ok, ${errCount} failed.`, 'ok');
if (errCount > 0) api.log('Re-run to retry the failed pages.', 'err');
api.log('Zip the folder yourself when ready.', 'ok');
api.done();
}
async function main() {
let info;
try {
info = await getPages();
} catch (e) {
log('init failed:', e);
return;
}
if (info.pages.length === 0) {
log('no main pages found; not showing panel');
return;
}
injectStyles();
const api = buildPanel({
title: info.title,
pageCount: info.pages.length,
onCancel: () => { cancelFlag.cancelled = true; },
onStart: async (api) => {
try {
await startDownload(info.pages, info.dir, api);
} catch (e) {
api.setStatus('Error.');
api.log(`FATAL: ${e.message}`, 'err');
api.done();
}
},
});
log('panel ready:', info.title, info.pages.length, 'pages');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(main, 300));
} else {
setTimeout(main, 300);
}
})();