Puppeteerでテーブルデータを取得する

PuppeteerでChrome自動操縦によるRPAアプリケーションを現在、現場にテスト導入中。これまでの課題は4つのウェブサービスにログインして、パラメータを指定し、請求書データをダウンロードするものでした。今の所バッチリ動いています。(大塚、日経、勤怠、ゼロックスはクリア)

さて、5つ目の課題がハードルが高い。ヤマトビジネスメンバーズの請求書ダウンロードです。このサイトは検索が出来ません。しかし、過去の請求書が数ヶ月分ほど列挙されています。それらを選択させて上げる必要があるのですが、ラジオボタンにはIDが振られており、テーブルデータをスクレイピングして、対象の年月を特定して、ラジオボタンのIDを特定してあげる必要があります。

今回はこのテーブルデータをスクレイピングして、自分が欲しい年月の請求書データをダウンロードに挑戦してみたいと思います。

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

今回はスクレイピングさせる為の補助として、cheerioと呼ばれるNode.js上でjQueryのようにDOM操作をする事のできるモジュールを入れています。

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

今回対象としてるウェブサービスでは、以下のような問題点があるため、そのままクリックでダウンロードですと、最新のファイルがダウンロードされてしまいます。

  1. 年度月でフィルタして特定する事が出来ない。
  2. 今回のコードではお客様コードの切り替えは考慮していないので、複数コードを持つ場合には、お客様コードの選択のコードの追加が必要です。
  3. 請求書ページまでは、セッションが必要になってるので、いきなりそのページには飛べないので、クリックさせて遷移が必要。
  4. 一番の肝は対象の年月のデータをラジオボタンで特定出来ない事。Tableをスクレイピング&解析して、請求年月日から推察、対象のラジオボタンをチェックしてあげる必要があります。これが一番のハードル

図:日付を比較し、対象のラジオボタンをクリックさせる

ソースコード

冒頭部分

//使用するモジュール
const puppeteer = require('puppeteer-core');
const prompts = require("prompts");
var fs = require('fs');
const path = require("path");
var shell = require('child_process').exec;
var spawnSync = require('child_process').spawnSync;
const makeDir = require("make-dir");
var os = require('os');
var cheerio = require('cheerio');

//デスクトップのパスを取得
var dir_home = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"];
var deskpath = require("path").join(dir_home, "Desktop");
var dir_desktop = deskpath + "\\tmpman\\";
var userInfo = os.userInfo();
var username = userInfo.username;
var subfolder = 'c:\\Users\\' + username + '\\Box\\請求書ダウンロード\\';
var nendoman = "";

//ダウンロードする日付を組み立てる
var choiceday;

//オープンするURL
var url = "https://bmypage.kuronekoyamato.co.jp/bmypage/servlet/jp.co.kuronekoyamato.wur.hmp.servlet.user.HMPLGI0010JspServlet";

//Chromeのパスを取得(ユーザ権限インストール時)
const userHome = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"];
var kiteipath = "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe";
var temppath = path.join(userHome, "AppData\\Local\\Google\\Chrome\\Application\\chrome.exe");

//chrome場所判定
if(fs.existsSync(kiteipath)){
  var chromepath = kiteipath
  console.log("プログラムフォルダにChromeみつかったよ");
}else{
  if(fs.existsSync(temppath)){
    var chromepath = temppath;
    console.log("ユーザディレクトリにChrome見つかったよ");
  }else{
    console.log("chromeのインストールが必要です。");
    //IEを起動してChromeのインストールを促す   
    shell('start "" "iexplore" "https://www.google.co.jp/chrome/"')
    return;
  }
}

//プロンプト表示
getprompt();
  • 今回はログイン画面から順番にたどっていく必要があるので、請求書ページにダイレクトに飛びません。
  • Box Driveに直接保存する為、subfolderはユーザIDと組み合わせてパスを生成しています。
  • グローバル変数でデスクトップのパスを取得しておきます。
  • つづけて、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: "text",
        name: "id",
        message: "ログインIDを入力してください(例:090xxxxxxxx)"
      },
      {
        type: 'password',
        name: "pass",
        message: "パスワードを入力してください"
      },
      {
        type: "number",
        name: "nendo",
        message: "年度を指定してください(例:2020)"
      },
      {
        type: 'text',
        name: "month",
        message: "月を指定してください(例:02)"
      }
    ];

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

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

    //ダウンロード日付を構築する
    choiceday= String(nendo) + String(month);

    //サブフォルダ名を定義(年月で命名))
    nendoman = subfolder + choiceday;

    //一時フォルダを作成する
    await makeDir(dir_desktop).then(path => {
     makeDir(nendoman).then(path => {
        //main関数に引き渡す
        main(userid,pw,nendo,month);
     });
    });
}
  • promptsを使って、4つの質問を受け付けるようにします。
  • useridとpassword、指定の年度月の4つを質問し、それぞれの形式で受け付けます。passwordを指定しておくと****と隠した状態で表示されるようになります。
  • 取得した数値を引数にmain()を実行します。
  • この段階で完成品フォルダをmakeDirにて作成してしまいます。
  • promptsのtypeがnumberは数値入力なのですが、バグで5桁以上入れようとすると(特に0の連続)入力がクリアされてしまうので、今回はtextにしています。

