从此类使用相同框架的漫画网站上下载免费或已付费的章节
// Copyright 2024 shadows // // Distributed under MIT license. // See file LICENSE for detail or copy at https://opensource.org/licenses/MIT // ==UserScript== // @name 漫画下载 // @namespace shadows // @version 0.2.0 // @description 从此类使用相同框架的漫画网站上下载免费或已付费的章节 // @author shadows // @license MIT License // @copyright Copyright (c) 2021 shadows // @icon https://dimg04.c-ctrip.com/images/0391j120008r0n8a84D94.png // @icon64 https://static.yximgs.com/bs2/adInnovationResource/367c797d005b4b1ab180f0a361a7ef43.png // A类型网站 // @match https://pocket.shonenmagazine.com/episode/* // @match https://shonenjumpplus.com/episode/* // @match https://comic-days.com/episode/* // @match https://comic-days.com/volume/* // @match https://comic-days.com/magazine/* // @match https://comic-gardo.com/episode/* // @match https://magcomi.com/episode/* // @match https://magcomi.com/volume/* // @match https://viewer.heros-web.com/episode/* // @match https://kuragebunch.com/episode/* // @match https://comic-zenon.com/episode/* // B类型 // @match https://feelweb.jp/episode/* // @match https://tonarinoyj.jp/episode/* // @match https://comic-action.com/episode/* // @match https://comicbushi-web.com/episode/* // @match https://comicborder.com/episode/* // @match https://comic-trail.com/episode/* // @match https://www.sunday-webry.com/episode/* // @match https://comic-ogyaaa.com/episode/* // @match https://www.corocoro.jp/episode/* // @match https://ourfeel.jp/episode/* // @match https://comic-growl.com/episode/* // @require https://cdn.jsdelivr.net/npm/@zip.js/[email protected]/dist/zip.min.js // @grant GM.xmlHttpRequest // @grant GM_xmlhttpRequest // ==/UserScript== "use strict"; const hostname = window.location.hostname; const typeA = /shonenmagazine|shonenjumpplus|-days|-gardo|magcomi|heros-web|kuragebunch|comic-zenon/i; const typeB = /feelweb|tonarinoyj|comic(-action|bushi-web|border|-trail|-ogyaaa|-growl)|sunday-webry|corocoro|ourfeel/i; let siteType = "typeA"; if (typeB.test(hostname)) { siteType = "typeB"; } console.log(siteType); let observer = new MutationObserver(addButton); observer.observe(document.querySelector("div#series-contents"), { childList: true, subtree: true }); window.navigation.addEventListener("navigate", async (event) => { observer = new MutationObserver(addButton); await waitForElm("div#series-contents .series-episode-list"); observer.observe(document.querySelector("div#series-contents"), { childList: true, subtree: true }); }) const xmlHttpRequest = (typeof(GM_xmlhttpRequest) === 'undefined') ? GM.xmlHttpRequest : GM_xmlhttpRequest; const xhr = option => new Promise((resolve, reject) => { xmlHttpRequest({ ...option, onerror: reject, onload: (response) => { if (response.status >= 200 && response.status < 300) { resolve(response); } else { reject(response); } }, }); }); const buttonCSS = `display: inline-block; background: linear-gradient(135deg, #6e8efb, #a777e3); color: white; padding: 3px 3px; margin: 4px 0px; text-align: center; border-radius: 3px; border-width: 0px;`; function addButton() { let targets = []; if (siteType == "typeA") { let freeEp = document.querySelectorAll("[class*=series-episode-list-is-free]"); let purchasedEp = document.querySelectorAll("[class*=series-episode-list-purchased]"); let rentalEp = document.querySelectorAll('[class*=series-episode-list-rental]') let subscribedEp = document.querySelectorAll('[class*=series-episode-list-subscribed]') targets = [...freeEp, ...purchasedEp, ...rentalEp, ...subscribedEp]; } else if (siteType == "typeB") { targets = document.querySelectorAll(":not(.private).episode .series-episode-list-title"); } for (let elem of targets) { if (elem.parentNode.querySelector(".download-button")) continue; //付费章节的标识调整样式,以避免button位于它下方 if (Array.prototype.some.call(elem.classList,(i) => i.includes("series-episode-list-purchased"))) { elem.style.display = "inline-block"; elem.style.paddingRight = "6px"; } let button = document.createElement("button"); button.classList.add("download-button"); button.textContent = "Download"; button.style.cssText = buttonCSS; let aElement = elem.closest("a"); //当前页面的章节找不到a跳转标签,使用窗口url button.dataset.href = aElement ? aElement.href : window.location.href; let name = elem.parentNode.querySelector("[class*=series-episode-list-title]").textContent; //过滤windows文件名中不允许的字符 button.dataset.name = name.replaceAll(/[\\\/:*?"<>|]/g, " ").trim(); button.onclick = clickButton; elem.parentNode.append(button); } } async function clickButton(event) { event.stopPropagation(); event.preventDefault(); let elem = event.target; let href = elem.dataset.href; let name = elem.dataset.name; console.log(`Click!href:${href} name:${name}`); let imagesData = await fetchImagesData(href); //console.log(imagesData); let imagesBlob = await downloadImages({ imagesData, name }); if (!imagesData[0].src.includes("/original/")) { imagesBlob = await Promise.all(imagesBlob.map(item => decryptImage(item))); } const blobWriter = new zip.BlobWriter("application/zip"); const zipWriter = new zip.ZipWriter(blobWriter); let targetLength = imagesBlob.length.toString().length; imagesBlob.forEach(async (item) => { await zipWriter.add(`${item.id.toString().padStart(targetLength,'0')}.jpg`, new zip.BlobReader(item.blob)); }); const zipFile = await zipWriter.close(); saveBlob(zipFile , `${name}.zip`); } // 返回包含每张图片对象的数组 // imagesData [{id,src,height,width},...] async function fetchImagesData(epUrl) { let epData = await xhr({ method: "GET", url: epUrl, }).then(resp => resp.responseText) .then(html =>{ const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html") return JSON.parse(doc.querySelector("#episode-json").dataset.value) }); let imagesData = epData.readableProduct.pageStructure.pages.filter(item => item.type == "main"); imagesData = imagesData.map((item, index) => ({ id: index + 1, ...item })); return imagesData; } async function downloadImages({ imagesData, name }) { async function downloadSingleImage(item) { return fetch(item.src).then(resp => resp.blob()).then(blob =>{ console.log(`${name}-${item.id} have downloaded.`); //返回包含序号与blob的对象 return { id: item.id, blob: blob }; }); } let imagesBlob = asyncPool(15, imagesData, downloadSingleImage); return imagesBlob; } async function decryptImage(imagesBlobItem) { let [images, width, height] = await blobToImageData(imagesBlobItem.blob); let cellWidth = Math.floor(width / 32) * 8; let cellHeight = Math.floor(height / 32) * 8; let canvas = document.createElement("canvas"); [canvas.width, canvas.height] = [width, height]; let ctx = canvas.getContext("2d"); ctx.putImageData(images, 0, 0); let targetCanvas = document.createElement("canvas"); [targetCanvas.width, targetCanvas.height] = [width, height]; let targetCtx = targetCanvas.getContext("2d"); //行 i for (let i = 0; i < 4; i++) { //列 j for (let j = 0; j < 4; j++) { let x = i * cellWidth; let y = j * cellHeight; let piece = ctx.getImageData(x, y, cellWidth, cellHeight); //转换后行列互换,得到真实位置 let targetX = j * cellWidth; let targetY = i * cellHeight; targetCtx.putImageData(piece, targetX, targetY); } } return new Promise(resolve => { targetCanvas.toBlob(blob => resolve({ id: imagesBlobItem.id, blob }), 'image/jpeg', 1); }) } async function blobToImageData(blob) { let blobUrl = URL.createObjectURL(blob); return new Promise((resolve, reject) => { let img = new Image(); img.onload = () => resolve(img); img.onerror = err => reject(err); img.src = blobUrl; }).then(img => { URL.revokeObjectURL(blobUrl); let canvas = document.createElement("canvas"); [canvas.width, canvas.height] = [img.width, img.height]; let ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); return [ctx.getImageData(0, 0, img.width, img.height), img.width, img.height]; }) } /** * @param poolLimit 并发控制数 (>= 1) * @param array 参数数组 * @param iteratorFn 异步任务,返回 promise 或是 async 方法 * https://www.luanzhuxian.com/post/60c2c548.html */ function asyncPool(poolLimit, array, iteratorFn) { let i = 0 const ret = [] // Promise.all(ret) 的数组 const executing = [] const enqueue = function() { // array 遍历完,进入 Promise.all 流程 if (i === array.length) { return Promise.resolve() } // 每调用一次 enqueue,就初始化一个 promise,并放入 ret 队列 const item = array[i++] const p = Promise.resolve().then(() => iteratorFn(item, array)) ret.push(p) // 插入 executing 队列,即正在执行的 promise 队列,并且 promise 执行完毕后,会从 executing 队列中移除 const e = p.then(() => executing.splice(executing.indexOf(e), 1)) executing.push(e) // 每当 executing 数组中 promise 数量达到 poolLimit 时,就利用 Promise.race 控制并发数,完成的 promise 会从 executing 队列中移除,并触发 Promise.race 也就是 r 的回调,继续递归调用 enqueue,继续 加入新的 promise 任务至 executing 队列 let r = Promise.resolve() if (executing.length >= poolLimit) { r = Promise.race(executing) } // 递归,链式调用,直到遍历完 array return r.then(() => enqueue()) } return enqueue().then(() => Promise.all(ret)) } function saveBlob(content,name) { const fileUrl = window.URL.createObjectURL(content); const anchorElement = document.createElement('a'); anchorElement.href = fileUrl; anchorElement.download = name; anchorElement.style.display = 'none'; document.body.appendChild(anchorElement); anchorElement.click(); anchorElement.remove(); window.URL.revokeObjectURL(fileUrl); } function waitForElm(selector) { return new Promise(resolve => { if (document.querySelector(selector)) { return resolve(document.querySelector(selector)); } const observer = new MutationObserver(mutations => { if (document.querySelector(selector)) { observer.disconnect(); resolve(document.querySelector(selector)); } }); // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336 observer.observe(document.body, { childList: true, subtree: true }); }); }