// ==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 });
});
})();