Puppeteerでポップアップウィンドウを操作する
日本のウェブサービスは、ウェブサービスと称していても実体は非常に旧式なサービスで、現代的なモダンな作りになっていないものが沢山まだあります。特に事務系の請求書をダウンロードするタイプのサービスではそれが顕著で、ダウンロードするだけなのに、わざわざポップアップさせてクリックさせるといった、いけてない作りのサービスがやまほどあります。
そんな一例がリクルートスタッフィングの請求書ダウンロード。このサイトからPuppeteerでダウンロードするには2つの面倒な問題をクリアする必要があります。今回はこのポップアップと、対象の月の請求書をダウンロードする厄介な問題をクリアしようと思います。
リンク
目次
今回使用するライブラリ等
今回はスクレイピングさせる為の補助として、cheerioと呼ばれるNode.js上でjQueryのようにDOM操作をする事のできるモジュールを入れています。
今回のダウンロード上の問題点
今回対象としてるウェブサービスでは、以下のような問題点があるため、そのままクリックでダウンロードしようとしてもダウンロードが出来ません。
- 年度月でフィルタして特定する事が出来ない。
- 請求書ページまでは、セッションが必要になってるので、いきなりそのページには飛べないので、クリックさせて遷移が必要。
- 一番の肝は対象の年月のデータを特定出来ない事。Tableをスクレイピング&解析して、請求年月日から推察、対象の文字がクリックのonClickに含まれているかどうかを判定して、対象のTDが何番目にあるかを特定し、クリックさせる必要がある。
- 請求データは過去4ヶ月分しか掲載されていない。1行のTRとして上部に表示されている。(2行目のTRは不要)
- 開封ボタンを押すとダウンロード用のポップアップが出る仕組み。ポップアップを捕捉してそちらを操作する必要がある。
- 請求は1月に複数含まれてる事があるので、全てチェックをして開封をする必要がある。
図:非常に面倒なUIです。
ソースコード
冒頭部分
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://www.r-staffing.co.jp/sol/op71/sd01/?clp=sks01"; //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を入力してください(例:xxx00000)" }, { type: 'password', name: "pass", message: "パスワードを入力してください" }, { type: "number", name: "nendo", message: "年度を指定してください(例:2020)" }, { type: 'text', name: "month", message: "月を指定してください(例:03)" } ]; // 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); }); }); } |
- 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 |
//ブラウザ操作メイン関数 async function main(userid,pw) { const browser = await puppeteer.launch({ headless: false, executablePath: chromepath, ignoreDefaultArgs: ["--guest",'--disable-extensions','--start-fullscreen','--incognito',], slowMo:150, }); //ブラウザのダウンロード先をすべて統一する 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('table > tbody > tr:nth-child(1) > .gitem > .txt') await page.click('table > tbody > tr:nth-child(1) > .gitem > .txt') await page.type('table > tbody > tr:nth-child(1) > .gitem > .txt', userid) await page.type('table > tbody > tr:nth-child(2) > .gitem > .txt', pw) await page.waitForSelector('.solOp71LoginForm > .mt10px > form > .mt5px > input') await page.click('.solOp71LoginForm > .mt10px > form > .mt5px > input') await navigationPromise //Web請求書ページへ移動する await page.waitForSelector('form > .fR > .btn_type > .btn_type_inner > span') await page.click('form > .fR > .btn_type > .btn_type_inner > span') await navigationPromise //テーブルを二次元配列でスクレイピング const result = await page.evaluate(() => { const rows = document.querySelectorAll('#contentsArea > div.clMainArea > form > table > tbody > tr'); return Array.from(rows, row => { const columns = row.querySelectorAll('td'); return Array.from(columns, column => column.innerHTML); }); }); //配列データを解析する var cnt = 1; regexp = new RegExp(choiceday, 'g'); for(let i in result[0]) { //空のデータはスルーする if(result[i] == ""){ continue; } //1行目のTRの内容を取得する var ele = result[0][i] var $ = await cheerio.load(ele); //onClickの内容を取得する let clickman = $('a').prop("onclick"); //clickmanの中身にchoicedayと一致する文字があるかどうか判定 var matchArr = clickman.match(regexp); if(matchArr == null){ //何もしないでスルーする }else{ //対象のchoicedayが含まれているのでループを抜ける break; } //カウンタを追加する cnt = cnt + 1; } //クリック場所が確定したので、対象のエレメントをクリックさせる await page.waitForSelector('table > tbody > tr > .billYyMm:nth-child(' + cnt + ') > a') await page.click('table > tbody > tr > .billYyMm:nth-child(' + cnt + ') > a') //全てチェックし開封ボタンをクリックさせる await page.waitForSelector('table:nth-child(6) > tbody > tr > td:nth-child(2) > input:nth-child(1)') await page.click('table:nth-child(6) > tbody > tr > td:nth-child(2) > input:nth-child(1)') await page.waitForSelector('table > tbody > tr > td > .w100') await page.click('table > tbody > tr > td > .w100') await navigationPromise //popupウィンドウを取得する const newPagePromise = new Promise(x => browser.once('targetcreated', target => x(target.page()))); const popup = await newPagePromise; var pages = await browser.pages(); var popupwin = pages[pages.length - 1]; //popupのファイルダウンロードをクリック await popupwin.waitForSelector('#contentsArea > center:nth-child(5) > form > input[type=submit]:nth-child(2)') await popupwin.click('#contentsArea > center:nth-child(5) > form > input[type=submit]:nth-child(2)') //ダウンロードが完了するまでウェイト 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 })()); //ポップアップを閉じる popupwin.close(); //終了メッセージを表示 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要素を取得して二次元配列データに変換する部分です。#contentsAreaの部分が年度月のTableなのでこれに対してTD要素内を二次元配列化してあげる。値はtextではなくinnerHTMLで取得する。
- リクルートの場合、2つ目のTRは利用しないので不要です。
- forループ内でcheerioに食わせてまず、対象aタグ内のonClick内の文字列をprop(“onclick”)にて取得します
- 取得した文字列内にchoicedayと同じ文字列が含まれているかどうかを判定させます。判定は正規表現にて行います。判定結果無い場合にはnullが返ってきます。
- 同じ文字列が入っていた場合には、ループを抜けて、あらかじめカウンタさせていた数字に該当するTDエレメントをクリックさせます。
- 全てチェックをチェックさせ、開封ボタンをクリックするとポップアップが表示されます。
- ポップアップページを捕捉させて、ロードが完了したら、対象のダウンロードボタンをクリックさせて請求書をダウンロードする。
- 完了したら、popupwinを閉じ、alertを出してあげて終了。
- 最後に保存先フォルダをspawnsyncにてexplorerを使ってオープンしています。
単一実行ファイルを作成する
Node.js 18よりSingle executable applicationsという機能が装備され、標準で単独実行ファイルが作成できるようになりました。結果pkgはプロジェクト終了となっています。よって、以下のエントリーの単一実行ファイルを作成するを参考に、Node18以降はexeファイルを作成することが可能です。