ZIPでファイルをまとめてダウンロード.user.js

投稿日 2021/01/18 更新日 2021/05/26

はじめに

ウェブページ内にあるファイル(主に画像)をまとめてダウンロードしたいと思ったことはないでしょうか?世の中には、たくさんのダウンロード方法があります。ですが、それは、大抵すべてをダウンロードするものです。ページ内の一部のファイルのみ選択してダウンロードしたいと考えてこのユーザースクリプトを作成しました。

本ユーザスクリプトの利点

  • CROS (Cross-Origin Resource Sharing) 問題を回避できます
    • 拡張機能の権限で実行できるため、CROS問題を回避できます
      • 通常のXMLHttpRequest, fetchでは、ユーザスクリプトでも回避できません
      • GM系の特別関数を使用することで、拡張機能の権限でダウンロードできます
    • 外部サイトのファイルでもダウンロードできます
    • Access-Control-Allow-Originの値に関わらずファイルをダウンロードできます
    • オリジン間リソース共有 (CORS) - HTTP | MDN
  • CSP (Content-Security-Policy) 問題を回避できます
  • 複数サイトに対応できます
    • ユーザの必要に応じて対応サイト増やすことができます
    • CSSセレクタ記法を使用して、簡単に保存ファイルを選択できます
      • 全ファイルではなく、必要なファイルのみをダウンロードすることができます
  • WebWorkerを使用することで、バックグランドタブでもある程度動作します
    • 重いZIP圧縮処理による、ページの処理遅延を回避できます

動作概要

  1. 拡張機能側でページにショートカットキーを設定する
    • [Alt+Shift+D]を設定します
      • ショートカットキーを変更することもできます
  2. ページからショートカットキーで拡張機能側の処理を開始する
  3. 拡張機能側で指定のURLからファイルをダウンロードする
    • GM.xmlHttpRequest()を使用することでCROS問題とCSP問題を回避します
  4. 拡張機能側でダウンロードしたデータをWebWorkerに転送する
    • WebWorkerが使用できない場合、拡張機能側で以降の処理を実施します
  5. WebWorkerでJSZipを使用してZIPデータに圧縮する
    • WebWorkerを使用することで、処理をページから分離できます
    • 処理をページから分離したことで、バックグランドタブでもある程度動作します
  6. WebWorkerから拡張機能側にZIPデータを転送データする
  7. 拡張機能側で<a download>を使用してZIPファイルを保存する

