ふしみのブログ

英語と旅行のノート

年末年始の新幹線をPythonで予約する

年末年始の新幹線はすぐに予約で一杯になってしまう。最近はエクスプレス予約のWebサイトを使えばスマホやPCから即時予約できるようになったが、年末年始の「都合が変わって1週間前など新幹線を予約したい」みたいな状況では難しい。ぼくはエクスプレス予約の割引額が増えるJ-WESTカードを持っているのだけど、特にキャンセル待ちなどに優遇があるわけではないので、CYBER STATIONという空席確認Webサイトでじっといい時間の空席が現れるのを待つしかない。

f:id:rfushimi:20181226221611p:plain

というわけでPythonで解決しよう。 この記事は ふしみ Advent Calendar の10日目の記事です。

CYBER STATIONは (見た目的にも) パースがとても簡単そうなので、素直なスクレイピングが通用しそうだ。予約サイト (エクスプレス予約) は自動操作が難しそうだが、Pythonに空席状況を監視してもらって、空席が見つかった瞬間に教えてもらえれば自分で予約できるだろう。

  1. HTTPアクセスで空席を確認する
  2. 空席があれば say コマンドを使って読み上げる
  3. 空席がなければ1時間ずらして繰り返し

こんな感じでやってみよう。

まず、CYBER STATIONにアクセスした時にブラウザがどんなリクエストを送っているか確認してみる。

Host: www1.jr.cyberstation.ne.jp
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Origin: http://www1.jr.cyberstation.ne.jp
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://www1.jr.cyberstation.ne.jp/csws/Vacancy.do
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,ja-JP;q=0.8,ja;q=0.7
Cookie: JSESSIONID=379D37FA287B0B5DF6B2D203DAEB2D53

予約状況を確認するだけのサイトなのに Cookie が必要みたいだ。しかもこのJSESSIONIDはアクセスごとに変わるらしい。レスポンス内のCookieを次回リクエスト時に正しくセットされていないとエラーになってしまう。

ヘッダを組み立てる。HTTPリクエストには requests 、パースには PyQuery を使うことにする。

from pyquery import PyQuery
import time
import requests
import subprocess

HEADER = '''\
Host: www1.jr.cyberstation.ne.jp
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Origin: http://www1.jr.cyberstation.ne.jp
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://www1.jr.cyberstation.ne.jp/csws/Vacancy.do
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,ja-JP;q=0.8,ja;q=0.7
Cookie: JSESSIONID=379D37FA287B0B5DF6B2D203DAEB2D53\
'''

headers = dict([tuple(line.split(': ')) for line in HEADER.split('\n')])

リクエストボディはこんな感じ。

month=12&day=29&hour=09&minute=10&train=1&dep_stn=%93%8C%8B%9E&arr_stn=%89%AA%8ER&dep_stnpb=4000&arr_stnpb=6200&stn=4000&stn=6200&script=1

trainが列車種別 (東海道新幹線は1)、stnpbというのが駅コード (正確にはJR駅名コード) を表しているらしい。

url = 'http://www1.jr.cyberstation.ne.jp/csws/Vacancy.do'
body = {
    'month': '12',
    'day': '29',
    'hour': '09',
    'minute': '10',
    'train': '1',
    'dep_stn': '岡山',
    'arr_stn': '東京',
    'dep_stnpb': '6200', #出発駅コード
    'arr_stnpb': '4000', #到着駅コード
    'stn': '4000', #到着駅コード
    'script': '1'
}

主要な駅コードはこちら (出典)。

1000 函館
1065 札幌
2100 仙台
2150 盛岡
2300 秋田
3000 水上
3200 新潟
4000 東京
4050 横浜
4115 新宿
4320 大宮
4300 上野
5100 名古屋
5500 長野
6000 彦根
6050 京都
6100 新大阪
6110 大阪
6200 岡山
6500 鳥取
7000 高松
8100 広島
9000 門司港
9010 小倉
9050 博多
9100 熊本
9400 宮崎
9171 鹿児島中央

検索の開始時間 (8時なら hour = 8)、Cookie (生データ、上の例なら JSESSIONID=379D37FA287B0B5DF6B2D203DAEB2D53) を取ってリクエストを送り、レスポンスボディと次回リクエスト用のCookieを返す関数を書く。

def req(hour, cookie):
    headers['Cookie'] = cookie
    body['hour'] = ('0' + str(hour))[-2:]
    response = requests.post(url=baseurl, params=body, headers=headers, timeout=3)
    cookie = response.headers['Set-Cookie']
    return response.text, cookie

DOMツリーを眼力でパースしてみると、1つ目のtableの中に1つだけtableがあり、更にtableがあり、その中のtrタグが1本の列車に対応している。4行目から15行目までが列車情報のようだ。

f:id:rfushimi:20181226233627p:plain

PyQueryはjQueryと同じように、つまりCSSとほぼ同じ記法で書ける。

pyquery: a jquery-like library for python — pyquery 1.2.4 documentation

trs = pq(pq.find('table')[0]).find('table').find('table').find('tr')[4:16]
# 1回だけ空のCookieでリクエストを送りCookieを取得する
_, cookie = req(6, '')
while True:
    for hour in range(6, 15):
        try:
            text, cookie = req(hour, cookie)
        except:
            # タイムアウトしたら10秒待ってもういちど
            time.sleep(10)
            continue
        pq = PyQuery(text, parser='html')
        trs = pq(pq.find('table')[0]).find('table').find('table').find('tr')[4:16]
        for train in trs:
            tds = pq(train).find('td')
            # 列車名 出発時刻 普通車空き状況 グリーン車空き状況
            name = pq(tds[0]).text()
            departure = pq(tds[1]).text()
            futsu = pq(tds[3]).text()
            green = pq(tds[5]).text()
            if (futsu != '×'):
                say("普通車に空席があります: %s, %s" % (departure, name))
            if (green != '×'):
                say("グリーン車に空席があります: %s, %s" % (departure, name))
        time.sleep(5)

スクレイピングのマナーとして、必ずリクエストの間には充分な間隔を開けよう。 1回のレスポンス

sayは空席を見つけたらTTSで教えてくれる関数。

def say(text):
    print(text)
    subprocess.call('say "%s"' % text, shell=True)

見つかったらこんな感じで教えてくれる。

普通車に空席があります: 13:50, のぞみ113号
普通車に空席があります: 13:50, のぞみ113号
普通車に空席があります: 13:50, のぞみ113号

いつでもエクスプレス予約で予約ができるようにしておいて、Netflixでも観ながらのんびり待つとよいだろう。今回は12月29日(土)13時50分東京発というおそらくかなり人気が高そうなチケットを予約することができた。

adventar.org