bomtoon长截图

获取bomtoon图源

// ==UserScript==
// @name         bomtoon长截图
// @namespace    summer-script
// @version      0.5
// @description  获取bomtoon图源
// @author       summer
// @match        https://www.bomtoon.com/*
// @match        https://www.bomtoon.tw/*
// @icon         https://image.balcony.studio/BOMTOON_COM/images/common/favicon.ico
// @license      GPL-3.0
// @grant        GM_download
// @connect      image.balcony.studio
// ==/UserScript==

(function () {
  "use strict";

  const IMG_HEIGHT_MAX = 32000;
  const IMG_HEIGHT_DEFAULT = 9000;
  const WAIT_GET_IMG = 1000;
  const DOMAIN_KO = "www.bomtoon.com";
  const DOMAIN_TW = "www.bomtoon.tw";

  var tip = {
    initializing: "初始化中",
    startRun: "开始截图",
    running: "正在截图",
    progress: "正在截图 {1}%",
    finish: "截图完毕",
    notSupport: "发生错误, 当前作品不支持或脚本已失效",
    maxHeightLab: "截图高度",
    maxHeightTip: "最大" + IMG_HEIGHT_MAX,
  };
  var ui = initUI();
  var cache = {};

  // 0: 未运行, 1: 正在运行, 2: 正在停止, 3: 已完成
  var runstatus = 0;

  ui.listenBtn(run);
  ui.updateBtnText(tip.startRun);

  setInterval(function () {
    var inViewerPage = location.pathname.startsWith("/viewer/");
    if (!inViewerPage) {
      ui.hide();
      if (3 === runstatus) {
        runstatus = 0;
      }
      if (0 !== runstatus) {
        runstatus = 2;
      }
    } else {
      if (0 === runstatus) {
        ui.show();
        ui.updateBtnText(tip.startRun);
        ui.enableBtn();
      }
    }
  }, 1000);

  async function run() {
    runstatus = 1;
    ui.disableBtn();
    cleanDecryptScrambleKey();
    var maxHeight = ui.getInput();
    if (!maxHeight) {
      maxHeight = IMG_HEIGHT_DEFAULT;
      ui.setInput(maxHeight);
    }
    ui.updateBtnText(tip.running);

    var progress = 0;
    var bookData = await getBookData();
    var images = bookData.images;
    var canvas = initPageCanvas(bookData, maxHeight);

    for (var i = 0; i < images.length; i++) {
      if (2 === runstatus) {
        runstatus = 0;
        return;
      }
      await waitMs(WAIT_GET_IMG);
      progress = Math.round(((i + 1) / images.length) * 100);
      ui.updateBtnText(tip.progress, progress);
      var page = images[i];
      var img = await getPageImage(page);
      if (!img) {
        i--;
        cleanDecryptScrambleKey();
        images = await getBookImages();
        continue;
      }
      drawPageImage(canvas, page, img);

      if (!isDrawFull(canvas)) {
        revokeImgBlobURL(img);
        continue;
      }
      await downloadCanvas(canvas, bookData);
      resetPageDraw(canvas);

      if (isDrawOverflow(canvas)) {
        i--;
        continue;
      }
      revokeImgBlobURL(img);
    }
    ui.updateBtnText(tip.finish);
    runstatus = 3;
  }

  async function decryptScramble(cipherText, key) {
    var iv = key.substring(0, 16);
    var cipherData = base64ToArrayBuffer(cipherText);
    key = new TextEncoder().encode(key);
    iv = new TextEncoder().encode(iv);

    var keyObj = await window.crypto.subtle.importKey(
      "raw",
      key,
      {
        name: "AES-CBC",
        length: 256,
      },
      true,
      ["decrypt"]
    );

    var plainData = await window.crypto.subtle.decrypt(
      {
        name: "AES-CBC",
        iv: iv,
      },
      keyObj,
      cipherData
    );
    var json = new TextDecoder().decode(new Uint8Array(plainData));

    return JSON.parse(json);
  }

  async function getDecryptScrambleKey(dataLine) {
    var defaultKey = "thisisBalconyScrambledKey1234!@#";
    if (!dataLine) {
      cache.scrambleKey = defaultKey;
    }
    if (cache.scrambleKey) {
      return cache.scrambleKey;
    }
    var pathName = location.pathname;
    var regex = new RegExp(/\/viewer\/(.+?)\/(.+?)(\/|$)/);
    var urlParam = regex.exec(pathName);
    var queryParam = {
      alias: urlParam[1],
      epAlias: urlParam[2],
    };
    var pathEpisode = `/${queryParam.alias}/${queryParam.epAlias}`;
    var keyAPI = "/api/balcony-api-v2/contents/images" + pathEpisode;
    var dataPost = { line: dataLine };
    var resp = await apiReq(keyAPI, "POST", dataPost);
    cache.scrambleKey = resp.data ? resp.data : defaultKey;

    return cache.scrambleKey;
  }

  async function getAuthToken() {
    var resp = await fetch("/api/auth/session");
    var data = await resp.json();

    if (!data.user) {
      return null;
    }

    return data.user.accessToken.token;
  }

  async function apiReq(url, method, dataPost) {
    var authToken = await getAuthToken();
    var headerBalconyKO = {
      "x-balcony-id": "BOMTOON_COM",
      "x-balcony-timezone": "Asia/Seoul",
      "x-platform": "WEB",
    };
    var headerBalconyTW = {
      "x-balcony-id": "BOMTOON_TW",
      "x-balcony-timezone": "Asia/Taipei",
      "x-platform": "WEB",
    };
    var body = null;
    var headers = DOMAIN_KO === location.host
      ? headerBalconyKO
      : headerBalconyTW;

    headers.accept = "application/json";

    if ("POST" === method.toLocaleUpperCase()) {
      headers["Content-Type"] = "application/json";
      body = JSON.stringify(dataPost);
    }
    if (authToken) {
      headers.authorization = "Bearer " + authToken;
    }
    var opt = { headers, method, body };
    var resp = await fetch(url, opt);
    var data = await resp.json();

    return data;
  }

  async function getBookData() {
    var dataAPI = getAPIUrl();
    var resp = await fetch(dataAPI, { headers: { "X-Nextjs-Data": 1 } });
    var respData = await resp.json();

    if (respData.pageProps.episodeData) {
      return respData.pageProps.episodeData.result;
    }
    var pathName = location.pathname;
    var dataAPI = `/api/balcony-api-v2/contents${pathName}?isNotLoginAdult=false`;
    var respData = await apiReq(dataAPI, "GET");
    return respData.data;
  }

  async function getBookImages() {
    var bookData = await getBookData();
    return bookData.images;
  }

  async function getPageImage(page) {
    var imgURL = page.imagePath;
    var img = null;
    try {
      img = await loadImage(imgURL);
    } catch (error) {
      console.log("img load failed");
      return null;
    }
    if (page.point) {
      page.scrambleIndex = page.point;
    }
    if (!page.scrambleIndex) {
      return img;
    }
    var decryptKey = await getDecryptScrambleKey(page.line);
    page.scrambleIndex = await decryptScramble(page.scrambleIndex, decryptKey);
    img = await drawImageScramble(img, page);
    page.imagePath = img.src;
    page.scrambleIndex = null;
    page.point = null;
    page.line = null;

    return img;
  }

  function getBuildId() {
    var json = document.getElementById("__NEXT_DATA__").innerHTML;

    var data = JSON.parse(json);
    if (!data) {
      return null;
    }
    return data.buildId;
  }

  function getAPIUrl() {
    var buildId = getBuildId();
    var pathName = location.pathname;
    var regex = new RegExp(/\/viewer\/(.+?)\/(.+?)(\/|$)/);
    var urlParam = regex.exec(pathName);
    var queryParam = new URLSearchParams();
    queryParam.append("alias", urlParam[1]);
    queryParam.append("epAlias", urlParam[2]);
    var dataAPI = `/_next/data/${buildId}${pathName}.json?${queryParam}`;

    return dataAPI;
  }

  function cleanDecryptScrambleKey() {
    cache.scrambleKey = null;
  }

  function waitMs(ms) {
    return new Promise(function (resolve) {
      setTimeout(resolve, ms);
    });
  }

  function drawImageScramble(img, page) {
    var canvas = document.createElement("canvas");
    canvas.width = img.naturalWidth;
    canvas.height = img.naturalHeight;
    var ctx = canvas.getContext("2d");

    page.scrambleIndex.forEach(function (i, r) {
      let s = page.width / 4,
        a = page.defaultHeight / 4,
        d = (r % 4) * s,
        c = Math.floor(r / 4) * a,
        u = (i % 4) * s,
        g = Math.floor(i / 4) * a;
      ctx.drawImage(img, d, c, s, a, u, g, s, a);
    });

    return new Promise(function (resolve) {
      canvas.toBlob(function (blob) {
        var url = URL.createObjectURL(blob);
        loadImage(url).then(resolve);
      });
    });
  }

  function initPageCanvas(bookData, maxHeight) {
    var images = bookData.images;
    var canvas = document.createElement("canvas");
    var width = 0;
    var heightSum = 0;

    images.forEach(function (image) {
      if (!width) {
        width = image.width;
      }
      heightSum += image.height;
    });
    if (heightSum < maxHeight) {
      maxHeight = heightSum;
    }
    canvas.width = width;
    canvas.height = maxHeight;

    canvas.dataset.heightcurrent = 0;
    canvas.dataset.heightremain = heightSum;
    canvas.dataset.downloadno = 1;

    return canvas;
  }

  function drawPageImage(canvas, page, img) {
    var heightCurrent = parseInt(canvas.dataset.heightcurrent);
    var heightRemain = parseInt(canvas.dataset.heightremain);
    var ctx = canvas.getContext("2d");
    ctx.drawImage(img, 0, heightCurrent);

    if (heightCurrent < 0) {
      // overflow remain draw
      heightRemain -= heightCurrent;
    }
    heightCurrent += page.height;

    // heightCurrent(9000) + page.height(3000) = heightCurrent(12000)
    // heightCurrent(12000) > canvas.height(10000)
    // heightCurrent(12000) - canvas.height(10000) = drawRemaining(2000)
    // page.height(3000) - drawRemaining(2000) = drawnHeight(1000)
    // drawnHeight(1000) * -1 = drawOffset(-1000)

    if (heightCurrent > canvas.height) {
      // overflow
      var drawRemaining = heightCurrent - canvas.height;
      var drawnHeight = page.height - drawRemaining;
      var drawOffset = -1 * drawnHeight;
      heightCurrent = drawOffset;
      heightRemain -= drawnHeight;
    } else {
      // no overflow
      heightRemain -= page.height;
    }

    canvas.dataset.heightcurrent = heightCurrent;
    canvas.dataset.heightremain = heightRemain;
  }

  function isDrawFull(canvas) {
    var heightCurrent = parseInt(canvas.dataset.heightcurrent);

    return heightCurrent < 0 || heightCurrent >= canvas.height;
  }

  function isDrawOverflow(canvas) {
    var heightCurrent = parseInt(canvas.dataset.heightcurrent);

    return heightCurrent < 0;
  }

  function resetPageDraw(canvas) {
    var heightRemain = parseInt(canvas.dataset.heightremain);
    var heightCurrent = parseInt(canvas.dataset.heightcurrent);
    var downloadNo = parseInt(canvas.dataset.downloadno);

    if (heightRemain < canvas.height) {
      canvas.height = heightRemain;
    }
    if (heightCurrent >= canvas.height) {
      canvas.dataset.heightcurrent = 0;
    }
    canvas.dataset.downloadno = downloadNo + 1;

    var ctx = canvas.getContext("2d");
    ctx.clearRect(0, 0, canvas.width, canvas.height);
  }

  function loadImage(src) {
    return new Promise(function (resolve, reject) {
      var img = new Image();
      img.crossOrigin = "anonymous";
      img.onload = function () {
        resolve(this);
      };
      img.onerror = reject;
      img.src = src;
    });
  }

  function downloadCanvas(canvas, bookData) {
    var title = `${bookData.contentsTitle} - ${bookData.title}`;
    var pageNo = canvas.dataset.downloadno;
    pageNo = pageNo.padStart(2, "0");
    var filename = `${title} - ${pageNo}.png`;

    return new Promise(function (resolve) {
      canvas.toBlob(function (blob) {
        if (!blob) {
          console.log("blob null");
          resolve();
          return;
        }
        var blobURL = URL.createObjectURL(blob);
        GM_download({
          name: filename,
          url: blobURL,
          onload: function () {
            URL.revokeObjectURL(blobURL);
            resolve();
          },
        });
      });
    });
  }

  function revokeImgBlobURL(img) {
    var url = img.src;
    var protocol = new URL(url).protocol;

    if ("blob:" === protocol) {
      URL.revokeObjectURL(url);
    }
  }

  function base64ToArrayBuffer(base64) {
    var binaryString = atob(base64);
    var bytes = new Uint8Array(binaryString.length);
    for (var i = 0; i < binaryString.length; i++) {
      bytes[i] = binaryString.charCodeAt(i);
    }
    return bytes.buffer;
  }

  function initUI() {
    var ui = {
      btn: null,
      inputLm: null,
      labelLm: null,
      listenBtn: function (cb) {
        this.btn.addEventListener("click", cb);
      },
      disableBtn: function () {
        this.btn.disabled = true;
        this.inputLm.disabled = true;
      },
      enableBtn: function () {
        this.btn.disabled = false;
        this.inputLm.disabled = false;
      },
      show: function () {
        this.btn.style.display = "block";
        this.inputLm.style.display = "block";
        this.labelLm.style.display = "block";
      },
      hide: function () {
        this.btn.style.display = "none";
        this.inputLm.style.display = "none";
        this.labelLm.style.display = "none";
      },
      getInput: function () {
        return this.inputLm.value;
      },
      setInput: function (val) {
        this.inputLm.value = val;
      },
      updateBtnText: function (text) {
        var args = Array.prototype.slice.call(arguments, 1);
        this.btn.innerText = text.replace(/{(\d+)}/g, function (match, num) {
          var key = num - 1;
          return "undefined" !== typeof args[key] ? args[key] : match;
        });
      },
    };
    var btn = document.createElement("button");
    btn.innerText = tip.initializing;
    btn.style.display = "none";
    btn.style.position = "fixed";
    btn.style.top = "40px";
    btn.style.right = "50px";
    btn.style.zIndex = "10030";
    btn.style.padding = "9px";
    btn.style.background = "#fff";
    btn.style.border = "1px solid #aaa";
    btn.style.borderRadius = "4px";
    btn.style.minWidth = "112px";
    btn.style.color = "#000";
    btn.style.cursor = "pointer";
    btn.style.lineHeight = "16px";
    btn.style.fontSize = "14px";
    document.body.appendChild(btn);
    ui.btn = btn;

    var label = document.createElement("label");
    label.innerText = tip.maxHeightLab;
    label.style.display = "none";
    label.style.position = "fixed";
    label.style.top = "90px";
    label.style.right = "50px";
    label.style.zIndex = "10030";
    label.style.padding = "9px";
    label.style.background = "#eee";
    label.style.border = "1px solid #aaa";
    label.style.borderRadius = "4px 4px 0 0";
    label.style.color = "#000";
    label.style.lineHeight = "16px";
    label.style.borderBottom = "none";
    label.style.fontSize = "14px";
    label.style.textAlign = "center";
    label.style.width = "112px";
    document.body.appendChild(label);
    ui.labelLm = label;

    var text = document.createElement("input");
    text.placeholder = tip.maxHeightTip;
    text.type = "text";
    text.style.display = "none";
    text.style.position = "fixed";
    text.style.top = "124px";
    text.style.right = "50px";
    text.style.zIndex = "10030";
    text.style.padding = "9px";
    text.style.background = "#fff";
    text.style.border = "1px solid #aaa";
    text.style.borderRadius = "0 0 4px 4px";
    text.style.width = "112px";
    text.style.color = "#000";
    text.style.lineHeight = "16px";
    text.style.fontSize = "14px";
    text.style.textAlign = "center";

    text.addEventListener("keyup", function () {
      this.value = this.value.replace(/\D/g, "");
      var value = parseInt(this.value);
      var min = 0;
      var max = IMG_HEIGHT_MAX;
      if (value > max) {
        this.value = max;
      }
      if (value < min) {
        this.value = min;
      }
    });
    document.body.appendChild(text);
    ui.inputLm = text;

    return ui;
  }
})();