Puppeteerを使ってボタンクリックとダウンロード

PuppeteerでChrome自動化で一番良く利用されるであろうシーンが「パラメータを設定して、ボタンクリックでCSVファイルをダウンロード」ではないだろうかと思います。実はこれ、RPAだと非常にシンプルなサイトならばともかく、結構複雑な仕組みのサイトの場合、とても難しい作業の1つです。

しかし、Puppeteerの場合、JavaScriptの実行やエレメント操作が可能であったり、Chromeに対して命令を送れるので、非常に難しいダウンロードであっても可能だったりします。今回はとあるサイトのCSVファイルをデスクトップにダウンロードしてみようと思います。

今回使用するライブラリ等

今回、ユーザからの入力を受け付けるpromptsというモジュールを使っています。パスワードやIDをPuppeteerのスクリプト上に直書きするのはセキュリティ上マズイので、そういったケースで使います。今回は年度と月の指定だけで、パスワードは直書きです。注意が必要です。

また、今回pkgでパッケージングすると後述の問題が発生するので、nexeでパッケージを作ったところ、ファイルサイズも小さく、またきちんとevaluate出来たので、こちらでexeを作成しました。nexe自体はインストールは非常に簡単です。

//nexeのインストール
npm i -g nexe

今回のダウンロード上の問題点

今回対象にしているサイトは以下のような問題点を抱えています。そのまま素直にファイルを取りに行こうとすると、エラーを吐いて止まってしまいます。この問題点をクリアしつつ、指定のフォルダにダウンロードする為にいくつか細工を施します。

  1. CSVファイルの年度、月を指定するテキストボックスにはonChangeでJavaScriptが入ってしまっている。
  2. 1.のJavaScriptは実行されないと適切な期間指定が出来ないので、テキストボックスに値を入れればOKというわけにはいかない
  3. headlessの場合、名前をつけて保存のダイアログは出てこないので、Chromeに場所を指定する必要がある(デフォルトはWindowsならばダウンロードフォルダ)。
  4. サイト上のパラメータ指定画面等がiframe」の中に入ってしまっていて操作が面倒
  5. 年度と月を指定するテキストボックスははじめから値が入ってしまっている
  6. ダウンロードボタンのtarget属性が_blankになってしまっていて、新しいタブで開いてしまう。このままでは、page操作が出来ないでエラーが出てしまう。
  7. Chromeは管理者権限ではなくユーザ権限でインストールするので、いつもの場所ではない

特に面倒なのが、6.の問題。ボタンをクリックすると新しいタブで開かれダウンロードが始まるのですが、そうすると定義したpageからは外れてしまう為、2つ目のタブを操作しないとダウンロード完了と共にエラーを吐く。また、この時指定のフォルダではなくダウンロードフォルダにダウンロードされてしまいます。

この時のエラーは「Error: EPERM: operation not permitted」といったもので、chromeが不正終了した形になります。

ソースコード

冒頭部分

index.jsに全て記述します。冒頭の部分にはモジュールの読み込みと、デスクトップのパスを取得するコードを用意しておきます。

const puppeteer = require('puppeteer-core');
const prompts = require("prompts");
var fs = require('fs');
const path = require("path");

//デスクトップのパスを取得
var dir_home = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"];
var dir_desktop = require("path").join(dir_home, "Desktop"); 

//Chromeのパスを取得(ユーザ権限インストール時)
var chromepath = path.join(dir_home, "AppData\\Local\\Google\\Chrome\\Application\\chrome.exe");

//プロンプト表示
getprompt();
  • グローバル変数でデスクトップのパスを取得しておきます。
  • つづけて、getprompt()を実行してユーザの入力を受付待ちします。
  • chromeはいつもの「C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe」ではなく、「C:\\Users\\ユーザー名\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe」となるため、ユーザ毎のパスを取得して、chromepathに格納する

プロンプト入力受付部分

