jmcomic downloader plus

Download the current JM/18comic chapter, all chapters, or a selected chapter range as cbz files.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         jmcomic downloader plus
// @namespace    https://github.com/jeremylinnnnn/jmcomic_downloader/
// @version      1.2.0
// @description  Download the current JM/18comic chapter, all chapters, or a selected chapter range as cbz files.
// @author       eternalphane
// @contributor  jeremylinnnnn
// @license      MIT
// @supportURL   https://github.com/jeremylinnnnn/jmcomic_downloader/issues
// @match        https://18comic.vip/photo/*
// @match        https://18comic.vip/album/*
// @match        https://*.18comic.vip/photo/*
// @match        https://*.18comic.vip/album/*
// @match        https://18comic.org/photo/*
// @match        https://18comic.org/album/*
// @match        https://*.18comic.org/photo/*
// @match        https://*.18comic.org/album/*
// @match        https://jmcomic.me/photo/*
// @match        https://jmcomic.me/album/*
// @match        https://*.jmcomic.me/photo/*
// @match        https://*.jmcomic.me/album/*
// @match        https://jmcomic1.me/photo/*
// @match        https://jmcomic1.me/album/*
// @match        https://*.jmcomic1.me/photo/*
// @match        https://*.jmcomic1.me/album/*
// @match        https://jm-comic1.art/photo/*
// @match        https://jm-comic1.art/album/*
// @match        https://*.jm-comic1.art/photo/*
// @match        https://*.jm-comic1.art/album/*
// @match        https://jm-comic2.art/photo/*
// @match        https://jm-comic2.art/album/*
// @match        https://*.jm-comic2.art/photo/*
// @match        https://*.jm-comic2.art/album/*
// @match        https://jm-comic3.art/photo/*
// @match        https://jm-comic3.art/album/*
// @match        https://*.jm-comic3.art/photo/*
// @match        https://*.jm-comic3.art/album/*
// @require      https://unpkg.com/[email protected]/dist/jszip.min.js
// @run-at       document-idle
// ==/UserScript==
'use strict';

// Modified from eternalphane's MIT-licensed jmcomic downloader.
// Local changes by jeremylinnnnn: added all-chapters download support for album pages.

const ICON_DOWNLOAD = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1ODAgNTIwIj48cGF0aCBkPSJNNTI4IDI4OXEyMCAwIDM0IDE0dDE0IDM0djEyOHEwIDIwLTE0IDM0dC0zNCAxNEg0OHEtMjAgMC0zNC0xNFQwIDQ2NVYzMzdxMC0yMCAxNC0zNHQzNC0xNGg5MmwtNDYtNDZxLTIyLTIzLTEwLTUyLjV0NDQtMjkuNWg2NFY0OXEwLTIwIDE0LTM0dDM0LTE0aDk2cTIwIDAgMzQgMTR0MTQgMzR2MTEyaDY0cTMyIDAgNDQuNSAyOS41VDQ4MiAyNDNsLTQ2IDQ2aDkyem0tNDAwLTgwbDE2MCAxNjAgMTYwLTE2MEgzMzZWNDloLTk2djE2MEgxMjh6bTQwMCAyNTZWMzM3SDM4OGwtNjYgNjZxLTE0IDE0LTM0IDE0dC0zNC0xNGwtNjYtNjZINDh2MTI4aDQ4MHptLTgxLTQ3cS03LTctNy0xN3Q3LTE3cTctNyAxNy03dDE3IDdxNyA3IDcgMTd0LTcgMTdxLTcgNy0xNyA3dC0xNy03eiIvPjwvc3ZnPg==';
const PHOTO_ID_RE = /\/photo\/(\d+)/;
let downloadInProgress = false;

function init() {
    if (!document.body) {
        setTimeout(init, 100);
        return;
    }

    const progress = new ProgressCircle;
    progress.hidden = true;
    document.body.appendChild(progress);
    addButtons(progress);
}

init();

