Rippper

Rips manga from any site running Comici reader or Gigaviewer

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Rippper
// @namespace    http://tampermonkey.net/
// @version      3
// @description  Rips manga from any site running Comici reader or Gigaviewer
// @match        *://*/*
// @license      MIT
// @grant        none
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// ==/UserScript==

(async function () {
    'use strict';

    const komichiHosts = [
        "rimacomiplus.jp",
        "younganimal.com",
        "takecomic.jp",
        "kimicomi.com",
        "youngchampion.jp",
        "mangalt.jp",
        "mangaspa.nikkan-spa.jp",
        "piacomic.jp",
        "comic-room-base.com",
        "manga-zegra.com",
        "comic-growl.com",
        "asacomi.jp",
        "comicpash.jp",
        "comic.j-nbooks.jp",
        "hayacomic.jp",
        "championcross.jp",
        "kansai.mag-garden.co.jp",
        "comicride.jp",
        "carula.jp",
        "comic-medu.com",
        "comics.manga-bang.com",
        "bigcomics.jp",
        "studio.booklista.co.jp",
        "hanayume.com"
    ]

    const gigaHosts = [
      "comic-days.com",
      "ichicomi.com",
      "shonenjumpplus.com",
      "tonarinoyj.jp",
      "andsofa.com",
      "morningtwo.com",
      "getsumagakichi.com",
      "bibliosirius.com",
      "kuragebunch.com",
      "comicbunch-kai.com",
      "viewer.heros-web.com",
      "comicborder.com",
      "comic-gardo.com",
      "comic-zenon.com",
      "magcomi.com",
      "comic-action.com",
      "comic-trail.com",
      "feelweb.jp",
      "www.sunday-webry.com",
      "comic-ogyaaa.com",
      "comic-earthstar.com",
      "ourfeel.jp",
      "comic-seasons.com",
      "comic-y-ours.com"
    ]

    const ui = document.createElement("div");
    ui.id = "comici-dl-ui";
    ui.style = `
        position: fixed;
        top: 20px;
        right: 20px;
        z-index: 1000000;
        background: #1a1a1a;
        color: #ffffff;
        padding: 20px;
        font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        font-size: 14px;
        border-radius: 12px;
        box-shadow: 0 8px 32px rgba(0,0,0,0.6);
        border: 1px solid #333;
        min-width: 240px;
        transition: all 0.3s ease;
    `;

    // Create Close Button (X)
    const closeBtn = document.createElement("div");
    closeBtn.textContent = "×";
    closeBtn.style = `
        position: absolute;
        top: 10px;
        right: 15px;
        cursor: pointer;
        font-size: 20px;
        color: #888;
        line-height: 1;
        transition: color 0.2s;
    `;
    closeBtn.onmouseover = () => closeBtn.style.color = "#ff4444";
    closeBtn.onmouseout = () => closeBtn.style.color = "#888";
    closeBtn.onclick = () => ui.remove(); // Removes the UI from the page

    const title = document.createElement("div");
    title.innerHTML = "<strong style='color: #00ff88;'>Ripppper</strong>";
    title.style.marginBottom = "15px";
    title.style.borderBottom = "1px solid #333";
    title.style.paddingBottom = "8px";
    title.style.fontSize = "15px";
    title.style.letterSpacing = "0.5px";
    title.style.textAlign = "center";

    const statusText = document.createElement("div");
    statusText.id = "dl-status";
    statusText.textContent = "Ready to download";
    statusText.style.marginBottom = "15px";
    statusText.style.color = "#bbb";
    statusText.style.textAlign = "center"

    // Create the Action Button
    const btn = document.createElement("button");
    btn.textContent = "Start Download";
    btn.style = `
        width: 100%;
        background: #00ff88;
        color: #000;
        border: none;
        padding: 10px;
        border-radius: 6px;
        font-weight: bold;
        cursor: pointer;
        transition: background 0.2s;
    `;
    btn.onmouseover = () => btn.style.background = "#00cc6e";
    btn.onmouseout = () => btn.style.background = "#00ff88";

    const progressBarContainer = document.createElement("div");
    progressBarContainer.style = "width: 100%; background: #333; height: 6px; border-radius: 3px; margin-top: 15px; overflow: hidden; display: none;";

    const progressBar = document.createElement("div");
    progressBar.style = "width: 0%; background: #00ff88; height: 100%; transition: width 0.2s ease;";
    let isKomichi = false;
    let isGiga = false;

    if (komichiHosts.includes(window.location.href.split("/")[2])){
        progressBarContainer.appendChild(progressBar);
        ui.appendChild(closeBtn); // Add the X to the UI container
        ui.appendChild(title);
        ui.appendChild(statusText);
        ui.appendChild(btn);
        ui.appendChild(progressBarContainer);
        document.body.appendChild(ui);
        isKomichi = true;
    } else if (gigaHosts.includes(window.location.href.split("/")[2])) {
      progressBarContainer.appendChild(progressBar);
        ui.appendChild(closeBtn); // Add the X to the UI container
        ui.appendChild(title);
        ui.appendChild(statusText);
        ui.appendChild(btn);
        ui.appendChild(progressBarContainer);
        document.body.appendChild(ui);
        isGiga = true;
    }

    const setStatus = (txt, progress = null) => {
            statusText.textContent = txt;
            if (progress !== null) {
                progressBarContainer.style.display = "block";
                progressBar.style.width = `${progress}%`;
            }
        };

    const updateStatus = (msg, percent = null) => {
        const msgEl = document.getElementById('ui-msg');
        const bar = document.getElementById('ui-progress-bar');
        const wrapper = document.getElementById('ui-progress-wrapper');
        if (msgEl) msgEl.innerText = msg;
        if (percent !== null) {
            wrapper.style.display = 'block';
            bar.style.width = `${percent}%`;
        }
    };

    // --- De-scramble Logic ---
    async function descrambleImageGiga(blob, divideNum = 4, multiple = 8) {
        const img = new Image();
        const url = URL.createObjectURL(blob);
        await new Promise(res => { img.onload = res; img.src = url; });

        const canvas = document.createElement('canvas');
        canvas.width = img.width;
        canvas.height = img.height;
        const ctx = canvas.getContext('2d');

        const totalTiles = divideNum * multiple;
        const cellWidth = Math.floor(img.width / totalTiles) * multiple;
        const cellHeight = Math.floor(img.height / totalTiles) * multiple;

        ctx.drawImage(img, 0, 0);

        for (let i = 0; i < totalTiles * totalTiles; i++) {
            const row = Math.floor(i / totalTiles);
            const col = i % totalTiles;
            const sourceX = col * cellWidth;
            const sourceY = row * cellHeight;
            const destIndex = col * totalTiles + row;
            const destX = (destIndex % totalTiles) * cellWidth;
            const destY = Math.floor(destIndex / totalTiles) * cellHeight;

            if (destX < img.width && destY < img.height) {
                ctx.drawImage(img, sourceX, sourceY, cellWidth, cellHeight, destX, destY, cellWidth, cellHeight);
            }
        }

        URL.revokeObjectURL(url);
        return new Promise(res => canvas.toBlob(res, 'image/jpeg', 0.95));
    }

    const waitForJsonGiga = () => new Promise(resolve => {
        const check = () => document.getElementById('episode-json');
        if (check()) return resolve();
        const obs = new MutationObserver(() => { if (check()) { obs.disconnect(); resolve(); } });
        obs.observe(document.body, { childList: true, subtree: true });
    });



    // ---------- TOOLS ----------
    function getComiciViewerId() {
        const viewer = document.getElementById('comici-viewer');
        if (!viewer) throw new Error("Viewer not found");
        const id = viewer.getAttribute('comici-viewer-id') || viewer.dataset.comiciViewerId;
        if (!id) throw new Error("Viewer ID missing");
        const hashEl = document.getElementById('xHeader');
        const hash = hashEl.getAttribute('data-series-hash')
        return {
            id: id,
            hash: hash
        };
    }

    function getUrlComici() {
        const viewer = document.getElementById('comici-viewer');
        if (!viewer) throw new Error("Viewer not found");
        const url = viewer.getAttribute('data-api-domain')
        if (!url) throw new Error("Viewer ID missing");
        if (window.location.href.split('/')[2] === url){
            return url
        } else {
            return `${window.location.href.split('/')[2]}${url}`
        }
        return url;
    }

    async function loadZip() {
        if (window.JSZip) return;
        return new Promise(res => {
            const s = document.createElement("script");
            s.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
            s.onload = res;
            document.head.appendChild(s);
        });
    }

    async function fetchDataComici(viewerId, startURL, hash){
        if (hash !== null){
            const res = await fetch(`https://${startURL}/book/contentsInfo?user-id=&comici-viewer-id=${viewerId}&page-from=0&page-to=1`)
            const data = await res.json();
            console.log(data);
            if (!data || !data.result) throw new Error(data);
            return {
                pages: data.totalPages,
                name: ""
            }
        } else {
            const url = `https://${startURL}/book/episodeInfo?comici-viewer-id=${viewerId}`;
            const res = await fetch(url, { credentials: "include" });
            const data = await res.json();
            if (!data || !data.result) throw new Error("API failed");
            const result = data.result.find(({ id }) => id === viewerId);
            return {
                pages: result.page_count,
                name: result.name,
            }
        }
    }

    async function fetchPagesComici(viewerId, startURL, hash) {
        const pages = await fetchDataComici(viewerId, startURL, hash)
        const url = `https://${startURL}/book/contentsInfo?user-id=0&comici-viewer-id=${viewerId}&page-from=0&page-to=${(pages.pages === undefined) ? 100 : pages.pages}`;
        const res = await fetch(url, { credentials: "include" });
        const data = await res.json();
        if (!data || !data.result) throw new Error("Fetch all pages failed");
        return data.result;
    }

    // ---------- DESCRAMBLE (VERTICAL LOGIC) ----------
    async function descrambleComici(page) {
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.src = page.imageUrl;
        await img.decode();

        const mapping = JSON.parse(page.scramble);
        const canvas = document.createElement("canvas");
        const ctx = canvas.getContext("2d");

        canvas.width = page.width;
        canvas.height = page.height;

        const cols = 4;
        const rows = 4;
        const tileW = canvas.width / cols;
        const tileH = canvas.height / rows;

        for (let i = 0; i < 16; i++) {
            const sourceIndex = mapping[i];

            // Use Math.floor to prevent sub-pixel rendering lines
            const tileW = Math.floor(canvas.width / cols);
            const tileH = Math.floor(canvas.height / rows);

            // Column-major Source coordinates
            const sx = Math.floor(sourceIndex / rows) * tileW;
            const sy = (sourceIndex % rows) * tileH;

            // Column-major Destination coordinates
            const dx = Math.floor(i / rows) * tileW;
            const dy = (i % rows) * tileH;

            // Draw using integer dimensions to snap to the pixel grid
            ctx.drawImage(img, sx, sy, tileW, tileH, dx, dy, tileW, tileH);
        }

        return new Promise(res => canvas.toBlob(res, "image/jpeg", 0.95));
    }

    // ---------- MAIN EXECUTION ----------
    async function runComici() {
        try {
            btn.disabled = true;
            btn.style.background = "#555";
            btn.style.cursor = "not-allowed";
            btn.textContent = "Processing...";

            const { id, hash } = getComiciViewerId();

            setStatus("Loading ZIP library...");
            await loadZip();
            const zip = new JSZip();

            setStatus("Fetching metadata...");
            const firstURLPart = getUrlComici()
            const pages = await fetchPagesComici(id, firstURLPart, hash);
            const total = pages.length;

            for (let i = 0; i < total; i++) {
                const percent = Math.round(((i + 1) / total) * 100);
                setStatus(`Processing: ${i + 1}/${total}`, percent);

                try {
                    const blob = await descrambleComici(pages[i]);
                    const filename = `page_${String(pages[i].sort).padStart(3, "0")}.jpg`;
                    zip.file(filename, blob);
                } catch (err) {
                    console.error("Failed page:", i, err);
                }
            }

            setStatus("Packing ZIP...");
            const content = await zip.generateAsync({ type: "blob" });

            const a = document.createElement("a");
            a.href = URL.createObjectURL(content);
            const name = await fetchDataComici(id, firstURLPart, hash)
            a.download = `${name.name}.zip`;
            a.click();

            setStatus("Done!");
            btn.textContent = "Download Again";
            btn.disabled = false;
            btn.style.background = "#00ff88";
            btn.style.cursor = "pointer";

        } catch (err) {
            console.error(err);
            setStatus("Error: " + err.message);
            ui.style.border = "1px solid #ff4444";
            btn.disabled = false;
            btn.textContent = "Retry";
            btn.style.background = "#ff4444";
        }
    }

    async function runGiga() {
        await waitForJsonGiga();
        const script = document.getElementById('episode-json');
        const json = JSON.parse(script?.dataset?.value || script?.textContent.trim() || "{}");
        const pages = json.readableProduct?.pageStructure?.pages?.filter(p => p.type === 'main' && p.src) || [];

        if (pages.length === 0) {
          setStatus("No pages found.");
          return;
        }

        setStatus(`Found ${pages.length} pages.`);

        await loadZip();
        const zip = new JSZip();

        for (let i = 0; i < pages.length; i++) {
            const progress = Math.round(((i + 1) / pages.length) * 100);
            setStatus(`Processing page ${i + 1}/${pages.length}...`, progress);

            try {
                const res = await fetch(pages[i].src);
                const blob = await res.blob();
                const fixed = await descrambleImageGiga(blob);
                zip.file(`page_${(i + 1).toString().padStart(4, '0')}.jpg`, fixed);
            } catch (e) {
                console.error(e);
            }
        }


        setStatus("Downloading...");
        const content = await zip.generateAsync({ type: "blob" });
        const zipUrl = URL.createObjectURL(content);

        const a = document.createElement('a');
        a.href = zipUrl;
        a.download = `${json.readableProduct.title}.zip`;
        a.click();

        setStatus("Download Complete!");
    }

    if (isKomichi){
        btn.addEventListener("click", runComici);
    } else if (isGiga) {
        btn.addEventListener("click", runGiga);
    }
    // Attach click event to the confirm button
})();