☰

doujindistrict.com downloader

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
})();