function addButtons(progress) {
    const navTargets = [...document.querySelectorAll('li')].filter((li) => {
        return (!li.classList.contains('ph-active') && li.querySelector('a .fa-bookmark')) ||
            (li.classList.contains('ph-active') && li.querySelector('a .fa-heart'));
    });

    for (const liPrev of navTargets) {
        const styles = [`background:url(${ICON_DOWNLOAD}) center / contain no-repeat`];
        if (liPrev.classList.contains('ph-active')) {
            styles.push('filter:invert(1)');
        }

        if (isPhotoPage()) {
            const currentButton = createNavButton(liPrev, styles, '下载', (event) => {
                event.preventDefault();
                downloadCurrentChapter(progress);
            });
            liPrev.after(currentButton);

            const allButton = createNavButton(liPrev, styles, '下载全部', (event) => {
                event.preventDefault();
                downloadAllChapters(progress);
            });
            currentButton.after(allButton);

            const rangeButton = createNavButton(liPrev, styles, '下载范围', (event) => {
                event.preventDefault();
                downloadChapterRange(progress);
            });
            allButton.after(rangeButton);
        } else {
            const allButton = createNavButton(liPrev, styles, '下载全部', (event) => {
                event.preventDefault();
                downloadAllChapters(progress);
            });
            liPrev.after(allButton);

            const rangeButton = createNavButton(liPrev, styles, '下载范围', (event) => {
                event.preventDefault();
                downloadChapterRange(progress);
            });
            allButton.after(rangeButton);
        }
    }

    if (!navTargets.length || !isPhotoPage()) {
        addFloatingButtons(progress);
    }
}

function createNavButton(liPrev, styles, text, onClick) {
    const li = document.createElement('li');
    for (const attr of liPrev.attributes) {
        li.setAttribute(attr.name, attr.value);
    }

    const btn = document.createElement('a');
    btn.href = '#';
    btn.innerHTML = `
<i class="far" style="${styles.join(';')}">&#xfeff;</i>
<span>${text}</span>`;
    btn.addEventListener('click', onClick);
    li.appendChild(btn);
    return li;
}

function addFloatingButtons(progress) {
    const panel = document.createElement('div');
    panel.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483646;display:grid;gap:8px;padding:10px;background:#fff;border:1px solid #ccc;border-radius:8px;box-shadow:0 6px 24px #0003;';

    if (isPhotoPage()) {
        const current = document.createElement('button');
        current.textContent = '下载当前';
        current.addEventListener('click', () => downloadCurrentChapter(progress));
        panel.appendChild(current);
    }

    const all = document.createElement('button');
    all.textContent = '下载全部';
    all.addEventListener('click', () => downloadAllChapters(progress));
    panel.appendChild(all);

    const range = document.createElement('button');
    range.textContent = '下载范围';
    range.addEventListener('click', () => downloadChapterRange(progress));
    panel.appendChild(range);
    document.body.appendChild(panel);
}

