K MANGA Ripper

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

// ==UserScript==
// @name         K MANGA Ripper
// @version      2.3.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 ogFetch = fetch;
    fetch = async (...args) => {
      const response = await ogFetch(...args);
      if (args[0].startsWith("https://api.kmanga.kodansha.com/web/episode/viewer")) {
        currentChapterJson = await response.clone().json();
      }
      return response;
    }


    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 (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 + "%";
        }

        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 = [];
        const unscrambledCoords = getUnscrambledCoords(scrambleSeed);
        const getTileDimension = (size) => (Math.floor(size / 8) * 8) / 4;
        try {
            for (const page of pageBitmaps) {
                const pageWidth = page.width;
                const pageHeight = page.height;
                const tileWidth = getTileDimension(pageWidth);
                const tileHeight = getTileDimension(pageHeight);
                canvas.width = pageWidth;
                canvas.height = pageHeight;

                drawPage(page, unscrambledCoords, tileWidth, tileHeight);
                updateDlProgress();
                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 dlButtonWrapper = document.createElement("div");
        const progressBar = document.createElement("div");
        const dlButton = document.createElement("button");
        dlButtonWrapper.id = "dl-button-wrapper";
        progressBar.id = "progress-bar";
        dlButton.id = "dl-button";
        dlButton.innerText = "Download Chapter";

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

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

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


    const dlButtonStyle = document.createElement("style");
    dlButtonStyle.textContent = `
        @keyframes spin {
            from {
                transform: rotate(0deg);
            }
            to {
                transform: rotate(360deg);
            }
        }

        :root {
            --download-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11' fill='none' stroke='%230d3594' stroke-width='2' stroke-linecap='round'%3E%3C/path%3E%3C/svg%3E");
            --loading-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='%231DA1F2' stroke-width='4' opacity='0.4'%3E%3C/circle%3E%3Cpath d='M12,2 a10,10 0 0 1 10,10' fill='none' stroke='%230d3594' stroke-width='4' stroke-linecap='round'%3E%3C/path%3E%3C/svg%3E");
            --completed-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l3,4 q1,1 2,0 l8,-11' fill='none' stroke='%230d3594' stroke-width='2' stroke-linecap='round'%3E%3C/path%3E%3C/svg%3E");
            --fail-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='11' fill='red' stroke='black' stroke-width='2' opacity='0.8'%3E%3C/circle%3E%3Cpath d='M14,5 a1,1 0 0 0 -4,0 l0.5,9.5 a1.5,1.5 0 0 0 3,0 z M12,17 a2,2 0 0 0 0,4 a2,2 0 0 0 0,-4' fill='%23fff' stroke='none'%3E%3C/path%3E%3C/svg%3E");
            --locked-svg: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18 10.5C19.6569 10.5 21 11.8431 21 13.5V19.5C21 21.1569 19.6569 22.5 18 22.5H6C4.34315 22.5 3 21.1569 3 19.5V13.5C3 11.8431 4.34315 10.5 6 10.5V7.5C6 4.18629 8.68629 1.5 12 1.5C15.3137 1.5 18 4.18629 18 7.5V10.5ZM12 3.5C14.2091 3.5 16 5.29086 16 7.5V10.5H8V7.5C8 5.29086 9.79086 3.5 12 3.5ZM18 12.5H6C5.44772 12.5 5 12.9477 5 13.5V19.5C5 20.0523 5.44772 20.5 6 20.5H18C18.5523 20.5 19 20.0523 19 19.5V13.5C19 12.9477 18.5523 12.5 18 12.5Z' fill='%235e5e5e'/%3E%3C/svg%3E");
        }

        .p-episode__twitter {
            display: none;
        }

        #dl-button-wrapper {
            position: relative;
            width: 180px;
            height: 35px;
        }

        #progress-bar {
            position: absolute;
            z-index: 1;
            top: 2px;
            bottom: 0;
            left: 2px;
            right: 0;
            width: 0;
            max-width: 176px;
            height: 31px;
            border-radius: 4px;
            background-color: #d4d4d4;
        }

        #dl-button {
            position: absolute;
            display: flex;
            align-items: center;
            justify-content: center;
            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.5rem;
            font-weight: bold;
            color: #0d3594;
            border: 2px solid #0d3594;
            border-radius: 7px;
            cursor: pointer;
            transition: opacity 0.2s;
        }
        #dl-button:not(.loading):not(.locked):not(.fail):hover {
            opacity: 0.8;
        }
        #dl-button.loading {
            opacity: 0.8;
            cursor: default;
        }
        #dl-button.fail {
            color: red;
            border-color: red;
        }
        #dl-button.locked {
            color: #5e5e5e;
            border-color: #5e5e5e;
            cursor: not-allowed;
        }

        #dl-button::before {
            content: "";
            display: inline-block;
            background-image: var(--download-svg);
            background-size: contain;
            background-repeat: no-repeat;
            width: 20px;
            height: 20px;
            margin-left: -5px;
            margin-right: 5px;
        }
        #dl-button.loading::before {
            background-image: var(--loading-svg);
            animation: spin 1s linear infinite;
        }
        #dl-button.completed::before {
            background-image: var(--completed-svg);
        }
        #dl-button.fail::before {
            background-image: var(--fail-svg);
            margin-right: 8px;
        }
        #dl-button.locked::before {
            background-image: var(--locked-svg);
            margin-bottom: 2px;
        }
    `;


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

            if (currentLocation != location.pathname && currentChapterJson) currentChapterJson = null;

            if (!dlButton && target) insertDlButton(target);
            else {
                const isNotBought = document.querySelector(".p-episode-purchase");
                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", () => {
        document.head.appendChild(dlButtonStyle);
        const observer = new MutationObserver(checkChapter);
        observer.observe(document.body, { childList: true, subtree: true });
    });

})();