Python3 實現查詢火車票工具

前幾天看了一個爬取12306來獲得火車票信息的教程,發現12306官網的存儲車票信息的 Json 數據格式已經變了,導致這篇教程的代碼已經沒法繼續使用了,因此我針對新的格式重新進行了解析,最后達到了目的。在此記錄一下整個過程。


01/11/2018 更新:12306 更改了保存著余票信息的網址,有同學反映之前的代碼運行會出錯,于是我修改了一下代碼,現在可以正常運行了。最新的代碼在 GitHub 上,地址在文末倒數第二行。

先看一下最終效果吧

最終效果

只需要輸入查詢細節,就可以輸出你想查詢的車票信息,而且界面一目了然。

接口設計

用戶在使用這個工具的時候,需要輸入1.車次類型2.始發站3.終點站以及4.日期。火車有很多類型,可以大致分為如下幾種:

  • -g 高鐵
  • -d 動車
  • -t 特快
  • -k 快車
  • -z 直達

我們需要的接口就是剛剛提到的 4 種,因此接口看起來應該是這個樣子

$ python tickets.py [-gdtkz] from to date

其中,tickets.py 是這個程序的名字,-gdtkz 是車次類型,from 是始發站,to 是終點站,date 是日期,用戶在使用時需要填入這幾個信息。

需要的庫

  • requests 使用 Python 訪問 HTTP 資源
  • docopt Python3 命令行解析工具
  • prettytable 格式化信息打印工具,見過過 MySQL 打印數據的界面吧
  • colorama 命令行著色工具

最方便的下載方式還是pip,如果覺得pip的下載速度太慢可以參考這篇文章解決:更換 pip 源

解析參數

# coding: utf-8