//プロンプトを表示
async function getprompt(){
  // 入力を待ち受ける内容
  let question = [
    {
      type: "number",
      name: "nendo",
      message: "年度を指定してください(例:2020)"
    },
    {
      type: 'number',
      name: "month",
      message: "月を指定してください(例:02)"
    }
  ];

  // promptsの起動
  let response =  await prompts(question);

  //回答を取得
  var nendo = String(response.nendo);
  var month = String(response.month);

  //main関数に引き渡す
  main(nendo,month)

}
  • promptsを使って、2つの質問を受け付けるようにします。
  • nendoとmonthの2つを質問し、数値で入力をしてもらいます。
  • 取得した数値を引数にmain()を実行します。

図:プロンプトの入力画面

Puppeteer部分

//ブラウザ操作メイン関数
async function main(nendo,month) {
  const browser = await puppeteer.launch({
    headless: false,
    executablePath: chromepath,
    ignoreDefaultArgs: ["--guest",'--disable-extensions','--start-fullscreen','--incognito',],
    slowMo:200,
  });

  //ブラウザのダウンロード先をすべて統一する
  await browser.on('targetcreated', async () =>{
    const pageList = await browser.pages();
    pageList.forEach((page) => {
      page._client.send('Page.setDownloadBehavior', {
        behavior: 'allow',
        downloadPath: dir_desktop,
      });
    });
  });

  //pageを定義
  const page = await browser.newPage()
  const navigationPromise = page.waitForNavigation()

  //ログインページを開く
  await page.goto('ログインするページのURLを指定する')
  await page.setViewport({ width: 1536, height: 714 })
  await navigationPromise

  //IDとパスワードを入力してログイン
  await page.waitForSelector('tbody .t_lt:nth-child(1) > #textfield')
  await page.click('tbody .t_lt:nth-child(1) > #textfield')
  await page.type('tbody .t_lt:nth-child(1) > #textfield', 'ログインIDを入力')
  await page.type('tbody .t_lt:nth-child(2) > #textfield', 'パスワードを入力')
  await page.waitForSelector('tbody #login_loginCompletedRedirect')
  await page.click('tbody #login_loginCompletedRedirect')
  await navigationPromise

  //フレーム内のページを直接表示する
  await Promise.all([
    page.goto('フレーム内のページURLを指定する'),
    page.waitForNavigation(),
  ]);

  //年度と月の指定
  await page.focus('.duty_n #year')
  await page.$eval('.duty_n #year', el => el.value = "")
  await page.keyboard.type(nendo)
  await page.focus('#shainSelectCompleted_txtGroup_Begin')
  await sleep(3000);

  await page.focus('.duty_n #month')
  await page.$eval('.duty_n #month', el => el.value = "")
  await page.keyboard.type(month)
  await page.focus('#shainSelectCompleted_txtGroup_Begin')
  await sleep(3000);

  //部門を選択してCSVダウンロードを実行
  await page.waitForSelector('.duty_n #gyoumuType1')
  await page.click('.duty_n #gyoumuType1')
  await page.waitForSelector('#soshikiList #area-bumon-list')
  await page.click('#soshikiList #area-bumon-list')
  
  //部門を選択する
  await page.waitForSelector('table #searchBumon')
  await page.click('table #searchBumon')
  
  //パラメータでデータ検索
  await page.select('table #searchBumon', '0001')
  await page.waitForSelector('body > table > tbody > tr > td')
  await page.click('body > table > tbody > tr > td')
  await page.waitForSelector('td #searchButton')
  await page.click('td #searchButton')
  await navigationPromise
  await page.waitForSelector('tr > td > div > #shainSelectCompleted > .btn_area')
  await page.click('tr > td > div > #shainSelectCompleted > .btn_area')

  //formのtarget属性を変更してしまう(blankで開かせない)
  await page.$eval('formタグのIDを指定する', el => el.target = '')

  //ダウンロードボタンをクリックする
  await page.waitForSelector('.duty_n #getuji_csv_kinmu_daily_csv_0')
  await page.click('.duty_n #getuji_csv_kinmu_daily_csv_0')

  //ダウンロードが完了するまでウェイト
  let filename = await ((async () => {
    let filename;
    while ( ! filename || filename.endsWith('.crdownload')) {
        filename = fs.readdirSync(dir_desktop)[0];
        await sleep(10000);
    }
    return filename
  })());

  //終了メッセージを表示
  const script = `window.alert('処理が完了しました')`;
  await page.addScriptTag({ content: script });
  
  //ブラウザを閉じる
  await browser.close()

}

