☰

doujindistrict.com downloader

DoujinDistrict: все страницы текущей главы в ZIP, папка по названию главы, нумерация 001, 002…

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         doujindistrict.com downloader
// @namespace    local.manga.tools
// @version      1.1.0
// @description  DoujinDistrict: все страницы текущей главы в ZIP, папка по названию главы, нумерация 001, 002…
// @match        https://doujindistrict.com/read/*
// @match        https://www.doujindistrict.com/read/*
// @run-at       document-end
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      self
// @connect      doujindistrict.
// @license      MIT
// @noframes
// ==/UserScript==

(() => {
  'use strict';

  const UI_ID = 'manga-chapter-zip-tool';
  const IMAGE_SELECTOR = '.reading-content img.wp-manga-chapter-img';
  const PARALLEL_DOWNLOADS = 3;
  const MAX_ZIP_SIZE = 0xffffffff;
  const requests = new Set();
  const cache = new Map();
  let busy = false;
  let cancelled = false;
  let listDocument = null;
  let chapterLocation = location.href;
  let ui = null;

  function cleanName(name) {
    let result = String(name || '').normalize('NFC')
      .replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, '_')
      .replace(/\s+/g, ' ').trim().replace(/[. ]+$/g, '');
    result = [...result].slice(0, 140).join('').replace(/[. ]+$/g, '');
    if (!result || /^\.+$/.test(result)) result = 'Глава';
    if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(result)) result = `_${result}`;
    return result;
  }

  function guessTitle(root = listDocument || document) {
    return root.querySelector('#chapter-heading')?.textContent.replace(/\s+/g, ' ').trim()
      || document.querySelector('#chapter-heading')?.textContent.replace(/\s+/g, ' ').trim()
      || document.title.split(' - H-MANGA ')[0].trim() || 'Глава';
  }

  function resolveImageUrl(raw) {
    if (!raw || !raw.trim()) return null;
    try {
      const url = new URL(raw.trim(), document.baseURI);
      return /^(https?:|blob:|data:)$/i.test(url.protocol) ? url.href : null;
    } catch { return null; }
  }

  function bestSrcset(value) {
    if (!value) return null;
    // data: URLs are handled through src, not as srcset candidates.
    return value.split(',').map((candidate) => {
      const [url, descriptor = '1x'] = candidate.trim().split(/\s+/);
      const score = parseFloat(descriptor) || 1;
      return { url: resolveImageUrl(url), score };
    }).filter((candidate) => candidate.url)
      .sort((a, b) => b.score - a.score)[0]?.url || null;
  }

  function imageUrl(image) {
    for (const attribute of ['data-original', 'data-src', 'data-lazy-src', 'data-url']) {
      const url = resolveImageUrl(image.getAttribute(attribute));
      if (url && !url.startsWith('data:')) return url;
    }
    return bestSrcset(image.getAttribute('data-srcset'))
      || bestSrcset(image.getAttribute('srcset'))
      || resolveImageUrl(image.currentSrc)
      || resolveImageUrl(image.getAttribute('src'));
  }

  function collectPages(root = listDocument || document) {
    // DOM order is the reading order. Include unloaded pages; omit all non-reader images.
    const seen = new Set();
    return [...root.querySelectorAll(IMAGE_SELECTOR)].map(imageUrl).filter((url) => {
      if (!url || seen.has(url)) return false;
      seen.add(url);
      return true;
    });
  }

  function isPagedView() {
    return new URL(location.href).searchParams.get('style') === 'paged'
      || /paged/i.test(document.querySelector('.reading-style-select option:checked')?.textContent || '');
  }

  async function loadWholeChapter() {
    if (!isPagedView() || listDocument) return;
    setStatus('Получение всех страниц главы…');
    const options = [...document.querySelectorAll('.reading-style-select option[data-redirect]')];
    const listOption = options.find((option) => /list/i.test(option.textContent));
    const url = new URL(listOption?.getAttribute('data-redirect') || location.href, location.href);
    if (url.origin !== location.origin) throw new Error('Ссылка на полную главу ведёт на другой сайт.');
    url.searchParams.delete('style');
    url.searchParams.delete('manga-paged');
    url.searchParams.delete('page');
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 60000);
    const request = { abort: () => controller.abort() };
    requests.add(request);
    try {
      const response = await fetch(url.href, { credentials: 'same-origin', signal: controller.signal });
      if (!response.ok) throw new Error(`Не удалось получить главу: HTTP ${response.status}`);
      const parsed = new DOMParser().parseFromString(await response.text(), 'text/html');
      if (!parsed.querySelector(IMAGE_SELECTOR)) throw new Error('В полной версии главы не найдены страницы.');
      listDocument = parsed;
    } finally {
      clearTimeout(timeout);
      requests.delete(request);
    }
  }

  function extensionFor(bytes, contentType) {
    const starts = (...values) => values.every((value, index) => bytes[index] === value);
    const ascii = new TextDecoder('ascii').decode(bytes.subarray(0, 100));
    if (starts(0xff, 0xd8, 0xff)) return 'jpg';
    if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return 'png';
    if (/^GIF8[79]a/.test(ascii)) return 'gif';
    if (ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WEBP') return 'webp';
    if (ascii.slice(4, 8) === 'ftyp' && /avif|avis/.test(ascii)) return 'avif';
    if (ascii.slice(4, 8) === 'ftyp' && /heic|heix|hevc|hevx|mif1/.test(ascii)) return 'heic';
    if (starts(0x42, 0x4d)) return 'bmp';
    if (starts(0x49, 0x49, 0x2a, 0) || starts(0x4d, 0x4d, 0, 0x2a)) return 'tif';
    if (starts(0xff, 0x0a) || starts(0, 0, 0, 0x0c, 0x4a, 0x58, 0x4c, 0x20)) return 'jxl';
    if (contentType?.split(';')[0].trim().toLowerCase() === 'image/svg+xml'
      && /<svg[\s>]/i.test(new TextDecoder().decode(bytes.subarray(0, 2048)))) return 'svg';
    throw new Error('В ответе нет поддерживаемого изображения; возможно, сервер вернул страницу ошибки.');
  }

  function abortError() { return new Error('Загрузка отменена.'); }
  const pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));

  function requestImage(url) {
    if (cancelled) return Promise.reject(abortError());
    if (/^(blob:|data:)/.test(url)) {
      return fetch(url).then(async (response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return { bytes: new Uint8Array(await response.arrayBuffer()), type: response.headers.get('Content-Type') };
      });
    }

    return new Promise((resolve, reject) => {
      let handle;
      function fail(error) {
        requests.delete(handle);
        reject(error);
      }
      handle = GM_xmlhttpRequest({
        method: 'GET', url, responseType: 'arraybuffer', timeout: 60000,
        headers: { Referer: location.href },
        onload(response) {
          requests.delete(handle);
          if (response.status < 200 || response.status >= 300) {
            const error = new Error(`HTTP ${response.status}`);
            error.status = response.status;
            reject(error);
            return;
          }
          const type = response.responseHeaders?.match(/^content-type:\s*([^\r\n]+)/im)?.[1] || '';
          resolve({ bytes: new Uint8Array(response.response), type });
        },
        onerror() { fail(new Error('Ошибка соединения или нет разрешения Tampermonkey на домен картинки.')); },
        ontimeout() { fail(new Error('Истекло время ожидания изображения.')); },
        onabort() { fail(abortError()); },
      });
      requests.add(handle);
    });
  }

  async function downloadImage(url) {
    if (cache.has(url)) return cache.get(url);
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        const result = await requestImage(url);
        if (cancelled) throw abortError();
        if (!result.bytes.length) throw new Error('Сервер вернул пустой файл.');
        const image = { bytes: result.bytes, extension: extensionFor(result.bytes, result.type) };
        cache.set(url, image);
        return image;
      } catch (error) {
        if (cancelled || [400, 401, 403, 404, 410, 451].includes(error.status) || attempt === 2) throw error;
        await pause(error.status === 429 ? 10000 * (attempt + 1) : 1000 * (attempt + 1));
      }
    }
  }

  const crcTable = Uint32Array.from({ length: 256 }, (_, index) => {
    let value = index;
    for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
    return value >>> 0;
  });

  function crc32(bytes) {
    let value = 0xffffffff;
    for (const byte of bytes) value = crcTable[(value ^ byte) & 255] ^ (value >>> 8);
    return (value ^ 0xffffffff) >>> 0;
  }

  function makeEntries(title, pages) {
    const folder = `${cleanName(title)}/`;
    const digits = Math.max(3, String(pages.length).length);
    return [{ name: folder, bytes: new Uint8Array(0), directory: true },
      ...pages.map((page, index) => ({
        name: `${folder}${String(index + 1).padStart(digits, '0')}.${page.extension}`,
        bytes: page.bytes,
        directory: false,
      }))];
  }

  // ZIP STORE, UTF-8 names, CRC-32. Image bytes remain unchanged; no external library.
  async function buildZip(entries, onProgress = () => {}) {
    if (entries.length >= 65535) throw new Error('Слишком много файлов для одного архива.');
    const parts = [], central = [];
    const encoder = new TextEncoder();
    const now = new Date();
    const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >>> 1);
    const dosDate = ((Math.min(2107, Math.max(1980, now.getFullYear())) - 1980) << 9)
      | ((now.getMonth() + 1) << 5) | now.getDate();
    let offset = 0, centralSize = 0;

    for (let index = 0; index < entries.length; index++) {
      if (cancelled) throw abortError();
      const entry = entries[index];
      const name = encoder.encode(entry.name);
      const size = entry.bytes.byteLength;
      if (name.length > 65535 || size > MAX_ZIP_SIZE || offset + size + name.length + 30 > MAX_ZIP_SIZE) {
        throw new Error('Глава слишком большая для обычного ZIP (предел 4 ГБ).');
      }
      const checksum = crc32(entry.bytes);
      const local = new Uint8Array(30 + name.length);
      const localView = new DataView(local.buffer);
      localView.setUint32(0, 0x04034b50, true);
      localView.setUint16(4, 20, true);
      localView.setUint16(6, 0x0800, true);
      localView.setUint16(10, dosTime, true);
      localView.setUint16(12, dosDate, true);
      localView.setUint32(14, checksum, true);
      localView.setUint32(18, size, true);
      localView.setUint32(22, size, true);
      localView.setUint16(26, name.length, true);
      local.set(name, 30);

      const record = new Uint8Array(46 + name.length);
      const view = new DataView(record.buffer);
      view.setUint32(0, 0x02014b50, true);
      view.setUint16(4, 20, true);
      view.setUint16(6, 20, true);
      view.setUint16(8, 0x0800, true);
      view.setUint16(12, dosTime, true);
      view.setUint16(14, dosDate, true);
      view.setUint32(16, checksum, true);
      view.setUint32(20, size, true);
      view.setUint32(24, size, true);
      view.setUint16(28, name.length, true);
      view.setUint32(38, entry.directory ? 0x10 : 0, true);
      view.setUint32(42, offset, true);
      record.set(name, 46);

      parts.push(local, entry.bytes);
      central.push(record);
      offset += local.length + size;
      centralSize += record.length;
      onProgress(Math.round((index + 1) / entries.length * 100));
      await pause(0);
    }

    if (offset + centralSize + 22 > MAX_ZIP_SIZE) throw new Error('Архив превышает предел 4 ГБ.');
    const end = new Uint8Array(22);
    const endView = new DataView(end.buffer);
    endView.setUint32(0, 0x06054b50, true);
    endView.setUint16(8, entries.length, true);
    endView.setUint16(10, entries.length, true);
    endView.setUint32(12, centralSize, true);
    endView.setUint32(16, offset, true);
    return new Blob([...parts, ...central, end], { type: 'application/zip' });
  }

  function setStatus(message) { ui.status.textContent = message; }

  function syncChapterLocation() {
    if (chapterLocation === location.href) return;
    chapterLocation = location.href;
    listDocument = null;
    cache.clear();
    ui.title.value = guessTitle(document);
  }

  function updatePageCount() {
    syncChapterLocation();
    const count = collectPages().length;
    const paged = isPagedView() && !listDocument;
    ui.count.textContent = paged
      ? 'Постраничный режим: при скачивании будет получена вся глава.'
      : `Страниц главы: ${count}`;
    ui.download.disabled = busy || (!paged && count === 0);
    return count;
  }

  async function startDownload() {
    if (busy) return;
    syncChapterLocation();
    busy = true;
    cancelled = false;
    ui.download.disabled = true;
    ui.refresh.disabled = true;
    ui.title.disabled = true;
    ui.cancel.hidden = false;

    try {
      await loadWholeChapter();
      if (cancelled) throw abortError();
      const urls = collectPages();
      if (!urls.length) throw new Error('В читалке не найдены страницы главы.');
      const title = cleanName(ui.title.value || guessTitle());
      ui.title.value = title;
      const usedUrls = new Set(urls);
      for (const key of cache.keys()) if (!usedUrls.has(key)) cache.delete(key);
      const pages = new Array(urls.length), errors = [];
      let next = 0, finished = 0;

      setStatus(`Загрузка: 0 / ${urls.length}`);
      async function worker() {
        while (!cancelled && next < urls.length) {
          const index = next++;
          try { pages[index] = await downloadImage(urls[index]); }
          catch (error) { errors.push({ page: index + 1, message: error.message }); }
          finished++;
          setStatus(`Обработано: ${finished} / ${urls.length}${errors.length ? `; ошибок: ${errors.length}` : ''}`);
        }
      }
      await Promise.all(Array.from({ length: Math.min(PARALLEL_DOWNLOADS, urls.length) }, worker));
      if (cancelled) throw abortError();
      if (errors.length) {
        throw new Error(`Не удалось скачать страницы: ${errors.map((error) => error.page).join(', ')}. `
          + `${errors[0].message} ZIP не сохранён. Нажмите «Скачать ZIP», чтобы повторить только недостающие файлы.`);
      }

      const blob = await buildZip(makeEntries(title, pages), (percent) => setStatus(`Создание ZIP: ${percent}%`));
      if (cancelled) throw abortError();
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = `${title}.zip`;
      document.body.append(link);
      link.click();
      link.remove();
      setTimeout(() => URL.revokeObjectURL(url), 120000);
      cache.clear();
      setStatus(`Готово: ${pages.length} стр. → ${title}.zip`);
    } catch (error) {
      setStatus(cancelled ? 'Загрузка отменена.' : error.message || String(error));
    } finally {
      busy = false;
      ui.refresh.disabled = false;
      ui.title.disabled = false;
      ui.cancel.hidden = true;
      updatePageCount();
    }
  }

  function mount() {
    if (document.getElementById(UI_ID)) return;
    if (!document.querySelector('#chapter-heading, .reading-content img.wp-manga-chapter-img')) return;
    const host = document.createElement('span');
    host.id = UI_ID;
    host.style.cssText = 'all:initial!important;position:fixed!important;right:16px!important;bottom:16px!important;z-index:2147483647!important;display:block!important;';
    const shadow = host.attachShadow({ mode: 'open' });
    const style = document.createElement('style');
    style.textContent = `
      * { box-sizing: border-box; }
      [hidden] { display: none !important; }
      .panel { width: min(360px, calc(100vw - 32px)); margin-bottom: 10px; padding: 16px;
        border: 1px solid #606075; border-radius: 14px; background: #242430; color: #f3f1ff;
        box-shadow: 0 5px 28px #0008; font: 14px/1.45 system-ui,sans-serif; }
      strong { display: block; margin-bottom: 12px; font-size: 16px; }
      label { display: block; margin-bottom: 5px; }
      input { width: 100%; padding: 8px; margin-bottom: 12px; border: 1px solid #66667c;
        border-radius: 7px; background: #14141e; color: inherit; font: inherit; }
      button { padding: 9px 12px; border: 1px solid #777390; border-radius: 9px;
        background: #444055; color: #fff; font: 600 13px/1.3 system-ui,sans-serif; cursor: pointer; }
      button:hover { background: #5c526f; }
      button:disabled { opacity: .5; cursor: default; }
      button:focus-visible, input:focus-visible { outline: 2px solid #c8afff; outline-offset: 2px; }
      .buttons { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0; }
      .count { color: #d5c4ff; }
      .status { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 10px; }
      small { display: block; margin-top: 10px; color: #b4b2c3; }
      .launcher { display: block; margin-left: auto; background: #39304c; box-shadow: 0 3px 16px #0007; }
    `;
    function element(tag, className, text = '') {
      const node = document.createElement(tag);
      if (className) node.className = className;
      node.textContent = text;
      return node;
    }
    const panel = element('section', 'panel');
    panel.hidden = true;
    const heading = element('strong', '', 'DoujinDistrict · Архив главы');
    const label = element('label', '', 'Название главы / папки');
    const title = element('input');
    title.id = 'chapter-title';
    label.htmlFor = title.id;
    title.value = guessTitle();
    const count = element('div', 'count');
    const buttons = element('div', 'buttons');
    const refresh = element('button', '', 'Обновить список');
    const download = element('button', '', 'Скачать ZIP');
    const cancel = element('button', '', 'Отменить');
    cancel.hidden = true;
    const status = element('div', 'status');
    status.setAttribute('role', 'status');
    status.setAttribute('aria-live', 'polite');
    const help = element('small', '', 'Все страницы главы определяются автоматически. Внутри ZIP — папка с названием главы и файлы 001, 002… в исходном формате.');
    const launcher = element('button', 'launcher', '↓ ZIP главы');
    launcher.setAttribute('aria-expanded', 'false');
    buttons.append(refresh, download, cancel);
    panel.append(heading, label, title, count, buttons, status, help);
    shadow.append(style, panel, launcher);
    document.documentElement.append(host);
    ui = { host, panel, title, count, refresh, download, cancel, status, launcher };

    function openPanel() {
      panel.hidden = false;
      launcher.setAttribute('aria-expanded', 'true');
      if (!busy) updatePageCount();
    }
    launcher.addEventListener('click', () => {
      if (panel.hidden) openPanel();
      else { panel.hidden = true; launcher.setAttribute('aria-expanded', 'false'); }
    });
    refresh.addEventListener('click', () => {
      if (busy) return;
      listDocument = null;
      ui.title.value = guessTitle(document);
      updatePageCount();
      setStatus('Список страниц обновлён.');
    });
    download.addEventListener('click', startDownload);
    cancel.addEventListener('click', () => {
      cancelled = true;
      for (const request of [...requests]) request.abort();
      setStatus('Отмена загрузки…');
    });
    GM_registerMenuCommand('Скачать главу в ZIP…', openPanel);
    updatePageCount();
  }

  mount();
})();