LoginSignup
1
0

More than 5 years have passed since last update.

年末年始連休明けでも続ける自習の記録8: Electronでダウンローダーを作る

Posted at

はじめに

連休を機に考える、怠惰な私の自習戦略にて立てた計画に沿った自習の記録です。
前回:年末年始連休明けでも続ける自習の記録7: Node.js+Electron開発環境準備

概要

最低限のUIを持ったダウンローダーを作りました。
このシリーズ最初の自習戦略で語っていますが、とにかく動くものをアウトプットすることを優先しているのでクオリティは低いです。

レンダラープロセス

UIは二画面です。初期の画面から必要事項を入力し、Generate linksボタンを押すことで、ダウンロードするURLの一覧画面が表示されます。

初期ウィンドウ

index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8" />
  <title>simple-downloader</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"
  crossorigin="anonymous" />
</head>

<body>
  <script type="text/javascript" src="./renderer.js"></script>
  <div id="content_fixed">
    <table class="table table-striped">
      <tr>
        <td style="text-align: right; vertical-align: middle">URL:</td>
        <td colspan="3">
          <input type="text" class="form-control" id="inputUrl" value="">
        </td>
      </tr>
      <tr>
        <td style="text-align: right; vertical-align: middle">
          Query Strings:
        </td>
        <td colspan="3">
          <input type="text" class="form-control" id="inputQueryStrings" value="" />
        </td>
      </tr>
      <tr>
        <td style="text-align: right; vertical-align: middle">Attribute:</td>
        <td colspan="3">
          <input type="text" class="form-control" id="inputAttribute" value="" />
        </td>
      </tr>
      <tr>
        <td style="text-align: right; vertical-align: middle">Regex:</td>
        <td colspan="3">
          <input type="text" class="form-control" id="inputRegex" value="" />
        </td>
      </tr>
      <tr>
        <td style="text-align: right; vertical-align: middle">Format:</td>
        <td colspan="1">
          <input type="text" class="form-control" id="inputFormatPrefix" value="" />
        </td>
        <td style="text-align: center; vertical-align: middle">
          + group[1] +
        </td>
        <td colspan="1">
          <input type="text" class="form-control" id="inputFormatSuffix" value="" />
        </td>
      </tr>
      <tr>
        <td colspan="3"></td>
        <td style="text-align: center;">
          <button id="generate_link" class="btn btn-default btn-primary">
            Generate links
          </button>
        </td>
      </tr>
    </table>
  </div>
</body>

</html>
renderer.js
const {
  ipcRenderer,
} = require('electron');

window.onload = (event) => {
  document.getElementById('generate_link').addEventListener('click', generateLinks);
};

function generateLinks() {
  const inputUrl = document.getElementById('inputUrl').value;
  const inputQueryStrings = document.getElementById('inputQueryStrings').value;
  const inputAttribute = document.getElementById('inputAttribute').value;
  const inputRegex = document.getElementById('inputRegex').value;
  const formatPrefix = document.getElementById('inputFormatPrefix').value;
  const formatSuffix = document.getElementById('inputFormatSuffix').value;

  ipcRenderer.on('reply', (event, arg) => {
    console.log(arg);
  });

  ipcRenderer.send('generateLink', {
    inputUrl,
    inputQueryStrings,
    inputAttribute,
    inputRegex,
    formatPrefix,
    formatSuffix,
  });
}

htmlではrequireが使えなかった

たしか公式チュートリアルだかQuick Startだかでは下記のようになっていたと思います。

<script>require('./renderer.js')</script>

しかしこれ、なんかうまく動きませんでした。結局古風なこちらの書き方に落ち着きます。

<script type="text/javascript" src="./renderer.js"></script>

プロセス間通信

プロセス間通信にはipcというものが使えるとわかりました。便利ですが共通の文字列を示し合わせておかないといけないので普通ですね。
レンダラープロセスからメインプロセスでしか使えないモジュールを使えるようになるremoteというモジュールがあることもわかりましたが、利点が想像できなかったので使いませんでした。

ダウンロード一覧とダウンロード画面

Downloadボタンを押して保存場所を選択すると、ダウンロードを開始します。

download.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8" />
  <title>Downlad list</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"
    crossorigin="anonymous" />
</head>