function requestChapterRange(chapterTotal) {
    return new Promise((resolve) => {
        const dialog = document.createElement('dialog');
        dialog.className = 'jmcomic-range-dialog';
        dialog.setAttribute('aria-label', '下载章节范围');
        dialog.innerHTML = /* html */`
<style>
  .jmcomic-range-dialog {
    width: min(320px, calc(100vw - 32px));
    box-sizing: border-box;
    padding: 20px;
    border: 0;
    border-radius: 8px;
    background: #fff;
    color: #222;
    box-shadow: 0 12px 36px #0005;
    font: 14px/1.5 system-ui, sans-serif;
  }

  .jmcomic-range-dialog::backdrop {
    background: #0008;
  }

  .jmcomic-range-dialog h2 {
    margin: 0 0 4px;
    font-size: 18px;
    letter-spacing: 0;
  }

  .jmcomic-range-dialog p {
    margin: 0 0 16px;
    color: #666;
  }

  .jmcomic-range-fields {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
  }

  .jmcomic-range-fields label {
    display: grid;
    gap: 6px;
    font-weight: 600;
  }

  .jmcomic-range-fields input {
    width: 100%;
    min-width: 0;
    box-sizing: border-box;
    padding: 8px 10px;
    border: 1px solid #aaa;
    border-radius: 4px;
    background: #fff;
    color: #222;
    font: inherit;
  }

  .jmcomic-range-error {
    min-height: 21px;
    margin: 8px 0 0 !important;
    color: #b42318 !important;
  }

  .jmcomic-range-actions {
    display: flex;
    justify-content: flex-end;
    gap: 8px;
    margin-top: 12px;
  }

  .jmcomic-range-actions button {
    padding: 8px 14px;
    border: 1px solid #aaa;
    border-radius: 4px;
    background: #fff;
    color: #222;
    font: inherit;
    cursor: pointer;
  }

  .jmcomic-range-actions button[type="submit"] {
    border-color: #16794b;
    background: #16794b;
    color: #fff;
  }
</style>
<form>
  <h2>下载章节范围</h2>
  <p>按页面章节列表顺序,共 ${chapterTotal} 话</p>
  <div class="jmcomic-range-fields">
    <label>起始章节
      <input name="start" type="number" min="1" max="${chapterTotal}" step="1" value="1" inputmode="numeric" required>
    </label>
    <label>结束章节
      <input name="end" type="number" min="1" max="${chapterTotal}" step="1" value="${chapterTotal}" inputmode="numeric" required>
    </label>
  </div>
  <p class="jmcomic-range-error" role="alert"></p>
  <div class="jmcomic-range-actions">
    <button type="button" data-action="cancel">取消</button>
    <button type="submit">开始下载</button>
  </div>
</form>`;

        const form = dialog.querySelector('form');
        const startInput = form.elements.namedItem('start');
        const endInput = form.elements.namedItem('end');
        const errorMessage = dialog.querySelector('.jmcomic-range-error');
        let settled = false;

        const finish = (range) => {
            if (settled) {
                return;
            }
            settled = true;
            if (dialog.open) {
                dialog.close();
            }
            dialog.remove();
            resolve(range);
        };

        form.addEventListener('submit', (event) => {
            event.preventDefault();
            if (!form.reportValidity()) {
                return;
            }

            const start = Number(startInput.value);
            const end = Number(endInput.value);
            const error = getChapterRangeError(start, end, chapterTotal);
            if (error) {
                errorMessage.textContent = error;
                return;
            }
            finish({ start, end });
        });
        form.addEventListener('input', () => errorMessage.textContent = '');
        dialog.querySelector('[data-action="cancel"]').addEventListener('click', () => finish(null));
        dialog.addEventListener('cancel', (event) => {
            event.preventDefault();
            finish(null);
        });
        dialog.addEventListener('close', () => finish(null));

        document.body.appendChild(dialog);
        dialog.showModal();
        startInput.select();
    });
}

function getChapterRangeError(start, end, chapterTotal) {
    if (!Number.isInteger(start) || !Number.isInteger(end)) {
        return '章节序号必须是整数';
    }
    if (start < 1 || start > chapterTotal || end < 1 || end > chapterTotal) {
        return `章节范围必须在 1-${chapterTotal} 之间`;
    }
    if (start > end) {
        return '起始章节不能大于结束章节';
    }
    return '';
}

function acquireDownloadLock() {
    if (downloadInProgress) {
        alert('已有下载任务正在进行,请等待当前任务完成');
        return false;
    }
    downloadInProgress = true;
    return true;
}

function releaseDownloadLock() {
    downloadInProgress = false;
}

async function downloadCurrentChapter(progress) {
    if (!acquireDownloadLock()) {
        return;
    }
    try {
        progress.hidden = false;
        progress.text = 'Downloading...';
        const urls = getCurrentReadableUrls();
        const chapter = {
            id: getCurrentPhotoId(),
            title: getTitle(document, getCurrentPhotoId()),
            root: document,
            html: document.documentElement.innerHTML,
            url: location.href
        };
        const result = await createChapterCbz(chapter, urls, progress, 1, 1);
        saveBlob(result.blob, result.fileName);
    } catch (error) {
        console.error('[18comic downloader] current chapter failed:', error);
        alert(`下载失败:${error.message}`);
    } finally {
        progress.hidden = true;
        releaseDownloadLock();
    }
}