Puppeteer部分

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

    //ブラウザのダウンロード先をすべて統一する
    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: 1300, height: 900 })
    await navigationPromise

    //IDとパスワードを入力してログイン
    await page.waitForSelector('form > .lyt-form-customer-code > dl > .nav-login-btn > a')
    await page.type('.lyt-form-customer-code #code1', userid)
    await page.type('form #password', pw)
    await page.type('.lyt-form-customer-code #quickLoginCheck', '1')
    await page.click('form > .lyt-form-customer-code > dl > .nav-login-btn > a')
    await navigationPromise

    //Web請求書ページへ移動する
    await page.waitForSelector('.list-service-container > .list-service > li:nth-child(2) > a > span')
    await page.click('.list-service-container > .list-service > li:nth-child(2) > a > span')
    await navigationPromise
    await page.waitForSelector('tbody > tr:nth-child(1) > .btn > a > .imgover')
    await page.click('tbody > tr:nth-child(1) > .btn > a > .imgover')
    await navigationPromise

    //テーブルを二次元配列でスクレイピング
    const result = await page.evaluate(() => {
      const rows = document.querySelectorAll('#contents > table tr');
      return Array.from(rows, row => {
        const columns = row.querySelectorAll('td');
        return Array.from(columns, column => column.innerHTML);
      });
    });

    //配列データを解析する
    var targetid = "";
    for(let i in result) {
      //空のデータはスルーする
      if(result[i] == ""){
        continue;
      }

      //inputのIDを取得する
      var ele = result[i][0]
      //cheerioに食わせる
      var $ = await cheerio.load(ele);

      //inputのIDを取得する
      let eleid = $('input[type="radio"]').prop("id");

      //日付データを取得する
      var $  = await cheerio.load(result[i][2]);
      let seikyuday = $('a').text();

      //日付が空の場合は配列から直接取る
      if(seikyuday == ""){
        seikyuday = result[i][2];
      }

      //6桁の数値に変換する
      seikyuday = seikyuday.substr( 0, 4 ) + seikyuday.substr( 5, 2 );

      //入力年月と比較する
      if(choiceday == seikyuday){
        targetid = "#" + eleid;
        break;
      }
    }

    //指定のIDのラジオボタンをクリックする
    await page.click(targetid)

    //請求書ダウンロード
    await page.waitForSelector('#content > #main > .submit > #BILL_DOWNLOAD > .imgover')
    await page.click('#content > #main > .submit > #BILL_DOWNLOAD > .imgover')

    //ダウンロードが完了するまでウェイト
    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);
                });
                await sleep(2000);
            }
          }
      }
      return filename
    })());

    //請求明細書ダウンロード
    await page.waitForSelector('#content > #main > .submit > #DETAIL_DOWNLOAD > .imgover')
    await page.click('#content > #main > .submit > #DETAIL_DOWNLOAD > .imgover')
    
    //ダウンロードが完了するまでウェイト
    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
    })());

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

    //ブラウザを閉じる
    await browser.close()

}

//スリープ用関数
function sleep(milliSeconds) {
    return new Promise((resolve, reject) => {
      setTimeout(resolve, milliSeconds);
    });
}
  • 前半部分は前回のエントリーとほぼ同じ。
  • 今回の1番目の肝は、Table要素を取得して二次元配列データに変換する部分です。#contentsの部分がTableのIDなのでこれに対してTD要素内を二次元配列化してあげる。値はtextではなくinnerHTMLで取得する。
  • ヤマトの場合、配列内に空っぽのものが含まれているので、これはスルーします。
  • forループ内でcheerioに食わせてまず、radioボタンのIDをprop("id")にてIDを取得します
  • 日付データですが、aタグのあるものとタダの値の2つが混在するので、条件判定しながら、202003といった年月数値を取り出し、入力したchoicedayと比較
  • 比較結果が一致したら取得済みのIDをtargetidに格納(頭に#を追加する)して次の処理。
  • await page.clickにて、targetidをクリックさせる。
  • 後は2つのボタンをクリックして、請求書と請求明細書をダウンロードさせる。
  • 完了したら、newPageのタブを閉じ、alertを出してあげて終了。
  • 最後に保存先フォルダをspawnsyncにてexplorerを使ってオープンしています。

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

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

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

関連リンク

コメントを残す

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

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