HumbleBundle download all ebook as zip

Download all books with supplement as a single zip file.

// ==UserScript==
// @name              HumbleBundle download all ebook as zip
// @name:zh-CN        HB 慈善包电子书打包下载
// @namespace         moe.jixun
// @license           MIT
// @version           1.0.1
// @author            Jixun
// @description       Download all books with supplement as a single zip file.
// @description:zh-cn 打包所有电子书以及补充内容为一个 ZIP 文件。
// @match             https://www.humblebundle.com/downloads
// @match             https://www.humblebundle.com/downloads*
// @grant             none
// @require           https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
// @require           https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @run-at            document-end
// ==/UserScript==

function formatName(name) {
  return name.replace(/[/\\:'"]/g, "_");
}

async function addDownload(zip, name, $btn) {
  const url = $btn.prop("href");
  const ext = url.match(/\.(\w+?)\?/)[1];
  const fileName = `${formatName(name)}.${ext}`;
  console.info("downloading: %s", fileName);
  const fileBlob = await fetch(url).then(r => r.blob());
  zip.file(fileName, fileBlob);
}

async function generateZipBlob(zip) {
  return new Promise((resolve, reject) => {
    let slices = [];

    // Use internal stream to support large zip file download (to some extend).
    const stream = zip.generateInternalStream({ type: "blob" });
    stream.on("data", (data) => {
      slices.push(data);
    });
    stream.on("error", (err) => {
      slices = null;
      reject(err);
    });
    stream.on("end", () => {
      const blob = new Blob(slices, { type: "application/zip" });
      slices = null;
      resolve(blob);
    });
    stream.resume();
  });
}

async function doWork($el) {
  const $root = $el.parents(".wrapper");
  const dlFormat = $(".js-file-type-select", $root).val();

  if (dlFormat === 'Supplement') {
    alert('Please select a different format to download.');
    return;
  }

  const zip = new JSZip();
  const $rows = $(".js-download-rows .row", $root);
  $rows.css("opacity", 0.5);

  for (const row of Array.from($rows)) {
    const $row = $(row);
    const bookName = $row.data("human-name");
    const $dlBtns = $(".js-start-download a", $row);
    if ($dlBtns.length === 0) {
      console.warn("%s does not contain a download link", bookName);
      continue;
    }

    const getByType = (type) =>
      $dlBtns.filter((i, el) => $(el).text().trim() === type);
    const $dlBtn = getByType(dlFormat);
    await addDownload(zip, bookName, $dlBtn.length ? $dlBtn : $($dlBtns[0]));

    const $supplement = getByType("Supplement");
    if ($supplement.length > 0) {
      await addDownload(zip, `${bookName} (Supplement)`, $supplement);
    }
    const $zip = getByType("ZIP");
    if ($zip.length > 0) {
      await addDownload(zip, `${bookName} (ZIP Supplement)`, $zip);
    }
    $row.css("opacity", 1).css("background", "#beffcf");
  }

  console.info("generating zip...");
  const packageName = document.title.replace(/\(.+/, "").trim();
  const zipName = `${formatName(packageName)}.zip`;

  // For debug purpose
  window.__last_zip = zip;

  const zipBlob = await generateZipBlob(zip);
  saveAs(zipBlob, zipName);
}

function main() {
  $("<style>")
    .text(".js-zip-download:disabled { opacity: 0.7 }")
    .appendTo(document.head);

  const $bulkDl = $(".js-bulk-download").text("BULK");
  $bulkDl.each((i, $bulkDlBtn) => {
    const $zipButton = $(
      '<button class="js-zip-download button-v2 blue rectangular-button" type="button">ZIP</button>'
    )
      .insertAfter($bulkDlBtn)
      .css("margin-left", "0.25em");

    $zipButton.click((e) => {
      e.preventDefault();
      e.stopPropagation();

      $zipButton.prop("disabled", true);
      doWork($zipButton)
        .catch(console.error)
        .finally(() => {
          $zipButton.prop("disabled", false);
        });
    });
  });
}

function boot() {
  if (boot.counter-- > 0) {
    if (!window.$ || $(".js-bulk-download").length === 0) {
      setTimeout(boot, 500);
    }
    main();
  }
}
boot.counter = 30;

setTimeout(boot);