KindleMangaDownloader

Manga downloader for read.amazon.co.jp

// ==UserScript==
// @name         KindleMangaDownloader
// @namespace    https://github.com/Timesient/manga-download-scripts
// @version      0.6
// @license      GPL-3.0
// @author       Timesient
// @description  Manga downloader for read.amazon.co.jp
// @icon         https://m.media-amazon.com/images/G/01/kfw/mobile/kindle_favicon.png
// @homepageURL  https://greasyfork.org/scripts/451870-kindlemangadownloader
// @supportURL   https://github.com/Timesient/manga-download-scripts/issues
// @match        https://read.amazon.co.jp/manga/*
// @require      https://unpkg.com/axios@0.27.2/dist/axios.min.js
// @require      https://unpkg.com/jszip@3.7.1/dist/jszip.min.js
// @require      https://unpkg.com/file-saver@2.0.5/dist/FileSaver.min.js
// @require      https://update.greasyfork.org/scripts/451810/1398192/ImageDownloaderLib.js
// @grant        GM_info
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(async function(axios, JSZip, saveAs, ImageDownloader) {
  'use strict';

  // collect essential data
  const { asin, revision, pageAmount } = await new Promise(resolve => {
    const timer = setInterval(() => {
      const target1 = document.querySelector('#requestData');
      const target2 = document.querySelector('#bookInfo');
      const target3 = document.querySelector('#pageInfoCurrentPage');
      const target4 = document.querySelector('#pageInfoTotalPage');
      if (target1 && target2 && target3 && target4 && parseInt(target3.textContent) !== 0 && parseInt(target4.textContent) !== 1) {
        clearInterval(timer);
        resolve({
          asin: JSON.parse(target1.textContent).asin,
          revision: JSON.parse(target2.textContent).contentGuid,
          pageAmount: parseInt(target4.textContent)
        });
      }
    }, 200);
  });

  // get book info and build parameters
  const bookInfo = await axios.get(`https://read.amazon.co.jp/api/manga/open-next-book/${asin}`).then(res => res.data);
  const params = {
    version: '3.0',
    asin,
    contentType: 'FullBook',
    revision,
    fontFamily: 'Bookerly',
    fontSize: 4.95,
    lineHeight: 1.4,
    dpi: 160,
    height: 923,
    width: 400,
    maxNumberColumns: 2,
    theme: 'dark',
    packageType: 'TAR',
    numPage: -1 * pageAmount,
    skipPageCount: pageAmount,
    startingPosition: 0,
    token: bookInfo.karamelToken.token
  }

  // get pages
  const pages = [].concat(
    await getPages(params),
    await getPages({ ...params, numPage: 1 }),
  );

  // setup ImageDownloader
  ImageDownloader.init({
    maxImageAmount: pages.length,
    getImagePromises,
    title: bookInfo.title
  });

  // collect promises of image
  function getImagePromises(startNum, endNum) {
    return pages
      .slice(startNum - 1, endNum)
      .map(page => getDecryptedImage(page)
        .then(ImageDownloader.fulfillHandler)
        .catch(ImageDownloader.rejectHandler)
      )
  }

  // get decrypted image
  async function getDecryptedImage(page) {
    const src = `${page.baseUrl}/${page.url}?${page.authParameter}&token=${encodeURIComponent(bookInfo.karamelToken.token)}&expiration=${encodeURIComponent(bookInfo.karamelToken.expiresAt)}`;
    const encryptedBuffer = await fetch(src).then(res => res.arrayBuffer());
    const decryptedBuffer = await getDecryptedBuffer(encryptedBuffer, bookInfo.karamelToken);
    return new Blob([decryptedBuffer]);
  }

  async function getDecryptedBuffer(t, e) {
    const n = new TextDecoder('utf-8');
    const o = new TextEncoder;
    const r = n.decode(t);
    const a = r.slice(0, 24);
    const c = r.slice(24, 48);
    const h = r.slice(48, r.length);
    const l = base64StringToArrayBuffer(a);
    const d = base64StringToArrayBuffer(c);
    const u = base64StringToArrayBuffer(h);
    const g = getKey(e);
    const v = await window.crypto.subtle.importKey("raw", o.encode(g), { name: "PBKDF2" }, false, ["deriveBits", "deriveKey"]);
    const p = await window.crypto.subtle.deriveKey({ name: "PBKDF2", salt: l, iterations: 1e3, hash: "SHA-256" }, v, { name: "AES-GCM", length: 128 }, false, ["decrypt"]);
    
    return await window.crypto.subtle.decrypt({
      name: "AES-GCM",
      iv: d,
      additionalData: o.encode(g.slice(0, 9)),
      tagLength: 128
    }, p, u);
  }
  
  function base64StringToArrayBuffer(base64) {
    const origin = window.atob(base64);
    const result = new Uint8Array(origin.length);
    for (let i = 0; i < origin.length; i++) {
      result[i] = origin.charCodeAt(i);
    }
    return result.buffer;
  }
  
  function getKey(t) {
    if (t.token.length < 100) throw new Error('error in getKey');
    const i = t.expiresAt % 60;
    return t.token.substring(i, i + 40);
  }

  // request pages from API
  async function getPages(params) {
    const apiURL = `https://read.amazon.co.jp/renderer/render?${new URLSearchParams(params)}`;
    const tarString = await axios.get(apiURL).then(res => res.data.replaceAll('\u0000', ''));
    const manifest = JSON.parse(`{` + tarString.match(/"cdnResources".*"acr"/)[0] + `: "acr content" }`);
    return manifest.cdnResources.map(page => {
      page.baseUrl = manifest.cdn.baseUrl;
      page.authParameter = manifest.cdn.authParameter;
      page.order = parseInt(page.url.replace('resource/rsrc', ''), 36);
      return page;
    }).sort((a, b) => a.order - b.order);
  }

})(axios, JSZip, saveAs, ImageDownloader);