ARCANE — Utoon Chapter Downloader

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).

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
  }
})();