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 实现查询火车票工具

推荐阅读更多精彩内容