async function downloadAllChapters(progress) {
    return downloadChapters(progress, false);
}

async function downloadChapterRange(progress) {
    return downloadChapters(progress, true);
}

async function downloadChapters(progress, selectRange) {
    if (!acquireDownloadLock()) {
        return;
    }
    try {
        const chapters = getChapterList(document, location.href);
        if (!chapters.length) {
            throw new Error('没有解析到章节链接');
        }
        if (selectRange && chapters.some((chapter) => chapter.orderUncertain)) {
            throw new Error('当前阅读页无法确定完整章节顺序,请返回作品详情页使用“下载范围”');
        }

        const range = selectRange ?
            await requestChapterRange(chapters.length) :
            { start: 1, end: chapters.length };
        if (!range) {
            return;
        }

        progress.hidden = false;
        let ok = 0;
        const selectedTotal = range.end - range.start + 1;
        for (let chapterNumber = range.start; chapterNumber <= range.end; ++chapterNumber) {
            const chapter = await getChapterDocument(chapters[chapterNumber - 1]);
            const urls = getChapterImageUrls(chapter.root, chapter.url);
            const result = await createChapterCbz(chapter, urls, progress, chapterNumber, chapters.length);
            saveBlob(result.blob, result.fileName);
            ++ok;
        }
        progress.text = `Done ${ok}/${selectedTotal}`;
    } catch (error) {
        const label = selectRange ? '下载范围' : '下载全部';
        console.error(`[18comic downloader] ${label} failed:`, error);
        alert(`${label}失败:${error.message}`);
    } finally {
        progress.hidden = true;
        releaseDownloadLock();
    }
}

async function createChapterCbz(chapter, urls, progress, chapterIndex, chapterTotal) {
    if (!urls.length) {
        throw new Error(`${chapter.title || chapter.id} 没有图片`);
    }

    const zip = new JSZip;
    const context = getScrambleContext(chapter.root, chapter.html, chapter.id);
    let successCount = 0;
    progress.value = 0;
    progress.max = urls.length;
    progress.text = chapterTotal > 1 ?
        `Downloading ${chapterIndex}/${chapterTotal}` :
        'Downloading...';

    await Promise.all(urls.map(async (url) => {
        try {
            zip.file(...await download(url, context));
            ++successCount;
        } catch (error) {
            console.warn('[18comic downloader] image failed:', url, error);
        } finally {
            ++progress.value;
        }
    }));

    if (!successCount) {
        throw new Error(`${chapter.title || chapter.id} 所有图片下载失败`);
    }

    progress.text = chapterTotal > 1 ?
        `Compressing ${chapterIndex}/${chapterTotal}` :
        'Compressing...';
    progress.value = 0;
    progress.max = 100;

    const blob = await zip.generateAsync({
        type: 'blob',
        compression: 'DEFLATE',
        compressionOptions: { level: 9 },
        mimeType: 'application/vnd.comicbook+zip'
    }, (meta) => progress.value = meta.percent);

    const prefix = chapterTotal > 1 ? `${String(chapterIndex).padStart(3, '0')}-` : '';
    return {
        blob,
        fileName: `${prefix}${sanitizeFileName(chapter.title || chapter.id)}.cbz`
    };
}

/**
 * @param {string} url
 * @param {{ aid: number|string, scrambleId: number|string }} context
 */
