前幾天看了一個(gè)爬取12306來(lái)獲得火車票信息的教程,發(fā)現(xiàn)12306官網(wǎng)的存儲(chǔ)車票信息的 Json 數(shù)據(jù)格式已經(jīng)變了,導(dǎo)致這篇教程的代碼已經(jīng)沒(méi)法繼續(xù)使用了识窿,因此我針對(duì)新的格式重新進(jìn)行了解析箫措,最后達(dá)到了目的。在此記錄一下整個(gè)過(guò)程瘩将。
01/11/2018 更新:12306 更改了保存著余票信息的網(wǎng)址,有同學(xué)反映之前的代碼運(yùn)行會(huì)出錯(cuò),于是我修改了一下代碼蚌父,現(xiàn)在可以正常運(yùn)行了哮兰。最新的代碼在 GitHub 上,地址在文末倒數(shù)第二行苟弛。
先看一下最終效果吧
只需要輸入查詢細(xì)節(jié)喝滞,就可以輸出你想查詢的車票信息,而且界面一目了然膏秫。
接口設(shè)計(jì)
用戶在使用這個(gè)工具的時(shí)候右遭,需要輸入1.車次類型2.始發(fā)站3.終點(diǎn)站以及4.日期$拖鳎火車有很多類型狸演,可以大致分為如下幾種:
- -g 高鐵
- -d 動(dòng)車
- -t 特快
- -k 快車
- -z 直達(dá)
我們需要的接口就是剛剛提到的 4 種,因此接口看起來(lái)應(yīng)該是這個(gè)樣子
$ python tickets.py [-gdtkz] from to date
其中僻他,tickets.py
是這個(gè)程序的名字宵距,-gdtkz
是車次類型,from
是始發(fā)站吨拗,to
是終點(diǎn)站满哪,date
是日期,用戶在使用時(shí)需要填入這幾個(gè)信息劝篷。
需要的庫(kù)
-
requests
使用 Python 訪問(wèn) HTTP 資源 -
docopt
Python3 命令行解析工具 -
prettytable
格式化信息打印工具哨鸭,見(jiàn)過(guò)過(guò) MySQL 打印數(shù)據(jù)的界面吧 -
colorama
命令行著色工具
最方便的下載方式還是pip
,如果覺(jué)得pip
的下載速度太慢可以參考這篇文章解決:更換 pip 源
解析參數(shù)
# coding: utf-8
"""命令行火車票查看器
Usage:
tickets [-gdtkz] <from> <to> <date>
Options:
-h,--help 顯示幫助菜單
-g 高鐵
-d 動(dòng)車
-t 特快
-k 快速
-z 直達(dá)
Example:
tickets 武漢 上海 2017-11-20
tickets -dg 北京 南京 2017-11-20
"""
from docopt import docopt
def cli():
"""command-line interface"""
arguments = docopt(__doc__)
print(arguments)
if __name__ == '__main__':
cli()
上面的程序中娇妓,docopt
會(huì)根據(jù)我們?cè)诔绦蜷_(kāi)頭定義的格式自動(dòng)解析出參數(shù)并返回一個(gè)字典像鸡,也就是arguments
,然后打印出這個(gè)字典的內(nèi)容哈恰。
運(yùn)行一下這個(gè)程序只估,比如查詢一下11月20號(hào)從武漢到十堰的動(dòng)車和快車,可以得到解析的結(jié)果如下所示着绷,這和我們的接口是對(duì)應(yīng)的
獲取數(shù)據(jù)
整個(gè)過(guò)程的關(guān)鍵是從 12306 獲取數(shù)據(jù)和解析數(shù)據(jù)蛔钙。
打開(kāi) 12306 官網(wǎng),點(diǎn)擊“余票查詢”荠医,進(jìn)入如下網(wǎng)頁(yè)
隨便查詢一下車票吁脱,比如我查一下 11 月 20 號(hào)從武漢到十堰的票,如圖
然后進(jìn)入開(kāi)發(fā)者模式下的 Network 頁(yè)面彬向,如圖所示(我的瀏覽器是 Chrome兼贡,不同瀏覽器的進(jìn)入方法可能不一樣,不清楚的可以百度)
再點(diǎn)擊一次查詢按鈕娃胆,會(huì)發(fā)現(xiàn) Network 頁(yè)面有所變化遍希,點(diǎn)擊如圖所示的項(xiàng)目,然后進(jìn)入右邊顯示的 Request URL
你看到應(yīng)該是如下圖所示的一團(tuán)雜亂無(wú)章的數(shù)據(jù)
其實(shí)這是 Json 格式的數(shù)據(jù)缕棵,里面其實(shí)保存了我們查詢的車次的所有車票的信息孵班,我們的任務(wù)就是想辦法把它們提取出來(lái)并顯示出來(lái)。
我們先看看剛才的 URL:
不難發(fā)現(xiàn)幾個(gè)關(guān)鍵信息:
-
train_date=2017-11-20
這是我剛才查詢的日期 -
from_station=WHN
這是始發(fā)站 -
to_station=SNN
這是終點(diǎn)站
其中始發(fā)站和終點(diǎn)站的名字是用大寫字母組成的代號(hào)代替的招驴,然而用戶輸入的是漢字篙程,我們需要找到漢字和代號(hào)的對(duì)應(yīng)關(guān)系。查看一下網(wǎng)頁(yè)的源代碼别厘,搜索 station_version 關(guān)鍵字虱饿,找到如下位置
復(fù)制 src 中的鏈接,并在前面加上 12306 的一級(jí)域名触趴,即 https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.9030
打開(kāi)這個(gè)鏈接氮发,你會(huì)發(fā)現(xiàn)一個(gè)驚喜
這里面存儲(chǔ)了全國(guó)的城市代號(hào),接下來(lái)我們寫一個(gè)腳本冗懦,把城市和代號(hào)以字典的形式存入一個(gè) Python 文件
新建 parse_station.py
文件爽冕,并寫入以下代碼
import re
import requests
from pprint import pprint
url = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.8971'
response = requests.get(url, verify=False)
stations = re.findall(u'([\u4e00-\u9fa5]+)\|([A-Z]+)', response.text)
pprint(dict(stations), indent=4)
這里用到了正則表達(dá)式,通過(guò)正則表達(dá)式把所有漢字和后面緊跟著的字母解析出來(lái)披蕉。
運(yùn)行這個(gè)腳本颈畸,它將以字典的形式返回所有車站和代號(hào), 并將結(jié)果保存到到 stations.py
文件中
$ python3 parse_station.py > stations.py
打開(kāi)stations.py
文件,看起來(lái)是這樣的(因?yàn)檫@個(gè)字典沒(méi)有名字没讲,所以 Pycharm 發(fā)出了 warning眯娱,所以界面看起來(lái)黃黃的...)
給這個(gè)字典命名為 stations,最終stations.py
看起來(lái)是這樣的
現(xiàn)在爬凑,用戶輸入車站的中文名徙缴,我們就可以直接從這個(gè)字典中獲取它的字母代碼了:
...
from stations import stations
def cli():
"""command-line interface"""
arguments = docopt(__doc__)
from_station = stations.get(arguments['<from>'])
to_station = stations.get(arguments['<to>'])
date = arguments['<date>']
# 構(gòu)建 URL
url = 'https://kyfw.12306.cn/otn/leftTicket/queryZ?leftTicketDTO.train_date={}&leftTicketDTO.from_station=' \
'{}&leftTicketDTO.to_station={}&purpose_codes=ADULT'.format(date, from_station, to_station)
回想一下我們的最終目的是從 Json 數(shù)據(jù)中解析出車票的信息,我們先向存儲(chǔ) Json 數(shù)據(jù)的 URL 發(fā)送請(qǐng)求:
...
import requests
def cli():
...
# 添加verify=False參數(shù)不驗(yàn)證證書(shū)
r = requests.get(url, verify=False)
print(r.json())
這里打印出了 Json 數(shù)據(jù)嘁信,的確是雜亂無(wú)章的于样,下一步就進(jìn)行解析。
解析數(shù)據(jù)
仔細(xì)觀察和對(duì)比 Json 數(shù)據(jù)和 12306 網(wǎng)站上顯示的車票信息潘靖,可以發(fā)現(xiàn)所有的車票信息都存儲(chǔ)在 r.json()["data"]["result"]
下百宇,并且存儲(chǔ)的形式是 Python 中的列表,一個(gè)車次對(duì)應(yīng)列表中的一個(gè)元素秘豹,這個(gè)元素是一個(gè)特別長(zhǎng)的字符串携御,但是里面卻有我們需要的所有信息,包括始發(fā)站既绕,終點(diǎn)站啄刹,開(kāi)車時(shí)間,到達(dá)時(shí)間凄贩,總時(shí)間誓军,以及各個(gè)座位的車票是否有剩余,下面用紅框框住的是其中一個(gè)車次的數(shù)據(jù)
這里面除了兩段很長(zhǎng)的貌似沒(méi)有意義的字符串疲扎,剩余的信息都用 |
隔開(kāi)了昵时,剩下的工作就是遍歷這個(gè)列表里的所有元素捷雕,并針對(duì)每個(gè)元素進(jìn)行解析。
class TrainsCollection:
header = '車次 車站 時(shí)間 歷時(shí) 商務(wù)特等座 一等座 二等座 高級(jí)軟臥 軟臥 硬臥 硬座 無(wú)座'.split()
def __init__(self, available_trains, station_map, options):
"""查詢到的火車班次集合
:param available_trains: 一個(gè)列表, 包含著所有車次的信息
:param station_map: 一個(gè)字典壹甥,包含不同代號(hào)對(duì)應(yīng)的站點(diǎn)
:param options: 查詢的選項(xiàng), 如高鐵, 動(dòng)車, etc...
"""
self.available_trains = available_trains
self.station_map = station_map
self.options = options
def geturation(self, duration):
duration = duration.replace(':', '小時(shí)') + '分'
if duration.startswith('00'):
return duration[4:]
if duration.startswith('0'):
return duration[1:]
return duration
@property
def trains(self):
for raw_train in self.available_trains:
# 利用正則表達(dá)式得到列車的類型
train_type = re.findall('[\u4e00-\u9fa5]+\|\w+\|(\w)', raw_train)[0].lower()
if train_type in self.options and '售' not in raw_train and '停運(yùn)' not in raw_train:
station = re.findall('(\w+)\|(\w+)\|\d+:', raw_train)[0] # 元組救巷,保存始發(fā)站和終點(diǎn)站的代號(hào)
s_station = station[0] # 始發(fā)站的代號(hào)
e_station = station[1] # 終點(diǎn)站的代號(hào)
train = [
# 車次
re.findall('[\u4e00-\u9fa5]+\|\w+\|(\w+)', raw_train)[0],
# 始發(fā)站和終點(diǎn)站
'\n'.join([Fore.MAGENTA+self.station_map[s_station]+Fore.RESET,
Fore.BLUE+self.station_map[e_station]+Fore.RESET]),
# 發(fā)車時(shí)間和到站時(shí)間
'\n'.join([Fore.MAGENTA+re.findall('\|(\d+:\d+)', raw_train)[0]+Fore.RESET,
Fore.BLUE+re.findall('\|(\d+:\d+)', raw_train)[1]+Fore.RESET]),
self.geturation(re.findall('\|(\d+:\d+)', raw_train)[-1]), # 行駛總時(shí)間
re.findall('(\d){8}\|(\w*\|){18}(\w*)', raw_train)[0][-1], # 商務(wù)特等座
re.findall('(\d){8}\|(\w*\|){17}(\w*)', raw_train)[0][-1], # 一等座
re.findall('(\d){8}\|(\w*\|){16}(\w*)', raw_train)[0][-1], # 二等座
re.findall('(\d){8}\|(\w*\|){7}(\w*)', raw_train)[0][-1], # 高級(jí)軟臥
re.findall('(\d){8}\|(\w*\|){9}(\w*)', raw_train)[0][-1], # 軟臥
re.findall('(\d){8}\|(\w*\|){14}(\w*)', raw_train)[0][-1], # 硬臥
re.findall('(\d){8}\|(\w*\|){15}(\w*)', raw_train)[0][-1], # 硬座
re.findall('(\d){8}\|(\w*\|){12}(\w*)', raw_train)[0][-1] # 無(wú)座
]
yield train
def pretty_print(self):
pt = PrettyTable()
pt._set_field_names(self.header)
for train in self.trains:
pt.add_row(train)
print(pt)
我們封裝一個(gè)類專門用來(lái)解析數(shù)據(jù),這個(gè)類對(duì)傳來(lái)的列表進(jìn)行遍歷句柠,并用正則表達(dá)式解析每一個(gè)元素浦译,然后把這些信息存儲(chǔ)在列表train
中,最后再通過(guò)prettytable
庫(kù)將所有信息有序的打印出來(lái)溯职。
在原教程中精盅,車票的信息是存儲(chǔ)在 12306 網(wǎng)站中的字典里的,因此解析十分方便谜酒,然而后來(lái) 12306 將車票信息的存儲(chǔ)格式改為了列表叹俏,使得信息的提取變難了,但是只要將正則表達(dá)式正確運(yùn)用僻族,依然可以解析出我們想要的信息她肯,只不過(guò)比字典要麻煩一些而已。
顯示結(jié)果
最后鹰贵,我們將上述過(guò)程進(jìn)行匯總并將結(jié)果輸出到屏幕上:
def cli():
"""command-line interface"""
arguments = docopt(__doc__)
from_station = stations.get(arguments['<from>'])
to_station = stations.get(arguments['<to>'])
date = arguments['<date>']
# 構(gòu)建 URL
url = 'https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date={}&leftTicketDTO.from_station=' \
'{}&leftTicketDTO.to_station={}&purpose_codes=ADULT'.format(date, from_station, to_station)
options = ''.join([
key for key, value in arguments.items() if value is True
])
r = requests.get(url, verify=False)
available_trains = r.json()['data']['result']
station_map = r.json()['data']['map']
TrainsCollection(available_trains, station_map, options).pretty_print()
其中晴氨,我們通過(guò)colorama
庫(kù)為站點(diǎn)和時(shí)間信息添加了顏色,使結(jié)果看起來(lái)更加舒服碉输。
全部代碼
由于stations.py
中的字典很長(zhǎng)籽前,所以就不在這里將所有代碼貼出來(lái)了,感興趣的可以到 Github 上下載查看:Python3 實(shí)現(xiàn)火車票查詢工具