18comic漫画下载

从18comic上下载cbz格式(整话阅读)或webp格式(分页阅读)的漫画

// ==UserScript==
// @name         18comic漫画下载
// @namespace    https://github.com/eternalphane/Userscripts/
// @version      1.1.1
// @description  从18comic上下载cbz格式(整话阅读)或webp格式(分页阅读)的漫画
// @author       eternalphane
// @license      MIT
// @supportURL   https://github.com/eternalphane/UserScripts/issues
// @match        https://18comic.vip/photo/*
// @match        https://18comic.org/photo/*
// @match        https://jmcomic.me/photo/*
// @match        https://jmcomic1.me/photo/*
// @match        https://jm-comic1.art/photo/*
// @match        https://jm-comic2.art/photo/*
// @match        https://jm-comic3.art/photo/*
// @require      https://unpkg.com/jszip@3.9.1/dist/jszip.min.js
// ==/UserScript==
'use strict';

setTimeout(async () => {
    const ICON_DOWNLOAD = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1ODAgNTIwIj48cGF0aCBkPSJNNTI4IDI4OXEyMCAwIDM0IDE0dDE0IDM0djEyOHEwIDIwLTE0IDM0dC0zNCAxNEg0OHEtMjAgMC0zNC0xNFQwIDQ2NVYzMzdxMC0yMCAxNC0zNHQzNC0xNGg5MmwtNDYtNDZxLTIyLTIzLTEwLTUyLjV0NDQtMjkuNWg2NFY0OXEwLTIwIDE0LTM0dDM0LTE0aDk2cTIwIDAgMzQgMTR0MTQgMzR2MTEyaDY0cTMyIDAgNDQuNSAyOS41VDQ4MiAyNDNsLTQ2IDQ2aDkyem0tNDAwLTgwbDE2MCAxNjAgMTYwLTE2MEgzMzZWNDloLTk2djE2MEgxMjh6bTQwMCAyNTZWMzM3SDM4OGwtNjYgNjZxLTE0IDE0LTM0IDE0dC0zNC0xNGwtNjYtNjZINDh2MTI4aDQ4MHptLTgxLTQ3cS03LTctNy0xN3Q3LTE3cTctNyAxNy03dDE3IDdxNyA3IDcgMTd0LTcgMTdxLTcgNy0xNyA3dC0xNy03eiIvPjwvc3ZnPg==';
    const progress = new ProgressCircle;
    progress.hidden = true;
    document.body.appendChild(progress);
    for (const liPrev of document.querySelectorAll('li:not(.ph-active):has(a .fa-bookmark), li.ph-active:has(a .fa-heart)')) {
        const li = document.createElement('li');
        for (const attr of liPrev.attributes) {
            li.setAttribute(attr.name, attr.value);
        }
        const btn = document.createElement('a');
        btn.href = '#';
        const styles = [`background:url(${ICON_DOWNLOAD}) center / contain no-repeat`];
        if (liPrev.classList.contains('ph-active')) {
            styles.push('filter:invert(1)');
        }
        btn.innerHTML = `
<i class="far" style="${styles.join(';')}">&#xfeff;</i>
<span>下载</span>`;
        btn.addEventListener('click', async e => {
            e.preventDefault();
            btn.href = null;
            progress.hidden = false;
            progress.text = 'Downloading...';
            const selector = new URLSearchParams(location.search).get('read_mode') === 'read-by-page' ?
                '.owl-item .center img' :
                '.scramble-page img';
            const pages = [...document.querySelectorAll(selector)];
            progress.max = pages.length;
            const zip = new JSZip;
            await Promise.all(pages.map(async page => {
                try {
                    zip.file(...await download(page.dataset.original ?? page.dataset.src));
                } catch {}
                ++progress.value;
            }));
            progress.text = 'Compressing...';
            progress.value = 0;
            progress.max = 100;
            // TODO: Select output format? (cbz, cbt, pdf)
            btn.download = `${document.querySelector('.panel-heading .pull-left').textContent.trim()}.cbz`
            btn.href = URL.createObjectURL(await zip.generateAsync({
                type: 'blob',
                compression: 'DEFLATE',
                compressionOptions: { level: 9 },
                mimeType: 'application/vnd.comicbook+zip'
            }, (meta) => progress.value = meta.percent));
            progress.hidden = true;
            setTimeout(() => btn.click(), 0);
        }, { once: true });
        li.appendChild(btn);
        liPrev.after(li);
    }
}, 0);

/**
 * @param {string} url
 */
const download = async url => {
    const filename = new URL(url).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');
    // `aid`, `scramble_id` and `get_num` are both global variables
    if (url.includes('.gif') || aid < scramble_id) {
        ctx.drawImage(img, 0, 0);
    } else {
        const num = get_num(btoa(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);
    // TODO: Select image type? Change quality?
    return [filename, await canvas.convertToBlob({
        type: {
            jpg: 'image/jpeg',
            png: 'image/png',
            webp: 'image/webp'
        }[ext],
        quality: 1
    })];
};

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: 999;
    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);