const download = async (url, context = getScrambleContext(document, document.documentElement.innerHTML, getCurrentPhotoId())) => {
    const filename = new URL(url, location.href).pathname.split('/').at(-1);
    const [id, ext] = filename.split('.');
    const img = new Image;
    img.src = URL.createObjectURL(await (await fetch(url)).blob());
    await new Promise((resolve, reject) => (img.onload = resolve, img.onerror = reject));
    const w = img.naturalWidth;
    const h = img.naturalHeight;
    const canvas = new OffscreenCanvas(w, h);
    const ctx = canvas.getContext('2d');

    if (url.includes('.gif') ||
        Number(context.aid) < Number(context.scrambleId) ||
        typeof get_num !== 'function') {
        ctx.drawImage(img, 0, 0);
    } else {
        const num = get_num(btoa(String(context.aid)), btoa(id));
        const rem = h % num;
        const sh = Math.floor(h / num);
        let sy = h - rem - sh, dy = rem;
        ctx.drawImage(img, 0, sy, w, rem + sh, 0, 0, w, rem + sh);
        for (let i = 1; i < num; ++i) {
            ctx.drawImage(img, 0, sy -= sh, w, sh, 0, dy += sh, w, sh);
        }
    }
    URL.revokeObjectURL(img.src);

    return [filename, await canvas.convertToBlob({
        type: {
            jpg: 'image/jpeg',
            jpeg: 'image/jpeg',
            png: 'image/png',
            webp: 'image/webp'
        }[(ext || '').toLowerCase()] || 'image/jpeg',
        quality: 1
    })];
};

function getCurrentReadableUrls() {
    const selector = new URLSearchParams(location.search).get('read_mode') === 'read-by-page' ?
        '.owl-item .center img' :
        '.scramble-page img';
    const urls = [...document.querySelectorAll(selector)]
        .map((img) => normalizeUrl(getImageAttr(img), location.href))
        .filter(Boolean);
    return urls.length ? urls : getChapterImageUrls(document, location.href);
}

function getChapterImageUrls(root = document, baseUrl = location.href) {
    const images = [...root.querySelectorAll('img')]
        .map((img) => ({
            id: img.id || '',
            url: normalizeUrl(getImageAttr(img), baseUrl)
        }))
        .filter((item) => item.url)
        .filter((item) => /^album_photo_\d{5}\./.test(item.id) ||
            /\/\d{5}\.(?:jpe?g|png|webp|gif)(?:\?|$)/i.test(item.url))
        .sort((a, b) => getImagePageNumber(a) - getImagePageNumber(b));

    const seen = new Set();
    const urls = images
        .map((item) => item.url)
        .filter((url) => {
            if (seen.has(url)) {
                return false;
            }
            seen.add(url);
            return true;
        });

    const pageCount = getPageCount(root);
    if (!pageCount || urls.length >= pageCount) {
        return urls;
    }

    const series = splitSeriesUrl(urls[0]);
    if (!series) {
        return urls;
    }

    return Array.from({ length: pageCount }, (_, index) => {
        return `${series.head}${String(index + 1).padStart(5, '0')}${series.ext}${series.query}`;
    });
}

function getImageAttr(img) {
    return img.getAttribute('data-original') ||
        img.getAttribute('data-src') ||
        img.getAttribute('data-lazy-src') ||
        img.currentSrc ||
        img.src ||
        '';
}

function normalizeUrl(url, baseUrl = location.href) {
    try {
        return new URL(url, baseUrl).href;
    } catch {
        return '';
    }
}

function getImagePageNumber(item) {
    const value = `${item.id || ''} ${item.url || ''}`;
    const match = value.match(/(?:album_photo_|\/)(\d{5})\.(?:jpe?g|png|webp|gif)/i);
    return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
}

function getPageCount(root = document) {
    const selects = [...root.querySelectorAll('select')];
    const pageSelect = root.querySelector('select[name="page_select"]') || selects[2];
    return pageSelect ? pageSelect.querySelectorAll('option').length : 0;
}

function splitSeriesUrl(url) {
    const match = url?.match(/^(.*\/)(\d{5})(\.(?:jpe?g|png|webp|gif))(\?.*)?$/i);
    return match ? {
        head: match[1],
        ext: match[3],
        query: match[4] || ''
    } : null;
}

