ينزّل فصول utoon.net كملفات ZIP باستخدام iframe مخفي عشان يتخطى رفض Cloudflare للطلبات الخلفية، ويدعم الفصول المدفوعة اللي مالهاش لينك مباشر.
// ==UserScript==
// @name ARCANE — Utoon Chapter Downloader
// @name:ar ARCANE — تنزيل فصول Utoon
// @namespace https://arcane.app/utoon
// @version 1.3.0
// @description Download utoon.net chapters as numbered ZIPs using your own browser session. Loads each chapter via a hidden same-origin iframe to bypass Cloudflare 403 on background requests, and supports paid/locked chapter rows (no real anchor).
// @description:ar ينزّل فصول utoon.net كملفات ZIP باستخدام iframe مخفي عشان يتخطى رفض Cloudflare للطلبات الخلفية، ويدعم الفصول المدفوعة اللي مالهاش لينك مباشر.
// @author ARCANE
// @license MIT
// @match https://utoon.net/*
// @match https://*.utoon.net/*
// @icon https://utoon.net/favicon.ico
// @grant GM_xmlhttpRequest
// @connect utoon.net
// @connect *
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const IMAGE_SELECTORS = [
'div.page-break img',
'li.blocks-gallery-item img',
'div.reading-content img',
'div.text-left img',
];
const CHAPTER_PATH_RE = /\/manga\/[^/]+\/(chapter|ep|episode)[-_ ]?\d/i;
// ---------------- helpers ----------------
function safeName(s) {
return (s || '').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 80);
}
function pad(i, n) { return String(i).padStart(String(n).length, '0'); }
function extOf(url, blob) {
const m = url.split('?')[0].match(/\.([a-z0-9]{2,5})$/i);
if (m) return m[1].toLowerCase();
if (blob && blob.type) {
const t = blob.type.split('/')[1];
if (t) return t.split('+')[0];
}
return 'jpg';
}
function pickSrc(img) {
const v = (img.getAttribute('data-lazy-src') ||
img.getAttribute('data-src') ||
img.getAttribute('src') || '').trim();
if (!v || v.startsWith('data:')) return '';
try { return new URL(v, location.origin).href; } catch { return ''; }
}
function fetchBlob(url, referer) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'blob',
headers: { 'Referer': referer || (location.origin + '/') },
timeout: 90000,
onload: r => {
if (r.status >= 200 && r.status < 300 && r.response) resolve(r.response);
else reject(new Error(`HTTP ${r.status}`));
},
onerror: () => reject(new Error('network')),
ontimeout: () => reject(new Error('timeout')),
});
});
}
function triggerDownload(blob, fileName) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 2000);
}
// ---------------- iframe-based chapter fetch ----------------
function loadChapterInIframe(url, onProgress) {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed; left:-99999px; top:0; width:1280px; height:900px; visibility:hidden;';
iframe.src = url;
let settled = false;
const cleanup = () => { try { iframe.remove(); } catch {} };
const fail = msg => { if (settled) return; settled = true; cleanup(); reject(new Error(msg)); };
const ok = doc => { if (settled) return; settled = true; cleanup(); resolve(doc); };
const timeoutMs = 60000;
const to = setTimeout(() => fail('iframe timeout'), timeoutMs);
iframe.addEventListener('load', async () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) { clearTimeout(to); return fail('no iframe doc (cross-origin?)'); }
// detect locked chapter screen
const bodyText = (doc.body?.innerText || '').toLowerCase();
if (bodyText.includes('purchase') || bodyText.includes('coin') && bodyText.includes('unlock')) {
// still try to extract — might have content + paywall overlay
}
// Auto-scroll inside iframe to trigger lazy-loaded images
await scrollIframeToBottom(iframe, onProgress);
clearTimeout(to);
ok(doc);
} catch (e) {
clearTimeout(to);
fail(e.message || 'iframe error');
}
}, { once: true });
document.body.appendChild(iframe);
});
}
async function scrollIframeToBottom(iframe, onProgress) {
const win = iframe.contentWindow;
const doc = iframe.contentDocument;
if (!win || !doc) return;
const total = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight);
const step = 800;
for (let y = 0; y <= total + 800; y += step) {
win.scrollTo(0, y);
onProgress && onProgress(`تحميل الصور… ${Math.min(100, Math.round((y / Math.max(total, 1)) * 100))}%`);
await new Promise(r => setTimeout(r, 180));
}
win.scrollTo(0, 0);
// Give lazy loaders one more tick
await new Promise(r => setTimeout(r, 600));
}
function extractImagesFromDoc(doc) {
for (const sel of IMAGE_SELECTORS) {
const nodes = doc.querySelectorAll(sel);
const urls = [];
const seen = new Set();
nodes.forEach(img => {
const v = (img.getAttribute('data-lazy-src') ||
img.getAttribute('data-src') ||
img.getAttribute('src') || '').trim();
if (!v || v.startsWith('data:')) return;
let abs = v;
try { abs = new URL(v, doc.baseURI || 'https://utoon.net/').href; } catch {}
if (!seen.has(abs)) { seen.add(abs); urls.push(abs); }
});
if (urls.length) return urls;
}
return [];
}
async function buildZipFromUrls(urls, referer, onProgress) {
const zip = new JSZip();
let okN = 0, failN = 0;
const concurrency = 5;
let cursor = 0;
async function worker() {
while (cursor < urls.length) {
const i = cursor++;
const url = urls[i];
try {
const blob = await fetchBlob(url, referer);
zip.file(`${pad(i + 1, urls.length)}.${extOf(url, blob)}`, blob);
okN++;
} catch (e) {
console.warn('[ARCANE] img failed', url, e);
failN++;
}
onProgress && onProgress(`تنزيل ${okN + failN}/${urls.length} (✓${okN} ✗${failN})`);
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
if (okN === 0) throw new Error('فشل تنزيل كل الصور');
const zipBlob = await zip.generateAsync({ type: 'blob' }, m => {
onProgress && onProgress(`ضغط ${Math.round(m.percent || 0)}%`);
});
return { zipBlob, ok: okN, fail: failN };
}
async function downloadChapterByUrl(chUrl, label, onStatus) {
onStatus('بفتح الفصل…');
const doc = await loadChapterInIframe(chUrl, onStatus);
const urls = extractImagesFromDoc(doc);
if (!urls.length) {
// Try a soft-retry: maybe images are still rendering
await new Promise(r => setTimeout(r, 1500));
const urls2 = extractImagesFromDoc(doc);
if (!urls2.length) throw new Error('لا صور (مغلق أو مدفوع غير مفتوح؟)');
urls.push(...urls2);
}
onStatus(`${urls.length} صورة، بنزّل…`);
const { zipBlob, ok } = await buildZipFromUrls(urls, chUrl, onStatus);
const series = safeName(seriesTitleFromPage());
const chapter = safeName(label || chapterSlugFromUrl(chUrl));
const fileName = `${series}__${chapter}.zip`;
triggerDownload(zipBlob, fileName);
onStatus(`✅ ${ok}/${urls.length}`);
}
// ---------------- chapter URL derivation ----------------
function chapterSlugFromUrl(href) {
try {
const u = new URL(href);
const parts = u.pathname.split('/').filter(Boolean);
return parts[parts.length - 1] || 'chapter';
} catch { return 'chapter'; }
}
function seriesSlugFromLocation() {
const m = location.pathname.match(/^\/manga\/([^/]+)/);
return m ? m[1] : null;
}
function seriesTitleFromPage() {
const el = document.querySelector('.post-title h1, .post-title h3, h1.entry-title, h1');
return el ? el.textContent.trim() : (seriesSlugFromLocation() || 'series');
}
// From a chapter row text like "Chapter 18", build /manga/<slug>/chapter-18/
function deriveChapterUrlFromLabel(label) {
const slug = seriesSlugFromLocation();
if (!slug) return null;
const m = (label || '').match(/(?:chapter|ep(?:isode)?)\s*[-_# ]?\s*([\d.]+)/i);
if (!m) return null;
return `${location.origin}/manga/${slug}/chapter-${m[1]}/`;
}
// ---------------- per-chapter row injection ----------------
function findChapterRows() {
const rows = [];
const seen = new Set();
// 1) Standard Madara markup
document.querySelectorAll('div.ch-item, li.wp-manga-chapter, .listing-chapters_wrap li, .main.version-chap li').forEach(r => {
if (seen.has(r)) return; seen.add(r);
rows.push(r);
});
// 2) Card-grid layout (utoon's "LATEST MANGA RELEASES")
if (!rows.length || rows.length < 3) {
const slug = seriesSlugFromLocation();
if (slug) {
// any element whose text starts with "Chapter <n>"
const all = document.querySelectorAll('a, div, li, article, section');
all.forEach(el => {
if (seen.has(el)) return;
// skip overly large containers
if (el.children && el.children.length > 30) return;
const text = (el.textContent || '').trim();
if (!/^chapter\s*\d/i.test(text)) return;
// must be a small-ish row
if (text.length > 200) return;
// ensure it's a row-like element (has href to chapter OR has chapter text + sibling structure)
const a = el.matches('a') ? el : el.querySelector('a');
const hasChapterHref = a && /\/manga\/[^/]+\/(chapter|ep|episode)[-_]?\d/i.test(a.getAttribute('href') || a.href || '');
const looksLikeRow = hasChapterHref || el.querySelector('.fa-lock, .lock-icon, .locked-icon, [class*="lock"], [class*="coin"]');
if (!looksLikeRow) return;
seen.add(el);
rows.push(el);
});
}
}
return rows;
}
function rowChapterUrl(row) {
// Try real href first
const a = row.matches?.('a') ? row : row.querySelector('a[href*="/manga/"]');
if (a) {
const href = a.getAttribute('href') || a.href;
if (href && href !== '#' && /\/manga\/[^/]+\/[^/]+/.test(href)) {
try { return new URL(href, location.origin).href; } catch {}
}
}
// Derive from label text
const label = (row.textContent || '').trim().split('\n')[0].slice(0, 100);
const derived = deriveChapterUrlFromLabel(label);
return derived;
}
function rowLabel(row) {
// First line of text usually contains "Chapter N"
const t = (row.textContent || '').trim().split('\n').map(s => s.trim()).filter(Boolean)[0] || '';
const m = t.match(/(chapter|ep(?:isode)?)\s*[-_# ]?\s*[\d.]+/i);
return m ? m[0].replace(/\s+/g, '-').toLowerCase() : (t.slice(0, 40) || 'chapter');
}
function buildChapterButton(chUrl, label) {
const wrap = document.createElement('span');
wrap.className = 'arcane-ch-btn';
wrap.style.cssText = `
display: inline-flex; align-items: center; gap: 6px;
margin: 4px; padding: 5px 10px; background: #6a4cff; color: #fff;
border-radius: 6px; font-size: 11px; font-family: system-ui, sans-serif;
cursor: pointer; user-select: none; vertical-align: middle;
direction: rtl; box-shadow: 0 2px 6px rgba(106,76,255,.4);
position: relative; z-index: 9999;
`;
const btn = document.createElement('span');
btn.textContent = '📥 ZIP';
btn.style.cssText = 'font-weight: 700;';
const status = document.createElement('span');
status.style.cssText = 'font-size: 10px; opacity: .9; max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
wrap.appendChild(btn); wrap.appendChild(status);
let busy = false;
wrap.addEventListener('click', async e => {
e.preventDefault(); e.stopPropagation();
if (busy) return;
busy = true;
const orig = btn.textContent;
btn.textContent = '⏳';
try {
await downloadChapterByUrl(chUrl, label, t => { status.textContent = t; status.title = t; });
} catch (err) {
status.textContent = '❌ ' + (err.message || 'error');
console.error('[ARCANE]', chUrl, err);
} finally {
busy = false;
btn.textContent = orig;
}
});
return wrap;
}
function injectChapterButtons() {
const rows = findChapterRows();
let n = 0;
rows.forEach(row => {
if (row.dataset.arcaneInjected) return;
const url = rowChapterUrl(row);
if (!url) return;
row.dataset.arcaneInjected = '1';
const btn = buildChapterButton(url, rowLabel(row));
// Place button at end of row, but if row IS an <a>, place button after it instead
try {
if (row.matches?.('a')) {
row.parentElement?.insertBefore(btn, row.nextSibling);
} else {
row.appendChild(btn);
}
} catch {
row.appendChild(btn);
}
n++;
});
return n;
}
function injectBanner(count) {
let banner = document.getElementById('arcane-series-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'arcane-series-banner';
banner.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
background: #1a1a2e; color: #fff; padding: 10px 14px; border-radius: 10px;
font-family: system-ui, sans-serif; font-size: 12px; direction: rtl;
box-shadow: 0 4px 18px rgba(0,0,0,.5); border: 1px solid #6a4cff;
`;
document.body.appendChild(banner);
}
banner.textContent = `ARCANE: ${count} فصل جاهز للتنزيل ⬇`;
}
// ---------------- chapter-page floating button ----------------
function isChapterPage() { return CHAPTER_PATH_RE.test(location.pathname); }
async function downloadCurrentChapter(setStatus) {
setStatus('سحب الصفحة لتحميل الصور…');
const total = document.body.scrollHeight;
for (let y = 0; y <= total + 800; y += 800) {
window.scrollTo(0, y);
await new Promise(r => setTimeout(r, 150));
}
window.scrollTo(0, 0);
await new Promise(r => setTimeout(r, 500));
const urls = [];
for (const sel of IMAGE_SELECTORS) {
const nodes = document.querySelectorAll(sel);
if (!nodes.length) continue;
const seen = new Set();
nodes.forEach(img => { const u = pickSrc(img); if (u && !seen.has(u)) { seen.add(u); urls.push(u); } });
if (urls.length) break;
}
if (!urls.length) throw new Error('ما لقيتش صور الفصل');
setStatus(`${urls.length} صورة، بنزّل…`);
const { zipBlob, ok } = await buildZipFromUrls(urls, location.href, setStatus);
const slug = location.pathname.split('/').filter(Boolean);
const fileName = `${safeName(seriesTitleFromPage())}__${safeName(slug[slug.length - 1] || 'chapter')}.zip`;
triggerDownload(zipBlob, fileName);
setStatus(`✅ ${ok}/${urls.length}`);
}
function injectFloatingUI() {
if (document.getElementById('arcane-utoon-ui')) return;
const wrap = document.createElement('div');
wrap.id = 'arcane-utoon-ui';
wrap.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
background: #1a1a2e; color: #fff; padding: 10px 14px; border-radius: 10px;
font-family: system-ui, sans-serif; font-size: 13px; direction: rtl;
box-shadow: 0 4px 18px rgba(0,0,0,.5); border: 1px solid #6a4cff; max-width: 280px;
`;
const btn = document.createElement('button');
btn.textContent = '📥 تنزيل الفصل (ZIP)';
btn.style.cssText = 'background:#6a4cff; color:#fff; border:0; padding:8px 14px; border-radius:6px; cursor:pointer; font-weight:600; font-size:13px; width:100%;';
const status = document.createElement('div');
status.style.cssText = 'margin-top:6px; font-size:11px; color:#b8b8d4; min-height:14px;';
status.textContent = 'جاهز';
btn.onclick = async () => {
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = '...جاري التنزيل';
try { await downloadCurrentChapter(t => status.textContent = t); }
catch (e) { status.textContent = '❌ ' + e.message; alert('ARCANE: ' + e.message); }
finally { btn.disabled = false; btn.textContent = orig; }
};
wrap.appendChild(btn); wrap.appendChild(status);
document.body.appendChild(wrap);
}
// ---------------- init ----------------
function init() {
if (isChapterPage()) {
injectFloatingUI();
return;
}
// Treat all non-chapter /manga/* pages as series listings
if (/^\/manga\/[^/]+/.test(location.pathname)) {
const run = () => {
const n = injectChapterButtons();
if (n > 0) injectBanner(document.querySelectorAll('.arcane-ch-btn').length);
};
run();
const obs = new MutationObserver(() => run());
obs.observe(document.body, { childList: true, subtree: true });
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();