Greasy Fork is available in English.

ImageDownloaderLib

Image downloader for manga download scripts.

بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greasyfork.org/scripts/451810/1398192/ImageDownloaderLib.js

/*
 * Dependencies:
 *
 * GM_info(optional)
 * Docs: https://violentmonkey.github.io/api/gm/#gm_info
 * 
 * GM_xmlhttpRequest(optional)
 * Docs: https://violentmonkey.github.io/api/gm/#gm_xmlhttprequest
 *
 * JSZIP
 * Github: https://github.com/Stuk/jszip
 * CDN: https://unpkg.com/jszip@3.7.1/dist/jszip.min.js
 *
 * FileSaver
 * Github: https://github.com/eligrey/FileSaver.js
 * CDN: https://unpkg.com/file-saver@2.0.5/dist/FileSaver.min.js
 */

;const ImageDownloader = (({ JSZip, saveAs }) => {
  let maxNum = 0;
  let promiseCount = 0;
  let fulfillCount = 0;
  let isErrorOccurred = false;

  // elements
  let startNumInputElement = null;
  let endNumInputElement = null;
  let downloadButtonElement = null;
  let panelElement = null;

  // svg icons
  const externalLinkSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentcolor" width="16" height="16"><path fill-rule="evenodd" d="M10.604 1h4.146a.25.25 0 01.25.25v4.146a.25.25 0 01-.427.177L13.03 4.03 9.28 7.78a.75.75 0 01-1.06-1.06l3.75-3.75-1.543-1.543A.25.25 0 0110.604 1zM3.75 2A1.75 1.75 0 002 3.75v8.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 12.25v-3.5a.75.75 0 00-1.5 0v3.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25h3.5a.75.75 0 000-1.5h-3.5z"></path></svg>`;
  const reloadSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentcolor" width="16" height="16"><path fill-rule="evenodd" d="M8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 11-1.49.178A5.501 5.501 0 008 2.5zM1.705 8.005a.75.75 0 01.834.656 5.501 5.501 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834z"></path></svg>`;

  // initialization
  function init({
    maxImageAmount,
    getImagePromises,
    title = `package_${Date.now()}`,
    imageSuffix = 'jpg',
    zipOptions = {},
    positionOptions = {}
  }) {
    // assign value
    maxNum = maxImageAmount;

    // setup UI
    setupUI(positionOptions);

    // setup update notification
    setupUpdateNotification();

    // add click event listener to download button
    downloadButtonElement.onclick = function () {
      if (!isOKToDownload()) return;

      this.disabled = true;
      this.textContent = "Processing";
      this.style.backgroundColor = '#aaa';
      this.style.cursor = 'not-allowed';
      download(getImagePromises, title, imageSuffix, zipOptions);
    }
  }

  // setup UI
  function setupUI(positionOptions) {
    // common input element style
    const inputElementStyle = `
      box-sizing: content-box;
      padding: 1px 2px;
      width: 40%;
      height: 26px;

      border: 1px solid #aaa;
      border-radius: 4px;

      font-family: 'Consolas', 'Monaco', 'Microsoft YaHei';
      text-align: center;
    `;

    // create start number input element
    startNumInputElement = document.createElement('input');
    startNumInputElement.id = 'ImageDownloader-StartNumInput';
    startNumInputElement.style = inputElementStyle;
    startNumInputElement.type = 'text';
    startNumInputElement.value = 1;

    // create end number input element
    endNumInputElement = document.createElement('input');
    endNumInputElement.id = 'ImageDownloader-EndNumInput';
    endNumInputElement.style = inputElementStyle;
    endNumInputElement.type = 'text';
    endNumInputElement.value = maxNum;

    // prevent keyboard input from being blocked
    startNumInputElement.onkeydown = (e) => e.stopPropagation();
    endNumInputElement.onkeydown = (e) => e.stopPropagation();

    // create 'to' span element
    const toSpanElement = document.createElement('span');
    toSpanElement.id = 'ImageDownloader-ToSpan';
    toSpanElement.textContent = 'to';
    toSpanElement.style = `
      margin: 0 6px;
      color: black;
      line-height: 1;
      word-break: keep-all;
      user-select: none;
    `;

    // create download button element
    downloadButtonElement = document.createElement('button');
    downloadButtonElement.id = 'ImageDownloader-DownloadButton';
    downloadButtonElement.textContent = 'Download';
    downloadButtonElement.style = `
      margin-top: 8px;
      width: 128px;
      height: 48px;

      display: flex;
      justify-content: center;
      align-items: center;

      font-size: 14px;
      font-family: 'Consolas', 'Monaco', 'Microsoft YaHei';
      color: #fff;
      line-height: 1.2;

      background-color: #0984e3;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    `;

    // create range input container element
    const rangeInputContainerElement = document.createElement('div');
    rangeInputContainerElement.id = 'ImageDownloader-RangeInputContainer';
    rangeInputContainerElement.style = `
      display: flex;
      justify-content: center;
      align-items: baseline;
    `;

    // create panel element
    panelElement = document.createElement('div');
    panelElement.id = 'ImageDownloader-Panel';
    panelElement.style = `
      position: fixed;
      top: 72px;
      left: 72px;
      z-index: 999999999;

      box-sizing: border-box;
      padding: 8px;
      width: 146px;
      height: 106px;

      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: baseline;

      font-size: 14px;
      font-family: 'Consolas', 'Monaco', 'Microsoft YaHei';
      letter-spacing: normal;

      background-color: #f1f1f1;
      border: 1px solid #aaa;
      border-radius: 4px;
    `;

    // modify panel position according to 'positionOptions'
    for (const [key, value] of Object.entries(positionOptions)) {
      if (key === 'top' || key === 'bottom' || key === 'left' || key === 'right') {
        panelElement.style[key] = value;
      }
    }

    // assemble and then insert into document
    rangeInputContainerElement.appendChild(startNumInputElement);
    rangeInputContainerElement.appendChild(toSpanElement);
    rangeInputContainerElement.appendChild(endNumInputElement);
    panelElement.appendChild(rangeInputContainerElement);
    panelElement.appendChild(downloadButtonElement);
    document.body.appendChild(panelElement);
  }

  // setup update notification
  async function setupUpdateNotification() {
    if (typeof GM_info === 'undefined' || typeof GM_xmlhttpRequest === 'undefined') return;

    // get local version
    const localVersion = Number(GM_info.script.version);

    // get latest version
    const scriptID = (GM_info.script.homepageURL || GM_info.script.homepage).match(/scripts\/(?<id>\d+)-/)?.groups?.id;
    const scriptURL = `https://update.greasyfork.org/scripts/${scriptID}/raw.js`;
    const latestVersionString = await new Promise(resolve => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: scriptURL,
        responseType: 'text',
        onload: res => resolve(res.response.match(/@version\s+(?<version>[0-9\.]+)/)?.groups?.version)
      });
    });
    const latestVersion = Number(latestVersionString);

    if (Number.isNaN(localVersion) || Number.isNaN(latestVersion)) return;
    if (latestVersion <= localVersion) return;

    // show update notification
    const updateLinkElement = document.createElement('a');
    updateLinkElement.id = 'ImageDownloader-UpdateLink';
    updateLinkElement.href = scriptURL.replace('raw.js', 'raw.user.js');
    updateLinkElement.innerHTML = `Update to V${latestVersionString}${externalLinkSVG}`;
    updateLinkElement.style = `
      position: absolute;
      bottom: -38px;
      left: -1px;

      display: flex;
      justify-content: space-around;
      align-items: center;

      box-sizing: border-box;
      padding: 8px;
      width: 146px;
      height: 32px;

      font-size: 14px;
      font-family: 'Consolas', 'Monaco', 'Microsoft YaHei';
      text-decoration: none;
      color: white;

      background-color: #32CD32;
      border-radius: 4px;
    `;
    updateLinkElement.onclick = () => setTimeout(() => {
      updateLinkElement.removeAttribute('href');
      updateLinkElement.innerHTML = `Please Reload${reloadSVG}`;
      updateLinkElement.style.cursor = 'default';
    }, 1000);

    panelElement.appendChild(updateLinkElement);
  }

  // check validity of page nums from input
  function isOKToDownload() {
    const startNum = Number(startNumInputElement.value);
    const endNum = Number(endNumInputElement.value);

    if (Number.isNaN(startNum) || Number.isNaN(endNum)) { alert("请正确输入数值\nPlease enter page number correctly."); return false; }
    if (!Number.isInteger(startNum) || !Number.isInteger(endNum)) { alert("请正确输入数值\nPlease enter page number correctly."); return false; }
    if (startNum < 1 || endNum < 1) { alert("页码的值不能小于1\nPage number should not smaller than 1."); return false; }
    if (startNum > maxNum || endNum > maxNum) { alert(`页码的值不能大于${maxNum}\nPage number should not bigger than ${maxNum}.`); return false; }
    if (startNum > endNum) { alert("起始页码的值不能大于终止页码的值\nNumber of start should not bigger than number of end."); return false; }

    return true;
  }

  // start downloading
  async function download(getImagePromises, title, imageSuffix, zipOptions) {
    const startNum = Number(startNumInputElement.value);
    const endNum = Number(endNumInputElement.value);
    promiseCount = endNum - startNum + 1;

    // start downloading images, max amount of concurrent requests is limited to 4
    let images = [];
    for (let num = startNum; num <= endNum; num += 4) {
      const from = num;
      const to = Math.min(num + 3, endNum);
      try {
        const result = await Promise.all(getImagePromises(from, to));
        images = images.concat(result);
      } catch (error) {
        return; // cancel downloading
      }
    }

    // configure file structure of zip archive
    JSZip.defaults.date = new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000);
    const zip = new JSZip();
    const zipTitle = title.replaceAll(/\/|\\|\:|\*|\?|\"|\<|\>|\|/g, ''); // remove some characters
    const folder = zip.folder(zipTitle);
    for (const [index, image] of images.entries()) {
      const filename = `${String(index + 1).padStart(images.length >= 100 ? String(images.length).length : 2, '0')}.${imageSuffix}`;
      folder.file(filename, image, zipOptions);
    }

    // start zipping & show progress
    const zipProgressHandler = (metadata) => { downloadButtonElement.innerHTML = `Zipping<br>(${metadata.percent.toFixed()}%)`; }
    const content = await zip.generateAsync({ type: "blob" }, zipProgressHandler);

    // open 'Save As' window to save
    saveAs(content, `${zipTitle}.zip`);

    // all completed
    downloadButtonElement.textContent = "Completed";
  }

  // handle promise fulfilled
  function fulfillHandler(res) {
    if (!isErrorOccurred) {
      fulfillCount++;
      downloadButtonElement.innerHTML = `Processing<br>(${fulfillCount}/${promiseCount})`;
    }

    return res;
  }

  // handle promise rejected
  function rejectHandler(err) {
    isErrorOccurred = true;
    console.error(err);

    downloadButtonElement.textContent = 'Error Occurred';
    downloadButtonElement.style.backgroundColor = 'red';

    return Promise.reject(err);
  }

  return { init, fulfillHandler, rejectHandler };
})(window);