DoujinDistrict: все страницы текущей главы в ZIP, папка по названию главы, нумерация 001, 002…
// ==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();
})();