JavaScript 2 Advent Calendar 2019 の15日目の記事です。
TL;DR
- 京都市の施設予約のサイトを刷新して欲しい
Background
サークルで毎月京都市の体育館の抽選に応募しているのですが、一日ずつ抽選予約しなければならないのでとても大変...。
だいたい1月あたり10日程度の抽選予約をするのですが、1アカウントで完了するのに15〜20分ほどかかる...。
サークル内でアカウントがだいたい20アカウントほどあり、手分けしているにせよ 20垢x20分=400分 を全員が無駄に負担していることになるので、どうにか自動化して世界を平和にしたい。
と思って、自動化を始めたのですが、サイトがなかなかにレガシーでとても大変だったので、自動化の流れを puppeteer の一つのユースケースとしてつらつらと書いていきます。
そしてこのbotが動かなくなるのは良いので強く本サイトの改善を京都の片隅より祈っております...
※ puppeteerに関しての基本的なことは特に書かないので 公式ドキュメント をご参照ください
※ 抽選が毎月1日〜9日なので抽選自体の全体のキャプチャーが取れませんでした。来月の抽選時に更新するかもしないかもしれません。
自動化したサイト
京都府・市町村共同 公共施設案内予約システム
https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1
URLで察するASP.NET。別に悪いとは言っているわけではないが、個人的にはなかなかいい思い出がありません。
ちなみにドメインのルート(https://g-kyoto.pref.kyoto.lg.jp/ )に以下のような画面が表示されます。
なんだろう...一周回って、Health CheckをRootでやってるのかな?、と見ることができる人もいるかもしれない。
あとは不要な処理をしようとするととてもユーザーを不安にさせる文言がみれます。
不正なアクセスはドキッとするのでやめてほしいです。
なんとなく辛そうな予感がしてくる。
Flow
基本的には以下のようなユーザー操作ができれば抽選予約はできそうです。
puppeteer.launch({ headless: false }).then(async browser => {
// サイトに遷移
// ログイン
// 施設選択
// 日付選択
// 利用用途入力
// 確定
})
headless: false
に関しては突然別のwindowを開いて謎に内容をsyncする場面があり、 headlessでは対処できなかったのでfalseにしています。後ほどその場面がでてきます。
実装
サイトに遷移
特に問題なく、サイトに遷移します。
const pages = await browser.pages()
// 0番目のタブ
const page = pages[0]
// サイトに遷移
await page.goto('https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1')
ログインする
まずはログインをしないと予約までできないのでログインします。
1. TOPでマイメニューボタンを押す
2. formを入力してログインをする
だけです。
マイメニューボタンを押す
ボタンを押したいのでコードをみます。
f...fra...frameset!!!!
個人的には初めて使っているサイトをみたのですが、framesetはHTML5でサポートしていません。(僕が小学生くらいのときでもiframe使ってた気が...)
このframesetがあるおかげで簡単に書けるボタンのクリックが中のフレームを参照しなければなりません。
そしてこの処理はすべての画面で同じ構成です。
この構成をするのであれば中のframeのURLのみが変わるかと思いきや、ページ自体のURL遷移がある場合とframe内のみ更新される場合がありました。
なので毎回処理のたびにpageからframeを参照する必要があります。
// サイトに遷移
// 画面(frame)のロードを待つ
await page.waitFor('frame')
// frameの取得 (絶対にあると仮定)
const frame = page.mainFrame().childFrames()[0]
これでやっとframe内のコンテンツにアクセスできます。
すべてのコンテンツがframe内にあるのですべての(click, type, select..等)の処理でframeを参照する必要があります。
やっとコンテンツにアクセスできるようになったのでマイメニューのボタンを押したいのでdevtoolでどう押すか要素の確認をします。
type='image'
...!! 最近使わない人が多いと思います。
そして、 onclick='i40_click();return false;'
。
すべての関数に i01_click
〜iNN_click
の関数が仕様書にガッチリ書かれていたのか、ASP.NET関連の話なのかはわかりませんが、jQuery以前の時代につくられた
感じはします。
できれば name
でボタン選択したかったのですが、TOPページのすべてのボタンが name='btn_riyouhouhou_click'
です。
判断できるものがないので、ここはしょうがなく
// サイトに遷移
// frameの取得
// マイメニューボタンをクリック
frame.click('input[value="マイメニュー"]')
です。
あまり参照にマルチバイトは使いたくないですし、valueにマルチバイトは使わないほうが良いと個人的には思います。
ログインする
やっとログインができそうです。
formにid, passwordを入力して、OKボタンを押せば良さそうです。
ここは意外と普通ですね。
await frame.type('input[name="txt_usr_cd"]', USER_ID)
await frame.type('input[name="txt_pass"]', USER_PASSWORD)
OKボタンを押します。
こういう name
を求めていましたが、value
が全角のスペース込の "O K" なのがとてもユニークです。
await frame.click('input[name="btn_ok"]')
前途多難なログインが完了しました。
施設選択
TOPから施設名検索ページへ移動します。もうおなじみです
await frame.click('input[value="施設名検索"]')
フルで施設名を入力して、所在地を指定せずに検索すればよさそうです。
以下が、テキスト入力と選択するボタンです
このページでは cmdXXX_click
の命名規則のようです。
// 施設名入力
await frame.type('input[name="txt_keyword"]', FACILITY_NAME)
// 所在地を指定せずに検索ボタンを押す
await frame.click('input[name="btn_shortcut"]')
つぎのページで検索結果が出てくるので施設のリストから "抽選" ボタンをクリックします。
基本的には抽選結果がひとつだけでてくるという想定ですがこのinputにはnameが存在しません。
幸いpuppeteerが優秀なのでaltで指定します。
await frame.click('input[alt="抽選予約画面へ"]')
施設選択が完了しました。
日付選択
UIをみて分かる通り最難関です。
ユーザー操作のflowとしては
1. 年度選択
2. 月選択
3. 日選択
4. 施設内の使いたい場所選択
5. 時間帯選択
6. コートの面数選択
7. 次へボタンをクリック
の7段階です。
年・月・日付のテキストには、全てに同じクラスがつけられていて、判断ができない状態だったのでXPathで文字列を検索してクリックします。
const year = "予約年"
const month = "予約月"
const day = "予約日"
const escapeXpathString = (str: string) => {
const splitedQuotes = str.replace(/'/g, `', "'", '`)
return `concat('${splitedQuotes}', '')`
}
// 任意のテキストリンクをクリックする関数
const clickByText = async (page: puppeteer.Page, text: string) => {
const frame = page.mainFrame().childFrames()[0]
const escapedText = escapeXpathString(text)
const linkHandlers = await frame.$x(`//a[contains(text(), ${escapedText})]`)
if (linkHandlers.length > 0) {
linkHandlers[0].click()
} else {
// throw error
}
}
// 年選択
await clickByText(`${year}年`)
await frame.waitFor() // loadを待つ
// 月選択
await clickByText(`${month}月`)
await frame.waitFor() // loadを待つ
// 日選択
await clickByText(`${day}`)
await frame.waitFor() // loadを待つ
無事に申し込みたい日の申し込みたい時間の施設を選択すると山場です。
使いたいコートの領域を選択するために別windowでの操作が始まります。
browserから別のtargetが開かれると targetcreated
というイベントが呼ばれるのでその中でハンドリングします。
closeされたときは targetdestroyed
が呼ばれるのでメインwindowでそれをハンドリングします。
puppeteer.launch({ headless: false }).then(async browser => {
// 別のtargetが開いた
browser.on('targetcreated', async (target) => {
const page = await target.page();
// page内でalert等が呼ばれた際のイベント
// 確認alertが何回も出てくるのですべてokを押す
page.on('dialog', (dialog) => {
dialog.accept();
})
if (/Lot_i/.test(target.url())) { // domainの確認
// なぜか初期選択が可能になるまでに時間を要する(特にajaxをしているわけではない)
await page.waitFor(2000);
// コート一面を選択
await page.select('select[name="men_1_1"]', '1');
// なぜかセレクターを変更した後にreloadが走る
// なぜか初期選択が可能になるまでに時間を要する(特にajaxをしているわけではない)
await page.waitFor(2000);
// コート確定 (ここでmainのwindowにsyncされるはず)
await page.click('input[value="O K"]');
}
})
// 任意の時間帯が選択されているかを確認
const isSelectedTimeSpan = (page: puppeteer.Page) => {
// なぜかページ全体がリロードされるのでframeの再取得が必要
const frame = page.mainFrame().childFrames()[0]
return frame.evaluate(() => {
return document.querySelectorAll('img[alt="選択中"]').length > 1;
})
}
// コート選択のtargetが閉じた
browser.on('targetdestroyed', async (target) => {
// 一応URLでフィルター
if (/Lot_i/.test(target.url())) {
// たまに別windowで選択した内容がsyncされないのでsyncを確認
if (await isSelectedTimeSpan(page)) {
// つぎへを押して利用用途入力へ
await frame.click(`input[alt="次へ"]`)
} else {
// もう一度コートを選択(日付を選択して別windowを開く)
}
}
})
})
利用用途入力/確定
あとはだいたいようなコードを書けばよいので省略します。
特につまったポイント
- pageがrelaodされる場合とframeがreloadされる場合がある
- puppeteerの各操作APIをwrapして毎回pageからframeを取得することで解決
- つぎの操作をするために何を待つか、の判断が大変
- なぜか2秒またなければならないことがある
- わからない。なにしてるの
まとめ
最初はAPI Requestをみて自動化しようと思いましたが、難解すぎたのでpuppeteerで実装する方向に舵を切りました。
レガシーなサイトに苦しめられつつもなんとか自動化できて全体の予約がワンクリック(ワン・コマンド)でできるようになりました。
時間としても全アカウントを回してだいたい30分前後。自動化してもそんなにかかるのかよ...とも思いましたが、APEX LEGENDSを30分プレイしている間に全部終わってくれるのでとても助かります。
単純作業を繰り返してみんなのサイコパスが曇らない、という点でとても良き開発でした。
面白いので京都市の施設予約サイトの中身をinspectorで覗いてみてください。
https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1