使い方

  1. ユーザスクリプト用の拡張機能に下記のコードを設定する
  2. ユーザスクリプトに@include/@matchで対象ページのURLを追加する
    • 対象ページを含むように設定します
    • また、スクリプトのユーザ設定から追加することもできます
  3. ユーザスクリプトにサイト毎で処理を追記する
    • サイト毎にZIPファイル名とダウンロードファイルのURLを設定します
      • ファイル名は、次のようなものが考えられます
        • ページタイトル(例:document.title
        • ページ内の要素文字列(例:document.querySelector('.title').textContent
        • ユーザ入力(例:window.prompt('ファイル名を入力して下さい', '')
      • ファイルURLの取得方法は、次のようなものが考えられます
        • すべての画像
          • 例:[...document.querySelectorAll('img')].map(img => img.src)
        • 特定要素の子要素
          • 例:[...document.querySelectorAll('#container a')].map(a => a.href)
    • ページ毎に処理を変更することもできます
  4. 対象ページを開きユーザスクリプトを読み込む
  5. 対象ページでショートカットキーを入力しダウンロードを開始する
  6. 進歩画面(ダウンロード中)を表示する
  7. 進歩画面(圧縮中)を表示する
    • 対象ページをアクティブにしていない場合、進歩が遅くなります
      • ブラウザは、バックグランドタブのスクリプト実行を制限することがあります
  8. 圧縮したZIPファイルをダウンロードする
    • ブラウザ側のダウンロード開始を確認してから、ページをクローズします
      • それより先に、タブをクローズするとダウンロードされません

下記のコードを導入するだけでは、不十分です。
 対象ページ用の処理を利用者自らが作成する必要があります。
 ただし、動作テスト用のコードは記載されています。
※Greasemonkey/Violentmonkeyの使用を強く推奨します。
 Tampermonkeyと比べて実行速度が高速になります。
 この差は、ダウンロードするファイルサイズが大きくなるほど顕著になります。

コード

MatometeDownload.user.js// ==UserScript==
// @name        MatometeDownload
// @name:en     Download files with ZIP
// @name:ja     ZIPでファイルをまとめてダウンロード
// @description     Use the [Alt+Shift+D] shortcut keys to download files with ZIP.
// @description:en  Use the [Alt+Shift+D] shortcut keys to download files with ZIP.
// @description:ja  [Alt+Shift+D]のショートカットキーでZIPでファイルをまとめてダウンロードします。
// @note        ↓↓↓ Add target page URL ↓↓↓
// @match       *://www.bugbugnow.net/*
// @note        ↑↑↑ Add target page URL ↑↑↑
// @author      toshi (https://github.com/k08045kk)
// @license     MIT License | https://opensource.org/licenses/MIT
// @version     3.4.4
// @since       1.0.0 - 20210113 - 初版
// @since       2.0.0 - 20210115 - WebWorker対応(高速化対応)
// @since       3.0.0 - 20210116 - WebWorker/NoWorker対応(NoScript対応)
// @since       3.2.0 - 20210117 - Download_files_with_ZIP.user.js → MatometeDownload.user.js
// @since       3.3.0 - 20210118 - リリース版
// @since       3.4.0 - 20210131 - fix Blobを保存できない問題修正(Firefox+Greasemonkey+NoScript)
// @since       3.4.1 - 20210131 - fix arg.levelオプションを修正
// @since       3.4.2 - 20210131 - fix I/F修正等
// @since       3.4.3 - 20210408 - fix 完了時に背景がちらつくことがある
// @since       3.4.4 - 20210526 - エラー出力まわりを強化
// @see         https://github.com/k08045kk/UserScripts
// @see         https://www.bugbugnow.net/2021/01/download-files-with-zip.html
// @grant       GM.xmlHttpRequest
// @grant       window.close
// @require     https://cdn.jsdelivr.net/npm/jszip@3.5.0/dist/jszip.min.js
// @require     https://cdn.jsdelivr.net/npm/hotkeys-js@3.8.1/dist/hotkeys.min.js
// ==/UserScript==

(function() {
  'use strict';


  // 進歩表示
  const box = document.createElement('div');
  box.setAttribute('style', `
    z-index: 2147483647;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0,0,0,.7);
    color: #fff;
    font-size: 64px;
    font-family: monospace, monospace;
    white-space: pre;
  `);
  const root = document.createElement('div');
  root.attachShadow({mode:'closed'}).appendChild(box)
  const drawProgress = function(text) {
    if (!root.parentNode && text) {
      document.body.appendChild(root);
    }
    if (text) {
      box.textContent = text;
    } else if (root.parentNode) {
      root.remove();
    }
  };


  // 更新処理
  const onDefaultUpdate = function(arg) {
    switch (arg.status) {
    case 'ready':
      drawProgress(' ');
      break;
    case 'download': 
      const len = (arg.urls.length+'').length;
      drawProgress('Download: '+(Array(len).join(' ')+arg.success).slice(-len)+'/'+arg.urls.length);
      break;
    case 'compress': 
      drawProgress('Compress: '+(Array(3).join(' ')+Math.floor(arg.percent)).slice(-3)+'%');
      break;
    case 'complate':
      const title = 'Download '+arg.status;
      const second = Math.floor((arg.endtime - arg.starttime) / 1000);
      const info = arg.success+'/'+arg.urls.length+':'+arg.failure+' | '+second+'s';
      drawProgress(' ');
      console.log('complate', arg);
      arg.alert !== false && alert(title+'\n\n'+arg.name+'.zip\n'+info);
      arg.close !== false && arg.close != null && setTimeout(() => window.close(), typeof arg.close === 'number' ? arg.close :  1000);
      drawProgress();
      break;
    case 'error':
    default:
      drawProgress();
      const status = arg.substatus || arg.status;
      const message = arg.message;
      console.log('error', arg);
      alert('error ('+status+')\n\n'+message);
      break;
    }
  };


  /**
   * ZIPダウンロード
   * ファイルのダウンロード → ファイルのZIP圧縮 → ZIPファイルのダウンロード
   * @param {Object} arg - 引数
   * in     {string} arg.name - ZIPファイルのファイル名(拡張子を含まない)
   * in     {(string|Blob|File)[]} arg.urls - ダウンロードファイルのURL(or Blob or File)
   * in/out {string[]} arg.names - ダウンロードファイルのファイル名(拡張子を含む)
   *    out {boolean[]} arg.results - ダウンロードの結果
   * in/out {Function} [arg.onupdate=onDefaultUpdate] - 更新時のコールバック関数(例:(arg) => {})
   * in/out {boolean} [arg.worker=true] - WebWorkerを使用する
   * in     {number} [arg.level=0] - 圧縮レベル(0-9, 0:無圧縮 / 1:最高速度 / 9:最高圧縮)
   * in     {boolean} [arg.folder=true] - junk-pathsオプション(ディレクトリ構造を保持する)
   * in     {boolean} [arg.empty=true] - 取得失敗したファイルを保存する
   *    out {string} arg.status - 進歩状態
   *    out {string} arg.substatus - エラー以前の進歩状態
   *    out {string} arg.message - エラーメッセージ
   *    out {number} arg.success - ダウンロード成功件数
   *    out {number} arg.failure - ダウンロード失敗件数
   *    out {number} arg.percent - 圧縮の進歩(0-100の実数)
   *    out {number} arg.starttime - 開始時間
   *    out {number} arg.endtime - 終了時間
   * in     {boolean} [arg.alert=true] - 完了時にアラートを表示する
   * in     {(boolean|number)} [arg.close=false] - 完了時にタブをクローズする(Greasemonkeyは、対象外)
   * @return 実行有無
   *         同一ページ内での並列実行は許可されていません。
   */
  let isRun = false;
  const downloadFilesZipAsync = function(arg) {
    // 並列実行を防止
    if (isRun) {
      return false;
    }
    isRun = true;

    // 前処理
    arg.status = 'ready';
    arg.starttime = Date.now();
    arg.onupdate = arg.onupdate || onDefaultUpdate;
    arg.success = 0;
    arg.failure = 0;
    arg.percent = 0;
    arg.onupdate(arg);

    // 完了処理
    const complate = function(status, message) {
      arg.substatus = arg.substatus || arg.status;
      arg.status = status || 'complate';
      arg.message = message || 'complate';
      arg.endtime = Date.now();
      setTimeout(() => {
        arg.onupdate(arg);
        isRun = false;
        // 補足:簡易のブラウザ上のダウンロード開始待ち
      }, 0);
    };

    // ファイルのダウンロード
    const downloadFilesAsync = async function(obj) {
      const zip = obj instanceof JSZip ? obj : null;
      const worker = obj instanceof Worker ? obj : null;

      arg.status = 'download';
      arg.names = arg.names || [];
      arg.results = [];
      const onDownload = (name, data) => {
        arg.success++;
        !data && arg.failure++;
        arg.onupdate(arg);
        if (arg.empty !== false || data) {
          zip && zip.file(name, data);
          worker && worker.postMessage({command:'file', name:name, buffer:data}, data ? [data] : null);
        }
      };
      const promises = arg.urls.map((url, i) => {
        const name = arg.names[i] = arg.names[i] 
                                 || url.name 
                                 || (typeof url === 'string' && url.slice(url.lastIndexOf('/') + 1)) 
                                 || ''+i;
        return new Promise((resolve, reject) => {
          const success = (data) => { try { arg.results[i]=!!data; onDownload(name, data); } finally { resolve(); } };
          const failure = () => success();
          try {
            const blob = url;
            if (blob instanceof Blob) {
              // ページの内部データ
              blob.arrayBuffer().then((buffer) => {
                try {
                  if (buffer instanceof ArrayBuffer === false) {
                    // JavaScript無効時にJSZip内部でエラーする問題対応(JSZipがページコンテキストを想定していないため)
                    // 補足:Firefox + Greasemonkey + NoScript の問題
                    //       WebWorker時は、postMessage() で変換されるため、問題現象は発生しない。
                    // 説明:Greasemonkey の ArrayBuffer.arrayBuffer() / FileReader.readAsArrayBuffer() は、
                    //       スクリプトコンテキスト(ArrayBuffer)ではなく、
                    //       ページコンテキスト(window.ArrayBuffer)でArrayBufferを返す。
                    // see https://bugzilla.mozilla.org/show_bug.cgi?id=1427470
                    // see https://github.com/greasemonkey/greasemonkey/issues/2786
                    const temp = buffer;
                    buffer = new ArrayBuffer(temp.byteLength);
                    new Int8Array(buffer).set(new Int8Array(temp));
                  }
                  success(buffer);
                } catch (e) {
                  //console.log(e);
                  failure();
                }
              });
            } else if (typeof url === 'string') {
              // ページの外部データ
              GM.xmlHttpRequest({
                method: 'GET',
                url: url,
                responseType: 'arraybuffer',
                onload: (xhr) => {
                  const isSuccess = 200 <= xhr.status && xhr.status < 300 || xhr.status === 304;
                  success(isSuccess ? xhr.response : null);
                },
                onerror: failure,
                onabort: failure,
                ontimeout: failure
              });
              // 補足:Data URL/Blob URLは、使用禁止
            } else { failure(); }
          } catch (e) {
            //console.log(e);
            failure();
          }
        });
      });
      arg.onupdate(arg);
      await Promise.all(promises);
    };

    // ZIPのダウンロード
    const downloadZipAsync = async function(blob) {
      const dataUrl = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = dataUrl;
      a.download = arg.name+'.zip';
      a.dispatchEvent(new MouseEvent('click'));
      setTimeout(() => { URL.revokeObjectURL(dataUrl); }, 1E4); // 10s
      complate();
    };

    // Workerなしの処理
    const noworkerAsync = async function() {
      arg.worker = false;
      try {
        const zip = new JSZip();
        await downloadFilesAsync(arg.folder !== false ? zip.folder(arg.name) : zip);

        arg.status = 'compress';
        arg.onupdate(arg);
        const option = arg.level 
                     ? {type:'arraybuffer', compression:'DEFLATE', compressionOptions:{level:arg.level}}
                     : {type:'arraybuffer'};
        const buffer = await zip.generateAsync(option, (metadata) => {
          arg.percent = metadata.percent;
          arg.onupdate(arg);
        });
        arg.percent = 100;
        arg.onupdate(arg);

        await downloadZipAsync(new Blob([buffer]));
      } catch (e) {
        //console.log(e);
        complate('error', e.message);
      }
    };
    if (arg.worker === false) {
      noworkerAsync();
      return true;
    }
    arg.worker = true;


    // WebWorker作成
    const code = `
      importScripts('https://cdn.jsdelivr.net/npm/jszip@3.5.0/dist/jszip.min.js');
      const zip = new JSZip();
      let folder = zip;

      self.addEventListener('message', async (event) => {
        const data = event.data;
        try {
          switch (data.command) {
          case 'ready':
            if (data.name) {
              folder = zip.folder(data.name);
            }
            self.postMessage({command:'go'});
            break;
          case 'file':
            folder.file(data.name, data.buffer);
            break;
          case 'generate':
            const option = data.level 
                         ? {type:'arraybuffer', compression:'DEFLATE', compressionOptions:{level:data.level}}
                         : {type:'arraybuffer'};
            const buffer = await zip.generateAsync(option, (metadata) => {
              self.postMessage({command:'progress', percent:metadata.percent});
            });
            self.postMessage({command:'progress', percent:100});
            self.postMessage({command:'complate', buffer:buffer}, [buffer]);
            break;
          default:
            self.postMessage({command:'error', message:'The command cannot be interpreted. (command:'+data.command+')'});
            break;
          }
        } catch (e) {
          //console.log(e);
          self.postMessage({command:'error', message:e.message});
        }
      });
    `;
    const workerUrl = URL.createObjectURL(new Blob([code]));
    let worker = null;
    try {
      worker = new Worker(workerUrl);
    } catch (e) {
      // Chrome + NoScript
      //console.log(e);
      noworkerAsync();
      return true;
    } finally {
      URL.revokeObjectURL(workerUrl);
    }
    worker.addEventListener('error', (event) => {
      if (arg.status == 'ready') {
        // Firefox + NoScript
        noworkerAsync();
      } else {
        complate('error', event && event.message || '');
      }
      worker.terminate();
    });
    worker.addEventListener('message', async (event) => {
      const data = event.data;
      switch (data.command) {
      case 'go':
        await downloadFilesAsync(worker);
        arg.status = 'compress';
        arg.onupdate(arg);
        worker.postMessage({command:'generate', level:arg.level});
        break;
      case 'progress':
        arg.percent = data.percent;
        arg.onupdate(arg);
        break;
      case 'complate':
        await downloadZipAsync(new Blob([data.buffer]));
        worker.terminate();
        break;
      case 'error':
      default:
        complate('error', data.message || 'The command cannot be interpreted. (command:'+data.command+')');
        worker.terminate();
        break;
     }
    });
    worker.postMessage({command:'ready', name:(arg.folder !== false ? arg.name : null)});

    return true;

    // 備考:staus: ready > download > compress > complate >> error
    // 備考:command: ready > go > file > generate > progress > complate >> error
    // 備考:complateから実際のダウンロードがブラウザ上で発生するまでに、
    //       僅かなタイムラグが発生する可能性があります。
    //       実際のダウンロードがブラウザで開始するまで、ページをクローズしないで下さい。
    // 備考:urlsのファイル名に重複がある場合、最後のファイルのみ保存します。
    //       namesを指定して明示的にファイル名を指示することで回避できます。
    // 備考:urlsにBlobを使用できます。その場合、ファイル名はnameプロパティを使用します。
    //       Blobではなく、Fileを使用することを検討して下さい。
    // 備考:ファイル名が見つからない場合、最終的にインデックスの数値を使用します。
    // 備考:データの取得に失敗した場合、空ファイルを保存します。
    //       これには、データ取得に失敗したことを明示的に示す意味合いがあります。
    // 備考:onupdate()のargは、変更不可です。変更した場合、問題が発生する可能性があります。
    // 備考:対象ページがJavaScript無効の場合、WebWorkerは動作しません。
  };


  // ショートカットキー設定
  const shortcut = 'alt+shift+d';
  hotkeys(shortcut, (event, handler) => {
    console.log('shortcut', 'start', location.hostname);
    try {
      // ↓↓↓ Add processing for each site ↓↓↓
      if (location.hostname == 'www.bugbugnow.net') {
        const urls = [...document.querySelectorAll('body img')].map(img => img.src);
        urls.push(new File(['Download list.\n\n'+urls.join('\n')], 'list.txt', {type:'text/plain'}));
        downloadFilesZipAsync({
          name: 'バグ取りの日々', //document.title.trim(),
          urls: urls, 
          //names: [...document.querySelectorAll('body img')].map((img, i) => i+'.jpg'),
          //worker: false,
          //level: 6,
          //folder: false,
          //empty: false,
          //alert: false,
          //close: 1000,
        });
      } else {
        alert('Missing download settings');
      }
      // ↑↑↑ Add processing for each site ↑↑↑
    } catch (e) {
      console.log('shortcut', 'error', e);
    }
    console.log('shortcut', 'end');
  });
  console.log('shortcut', shortcut);
})();

変更履歴

最新版・変更履歴は、GitHubを参照して下さい。

上記のコードは、動作テスト用の処理を追加しています。

動作テスト

  1. 下記のユーザスクリプトをインストールする
  2. 本ページをリロードする
    • ユーザスクリプトを読み込む
  3. [Alt+Shift+D]キーを押下する
    • 外部ドメインへのアクセス許可が必要な場合は、許可する
  4. 進歩画面を表示する
  5. 「バグ取りの日々.zip」をダウンロードする
    • 次のファイルが格納されている
      • list.txt
      • icon-min.png(下記の画像ファイル)
バグ取りの日々

既知の問題

  • FirefoxのTampermonkeyがJavaScript無効時に動作しない
    • FirefoxのJavaScript無効時、Tampermonkeyのすべてのスクリプトが動作しない
      • Tampermonkeyの内部設計の問題
  • complateでクローズした場合、ダウンロードが開始しないことがある
    • window.close()前に、alert()することで回避できます
    • setTimeout()などで、十分な時間遅延することでも回避できます
  • Greasemonkeyでarg.closeが動作しない
    • Greasemonkeyでは、タブをクローズする権限がありません

参考

コメント