Download the current JM/18comic chapter, all chapters, or a selected chapter range as cbz files.
// ==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(';')}"></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);