"""命令行火車票查看器

Usage:
    tickets [-gdtkz] <from> <to> <date>

Options:
    -h,--help   顯示幫助菜單
    -g          高鐵
    -d          動車
    -t          特快
    -k          快速
    -z          直達

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會根據我們在程序開頭定義的格式自動解析出參數并返回一個字典,也就是arguments,然后打印出這個字典的內容。

運行一下這個程序,比如查詢一下11月20號從武漢到十堰的動車和快車,可以得到解析的結果如下所示,這和我們的接口是對應的

演示

獲取數據

整個過程的關鍵是從 12306 獲取數據和解析數據。

打開 12306 官網,點擊“余票查詢”,進入如下網頁

余票查詢

隨便查詢一下車票,比如我查一下 11 月 20 號從武漢到十堰的票,如圖

隨便查詢

然后進入開發者模式下的 Network 頁面,如圖所示(我的瀏覽器是 Chrome,不同瀏覽器的進入方法可能不一樣,不清楚的可以百度)

開發者模式-Network

再點擊一次查詢按鈕,會發現 Network 頁面有所變化,點擊如圖所示的項目,然后進入右邊顯示的 Request URL

URL

你看到應該是如下圖所示的一團雜亂無章的數據

雜亂無章的數據

其實這是 Json 格式的數據,里面其實保存了我們查詢的車次的所有車票的信息,我們的任務就是想辦法把它們提取出來并顯示出來。

我們先看看剛才的 URL:

https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date=2017-11-20&leftTicketDTO.from_station=WHN&leftTicketDTO.to_station=SNN&purpose_codes=ADULT

不難發現幾個關鍵信息:

  • train_date=2017-11-20 這是我剛才查詢的日期
  • from_station=WHN 這是始發站
  • to_station=SNN 這是終點站

其中始發站和終點站的名字是用大寫字母組成的代號代替的,然而用戶輸入的是漢字,我們需要找到漢字和代號的對應關系。查看一下網頁的源代碼,搜索 station_version 關鍵字,找到如下位置

station_version

復制 src 中的鏈接,并在前面加上 12306 的一級域名,即 https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.9030

打開這個鏈接,你會發現一個驚喜

station_version

這里面存儲了全國的城市代號,接下來我們寫一個腳本,把城市和代號以字典的形式存入一個 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)

這里用到了正則表達式,通過正則表達式把所有漢字和后面緊跟著的字母解析出來。

運行這個腳本,它將以字典的形式返回所有車站和代號, 并將結果保存到到 stations.py 文件中

$ python3 parse_station.py > stations.py

打開stations.py文件,看起來是這樣的(因為這個字典沒有名字,所以 Pycharm 發出了 warning,所以界面看起來黃黃的...)

stations.py

給這個字典命名為 stations,最終stations.py看起來是這樣的

stations.py

現在,用戶輸入車站的中文名,我們就可以直接從這個字典中獲取它的字母代碼了:

...
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>']
    # 構建 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 數據中解析出車票的信息,我們先向存儲 Json 數據的 URL 發送請求:

...
import requests

def cli():
    ...
    # 添加verify=False參數不驗證證書
    r = requests.get(url, verify=False)
    print(r.json())

這里打印出了 Json 數據,的確是雜亂無章的,下一步就進行解析。

解析數據

仔細觀察和對比 Json 數據和 12306 網站上顯示的車票信息,可以發現所有的車票信息都存儲在 r.json()["data"]["result"] 下,并且存儲的形式是 Python 中的列表,一個車次對應列表中的一個元素,這個元素是一個特別長的字符串,但是里面卻有我們需要的所有信息,包括始發站,終點站,開車時間,到達時間,總時間,以及各個座位的車票是否有剩余,下面用紅框框住的是其中一個車次的數據

json

這里面除了兩段很長的貌似沒有意義的字符串,剩余的信息都用 | 隔開了,剩下的工作就是遍歷這個列表里的所有元素,并針對每個元素進行解析。

class TrainsCollection:

    header = '車次 車站 時間 歷時 商務特等座 一等座 二等座 高級軟臥 軟臥 硬臥 硬座 無座'.split()

    def __init__(self, available_trains, station_map, options):
        """查詢到的火車班次集合

        :param available_trains: 一個列表, 包含著所有車次的信息
        :param station_map: 一個字典,包含不同代號對應的站點
        :param options: 查詢的選項, 如高鐵, 動車, etc...
        """
        self.available_trains = available_trains
        self.station_map = station_map
        self.options = options

    def geturation(self, duration):
        duration = duration.replace(':', '小時') + '分'
        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:
            # 利用正則表達式得到列車的類型
            train_type = re.findall('[\u4e00-\u9fa5]+\|\w+\|(\w)', raw_train)[0].lower()
            if train_type in self.options and '售' not in raw_train and '停運' not in raw_train:
                station = re.findall('(\w+)\|(\w+)\|\d+:', raw_train)[0]    # 元組,保存始發站和終點站的代號
                s_station = station[0]   # 始發站的代號
                e_station = station[1]   # 終點站的代號
                train = [
                    # 車次
                    re.findall('[\u4e00-\u9fa5]+\|\w+\|(\w+)', raw_train)[0],
                    # 始發站和終點站
                    '\n'.join([Fore.MAGENTA+self.station_map[s_station]+Fore.RESET,
                               Fore.BLUE+self.station_map[e_station]+Fore.RESET]),
                    # 發車時間和到站時間
                    '\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]),  # 行駛總時間
                    re.findall('(\d){8}\|(\w*\|){18}(\w*)', raw_train)[0][-1],  # 商務特等座
                    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],   # 高級軟臥
                    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]   # 無座
                ]
                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)

我們封裝一個類專門用來解析數據,這個類對傳來的列表進行遍歷,并用正則表達式解析每一個元素,然后把這些信息存儲在列表train中,最后再通過prettytable庫將所有信息有序的打印出來。

在原教程中,車票的信息是存儲在 12306 網站中的字典里的,因此解析十分方便,然而后來 12306 將車票信息的存儲格式改為了列表,使得信息的提取變難了,但是只要將正則表達式正確運用,依然可以解析出我們想要的信息,只不過比字典要麻煩一些而已。

顯示結果

最后,我們將上述過程進行匯總并將結果輸出到屏幕上:

def cli():
    """command-line interface"""
    arguments = docopt(__doc__)
    from_station = stations.get(arguments['<from>'])
    to_station = stations.get(arguments['<to>'])
    date = arguments['<date>']
    # 構建 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()

其中,我們通過colorama庫為站點和時間信息添加了顏色,使結果看起來更加舒服。

全部代碼

由于stations.py中的字典很長,所以就不在這里將所有代碼貼出來了,感興趣的可以到 Github 上下載查看:Python3 實現火車票查詢工具


原文地址:Python3 實現查詢火車票工具

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容