function getChapterList(root = document, baseUrl = location.href) {
    const scopedSelectors = [
        'ul.btn-toolbar a[href*="/photo/"]',
        '.btn-toolbar a[href*="/photo/"]',
        '.chapter a[href*="/photo/"]',
        '.chapter-list a[href*="/photo/"]',
        '.episode a[href*="/photo/"]',
        '.episode-list a[href*="/photo/"]'
    ];
    let anchors = [...root.querySelectorAll(scopedSelectors.join(','))];
    const usesPageWideFallback = !anchors.length;
    if (!anchors.length) {
        anchors = [...root.querySelectorAll('a[href*="/photo/"]')];
    }

    const seen = new Set();
    const chapters = [];
    for (const anchor of anchors) {
        const href = normalizeUrl(anchor.getAttribute('href') || '', baseUrl);
        const id = extractPhotoId(href);
        if (!id || seen.has(id)) {
            continue;
        }

        seen.add(id);
        chapters.push({
            id,
            url: toPhotoUrl(href),
            title: sanitizeFileName(anchor.textContent || id),
            orderUncertain: isPhotoPage() && usesPageWideFallback
        });
    }

    const currentId = getCurrentPhotoId();
    if (currentId && !seen.has(currentId)) {
        chapters.unshift({
            id: currentId,
            url: toPhotoUrl(location.href),
            title: getTitle(document, currentId),
            orderUncertain: true
        });
    }

    return chapters;
}

async function getChapterDocument(chapter) {
    const target = new URL(chapter.url, location.href);
    if (target.origin === location.origin && target.pathname === location.pathname) {
        return {
            ...chapter,
            root: document,
            html: document.documentElement.innerHTML,
            title: getTitle(document, chapter.id)
        };
    }

    const response = await fetch(chapter.url, { credentials: 'include' });
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
    }
    const html = await response.text();
    const root = new DOMParser().parseFromString(html, 'text/html');
    return {
        ...chapter,
        root,
        html,
        title: getTitle(root, chapter.title || chapter.id)
    };
}

function getScrambleContext(root, html, fallbackAid) {
    return {
        aid: getNumberGlobal('aid', root, html, fallbackAid),
        scrambleId: getNumberGlobal('scramble_id', root, html, Number.POSITIVE_INFINITY)
    };
}

function getNumberGlobal(name, root, html, fallback) {
    if (root === document && typeof window[name] !== 'undefined' && Number.isFinite(Number(window[name]))) {
        return window[name];
    }

    const source = html || root?.documentElement?.innerHTML || '';
    const direct = new RegExp(`\\b${name}\\s*[:=]\\s*['"]?(\\d+)`, 'i').exec(source);
    const quoted = new RegExp(`['"]${name}['"]\\s*:\\s*['"]?(\\d+)`, 'i').exec(source);
    return direct?.[1] || quoted?.[1] || fallback;
}

function getTitle(root = document, fallback = getCurrentPhotoId()) {
    const heading = root.querySelector('.panel-heading .pull-left')?.textContent?.trim();
    const title = (heading || root.title || fallback || '').split('|')[0];
    return sanitizeFileName(title || fallback || 'comic');
}

