Puppeteerでポップアップウィンドウを操作する
日本のウェブサービスは、ウェブサービスと称していても実体は非常に旧式なサービスで、現代的なモダンな作りになっていないものが沢山まだあります。特に事務系の請求書をダウンロードするタイプのサービスではそれが顕著で、ダウンロードするだけなのに、わざわざポップアップさせてクリックさせるといった、いけてない作りのサービスがやまほどあります。
そんな一例がリクルートスタッフィングの請求書ダウンロード。このサイトからPuppeteerでダウンロードするには2つの面倒な問題をクリアする必要があります。今回はこのポップアップと、対象の月の請求書をダウンロードする厄介な問題をクリアしようと思います。
リンク
目次
今回使用するライブラリ等
今回はスクレイピングさせる為の補助として、cheerioと呼ばれるNode.js上でjQueryのようにDOM操作をする事のできるモジュールを入れています。
今回のダウンロード上の問題点
今回対象としてるウェブサービスでは、以下のような問題点があるため、そのままクリックでダウンロードしようとしてもダウンロードが出来ません。
- 年度月でフィルタして特定する事が出来ない。
- 請求書ページまでは、セッションが必要になってるので、いきなりそのページには飛べないので、クリックさせて遷移が必要。
- 一番の肝は対象の年月のデータを特定出来ない事。Tableをスクレイピング&解析して、請求年月日から推察、対象の文字がクリックのonClickに含まれているかどうかを判定して、対象のTDが何番目にあるかを特定し、クリックさせる必要がある。
- 請求データは過去4ヶ月分しか掲載されていない。1行のTRとして上部に表示されている。(2行目のTRは不要)
- 開封ボタンを押すとダウンロード用のポップアップが出る仕組み。ポップアップを捕捉してそちらを操作する必要がある。
- 請求は1月に複数含まれてる事があるので、全てチェックをして開封をする必要がある。
図:非常に面倒なUIです。
ソースコード
冒頭部分
//使用するモジュール
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に格納する
プロンプト入力受付部分
//プロンプトを表示
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部分
//ブラウザ操作メイン関数
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ファイルを作成することが可能です。


