K MANGA Ripper

Adds a download button to rip chapters from K MANGA (bypasses image scrambling protection)

当前为 2023-12-26 提交的版本,查看 最新版本

// ==UserScript==
// @name         K MANGA Ripper
// @version      2.2.0
// @description  Adds a download button to rip chapters from K MANGA (bypasses image scrambling protection)
// @author       /a/non
// @namespace    K MANGA Ripper
// @match        https://kmanga.kodansha.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=kodansha.com
// @grant        none
// @run-at       document-start
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==

(function() {
    'use strict';

    let currentChapterJson;

    const ogXhrOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (...args) {
        if (args[1].startsWith("https://api.kmanga.kodansha.com/web/episode/viewer")) {
            this.addEventListener("load", () => {currentChapterJson = JSON.parse(this.responseText)});
        }
        return ogXhrOpen.apply(this, args);
    }


    const getUnscrambledCoords = (seed) => {
        const seed32 = new Uint32Array(1);
        seed32[0] = seed;
        const pairs = [];
        for (let i = 0; i < 16; i++) {
            seed32[0] ^= seed32[0] << 13;
            seed32[0] ^= seed32[0] >>> 17;
            seed32[0] ^= seed32[0] << 5;
            pairs.push([seed32[0], i]);
        }
        pairs.sort((a, b) => a[0] - b[0]);
        const sortedVal = pairs.map(e => e[1]);

        const coords = sortedVal.map((e, i) => ({
            source: {
                x: e % 4,
                y: Math.floor(e / 4)
            },
            dest: {
                x: i % 4,
                y: Math.floor(i / 4)
            }
        }));

        return coords;
    }


    const canvas = new OffscreenCanvas(0, 0);
    const ctx = canvas.getContext("2d");
    const drawPage = (page, coords, tileWidth, tileHeight) => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(page, 0, 0);
        for (const c of coords) {
            ctx.drawImage(page, c.source.x * tileWidth, c.source.y * tileHeight, tileWidth, tileHeight, c.dest.x * tileWidth, c.dest.y * tileHeight, tileWidth, tileHeight);
        }
    }


    const getTitle = () => {
        const seriesName = document.querySelector(".p-episode__comic-ttl").innerText;
        const chapterName = document.querySelector(".p-episode__header-ttl").innerText;

        const words = chapterName.split(" ");
        const fixedChapterName = words.map(word => word[0] + word.substring(1).toLowerCase()).join(" ");
        const title = seriesName + " - " + fixedChapterName;
        const invalidChar = /[<>:"\/\|?*]/g;
        const fixedTitle = title.replace(invalidChar, "");

        return fixedTitle;
    }


    const downloadChapter = async (dlButton, progressBar) => {
        if (!currentChapterJson) throw new Error("No chapter found");
        const scrambleSeed = currentChapterJson.scramble_seed;
        const pageList = currentChapterJson.page_list;
        if (!scrambleSeed) throw new Error("Invalid scramble seed");
        if (!pageList || pageList.length === 0) throw new Error("No pages found");
        const pageCount = pageList.length;

        let title;
        try {title = getTitle()} catch(e) {
            console.error(e);
            title = "Undefined";
        }

        let pageCountProgress = 0;
        const updateDlProgress = () => {
            const percentage = Math.round((++pageCountProgress / (pageCount * 5)) * 100);
            progressBar.style.width = percentage + "%";
            dlButton.innerText = `Downloading ${percentage}%`;
        }

        let pageBitmaps;
        try {
            const responses = await Promise.all(pageList.map(async (pageUrl) => {
                const response = await fetch(pageUrl);
                updateDlProgress();
                return response;
            }));

            const blobs = await Promise.all(responses.map(async (response) => {
                const blob = await response.blob();
                updateDlProgress();
                return blob;
            }));

            pageBitmaps = await Promise.all(blobs.map(async (blob) => {
                const pageBitmap = await createImageBitmap(blob);
                updateDlProgress();
                return pageBitmap;
            }));
        }
        catch(e) {
            console.error(e);
            throw new Error("Couldn't retrieve chapter pages");
        }

        const unscrambledPageBlobs = [];
        try {
            const unscrambledCoords = getUnscrambledCoords(scrambleSeed);
            const pageWidth = pageBitmaps[0].width;
            const pageHeight = pageBitmaps[0].height;
            canvas.width = pageWidth;
            canvas.height = pageHeight;
            const getTileDimension = (size) => (Math.floor(size / 8) * 8) / 4;
            const tileWidth = getTileDimension(pageWidth);
            const tileHeight = getTileDimension(pageHeight);

            for (const page of pageBitmaps) {
                updateDlProgress();
                drawPage(page, unscrambledCoords, tileWidth, tileHeight);
                const blob = await canvas.convertToBlob({type: "image/jpeg", quality: 0.8});
                updateDlProgress();
                unscrambledPageBlobs.push(blob);
            }
        }
        catch(e) {
            console.error(e);
            throw new Error("Couldn't unscramble pages");
        }

        try {
            const zip = new JSZip();
            unscrambledPageBlobs.forEach((page, index) => {
                const paddedFileName = (index + 1).toString().padStart(pageCount.toString().length, "0") + ".jpg";
                zip.file(paddedFileName, page, {binary: true});
            });
            zip.generateAsync({type: "blob"}).then(blob => saveAs(blob, title + ".zip"));
        }
        catch (e) {
            console.error(e);
            throw new Error("Couldn't process zip file");
        }
    }


    const insertDlButton = (target) => {
        const dlButtonContainer = document.createElement("div");
        const progressBar = document.createElement("div");
        const dlButton = document.createElement("button");
        dlButtonContainer.id = "dl-button-container";
        progressBar.id = "progress-bar";
        dlButton.id = "dl-button";
        dlButton.innerText = "Download Chapter";

        dlButton.onclick = async () => {
            dlButton.disabled = true;
            dlButton.innerText = "Downloading...";
            dlButton.classList.add("loading");
            const resetButton = () => {
                dlButton.disabled = false;
                dlButton.classList = [];
                dlButton.innerText = "Download Chapter";
                progressBar.style.width = "0";
            }

            try {
                if (currentChapterJson) {
                    await downloadChapter(dlButton, progressBar);
                    setTimeout(() =>  resetButton(), 500);
                }
                else {
                    setTimeout(async () => {
                        await downloadChapter(dlButton, progressBar);
                        setTimeout(() =>  resetButton(), 500);
                    }, 2000);
                }
            }
            catch(e) {
                dlButton.innerText = "Download Failed";
                dlButton.classList.add("fail");
                setTimeout(() => resetButton(), 2000);
                setTimeout(() => alert(`Error downloading chapter: ${e.message}\nReload the page and try again`), 100);
            }
        }

        target.appendChild(dlButtonContainer);
        dlButtonContainer.appendChild(progressBar);
        dlButtonContainer.appendChild(dlButton);
    }


    const dlButtonStyle = document.createElement("style");
    dlButtonStyle.textContent = `
        #dl-button-container {
            position: relative;
            top: 6px;
            width: 180px;
            height: 35px;
        }

        #progress-bar {
            position: absolute;
            z-index: 1;
            top: 3px;
            bottom: 0;
            left: 3px;
            right: 0;
            width: 0;
            max-width: 177px;
            height: 29px;
            border-radius: 0 3px 3px 0;
            background-color: #d6d6d6;
        }

        #dl-button {
            position: absolute;
            z-index: 2;
            top: 0;
            bottom: 0;
            left: 0;
            right: 0;
            width: 180px;
            height: 35px;
            font-family: "Helvetica Neue", "Helvetica", "Aral", "sans-serif";
            font-size: 1.7rem;
            font-weight: bold;
            color: #2a93d9;
            border: 3px solid #2a93d9;
            border-radius: 7px;
            cursor: pointer;
        }
        #dl-button:not(.loading):not(.locked):not(.fail):hover {
            color: #2287c9;
            border-color: #2287c9;
            background-color: #f0f0f0;
        }
        #dl-button.loading {
            color: #2287c9;
            border-color: #2287c9;
            cursor: default;
        }
        #dl-button.fail {
            color: red;
            border-color: red;
            background-color: #f0f0f0;
        }
        #dl-button.locked {
            color: gray;
            border-color: gray;
            cursor: not-allowed;
        }
    `;
    document.head.appendChild(dlButtonStyle);


    let currentLocation = location.pathname;
    const chapterPathTemplate = /^\/title\/.*\/episode\/.*$/;
    const checkChapter = (mutationList, observer) => {
        if (location.pathname.match(chapterPathTemplate)) {
            const dlButton = document.querySelector("#dl-button");
            const target = document.querySelector(".p-episode__header-inner");
            const isNotBought = document.querySelector(".p-episode-purchase");

            if (!dlButton) {
                if (target) insertDlButton(target);
            }
            else {
                if (location.pathname !== currentLocation && currentChapterJson) currentChapterJson = null;

                if (isNotBought) {
                    dlButton.disabled = true;
                    dlButton.setAttribute("title", "Cannot download a chapter you don't have access to");
                    dlButton.classList.add("locked");
                }
                else if (dlButton.classList.contains("locked")) {
                    dlButton.disabled = false;
                    dlButton.removeAttribute("title");
                    dlButton.classList.remove("locked");
                }
            }

            currentLocation = location.pathname;
        }
    }

    document.addEventListener(("DOMContentLoaded"), () => {
        const observer = new MutationObserver(checkChapter);
        observer.observe(document.body, {childList: true, subtree: true});
    });

})();