Puppeteerでテーブルデータを取得する
PuppeteerでChrome自動操縦によるRPAアプリケーションを現在、現場にテスト導入中。これまでの課題は4つのウェブサービスにログインして、パラメータを指定し、請求書データをダウンロードするものでした。今の所バッチリ動いています。(大塚、日経、勤怠、ゼロックスはクリア)
さて、5つ目の課題がハードルが高い。ヤマトビジネスメンバーズの請求書ダウンロードです。このサイトは検索が出来ません。しかし、過去の請求書が数ヶ月分ほど列挙されています。それらを選択させて上げる必要があるのですが、ラジオボタンにはIDが振られており、テーブルデータをスクレイピングして、対象の年月を特定して、ラジオボタンのIDを特定してあげる必要があります。
今回はこのテーブルデータをスクレイピングして、自分が欲しい年月の請求書データをダウンロードに挑戦してみたいと思います。
目次
今回使用するライブラリ等
今回はスクレイピングさせる為の補助として、cheerioと呼ばれるNode.js上でjQueryのようにDOM操作をする事のできるモジュールを入れています。
今回のダウンロード上の問題点
今回対象としてるウェブサービスでは、以下のような問題点があるため、そのままクリックでダウンロードですと、最新のファイルがダウンロードされてしまいます。
- 年度月でフィルタして特定する事が出来ない。
- 今回のコードではお客様コードの切り替えは考慮していないので、複数コードを持つ場合には、お客様コードの選択のコードの追加が必要です。
- 請求書ページまでは、セッションが必要になってるので、いきなりそのページには飛べないので、クリックさせて遷移が必要。
- 一番の肝は対象の年月のデータをラジオボタンで特定出来ない事。Tableをスクレイピング&解析して、請求年月日から推察、対象のラジオボタンをチェックしてあげる必要があります。これが一番のハードル
図:日付を比較し、対象のラジオボタンをクリックさせる
ソースコード
冒頭部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
//使用するモジュール 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に格納する
プロンプト入力受付部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
//プロンプトを表示 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部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
//ブラウザ操作メイン関数 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ファイルを作成することが可能です。
関連リンク
- Want to scrape table using Puppeteer. How can I get all rows, iterate through rows, and then get “td's” for each row?
- [JavaScript]table-to-jsonを使ってテーブル内容をJSONにする
- How to output proper json from a pupeteer scraped table?
- Using Puppeteer to Transform HTML content into JSON
- Table to JSON - npm
- Puppeteerのクローリングで、Tableタグの表のデータをCSV出力する方法
- Node.jsのHTMLパーサ「cheerio」
- Node.jsのスクレイピングモジュール「cheerio-httpcli」が第3形態に進化したようです
- Using proxy #130