mizdra's blog

ぽよぐらみんぐ

Headless Chrome を使って自動車学校の技能教習の予約が空いたら通知するスクリプトを書いた

最近, 免許を取るために自動車学校に通っていたのですが, 技能講習の予約が一杯で中々教習が進まず困っていました. 通っていた教習所ではオンラインで技能講習の予約を取れるサービスが公開されていたので, また最近話題の Headless Chrome を触ってみたいとも思っていたので, 勢いでスクレイピングして自動車学校の技能教習の予約が空いたら通知するスクリプトを書いてみました.

この記事ではどのようにコードを書いたのか (主に GoogleChrome/puppeteer 周り) を知見を含めて紹介します.

スクレイピング

Headless ChromeAPI を wrap したライブラリである GoogleChrome/puppeteer を使ってスクレイピングしました.

スクレイピングする対象のサービスについて説明しておくと, トップのログインフォームでform認証するとメニューに飛び, そこから技能教習予約ページと予約キャンセルページに飛べる仕様となっています.

# 例
reservation.car.com ... formによるログイン認証ページ
  /menu             ... メニュー
  /reservation      ... 技能教習予約ページ
  /cancel           ... 予約キャンセルページ

セットアップ

スクレイピングを始める前に, まずはpuppeteerのセットアップをします.

const setup = async () => {
  const browser = await puppeteer.launch()    // HeadlessモードでChromeを起動
  const page    = await browser.newPage()     // 新しいタブを開く
  page.setViewport({width: 900, height: 800}) // Viewportの大きさを指定
  return {browser, page}
}

const scrape = async () => {
  const {browser, page} = await setup()
  
  // スクレイピング

  await browser.close() // Chromeを終了する
}

// 開発時は unhandledRejection を subscribe する
process.on('unhandledRejection', (e) => console.log(e))

scrape()

puppeteer のAPIの多くは Promise を返すので async/await を使うと楽に書けます. また, puppeteer内部で発生した unhandledRejection はそのままだとエラーの詳細が出力されないため, 開発時は subscribe しておくと良いでしょう.*1

ちなみに puppeteer.launch() にオプションを渡すと Headless モードをオフにして Chrome を起動したり, ブラウザの操作を一定間隔空けて実行することができます. これによって実際の挙動を画面で, 目で追いやすい速度で確認することができます.

const browser = await puppeteer.launch({
    headless: false, // 画面を表示
    slowMo: 500      // 500ms間隔でブラウザを操作
})

f:id:mizdra:20171001193642g:plain

form認証

page.$eval でクエリにマッチした要素をコールバック関数で受け取ることができるので, これを使ってフォームの値を書き換えます. 値を入力し終えたら page.click を用いて送信ボタンをクリックし, page.waitForNavigation で遷移後のページで load イベントが発火するまで待機します.

const login = async (page) => {
  // 予約サービスに移動
  await page.goto('http://reservation.car.com')

  // Node を取得し, フォームの値を書き換える
  await page.$eval('input[name="username"]', (el) => { el.value = 'mizdra' })
  await page.$eval('input[name="pass"]', (el) => { el.value = 'password' })

  // submit ボタンをクリック
  await page.click('input[type="submit"]')

  // ページの遷移が完了するまで待機
  await page.waitForNavigation({waitUntil: 'load'})
}

予約に空きのある時間帯の取得

page.evaluate は引数で渡した関数をブラウザ上で実行するため, DOM APIにアクセスすることができます. 以下では page.evaluate を使って予約に空きのある時間帯を取得しつつ, 予約ページのスクリーンショットを作成しています.

const screenshot = async (page) => {
  // ページの読み込みが終わるまで待機
  await page.waitForNavigation({waitUntil: 'load'})
  await page.screenshot({path: `screenshots/${Date.now()}.png`})
}

const getFreeClassList = async (page, classList) => {
  const freeClassList = await page.evaluate(() => {
    const buttons = Array.from(document.querySelectorAll('button.free'))
    // value プロパティから日付 (ex. '2017/09/28 16時') を取り出す
    return buttons.map(button => button.value)
  })
  return freeClassList
}

const filterClassListByFree = async (page) => {
  await gotoReservation() // 技能教習予約ページに移動
  await screenshot(page)  // 予約状況をスクリーンショットする

  // 予約に空きのあるクラスのリストを取得
  const freeClassList = await getFreeClassList(page, classList)

  await gotoMenu(page) // メニューに戻る

  // 予約に空きのあるクラスのみを返す
  return classList.filter(cls => freeClassList.includes(cls))
}

ここで注意ですが, デバッグのために page.evaluate に渡した関数の中で console.log を呼び出してもログはブラウザのコンソールに表示されるだけで, Node.js のコンソールには何も出力されません.*2

await page.evaluate(() => {
   // ブラウザのコンソールには出力されるが, Node.js のコンソールには出力されない
  console.log('Hello world!')
})

通常ブラウザのコンソールにログを出力することは無いので次のように console イベントを subscribe して警告を出すようにしておくと良いでしょう.

page.on('console', (...args) => {
  console.warn('Warning: Console API methods is called in browser context.')  
  console.log(...args)
})
await page.evaluate(() => {
  // ブラウザと Node.js の両方のコンソールに出力
  console.log('Hello world!')
})

通知

取得した時間帯を nodemailer/nodemailer を使ってメールで送信します. 今回はメールアカウントに Gmail を使い, SMTP で送信します.

import nodemailer from 'nodemailer'

// 本文のレンダリング関数
const renderHTML = (freeClassList) => `
<p>次の時間帯の予約が空きました. <a href="http://reservation.car.com">ここをクリック</a>して予約を完了して下さい.</p>
<ul>${freeClassList.map(cls => `<li>${cls}</li>`).join('')}</ul>
`

// 予約の空いたクラスをメールで通知する
const sendMail = (freeClassList) => {
  const transporter = nodemailer.createTransport({
    host: 'smtp.gmail.com',
    port: 465,
    secure: true,
    auth: {
      user: '<Gmail アドレス>',
      pass: '<Google アカウントのパスワード>'
    }
  })
  transporter.sendMail({
    from: '"free-driving-class-notification-bot"',
    to: '<通知先のメールアドレス>',
    subject: '技能教習の予約が空きました',
    html: renderHTML(freeClassList)
  }, (err) => {
    if (err) return console.log(err)
  })
}

予約が開くと, 以下のようにメールが飛んできます. これにて完成です 🎉🎉🎉

f:id:mizdra:20171001184759j:plain

その他: 遭遇した問題

puppeteer を弄っているといくつか怪しい挙動を見つけたのでIssue&PRを出しました.

おわりに

*1:http://yosuke-furukawa.hatenablog.com/entry/2016/07/12/103734

*2:これのせいでn時間吸われた

*3:これのせいでn時間吸われた