<body>
  <header class="sticky-top">
  </header>
  <main class="mb-6">
    <div id="content_fixed">
      <table class="table table-striped mb-5" id="download_list">
        <tbody></tbody>
      </table>
    </div>
  </main>

  <footer class="fixed-bottom">
    <div class="m-1">
      <button id="execute_button" class="btn btn-default btn-primary">Download</button>
      <div id="resultText"></div>
    </div>
  </footer>

  <script type="text/javascript" src="./downloader.js"></script>
</body>

</html>
downloader.js
const {
  ipcRenderer,
} = require('electron');

let downloadList;
let executeButton;

window.onload = (event) => {
  ipcRenderer.on('downloadList', (event, arg) => {
    downloadList = arg;
    const element = document.querySelector('#download_list tbody');
    element.appendChild(generateDownloadListFragment(downloadList));
  });

  ipcRenderer.send('downloadWindowLoadCompleted');

  ipcRenderer.on('downloadCompleted', finish);
  ipcRenderer.on('downloadError', finish);

  executeButton = document.getElementById('execute_button');
  executeButton.addEventListener('click', download);
};

generateDownloadListFragment = ((listArray) => {
  const fragment = document.createDocumentFragment();
  listArray.forEach((element) => {
    const tr = document.createElement('tr');
    tr.innerHTML = `<td><a href="${element}">${element}</a></td>`;
    fragment.append(tr);
  });
  return fragment;
});

function download() {
  executeButton.removeEventListener('click', download);
  ipcRenderer.send('startDownload', downloadList);

  executeButton.innerText = 'Cancel';
  executeButton.addEventListener('click', cancel);
}

function cancel() {
  executeButton.removeEventListener('click', cancel);
  ipcRenderer.send('cancelDownload', downloadList);

  executeButton.innerText = 'Download';
  executeButton.addEventListener('click', download);
}

function finish(event, arg) {
  document.getElementById('resultText').innerText = arg;
  executeButton.removeEventListener('click', cancel);
  executeButton.removeEventListener('click', download);

  executeButton.innerText = 'Download';
  executeButton.addEventListener('click', download);
}

フラグメント

リストは動的に生成するため、表の切れ端を作ってくっつけた。ゴリ押し感すごいが、これが素のHTMLとJavascriptでやる感じなんでしょうかね。
ステートによるボタンコントロールもそうだが、テンプレートエンジンではもっと楽できることを期待したいです。

メインプロセス

エントリポイント

main.js
const {
  app,
  BrowserWindow,
  ipcMain,
  dialog,
} = require('electron');
const fs = require('fs');
const request = require('request');
var path = require('path');
const DownloadLinkGenerator = require('./DownloadLinkGenerator');

let mainWindow;
let childWindow;
const canselSignal = false;

function createMainWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    show: false,
  });

  mainWindow.once('ready-to-show', () => {
    mainWindow.show();
  });

  mainWindow.loadFile('index.html');
  mainWindow.on('closed', function() {
    mainWindow = null;
  });
}

function createChildWindow() {
  childWindow = new BrowserWindow({
    width: 800,
    height: 600,
    show: false,
    parent: mainWindow,
  });

  childWindow.once('ready-to-show', () => {
    childWindow.show();
  });
  childWindow.webContents.openDevTools();
  childWindow.loadFile('download.html');
  childWindow.on('closed', function() {
    childWindow = null;
  });
}

app.on('ready', createMainWindow);

app.on('window-all-closed', function() {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', function() {
  if (mainWindow === null) {
    createMainWindow();
  }
});

ipcMain.on('generateLink', ((event, arg) => {
  const inputs = new DownloadLinkGenerator(
      arg.inputUrl,
      arg.inputQueryStrings,
      arg.inputAttribute,
      arg.inputRegex,
      arg.formatPrefix,
      arg.formatSuffix
  );

  const isValid = inputs.validate();

  if (isValid === true) {
    const result = inputs.generateLinks((links) => {
      if (result === true) {
        ipcMain.on('downloadWindowLoadCompleted', function(event, arg) {
          event.sender.send('downloadList', links);
        });
        createChildWindow();
      }
    });
  } else {
    event.sender.send('replyError', isValid);
  }
}));

