// ==UserScript==
// @name 漫画下载
// @namespace shadows
// @version 0.1.2
// @description 从此类使用相同框架的漫画网站上下载免费或已付费的章节
// @author shadows
// @icon64 https://dimg04.c-ctrip.com/images/0391j120008r0n8a84D94.png
// A类型网站
// @match https://pocket.shonenmagazine.com/episode/*
// @match https://shonenjumpplus.com/episode/*
// @match https://tonarinoyj.jp/episode/*
// @match https://comic-days.com/episode/*
// @match https://comic-gardo.com/episode/*
// @match https://magcomi.com/episode/*
// @match https://viewer.heros-web.com/episode/*
// B类型
// @match https://feelweb.jp/episode/*
// C类型
// @match https://comic-action.com/episode/*
// @match https://comicbushi-web.com/episode/*
// @match https://comicborder.com/episode/*
// @match https://comic-zenon.com/episode/*
// @match https://comic-trail.com/episode/*
// @match https://kuragebunch.com/episode/*
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// @grant GM.xmlHttpRequest
// @grant GM_xmlhttpRequest
// ==/UserScript==
"use strict";
const hostname = window.location.hostname;
const typeA = /shonenmagazine|shonenjumpplus|tonarinoyj|-days|-gardo|magcomi|heros-web/i;
const typeB = /feelweb/i;
let siteType = "typeC";
if (typeA.test(hostname)) {
siteType = "typeA";
} else if (typeB.test(hostname)) {
siteType = "typeB";
}
console.log(siteType);
let observer = new MutationObserver(addButton);
observer.observe(document.querySelector("div.js-readable-product-list"), { childList: true, subtree: true });
const xhr = option => new Promise((resolve, reject) => {
GM.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(".series-episode-list-is-free");
let purchasedEp = document.querySelectorAll(".series-episode-list-purchased");
targets = [...freeEp, ...purchasedEp];
} else if (siteType == "typeB") {
targets = document.querySelectorAll(".series-episode-list-title");
} else if (siteType == "typeC") {
targets = document.querySelectorAll(".series-episode-list-title");
targets = Array.prototype.filter.call(targets, node => node.nextSibling == null);
}
for (let elem of targets) {
if (elem.parentNode.querySelector(".download-button")) continue;
//付费章节的标识调整样式,以避免button位于它下方
if (elem.classList.contains("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(".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 + ".json");
//console.log(imagesData);
let imagesBlob = await downloadImages({ imagesData, name });
if (!imagesData[0].src.includes("/original/")) {
imagesBlob = await Promise.all(imagesBlob.map(item => decryptImage(item)));
}
let zip = new JSZip();
imagesBlob.forEach((item) => {
zip.file(`${item.id}.jpg`, item.blob);
});
zip.generateAsync({ type: "blob", base64: true }).then(content => saveAs(content, `${name}.zip`));
}
// 返回包含每张图片对象的数组
// imagesData [{id,src,height,width},...]
async function fetchImagesData(epUrl) {
let epData = await xhr({
method: "GET",
url: epUrl,
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; ONEPLUS A5000) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Mobile Safari/537.36',
},
responseType: "json",
}).then(resp => resp.response);
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 => {
console.log(`${name}-${item.id} have downloaded.`);
//返回包含序号与blob的对象
return { id: item.id, blob: resp.blob() };
});
}
let imagesBlob = asyncPool(10, 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(await 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))
}