Warning: fopen(/www/sites/update.greasyfork.icu/index/store/temp/27a2d6eb24ea9224656e7dd867e0f41c.js): failed to open stream: No space left on device in /www/sites/update.greasyfork.icu/index/scriptControl.php on line 65
// ==UserScript==
// @name ComiciViewer Download
// @namespace http://tampermonkey.net/
// @version 1.1.0
// @description Download images from Comici.jp viewer based websites
// @author shadows
// @license MIT License
// @copyright Copyright (c) 2025 shadows
// @match https://bigcomics.jp/episodes/*
// @match https://youngchampion.jp/episodes/*
// @match https://younganimal.com/episodes/*
// @match https://comic-medu.com/episodes/*
// @match https://comicride.jp/episodes/*
// @match https://rimacomiplus.jp/*/episodes/*
// @match https://kansai.mag-garden.co.jp/episodes/*
// @match https://comicpash.jp/episodes/*
// @match https://comic-growl.com/episodes/*
// @match https://championcross.jp/episodes/*
// @icon https://dimg04.c-ctrip.com/images/0391j120008r0n8a84D94.png
// @icon64 https://static.yximgs.com/bs2/adInnovationResource/367c797d005b4b1ab180f0a361a7ef43.png
// @require https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.62/dist/zip.min.js
// @grant none
// @downloadURL https://update.greasyfork.icu/scripts/540999/ComiciViewer%20Download.user.js
// @updateURL https://update.greasyfork.icu/scripts/540999/ComiciViewer%20Download.meta.js
// ==/UserScript==
const scrambleMatrix = new Array(16).fill(null).map((_, index) => [index / 4 >> 0, index % 4 >> 0]);
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;`;
let button = document.createElement("button");
button.classList.add("download-button");
button.textContent = "Download";
button.style.cssText = buttonCSS
button.onclick = clickButton;
document.querySelector("div.article-section").after(button);
async function clickButton(event) {
event.stopPropagation();
event.preventDefault();
let elem = event.target;
const name = document.querySelector("div.article-title").textContent;
const viewer = document.getElementById("comici-viewer");
const viewerId = viewer.getAttribute("comici-viewer-id");
const userId = viewer.dataset.memberJwt;
const domain = viewer.dataset.apiDomain;
console.log(`Click!name:${name}`);
const imagesData = await fetchImagesData(domain,viewerId,userId);
//console.log(imagesData);
let imagesBlob = await downloadImages({ imagesData, name });
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`);
}
async function downloadImages({ imagesData, name }) {
async function downloadSingleImage(item) {
return fetch(item.url).then(resp => resp.blob()).then(blob =>{
console.log(`${name}-${item.id} have downloaded.`);
//返回包含序号与blob的对象
return { id: item.id, blob: blob, scramble: item.scramble };
});
}
let imagesBlob = asyncPool(15, imagesData, downloadSingleImage);
return imagesBlob;
}
async function fetchImagesData(domain,viewerId,userId) {
const totalPages= (await createContentsInfo(domain,viewerId,userId,"1")).totalPages;
const imagesData = (await createContentsInfo(domain,viewerId,userId,totalPages)).result;
return imagesData.map((item, index) => ({ id: index + 1, url: item.imageUrl, scramble: item.scramble}))
}
async function createContentsInfo(domain,viewerId,userId, pageTo){
const data = await fetch(`https://${domain}/book/contentsInfo?comici-viewer-id=${viewerId}&user-id=${userId}&page-from=0&page-to=${pageTo}`)
.then(r=>r.json());
return data
}
async function decryptImage(imagesBlobItem) {
const {scramble,blob} = imagesBlobItem
const decodedArray = decodeScrambleArray(imagesBlobItem.scramble);
const {ctx, width, height} = await blobToCanvas(blob);
const cellWidth = Math.floor(width / 4);
const cellHeight = Math.floor(height / 4);
let targetCanvas = document.createElement("canvas");
[targetCanvas.width, targetCanvas.height] = [width, height];
let targetCtx = targetCanvas.getContext("2d");
//行 i
for (let n=0,i = 0; i < 4; i++) {
//列 j
for (let j = 0; j < 4; j++) {
let x = decodedArray[n][0] * cellWidth;
let y = decodedArray[n][1] * cellHeight;
let piece = ctx.getImageData(x, y, cellWidth, cellHeight);
let targetX = i * cellWidth;
let targetY = j * cellHeight;
targetCtx.putImageData(piece, targetX, targetY);
n++;
}
}
return new Promise(resolve => {
targetCanvas.toBlob(blob => resolve({ id: imagesBlobItem.id, blob }), 'image/jpeg', 1);
})
}
function decodeScrambleArray(scramble) {
const decoded = [];
const encoded = scramble.replace(/\s+/g, '').slice(1).slice(0, -1).split(',');
for (let i = 0; i < scrambleMatrix.length; i++) {
decoded.push(scrambleMatrix[encoded[i]]);
}
return decoded;
}
async function blobToCanvas(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, width:img.width, height:img.height};
})
}
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);
}
/**
* @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))
}