Magapoke Ripper

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

// ==UserScript==
// @name         Magapoke Ripper
// @version      1.0.0
// @description  Adds a download button to rip chapters from Magapoke (bypasses image scrambling protection)
// @author       /a/non
// @namespace    https://greasyfork.org/en/users/1090983-anon
// @license      MIT
// @match        https://pocket.shonenmagazine.com/*
// @icon         https://pocket.shonenmagazine.com/img/favicon.ico
// @grant        GM.xmlHttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

(function () {
    "use strict";

    let selectedChapter;
    const canvas = new OffscreenCanvas(0, 0);
    const ctx = canvas.getContext("2d", { alpha: false });

    // Hook fetch method to intercept request to selected chapter's info
    const ogFetch = unsafeWindow.fetch;
    unsafeWindow.fetch = async (url, options) => {
        const response = await ogFetch(url, options);
        if (url.includes("/web/episode/viewer")) {
            selectedChapter = await response.clone().json();
        }
        return response;
    };

    // Descrambling logic and tile dimension calculations from chunk-SYAGRXIy.js (simplified)
    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]);

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

    // Rearrange the tiles based on the unscrambled coordinates
    const drawPage = async (page, coords) => {
        const getTileDimension = (size) => Math.floor(size / 32) * 8;
        const pageWidth = page.width;
        const pageHeight = page.height;
        const tileWidth = getTileDimension(pageWidth);
        const tileHeight = getTileDimension(pageHeight);

        canvas.width = pageWidth;
        canvas.height = pageHeight;
        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
            );
        }

        return await canvas.convertToBlob({
            type: "image/jpeg",
            quality: 0.8, // Approximate original file size
        });
    };

    const getTitle = () => {
        const seriesName = document.querySelector(
            ".p-episode__comic-ttl"
        ).innerText;
        const chapterName = document.querySelector(
            ".p-episode__header-ttl"
        ).innerText;
        const title = `${seriesName} ー ${chapterName}`;
        return title.replace(/[<>:"\/\|?*]/g, "");
    };

    const downloadChapter = async (progressBar) => {
        if (!selectedChapter) {
            throw new Error("No chapter found");
        }
        const scrambleSeed = selectedChapter.scramble_seed;
        const pageList = selectedChapter.page_list;
        if (!scrambleSeed) {
            throw new Error("No scramble seed found for selected chapter");
        }
        if (!pageList || !pageList.length) {
            throw new Error("No page found in selected chapter");
        }
        const pageCount = pageList.length;
        const title = getTitle();

        let pageCountProgress = 0;
        const updateDlProgress = () => {
            const percentage = Math.round(
                (++pageCountProgress / (pageCount * 3)) * 100
            );
            progressBar.style.width = percentage + "%";
        };

        let pageBitmaps;
        try {
            const pageBlobs = await Promise.all(
                pageList.map(async (pageUrl) => {
                    // GM.xmlHttpRequest needed instead of native fetch to bypass same-origin policy
                    const response = await GM.xmlHttpRequest({
                        url: pageUrl,
                        responseType: "blob",
                    });
                    updateDlProgress();
                    return response.response;
                })
            );

            // Convert blobs to image bitmaps to use with canvas
            pageBitmaps = await Promise.all(
                pageBlobs.map(async (blob) => {
                    const pageBitmap = await createImageBitmap(blob);
                    updateDlProgress();
                    return pageBitmap;
                })
            );
        } catch (error) {
            console.error(error);
            throw new Error("Couldn't retrieve chapter pages");
        }

        const unscrambledPageBlobs = [];
        const unscrambledCoords = getUnscrambledCoords(scrambleSeed);
        try {
            for (const page of pageBitmaps) {
                const blob = await drawPage(page, unscrambledCoords);
                updateDlProgress();
                unscrambledPageBlobs.push(blob);
            }
        } catch (error) {
            console.error(error);
            throw new Error("Couldn't unscramble chapter pages");
        }

        try {
            await unsafeWindow.processZip(
                title,
                pageCount,
                unscrambledPageBlobs
            );
        } catch (error) {
            console.error(error);
            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 = ["loading"];
            const resetButton = (isSuccess) => {
                dlButton.disabled = false;
                dlButton.classList = isSuccess ? ["completed"] : [];
                dlButton.innerText = "Download Chapter";
                progressBar.style.width = "0";
            };

            try {
                if (!selectedChapter) {
                    await new Promise((resolve) => setTimeout(resolve, 2000));
                }
                await downloadChapter(progressBar);
                setTimeout(() => resetButton(true), 500);
            } catch (error) {
                dlButton.innerText = "Download Failed";
                dlButton.classList = ["fail"];
                setTimeout(() => resetButton(false), 2000);
                setTimeout(() => {
                    alert(
                        `Error downloading chapter: ${error.message}\nReload the page and try again\nIf the issue persists, please report it in the feedback section of the script's homepage`
                    );
                }, 100);
            }
        };

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

    // The userscript runs in a sandbox since @grant is not "none", which breaks the behaviors of the required JSZip and FileSaver libraries
    // As a workaround, we handle the zip creation and downloading process in the page context directly and then access it through unsafeWindow
    const injectDomScripts = () => {
        const jszip = document.createElement("script");
        const fileSaver = document.createElement("script");
        jszip.src =
            "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
        fileSaver.src =
            "https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js";

        const processZipScript = document.createElement("script");
        processZipScript.textContent = `
            window.processZip = async (title, pageCount, unscrambledPageBlobs) => {
                const zip = new JSZip();
                const padStartLength = pageCount.toString().length;
                unscrambledPageBlobs.forEach((page, index) => {
                    const paddedFileName = (index + 1).toString().padStart(padStartLength, "0") + ".jpg";
                    zip.file(paddedFileName, page, { binary: true });
                });
                const zipBlob = await zip.generateAsync({ type: "blob" });
                await saveAs(zipBlob, title + ".zip");
            }
        `;

        document.head.appendChild(jszip);
        document.head.appendChild(fileSaver);
        document.head.appendChild(processZipScript);
    };

    const injectDlButtonStyle = () => {
        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__sns {
                margin-top: 0.7rem;
            }

            .p-episode__rss-btn {
                margin-right: 0.8rem;
            }

            #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;
                cursor: default;
            }
            #dl-button.locked {
                color: #5e5e5e;
                border-color: #5e5e5e;
                cursor: default;
            }

            #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;
            }
        `;
        document.head.appendChild(dlButtonStyle);
    };

    const checkChapter = (mutationList, observer) => {
        if (!location.pathname.match(/^\/title\/.*\/episode\/.*$/)) {
            return;
        }
        const target = document.querySelector(".p-episode__sns-btn-list");
        if (!target) return;

        let dlButton = document.querySelector("#dl-button");
        if (!dlButton) {
            insertDlButton(target);
            dlButton = document.querySelector("#dl-button");
        }

        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");
        }
    };

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