//スリープ用関数
function sleep(milliSeconds) {  
  return new Promise((resolve, reject) => {
    setTimeout(resolve, milliSeconds);
  });
}
  • 今回はWindowsで実行してるので、executablePathはChromeがインストールされているパスを指定
  • Page.setDownloadBehaviorにてChromeに対してダウンロード先フォルダを指定しています。今回はデスクトップを指定。
  • ログインページでIDとPASSでログイン後、再度特定のページ(iframe内のURL)を直接表示するようにpage.gotoしています。
  • 年度と月はonChange属性が入ってる上に、maxlengthが指定されている。一度クリアしてからでないと入力が出来ない。
  • 上記のクリアをするために、page.$evalにてinputの中身を空にしています。
  • 続けてpage.keyboard.typeにて引数で受け取った値を入力
  • onChangeを働かせる為に適当なテキストボックスをfocusして、sleep実行。
  • 同様の作業を月の入力欄に対しても実行する
  • サイト上のオプションの選択、ドロップダウン項目の選択、検索のクリックをすると、条件に合致したデータが表示される。
  • 表示されたら、CSVダウンロードボタンをクリックするが、そのままでは_blankで開いてしまうので、対象のformのtarget属性をpage.$evalにて空に書き換えておく(これで、このタブの中でダウンロードが実行されるようになる)
  • ダウンロード中、拡張子が.crdownloadのファイルがある場合には、ループで10秒ウェイトを実行するようにしてある
  • 完了したら、browser.close()で処理を閉じる
  • 終了したよってメッセージがほしいならば、VBSのファイルでも用意して、呼び出して上げると良い。
  • Node.jsなので、特定の場所にフォルダの有無を確認してフォルダを作成して指定するであったり、ダウンロードしたファイルを特定のフォルダに移動するといった後処理を付け加えると尚、良いかもしれない。
  • 最後にawait page.addScriptTag({ content: script });を実行することで、サイトにJavaScriptを埋め込み実行。この時scriptにはwindow.alertをつけていますが、これで完了メッセージを出すようにすると、ユーザに通知が出来るのでGoodです。

ファイル名を変更して指定のフォルダへ移動

Puppeteerで指定のフォルダにそのままダウンロードは既に上記で示したコードで実現出来ています。しかし、実務の世界ではそれでオシマイではありません。所定の場所に例えば年度月でフォルダを作成し、その中へファイルを移動、ファイル名も年月日でわかりやすい名前をつけるといった作業が地味に時間を食います。ここら辺はNode.jsでの作業になります。

フォルダを作成

自分の場合、ファイルのダウンロード⇒リネーム⇒ファイルを移動をする場合、デスクトップに一時フォルダを作り、同時に所定の場所に年度月でフォルダを作成しています。しかし、フォルダ作成周りは、例えば既に同名のフォルダがあった場合であったり、対象のパスが存在しない(Box Driveなどを使っていて、Boxにログインしてなかった等)の場合のエラー処理等が非常に面倒です。

また、Box Driveの場合ファイルのパスにログインユーザ名が入るUsersディレクトリ以下にマウントされるので、人によってパスが異なるという問題もあったりします。これら面倒な問題を回避しつつ作成しなければなりません。以下に必要な部分だけのコードを掲示します。今回は、make-dirモジュールを使うことにします。