ipcMain.on('startDownload', ((event, arg) => {
  dialog.showOpenDialog(childWindow, {
    properties: ['openDirectory'],
  }, (filepaths, boolmarks) => {
    const folderPath = filepaths[0];

    arg.reduce((prev, current, index, array) => {
      return prev.then((prevResult) => {
        console.log('prevResult: '+ prevResult);
        return downloadPromise(current, folderPath);
      });
    }, Promise.resolve()).then((result) => {
      console.log('Result: ' + result);
      event.sender.send('downloadCompleted', 'Download Completed');
    }).catch((message) => {
      console.log('Result: ' + message);
      event.sender.send('downloadError', message);
    });
  });
}));

function downloadPromise(url, saveFolderPath) {
  console.log('url: ' + url + ', saveFolderPath: ' + saveFolderPath);
  return new Promise((resolve, reject) => {
    if (canselSignal === true) {
      reject('Canceled');
    }
    const filename = path.basename(url);
    try {
      request.get(url).on('complete', ((response, body) => {
        console.log(url);
      })).pipe(fs.createWriteStream(path.join(saveFolderPath, filename))).on('close', ()=> {
        console.log(`Closed: ${url}`);
        resolve();
      });
    } catch (e) {
      reject(e);
    }
  });
}

Promise

WindowやDialogの作り方は簡単すぎて解説する気も起きませんでした。その一方で最も苦戦したのがPromiseです。
レンダラープロセスからの'startDownload'のコールバックではダウンロード処理を1つずつ順次実行しています。
コード書いて2週間以上間が開いたせいか、半分くらい忘れてしまいました。
arg.reduce()というものはC#では見かけないものだったのでかなり戸惑いました。
reduce()内のreturnされた値が次回のprevにあたる部分にきます。Promise.then()Promiseオブジェクトを返すため、配列の場合はreduce()を使うことでPromiseチェーンが作れます。
downloadPromise(url, saveFolderPath)では1つのダウンロード処理を行います。非同期のコールバック関数を持つときは中でPromiseオブジェクトを生成してreturnするのがコツです。コールバック関数が成功したタイミングでresolve()することで、returnしたPromiseオブジェクトのthen()が実行されます。reject()するとcatch()にいきます。この機構面白いので気に入りました。C#にも欲しいです。

ダウンロードリンクを生成するクラス

DownloadLinkGenerator.js
const request = require('request');
const {
  JSDOM,
} = require('jsdom');

module.exports = class DownloadLinkGenerator {
  constructor(url, querystring, attribute, regex, formatPrefix, formatSuffix) {
    Object.assign(this, {
      url,
      querystring,
      attribute,
      regex,
      formatPrefix,
      formatSuffix,
    });
  }

  validate() {
    if (this.url === '') {
      return 'URL is Empty';
    }
    if (this.querystring === '') {
      return 'Query Strings is Empty';
    }
    if (this.attribute === '') {
      return 'Attribute is Empty';
    }
    if (this.regex === '') {
      return 'Rexex is Empty';
    }
    return true;
  }
  generateLinks(callback) {
    if (this.validate() !== true) {
      return null;
    } else {
      request(this.url, (e, response, body) => {
        if (e) {
          console.error(e);
        }

        try {
          const dom = new JSDOM(body);
          console.log(dom);
          const nodeList = dom.window.document.querySelectorAll(this.querystring);
          console.log(nodeList);
          const extractedAttributeValues = [];

          for (var node of nodeList) {
            extractedAttributeValues.push(node.getAttribute(this.attribute));
            console.log(node.getAttribute(this.attribute));
          }

          const regexedValues = [];
          const ex = new RegExp(this.regex, 'u');
          extractedAttributeValues.forEach((value) => {
            const group = value.match(ex);
            if (group !== undefined && group !== null) {
              console.log(group);
              if (group[1] !== undefined) {
                regexedValues.push(group[1]);
              } else if (group[0] !== undefined) {
                regexedValues.push(group[0]);
              }
            } else {
              console.log('not matched');
            }
          });

          const downloadList = [];
          regexedValues.forEach((value) => {
            downloadList.push(`${ this.formatPrefix }${ value }${ this.formatSuffix }`);
            console.log(`${ this.formatPrefix }${ value }${ this.formatSuffix }`);
          });

          callback(downloadList);
        } catch (e) {
          console.error(e);
        }
      });
    }
    return true;
  }
};

おわりに

やはり連休明けで仕事が始まると、集中して活動できなくなりますね。
当初の目的は果たせたので、このシリーズはここで終了します。
今後も単独の記事で間が開くと思いますが学習は続けていきます。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0