function sanitizeFileName(name) {
    return (name || '')
        .replace(/[\\/:*?"<>|]+/g, '')
        .replace(/\s+/g, ' ')
        .trim()
        .replace(/[. ]+$/g, '')
        .slice(0, 120) || 'comic';
}

function isPhotoPage() {
    return PHOTO_ID_RE.test(location.pathname);
}

function getCurrentPhotoId() {
    return extractPhotoId(location.href);
}

function extractPhotoId(url) {
    try {
        return new URL(url, location.href).pathname.match(PHOTO_ID_RE)?.[1] || '';
    } catch {
        return '';
    }
}

function toPhotoUrl(url) {
    const parsed = new URL(url, location.href);
    const id = extractPhotoId(parsed.href);
    return id ? `${parsed.origin}/photo/${id}` : '';
}

function saveBlob(blob, fileName) {
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = fileName;
    document.body.appendChild(link);
    link.click();
    setTimeout(() => {
        URL.revokeObjectURL(url);
        link.remove();
    }, 1000);
}

const template = document.createElement('template');
template.innerHTML = /* html */`
<style>
  #overlay {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    z-index: 2147483647;
    text-align: -moz-center;
    text-align: -webkit-center;
  }

  #card {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: 1.5em 4em;
    border-radius: 0.5em;
    background: white;
  }

  #progress {
    position: relative;
    width: 15em;
    height: 15em;
    border-radius: 50%;
  }

  #indicator {
    transition: .5s;
  }

  #percentage {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    font-size: 1.8em;
    display: flex;
    justify-content: center;
    align-items: center;
    border-radius: 50%
  }

  #value {
    font-size: 1.8em;
  }
</style>

<div id="overlay">
  <div id="card">
    <div id="progress">
      <svg viewBox="0 0 72 72" stroke="#cccccc" stroke-width="5" fill="none">
        <circle cx="36" cy="36" r="32"/>
        <circle id="indicator" cx="36" cy="36" r="32" stroke="#4cc790"/>
      </svg>
      <div id="percentage">
        <p><span id="value"></span>%</p>
      </div>
    </div>
    <h2 id="text"></h2>
  </div>
</div>
`;

class ProgressCircle extends HTMLElement {
    static #attrs = /** @type {const} */ (['max', 'value', 'text', 'hidden']);

    #isReady = false;
    #isRenderScheduled = false;
    #max = 1;
    #value = 0;
    #text = '';
    /** @type {SVGCircleElement} */
    #elIndicator;
    /** @type {HTMLSpanElement} */
    #elValue;
    /** @type {HTMLHeadingElement} */
    #elText;

    static get observedAttributes() {
        return this.#attrs;
    }

    get max() {
        return this.#max;
    }

    set max(value) {
        this.#max = value;
        this.#render();
    }

    get value() {
        return this.#value;
    }

    set value(value) {
        this.#value = value;
        this.#render();
    }

    get text() {
        return this.#text;
    }

    set text(value) {
        this.#text = value;
        this.#elText.textContent = value;
    }

    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }

    connectedCallback() {
        if (!this.#isReady) {
            this.#init();
        }
        this.#render();
    }

    /**
     * @template {typeof ProgressCircle.observedAttributes[number]} P
     * @param {P} name
     * @param {ProgressCircle[P]} _oldValue
     * @param {ProgressCircle[P]} newValue
     */
    attributeChangedCallback(name, _oldValue, newValue) {
        if ('hidden' === name) {
            null === newValue ?
                document.body.style.setProperty('overflow', 'hidden', 'important') :
                document.body.style.removeProperty('overflow');
        } else {
            this[name] = newValue;
        }
    }

    #init() {
        this.shadowRoot.appendChild(template.content.cloneNode(true));
        this.#elIndicator = this.shadowRoot.getElementById('indicator');
        this.#elValue = this.shadowRoot.getElementById('value');
        this.#elText = this.shadowRoot.getElementById('text');
        this.#max = +(this.getAttribute('max') ?? this.#max);
        this.#value = +(this.getAttribute('value') ?? this.#value);
        this.text = this.getAttribute('text') ?? this.#text;
        this.#isReady = true;
    }

    #render() {
        if (!this.isConnected || !this.#isReady || this.hidden || this.#isRenderScheduled) {
            return;
        }
        this.#isRenderScheduled = true;
        requestAnimationFrame(() => {
            this.#elIndicator.pathLength.baseVal = this.#max;
            this.#elIndicator.style.strokeDasharray = this.#max;
            this.#elIndicator.style.strokeDashoffset = this.#max - this.#value;
            this.#elValue.textContent = Math.round(this.#value * 100 / this.#max);
            this.#isRenderScheduled = false;
        });
    }
}

customElements.define('progress-circle', ProgressCircle);