//モジュールの読み込み
const path = require("path");
const makeDir = require("make-dir");
const os = require('os');

//デスクトップのパスを取得
var dir_home = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"];

//一時フォルダ
var deskpath = require("path").join(dir_home, "Desktop");
var dir_desktop = deskpath + "\\tmpman\\"; 
var subfolder = "";

//移動先フォルダ(Box Driveの中)
var userInfo = os.userInfo();
var username = userInfo.username;
var targetPath = 'c:\\Users\\' + username + '\\Box\\会社名\\総務\\経理\\請求書\\';

//プロンプト表示
getprompt();

//プロンプトを表示
async function getprompt(){
  // 入力を待ち受ける内容
  let question = [
    {
      type: "text",
      name: "id",
      message: "ログインIDを入力してください(例:tomato.taro@hogehoge.jp)"
    },
    {
      type: 'password',
      name: "pass",
      message: "パスワードを入力してください"
    },
    {
      type: 'text',
      name: "startday",
      message: "開始日の設定(例:2020/03/01)"
    },
    {
      type: 'text',
      name: "endday",
      message: "終了日の設定(例:2020/04/30)"
    },	
  ];

  // promptsの起動
  let response =  await prompts(question);

  //回答を取得
  var userid = String(response.id);
  var pw = String(response.pass);
  var startday = String(response.startday);
  var endday = String(response.endday);

  //サブフォルダ名を定義(年月で命名))
  subfolder = endday.substr( 0, 4 ) + endday.substr( 5, 2 );

  //一時フォルダを作成する
  await makeDir(dir_desktop).then(path => {
    makeDir(targetPath + subfolder).then(path => {
      //main関数に引き渡す
      main(userid,pw,startday,endday);
   });	
 });	
}
  • dir_desktop変数には、デスクトップに作成したtmpmanというフォルダを指定しています。これがダウンロード先の一時フォルダになります。
  • osモジュールからログインユーザ名を取得し、Box Driveの所定のフォルダをtargetPath変数に指定します。ここに年度月という名前でサブフォルダを作成することになります。
  • promptにて例えば開始日付を取得しておく。ユーザの入力した日付を元に年度月の値をsubstrにて取り出し、subfolder変数に格納する
  • make-dirにて一時フォルダを作成した後に、さらにmake-dirにてtargetPath指定の直下にsubfolderで指定したフォルダを作成する
  • プログラム終了時にfs.rmdirSync(dir_desktop);を実行すれば、一時フォルダを削除できます。非同期実行なので記述する場所に注意

ファイルの移動(名前の変更も込み)

Node.jsでのファイルの移動と名前変更は同じメソッドで出来るので、一発で実現が可能です。一時フォルダにダウンロードしてリネームと移動をすると、一時ファイルからファイルが消えてくれるので、次のダウンロードにもつなげることが出来ます。

//ブラウザ操作メイン関数
async function main(userid,pw,startday,endday) {

・・・中略・・・

  //ダウンロードが完了するまでウェイト
  let filename = await ((async () => {
    let filename;
    while ( ! filename || filename.endsWith('.crdownload')) {
        //一時フォルダの1個目のファイルを取得
        filename = fs.readdirSync(dir_desktop)[0];
        if(filename == undefined){
          //何もしない
          await sleep(2000);
        }else{
          //ファイルの拡張子がcrdownloadの場合スルーする
          var ext = filename.slice( -10 );

          if(ext == "crdownload"){
            //何もしない
            await sleep(2000);
          }else{
            //ファイルのフルパスを構築する
            let fullname = dir_desktop + filename;
            
            //ファイルのリネームと移動
            fs.rename(fullname, downPath + subfolder + "\\" + filename, (err) => {
              if (err) throw err;
              console.log('ファイルを移動しました');
            });

            await sleep(2000);
          }
        }
    }
    return filename
  })());

・・・中略・・・

}

