漫画下载

从此类使用相同框架的漫画网站上下载免费或已付费的章节

  1. // Copyright 2024 shadows
  2. //
  3. // Distributed under MIT license.
  4. // See file LICENSE for detail or copy at https://opensource.org/licenses/MIT
  5. // ==UserScript==
  6. // @name 漫画下载
  7. // @namespace shadows
  8. // @version 0.2.0
  9. // @description 从此类使用相同框架的漫画网站上下载免费或已付费的章节
  10. // @author shadows
  11. // @license MIT License
  12. // @copyright Copyright (c) 2021 shadows
  13. // @icon https://dimg04.c-ctrip.com/images/0391j120008r0n8a84D94.png
  14. // @icon64 https://static.yximgs.com/bs2/adInnovationResource/367c797d005b4b1ab180f0a361a7ef43.png
  15.  
  16. // A类型网站
  17. // @match https://pocket.shonenmagazine.com/episode/*
  18. // @match https://shonenjumpplus.com/episode/*
  19. // @match https://comic-days.com/episode/*
  20. // @match https://comic-days.com/volume/*
  21. // @match https://comic-days.com/magazine/*
  22. // @match https://comic-gardo.com/episode/*
  23. // @match https://magcomi.com/episode/*
  24. // @match https://magcomi.com/volume/*
  25. // @match https://viewer.heros-web.com/episode/*
  26. // @match https://kuragebunch.com/episode/*
  27. // @match https://comic-zenon.com/episode/*
  28. // B类型
  29. // @match https://feelweb.jp/episode/*
  30. // @match https://tonarinoyj.jp/episode/*
  31. // @match https://comic-action.com/episode/*
  32. // @match https://comicbushi-web.com/episode/*
  33. // @match https://comicborder.com/episode/*
  34. // @match https://comic-trail.com/episode/*
  35. // @match https://www.sunday-webry.com/episode/*
  36. // @match https://comic-ogyaaa.com/episode/*
  37. // @match https://www.corocoro.jp/episode/*
  38. // @match https://ourfeel.jp/episode/*
  39. // @match https://comic-growl.com/episode/*
  40.  
  41. // @require https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.34/dist/zip.min.js
  42. // @grant GM.xmlHttpRequest
  43. // @grant GM_xmlhttpRequest
  44. // ==/UserScript==
  45. "use strict";
  46.  
  47. const hostname = window.location.hostname;
  48. const typeA = /shonenmagazine|shonenjumpplus|-days|-gardo|magcomi|heros-web|kuragebunch|comic-zenon/i;
  49. const typeB = /feelweb|tonarinoyj|comic(-action|bushi-web|border|-trail|-ogyaaa|-growl)|sunday-webry|corocoro|ourfeel/i;
  50. let siteType = "typeA";
  51. if (typeB.test(hostname)) {
  52. siteType = "typeB";
  53. }
  54. console.log(siteType);
  55.  
  56. let observer = new MutationObserver(addButton);
  57. observer.observe(document.querySelector("div#series-contents"), { childList: true, subtree: true });
  58. window.navigation.addEventListener("navigate", async (event) => {
  59. observer = new MutationObserver(addButton);
  60. await waitForElm("div#series-contents .series-episode-list");
  61. observer.observe(document.querySelector("div#series-contents"), { childList: true, subtree: true });
  62. })
  63.  
  64.  
  65. const xmlHttpRequest = (typeof(GM_xmlhttpRequest) === 'undefined') ? GM.xmlHttpRequest : GM_xmlhttpRequest;
  66. const xhr = option => new Promise((resolve, reject) => {
  67. xmlHttpRequest({
  68. ...option,
  69. onerror: reject,
  70. onload: (response) => {
  71. if (response.status >= 200 && response.status < 300) {
  72. resolve(response);
  73. } else {
  74. reject(response);
  75. }
  76. },
  77. });
  78. });
  79. const buttonCSS = `display: inline-block;
  80. background: linear-gradient(135deg, #6e8efb, #a777e3);
  81. color: white;
  82. padding: 3px 3px;
  83. margin: 4px 0px;
  84. text-align: center;
  85. border-radius: 3px;
  86. border-width: 0px;`;
  87.  
  88. function addButton() {
  89. let targets = [];
  90. if (siteType == "typeA") {
  91. let freeEp = document.querySelectorAll("[class*=series-episode-list-is-free]");
  92. let purchasedEp = document.querySelectorAll("[class*=series-episode-list-purchased]");
  93. let rentalEp = document.querySelectorAll('[class*=series-episode-list-rental]')
  94. let subscribedEp = document.querySelectorAll('[class*=series-episode-list-subscribed]')
  95. targets = [...freeEp, ...purchasedEp, ...rentalEp, ...subscribedEp];
  96. } else if (siteType == "typeB") {
  97. targets = document.querySelectorAll(":not(.private).episode .series-episode-list-title");
  98. }
  99. for (let elem of targets) {
  100. if (elem.parentNode.querySelector(".download-button")) continue;
  101. //付费章节的标识调整样式,以避免button位于它下方
  102. if (Array.prototype.some.call(elem.classList,(i) => i.includes("series-episode-list-purchased"))) {
  103. elem.style.display = "inline-block";
  104. elem.style.paddingRight = "6px";
  105. }
  106. let button = document.createElement("button");
  107. button.classList.add("download-button");
  108. button.textContent = "Download";
  109. button.style.cssText = buttonCSS;
  110. let aElement = elem.closest("a");
  111. //当前页面的章节找不到a跳转标签,使用窗口url
  112. button.dataset.href = aElement ? aElement.href : window.location.href;
  113. let name = elem.parentNode.querySelector("[class*=series-episode-list-title]").textContent;
  114. //过滤windows文件名中不允许的字符
  115. button.dataset.name = name.replaceAll(/[\\\/:*?"<>|]/g, " ").trim();
  116. button.onclick = clickButton;
  117. elem.parentNode.append(button);
  118. }
  119. }
  120.  
  121. async function clickButton(event) {
  122. event.stopPropagation();
  123. event.preventDefault();
  124. let elem = event.target;
  125. let href = elem.dataset.href;
  126. let name = elem.dataset.name;
  127. console.log(`Click!href:${href} name:${name}`);
  128. let imagesData = await fetchImagesData(href);
  129. //console.log(imagesData);
  130. let imagesBlob = await downloadImages({ imagesData, name });
  131. if (!imagesData[0].src.includes("/original/")) {
  132. imagesBlob = await Promise.all(imagesBlob.map(item => decryptImage(item)));
  133. }
  134. const blobWriter = new zip.BlobWriter("application/zip");
  135. const zipWriter = new zip.ZipWriter(blobWriter);
  136. let targetLength = imagesBlob.length.toString().length;
  137. imagesBlob.forEach(async (item) => {
  138. await zipWriter.add(`${item.id.toString().padStart(targetLength,'0')}.jpg`, new zip.BlobReader(item.blob));
  139. });
  140. const zipFile = await zipWriter.close();
  141. saveBlob(zipFile , `${name}.zip`);
  142. }
  143.  
  144. // 返回包含每张图片对象的数组
  145. // imagesData [{id,src,height,width},...]
  146. async function fetchImagesData(epUrl) {
  147. let epData = await xhr({
  148. method: "GET",
  149. url: epUrl,
  150. }).then(resp => resp.responseText)
  151. .then(html =>{
  152. const parser = new DOMParser();
  153. const doc = parser.parseFromString(html, "text/html")
  154. return JSON.parse(doc.querySelector("#episode-json").dataset.value)
  155. });
  156. let imagesData = epData.readableProduct.pageStructure.pages.filter(item => item.type == "main");
  157. imagesData = imagesData.map((item, index) => ({ id: index + 1, ...item }));
  158. return imagesData;
  159. }
  160.  
  161.  
  162. async function downloadImages({ imagesData, name }) {
  163. async function downloadSingleImage(item) {
  164. return fetch(item.src).then(resp => resp.blob()).then(blob =>{
  165. console.log(`${name}-${item.id} have downloaded.`);
  166. //返回包含序号与blob的对象
  167. return { id: item.id, blob: blob };
  168. });
  169. }
  170. let imagesBlob = asyncPool(15, imagesData, downloadSingleImage);
  171. return imagesBlob;
  172. }
  173.  
  174. async function decryptImage(imagesBlobItem) {
  175. let [images, width, height] = await blobToImageData(imagesBlobItem.blob);
  176.  
  177. let cellWidth = Math.floor(width / 32) * 8;
  178. let cellHeight = Math.floor(height / 32) * 8;
  179.  
  180. let canvas = document.createElement("canvas");
  181. [canvas.width, canvas.height] = [width, height];
  182. let ctx = canvas.getContext("2d");
  183. ctx.putImageData(images, 0, 0);
  184.  
  185. let targetCanvas = document.createElement("canvas");
  186. [targetCanvas.width, targetCanvas.height] = [width, height];
  187. let targetCtx = targetCanvas.getContext("2d");
  188.  
  189. //行 i
  190. for (let i = 0; i < 4; i++) {
  191. //列 j
  192. for (let j = 0; j < 4; j++) {
  193. let x = i * cellWidth;
  194. let y = j * cellHeight;
  195. let piece = ctx.getImageData(x, y, cellWidth, cellHeight);
  196. //转换后行列互换,得到真实位置
  197. let targetX = j * cellWidth;
  198. let targetY = i * cellHeight;
  199. targetCtx.putImageData(piece, targetX, targetY);
  200. }
  201. }
  202. return new Promise(resolve => {
  203. targetCanvas.toBlob(blob => resolve({ id: imagesBlobItem.id, blob }), 'image/jpeg', 1);
  204. })
  205. }
  206.  
  207. async function blobToImageData(blob) {
  208. let blobUrl = URL.createObjectURL(blob);
  209. return new Promise((resolve, reject) => {
  210. let img = new Image();
  211. img.onload = () => resolve(img);
  212. img.onerror = err => reject(err);
  213. img.src = blobUrl;
  214. }).then(img => {
  215. URL.revokeObjectURL(blobUrl);
  216. let canvas = document.createElement("canvas");
  217. [canvas.width, canvas.height] = [img.width, img.height];
  218. let ctx = canvas.getContext("2d");
  219. ctx.drawImage(img, 0, 0);
  220. return [ctx.getImageData(0, 0, img.width, img.height), img.width, img.height];
  221. })
  222. }
  223.  
  224.  
  225. /**
  226. * @param poolLimit 并发控制数 (>= 1)
  227. * @param array 参数数组
  228. * @param iteratorFn 异步任务,返回 promise 或是 async 方法
  229. * https://www.luanzhuxian.com/post/60c2c548.html
  230. */
  231. function asyncPool(poolLimit, array, iteratorFn) {
  232. let i = 0
  233. const ret = [] // Promise.all(ret) 的数组
  234. const executing = []
  235. const enqueue = function() {
  236. // array 遍历完,进入 Promise.all 流程
  237. if (i === array.length) {
  238. return Promise.resolve()
  239. }
  240.  
  241. // 每调用一次 enqueue,就初始化一个 promise,并放入 ret 队列
  242. const item = array[i++]
  243. const p = Promise.resolve().then(() => iteratorFn(item, array))
  244. ret.push(p)
  245.  
  246. // 插入 executing 队列,即正在执行的 promise 队列,并且 promise 执行完毕后,会从 executing 队列中移除
  247. const e = p.then(() => executing.splice(executing.indexOf(e), 1))
  248. executing.push(e)
  249.  
  250. // 每当 executing 数组中 promise 数量达到 poolLimit 时,就利用 Promise.race 控制并发数,完成的 promise 会从 executing 队列中移除,并触发 Promise.race 也就是 r 的回调,继续递归调用 enqueue,继续 加入新的 promise 任务至 executing 队列
  251. let r = Promise.resolve()
  252. if (executing.length >= poolLimit) {
  253. r = Promise.race(executing)
  254. }
  255.  
  256. // 递归,链式调用,直到遍历完 array
  257. return r.then(() => enqueue())
  258. }
  259.  
  260. return enqueue().then(() => Promise.all(ret))
  261. }
  262.  
  263. function saveBlob(content,name) {
  264. const fileUrl = window.URL.createObjectURL(content);
  265. const anchorElement = document.createElement('a');
  266. anchorElement.href = fileUrl;
  267. anchorElement.download = name;
  268. anchorElement.style.display = 'none';
  269. document.body.appendChild(anchorElement);
  270. anchorElement.click();
  271. anchorElement.remove();
  272. window.URL.revokeObjectURL(fileUrl);
  273. }
  274.  
  275. function waitForElm(selector) {
  276. return new Promise(resolve => {
  277. if (document.querySelector(selector)) {
  278. return resolve(document.querySelector(selector));
  279. }
  280.  
  281. const observer = new MutationObserver(mutations => {
  282. if (document.querySelector(selector)) {
  283. observer.disconnect();
  284. resolve(document.querySelector(selector));
  285. }
  286. });
  287.  
  288. // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336
  289. observer.observe(document.body, {
  290. childList: true,
  291. subtree: true
  292. });
  293. });
  294. }

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址