//スリープ用関数
function sleep(milliSeconds) {  
  return new Promise((resolve, reject) => {
    setTimeout(resolve, milliSeconds);
  });
}
  • fs.renameにて、引数に現在のファイルのフルパス、移動先のファイルのフルパス(ここで新しい名前を指定)で、リネームと移動が同時に出来ます。
  • 複数個のファイルの処理の場合はループの作り方とフルパスのファイル名の取得の仕方を変える必要がありますが、一個ずつであればこの方法がベターです。
  • 冗長気味ですがダウンロード中ファイルの移動がないように拡張子がcrdowloadのファイルの場合はスルーするようにしています。

Box Driveにファイルを移動する場合

Box Driveの所定のフォルダ内に対して、Node.js上でフォルダを作ることは可能なのですが、問題は上記のコードでダウンロードしたファイルを移動させようとすると、「npm ERR! EXDEV: cross-device link not permitted, rename」と出て、ファイルが移動出来ません。そこで、Box Driveの所定のフォルダに対して、上記と同じようなことを実現するには、リネームではなく、ファイルのコピーを行い、その後ダウンロードしたファイルを削除するという手順を踏むと実現が可能です。

また、Box Driveの場合、マウントフォルダが各ユーザフォルダであるため、osモジュールを使ってログインユーザ名を取得して、所定の格納先フォルダまでのパスを生成しておく必要があるので注意が必要です。

//Box Driveの所定のパスを確定する
var os = require('os');
var userInfo = os.userInfo();
var username = userInfo.username;
var subfolder = 'c:\\Users\\' + username + '\\Box\\請求書ダウンロード\\';

・・・中略・・・

//ダウンロードが完了するまでウェイト
var filename = await ((async () => {
  var filename;
  while ( ! filename || filename.endsWith('.crdownload')) {
      filename = fs.readdirSync(dir_desktop)[0];

      if(filename == undefined){
        //何もしない
        await sleep(2000);
      }else{
        //ファイルの拡張子がcrdownloadの場合スルーする
        console.log(filename)
        var ext = filename.slice( -10 );

        if(ext == "crdownload"){
            //何もしない
            await sleep(2000);
        }else{
            //ファイルのフルパスを構築する
            let fullname = dir_desktop + filename;

            //ファイル名を新たに構築
            var newname = choiceday + "請求書.pdf";

            //完成品フォルダに移動
            fs.copyFile(fullname, subfolder + choiceday + "\\" + newname, (err) => {
                if (err) throw err;
                console.log('ファイルを移動しました');

                //一時フォルダ内のファイルを削除
                fs.unlinkSync(fullname);

                //tmpmanフォルダを削除
                fs.rmdirSync(dir_desktop);

                //コマンドを組み立てて実行
                var child = spawnSync('explorer.exe', [subfolder + choiceday] );
            });

            await sleep(2000);
        }
      }
  }
  return filename
})());
  • subfolderがBox Driveの所定の指定フォルダになります。ここに格納する
  • renameではなくcopyFileでファイルを指定の場所にコピー。その際にnewnameで指定したファイル名にする。
  • 完了後、ダウンロード元のファイルを削除しないと一時フォルダが削除できないので注意です。

input要素に変数の値をセットする

テキストボックスやセレクトボックスなどの選択要素に対して、page.typeにて1文字ずつ入力するテクニックはありますが、遅いのであまり使いたくない所。そこで直接、input要素などに対してvalueに値をセットしたいことがあります。その値も変数に格納されてる値をセットしたいケースが結構あるのですが

page.$eval('要素名', el => el.value = 変数) では、エラーとなり値はセット出来ません。以下のような形で、変数に入ってる値を要素にセットしましょう。同様のコードでel.targetであればtarget属性値を変数で変更させることが可能です。

var tekkotsu = "鉄骨";

await page.$eval('要素名', (el, tekkotsu) => {
   return el.value = tekkotsu;
}, tekkotsu);

また、selectボックスの場合は、各option値に通常はvalueがセットされてるので、それらを指定すれば選択させる事が可能です。

await page.select('select[name="tekkotsuman"]', '10');

上記の例だと、option値が10の選択肢を選択させる動作になります。

単一実行ファイルを作成する

Node.js 18よりSingle executable applicationsという機能が装備され、標準で単独実行ファイルが作成できるようになりました。結果pkgはプロジェクト終了となっています。よって、以下のエントリーの単一実行ファイルを作成するを参考に、Node18以降はexeファイルを作成することが可能です。

Puppeteerを使ってX(旧Twitter)へのポストを自動化する

pkgの問題点

現在のコード、Node.js上で動かす分には問題なく動作します。しかし、pkgでパッケージ化すると、page.$evalの部分で「Passed function is not well-serializable」というエラーが出ます。どうやら、pkgでevaluateを利用するとこのような現象が起きるようで、キー入力の部分については、以下のように修正する事で入力が可能になりました。

  //年度と月の指定
  await page.focus('.duty_n #year')
  //await page.$eval('.duty_n #year', el => el.value = "")
  await page.keyboard.down('Control');
  await page.keyboard.press('A');
  await page.keyboard.up('Control');
  await page.keyboard.press('Backspace');
  await page.keyboard.type(nendo)
  await page.focus('#shainSelectCompleted_txtGroup_Begin')
  await sleep(3000);

  await page.focus('.duty_n #month')
  //await page.$eval('.duty_n #month', el => el.value = "")
  await page.keyboard.down('Control');
  await page.keyboard.press('A');
  await page.keyboard.up('Control');
  await page.keyboard.press('Backspace');
  await page.keyboard.type(month)
  await page.focus('#shainSelectCompleted_txtGroup_Begin')
  await sleep(3000);

問題は、後半の「await page.$eval('formタグのIDを指定する', el => el.target = '')」で、対象のelementのtarget属性を変更する部分。ここがどうしても引っかかってしまう。そこで、pkgではなくnexeにパッケージャを変更してexeを作ってみました。

nexeに変更した所、今回のようなElementの書き換えでevaluateしても問題が発生することなく、無事にcsvダウンロードが可能になりました。パッケージングのコマンドも簡単です。nexeのほうがpkgよりもファイルサイズが10MBほど小さいのも良いですね。無理してpkgにこだわる理由はありません。ただし、Keytarのようなネイティブモジュールは同梱されないようで、node_modulesフォルダがある場所にexeを置くと使えますが、exe単体だと参照できず、エラーになります。

nexe index.js

nexeの問題点

Node.js v12.16.1nexe(v3.3.2)をインストールして使うと、以下のようなエラーが出てしまい、exeのビルドが出来ませんでした。バグですね。12.14.1の場合には問題なくビルドができましたので、最新版ではなくバージョンダウンして利用すると良いでしょう。

internal/assert.js:14
    throw new ERR_INTERNAL_ASSERTION(message);
    ^

Error [ERR_INTERNAL_ASSERTION]: This is caused by either a bug in Node.js or incorrect usage of Node.js internals.
Please open an issue with this stack trace at https://github.com/nodejs/node/issues

    at assert (internal/assert.js:14:11)
    at prepareMainThreadExecution (internal/bootstrap/pre_execution.js:129:3)
    at internal/main/run_main_module.js:7:1 {
  code: 'ERR_INTERNAL_ASSERTION'
}

また、何かの拍子でnexeを実行してもそんなコマンド無いって言われる事があります。その場合、Windowsの場合は以下のパスを環境変数に追加すると良いです。

  1. タスクバーの検索窓より「システムの詳細設定」を検索実行
  2. 詳細設定タブの中にある環境変数をクリック
  3. ユーザ環境変数の中にあるPathを選択して編集をクリック
  4. 新規をクリックする
  5. 以下のパスを追加してあげてOKをする
C:\Users\ユーザ名\AppData\Roaming\npm

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)