Python12306订票

用户登录
查询车票

[2018年12月29日]目前正在使用PyQT5完成界面设计,边学习边写,刚刚接触QT。等所有都写完,再发一篇文字。

[2019年1月15日]使用Requests进行网络请求时,会造成UI 卡死。接下来准备使用QtNetwork module 进行网络请求,先研究下。

[2019年1月23日]看了几次QtNetwork里的QNetworkAccessManager,使用起来比Requests module麻烦一些,主要涉及到一些异步处理。另外完成了自定义日历控件样式。

自定义的日历控件


写在前面

两周前完成了Python 12306验证码自动验证、用户登录和查询余票一文,后来总觉得写得有点凌乱,于是想进行重构,让整个项目结构看起来更加清晰明了。

项目结构

写完整个项目后觉得其实也很简单,无非是使用Session进行多次GetPost请求,难点在于Post请求时使用的Data从何而来?我们先使用抓包工具(浏览器F12)完成一次12306平台订票之完整过程,对需要进行哪些网络请求心里有个大概印象。使用Session的主要原因是为了避免每次请求数据时都去考虑Cookies,如此可能会方便很多。
我们将整个订票过程中使用到的API 放在一个文件里,原因很简单:一旦某个接口地址改变了,我们只需在此文件里进行修改,无法在代码里到处查找修改,省时省力。我自己之前在写iOS 应用时候也是采用这样的方式。

12306 API
class API(object):
    # 登录链接
    login = 'https://kyfw.12306.cn/passport/web/login'
    # 验证码验证链接
    captchaCheck = 'https://kyfw.12306.cn/passport/captcha/captcha-check'
    # 获取验证码图片
    captchaImage = 'https://kyfw.12306.cn/passport/captcha/captcha-image?login_site=E&module=login&rand=sjrand'
    # 车站Code
    stationCode = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js'
    # 查余票
    queryTicket = 'https://kyfw.12306.cn/otn/leftTicket/query'
    # 查票价
    queryPrice = 'https://kyfw.12306.cn/otn/leftTicket/queryTicketPrice'

    # 检查用户
    checkUser = 'https://kyfw.12306.cn/otn/login/checkUser'
    # 用户登录
    userLogin = 'https://kyfw.12306.cn/otn/login/userLogin'

    uamtk = 'https://kyfw.12306.cn/passport/web/auth/uamtk'

    uamauthclient = 'https://kyfw.12306.cn/otn/uamauthclient'

    initMy12306 = 'https://kyfw.12306.cn/otn/index/initMy12306'

    # 确定订单信息
    submitOrderRequest = 'https://kyfw.12306.cn/otn/leftTicket/submitOrderRequest'
    # initDc,获取globalRepeatSubmitToken
    initDc = 'https://kyfw.12306.cn/otn/confirmPassenger/initDc'
    # 获取曾经用户列表
    getPassengerDTOs = 'https://kyfw.12306.cn/otn/confirmPassenger/getPassengerDTOs'
    # 检查订单信息
    checkOrderInfo = 'https://kyfw.12306.cn/otn/confirmPassenger/checkOrderInfo'
    # 获取队列查询
    getQueueCount = 'https://kyfw.12306.cn/otn/confirmPassenger/getQueueCount'
    # 确认队列
    confirmSingleForQueue = 'https://kyfw.12306.cn/otn/confirmPassenger/confirmSingleForQueue'
常量

将项目里使用到的常量都集中在一个文件里,方便管理。特别需要注意的是座位类型不是固定的,我在写整个项目时发现有几个座位类型是变化的,比如硬座在我写本文的时候是1,但是之前都是A1,其他座位类型变化情况参见具体代码内容。

from  codePlatform import  CJYClient
# 12306登录用户名
userName = '你的12306账号'
# 12306密码
password = '你的12306密码'
# 超级鹰打码平台
chaoJiYing = CJYClient('你的超级鹰平台账户', '你的超级鹰平台密码','896970')
# 验证码图片路径
captchaFilePath = 'captcha.jpg'
# 车站电报码路径
stationCodesFilePath = 'stationsCode.txt'
# 座位类型,订票下单时需要传入
noSeat            = 'WZ' #无座
firstClassSeat    = 'M'  #一等座
secondClassSeat   = 'O'  #二等座
advancedSoftBerth = '6'  #高级软卧 A6
hardBerth         = '3'  #硬卧 A3
softBerth         = '4'  #软卧 A4
moveBerth         = 'F'  #动卧
hardSeat          = '1'  #硬座 A1
businessSeat      = '9'  #商务座 A9
Utility 工具类

通常项目中都会有很多共用方法,我们将这些方法抽离出来放在一个工具类文件里,如此可以减少冗余代码。

from datetime import datetime
from stationCodes import StationCodes
from color import Colored
import time
import requests

class Utility(object):

    @classmethod
    def getSession(self):

        session = requests.session()  # 创建session会话

        session.headers = {

            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36"
        }
        # session.verify = False  # 跳过SSL验证
        return session

    @classmethod
    def redColor(self,str):
        return  Colored.red(str)

    @classmethod
    def greenColor(self, str):
        return Colored.green(str)

    # 反转字典
    @classmethod
    def reversalDict(self, dict):
        return {v: k for k, v in dict.items()}

    # 将历时转化为小时和分钟的形式
    @classmethod
    def getDuration(self, timeStr):
        duration = timeStr.replace(':', '时') + '分'
        if duration.startswith('00'):
            return duration[4:]
        return duration

    # 获取一个时间是周几
    @classmethod
    def getWeekDay(self, date):
        weekDayDict = {
            0: '周一',
            1: '周二',
            2: '周三',
            3: '周四',
            4: '周五',
            5: '周六',
            6: '周天',
        }
        day = datetime.strptime(date, '%Y-%m-%d').weekday()
        return weekDayDict[day]

    # 转化日期格式
    @classmethod
    def getDateFormat(self, date):
        # date格式为2018-08-08
        dateList = date.split('-')
        if dateList[1].startswith('0'):
            month = dateList[1].replace('0', '')
       
        if dateList[2].startswith('0'):
            day = dateList[2].replace('0', '')
 
        return '{}月{}日'.format(month, day)

    # 检查购票日期是否合理
    @classmethod
    def checkDate(self, date):

        localTime = time.localtime()

        localDate = '%04d-%02d-%02d' % (localTime.tm_year, localTime.tm_mon, localTime.tm_mday)

        # 获得当前时间时间戳
        currentTimeStamp = int(time.time())
        # 预售时长的时间戳
        deltaTimeStamp = '2505600'
        # 截至日期时间戳
        deadTimeStamp = currentTimeStamp + int(deltaTimeStamp)
        # 获取预售票的截止日期时间
        deadTime = time.localtime(deadTimeStamp)
        deadDate = '%04d-%02d-%02d' % (deadTime.tm_year, deadTime.tm_mon, deadTime.tm_mday)
        # print(Colored.red('请注意合理的乘车日期范围是:{} 至 {}'.format(localDate, deadDate)))

        # 判断输入的乘车时间是否在合理乘车时间范围内
        # 将购票日期转换为时间数组
        trainTimeStruct = time.strptime(date, "%Y-%m-%d")
        # 转换为时间戳:
        trainTimeStamp = int(time.mktime(trainTimeStruct))
        # 将购票时间修改为12306可接受格式 ,如用户输入2018-8-7则格式改为2018-08-07
        trainTime = time.localtime(trainTimeStamp)
        trainDate = '%04d-%02d-%02d' % (trainTime.tm_year, trainTime.tm_mon, trainTime.tm_mday)
        # 比较购票日期时间戳与当前时间戳和预售截止日期时间戳
        if currentTimeStamp <= trainTimeStamp and trainTimeStamp <= deadTimeStamp:
            return True, trainDate
        else:
            print(Colored.red('Error:您输入的乘车日期:{}, 当前系统日期:{}, 预售截止日期:{}'.format(trainDate, localDate, deadDate)))
            return False, None

    @classmethod
    def getDate(self,dateStr):
       # dateStr格式为20180801
       year  = time.strptime(dateStr,'%Y%m%d').tm_year
       month = time.strptime(dateStr,'%Y%m%d').tm_mon
       day   = time.strptime(dateStr,'%Y%m%d').tm_mday
       return '%04d-%02d-%02d' % (year,month,day)

    # 根据车站名获取电报码
    @classmethod
    def getStationCode(self, station):
        codesDict = StationCodes().getCodesDict()
        if station in codesDict.keys():
            return codesDict[station]

    # 输入出发地和目的地
    @classmethod
    def inputStation(self, str):
        station = input('{}:\n'.format(str))
        if not station in StationCodes().getCodesDict().keys():
            print(Colored.red('Error:车站列表里无法查询到{}'.format(station)))
            station = input('{}:\n'.format(str))
        return station

    # 输入乘车日期
    @classmethod
    def inputTrainDate(self):
        trainDate = input('请输入购票时间,格式为2018-01-01:\n')
        try:
            trainTimeStruct = time.strptime(trainDate, "%Y-%m-%d")
        except:
            print('时间格式错误,请重新输入')
            trainDate = input('请输入购票时间,格式为2018-01-01:\n')
        timeFlag, trainDate = Utility.checkDate(trainDate)
        if timeFlag == False:
            trainDate = input('请输入购票时间,格式为2018-01-01:\n')
            timeFlag, trainDate = Utility.checkDate(trainDate)
        return trainDate

    @classmethod
    def getTrainDate(self,dateStr):
        # 返回格式 Wed Aug 22 2018 00: 00:00 GMT + 0800 (China Standard Time)
        # 转换成时间数组
        timeArray = time.strptime(dateStr, "%Y%m%d")
        # 转换成时间戳
        timestamp = time.mktime(timeArray)
        # 转换成localtime
        timeLocal = time.localtime(timestamp)
        # 转换成新的时间格式
        GMT_FORMAT = '%a %b %d %Y %H:%M:%S GMT+0800 (China Standard Time)'
        timeStr = time.strftime(GMT_FORMAT, timeLocal)
        return timeStr

特别要注意一下getTrainDate方法里返回时间字符串格式,我使用Firefox浏览器抓包时发现格式是Wed+Aug+22+2018+00:00:00+GMT+0800+(China+Standard+Time),但是在项目里使用此格式时会发现无法请求到数据。后来使用Google浏览器抓包发后现时间字符串里没有+符号。

Color 类

另外为了能在 Terminal里能使用不同颜色来显示打印信息,我们定义一个Color类:

from colorama import init, Fore, Back
init(autoreset=False)
class Color(object):
    #  前景色:红色  背景色:默认
    @classmethod
    def red(self, s):
        return Fore.RED + s + Fore.RESET

    @classmethod
    #  前景色:绿色  背景色:默认
    def green(self, s):
        return Fore.GREEN + s + Fore.RESET

    @classmethod
    #  前景色:黄色  背景色:默认
    def yellow(self, s):
        return Fore.YELLOW + s + Fore.RESET

    #  前景色:蓝色  背景色:默认
    @classmethod
    def blue(self, s):
        return Fore.BLUE + s + Fore.RESET

    #  前景色:洋红色  背景色:默认
    @classmethod
    def magenta(self, s):
        return Fore.MAGENTA + s + Fore.RESET

    #  前景色:青色  背景色:默认
    @classmethod
    def cyan(self, s):
        return Fore.CYAN + s + Fore.RESET

    #  前景色:白色  背景色:默认
    @classmethod
    def white(self, s):
        return Fore.WHITE + s + Fore.RESET

    #  前景色:黑色  背景色:默认
    @classmethod
    def black(self, s):
        return Fore.BLACK

    #  前景色:白色  背景色:绿色
    @classmethod
    def white_green(self, s):
        return Fore.WHITE + Back.GREEN + s + Fore.RESET + Back.RESET

验证码验证&用户登录

从抓包结果来看,12306平台首先进行验证码验证,验证通过后才会继续验证用户名和密码。
验证码图片接口是:https://kyfw.12306.cn/passport/captcha/captcha-image?login_site=E&module=login&rand=sjrand&0.05013721011282968链接里最后一个参数0.05013721011282968每次请求时都不一样。但我发现没有此参数同样能够请求到验证码图片。
验证码验证时提交的参数login_siterand是固定的,而answer是指正确图片位置坐标,但坐标基准原点是如下图红色箭头处而非整个验证码图片的左上角。

验证码图片

我们可以分别采用手动和打码平台自动方式对验证码进行验证,手动验证即把验证码图片分割成8个小图片,依次编号1-8,每个小图片上取固定的一个位置坐标,平台返回验证码图片后,用户手动输入正确验证码所在位置:
验证码图片分割

所谓的打码平台自动验证是指用户给打码平台传入一张验证码图片,平台通过码工去人工识别验证码(码工有出错可能),平台再将其结果返回给用户,这个过程一般也就2-3秒时间。12306验证码是多个坐标拼接成的字符串,因此我们需要平台返回多个坐标字符串。
百度搜索打码平台关键字能够找到很多相关平台,其中包含打码兔超级鹰等。写本文的时发现打码兔平台已经转型,不再提供打码服务,于是我只能去注册超级鹰账户。平台网站上有如何使用Python进行打码的相关文档,使用时需要注意验证码图片的类型,返回多个坐标对应的codetype9004,具体请参考验证码类型
如下代码是超级鹰官网提供的,我做了一些改动,原因是平台返回的坐标是以图片的左上角为原点,这与12306坐标基准不一致。
另外,我本想直接去掉ReportError方法的,后来发现超级鹰打码平台有出错几率。于是如果验证码验证失败,则向平台提交失败图片的ID。这样做的目的是节省平台积分,因为我们每提交一张验证码图片给平台进行识别都要付出积分,但倘若平台识别错误,则此题积分会返回。

import requests
import const
from hashlib import md5

class CJYClient:
    def __init__(self, username, password, soft_id):
        #平台账号
        self.username = username
        #平台密码
        self.password = md5(password.encode('utf-8')).hexdigest()
        # 软件ID
        self.soft_id = soft_id
        self.base_params = {
            'user'  : self.username,
            'pass2' : self.password,
            'softid': self.soft_id,
        }
        self.headers = {
            'Connection': 'Keep-Alive',
            'User-Agent': 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)',
        }

    def PostPic(self, img, codetype):
        params = {
            'codetype': codetype,
        }
        params.update(self.base_params)
        files = {'userfile': ('ccc.jpg', img)}
        result = requests.post('http://upload.chaojiying.net/Upload/Processing.php', data=params, files=files, headers=self.headers).json()
        answerList = result['pic_str'].replace('|',',').split(',')

        # 将平台返回的纵坐标减去30
        for index in range(len(answerList)):
            if index % 2 != 0:
                answerList[index] = str(int(answerList[index])-30)
            else:
                answerList[index] = str(answerList[index])
        answerStr = ','.join(answerList)
        print('打码平台返回的验证码为:'+ answerStr)
        return answerStr,result  # result是打码平台返回的结果,answerStr是纵坐标减去30后拼接成的字符串

    def ReportError(self, im_id):
        params = {
            'id': im_id,  # im_id:报错验证码的图片ID
        }
        params.update(self.base_params)
        r = requests.post('http://upload.chaojiying.net/Upload/ReportError.php', data=params, headers=self.headers)
        return r.json()
Login类
import const
import re
from utility import  Utility
from color import Color
from APIs import API

class Login(object):
    session = Utility.getSession()  # 创建session
    def __init__(self):
        self.session = Login.session

    # 获取验证码正确答案
    def getCaptchaAnswer(self):
        response= self.session.get(API.captchaImage)
        if response.status_code ==200:
            print('验证码图片请求成功')
            with open(const.captchaFilePath, 'wb') as f:
                f.write(response.content) # 写入文件
        else:
            print(Color.red('验证码图片下载失败, 正在重试...'))
            self.getCaptchaAnswer() #递归
        try:
            img = open(const.captchaFilePath, 'rb').read() #读取文件图片
            answerStr,cjyAnswerDict = const.chaoJiYing.PostPic(img, 9004)
            return answerStr,cjyAnswerDict  #返回自己写的验证码信息和平台反应的信息
        except Exception as e:
            print(str(e))

    # 验证码验证
    def captchaCheck(self):
        # 手动验证
        # self.getCaptchaAnswer()
        # imgLocation = input("请输入验证码图片位置,以英文状态下的分号','分割:\n")
        # coordinates = {'1':'35,35',  '2':'105,35',  '3':'175,35', '4':'245,35',
        #                '5':'35,105', '6':'105,105', '7':'175,105','8':'245,105'}
        # rightImgCoordinates =[]
        # for i in imgLocation.split(','):
        #     rightImgCoordinates.append(coordinates[i])
        # answer = ','.join(rightImgCoordinates)
        answer,cjyAnswerDict = self.getCaptchaAnswer()
        data = {
            'login_site':'E',  # 固定的
            'rand': 'sjrand',  # 固定的
            'answer': answer   # 验证码对应的坐标字符串
        }
        result = self.session.post(API.captchaCheck,data=data).json()
        if result['result_code'] == '4':
            print('验证码验证成功')
        else:
            print(Color.red('Error:{}'.format(result['result_message'])))
            picID = cjyAnswerDict['pic_id']
            # 报错到打码平台
            const.chaoJiYing.ReportError(picID)
            self.captchaCheck()
            return

    # 以下是登录过程进行的相关请求
    def userLogin(self):
        # step 1: check验证码
        self.captchaCheck()

        # step 2: login
        loginData = {
            'username': const.userName,   # 12306用户名
            'password': const.password,   # 12306密码
            'appid': 'otn'                #固定
        }
        result = self.session.post(API.login, data=loginData).json()

        # step 3:checkuser
        data = {
            '_json_att': ''
        }
        checkUser_res = self.session.post(API.checkUser, data=data)
        # if checkUser_res.json()['data']['flag']:
        #     print("用户在线验证成功")
        # else:
        #     print('检查用户不在线,请重新登录')
        #     self.userLogin()
        #     return

        # step 4: uamtk
        data = {
            'appid':'otn'  # 固定
        }
        uamtk_res = self.session.post(API.uamtk,data= data)
        newapptk = uamtk_res.json()['newapptk']

        # step 5: uamauthclient
        clientData = {
            'tk':newapptk
        }
        uamauthclient_res = self.session.post(API.uamauthclient,data = clientData)
        username = uamauthclient_res.json()['username']

        # step 6: initMy12306
        html = self.session.get(API.initMy12306).text
        genderStr = re.findall(r'<div id="my12306page".*?</span>(.*?)</h3>',html,re.S)[0].replace('\n','').split(',')[0] # 获取称谓,如先生
        print("{}{},恭喜您成功登录12306网站".format(Utility.redColor(username),genderStr))
        return username  # 返回用户名,便于抢票时使用。当然一个12306账户里可能有多个常用乘客,我们也可以获取联系人列表,给其他人抢票
查询余票

首先明确一点,即用户在不登录情况下也是可以查车票信息的。打开浏览器进入12306余票查询页面查询链接,然后打开开发者模式,在页面上输入出发地为上海,目的地为成都,出发日期为2018-08-28,车票类型选择成人,点击查询按钮。我们发现只有如下的1个Get请求:
https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date=2018-08-28&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=CDW&purpose_codes=ADULT

查询余票参数

leftTicketDTO.train_date,leftTicketDTO.from_station,leftTicketDTO.to_stationpurpose_codes几个参数,从参数的英文含义上不难判断它们分别代表出发日期、出发地、目的地和车票类型。但出发地怎么是SHH,目的地又怎么是CDW?这些都是什么?我百度了一下,这些字符是指车站电报码。可这些数据从何而来呢?
在开发者模式打开的情况下刷新查询页面,发现多了很多请求。仔细查看每个请求都在做些什么操作?服务器又返回了什么?Oh my gosh,竟然在刚打开查询页面的时候就请求到了。

电报码接口

我们把数据请求下来并加以保存,保存的原因是这些数据一般情况下都不会改变,请求一次,下次直接使用。

import os
import re
import json
import const
from APIs import API

class StationCodes(object):
    @classmethod
    def getAndSaveStationCodes(self,session): # session 还是使用Login时的session
        # 若文件存在,则直接return
        if os.path.exists(const.stationCodesFilePath):
            return
        res = session.get(API.stationCode)
        stations = re.findall(r'([\u4e00-\u9fa5]+)\|([A-Z]+)',res.text) #\u4e00-\u95fa5是汉字的首尾
        # 注意编码格式utf-8
        with open(const.stationCodesFilePath, 'w', encoding='utf-8') as f:
            # ensure_ascii = False 是为了防止乱码
            f.write(json.dumps(dict(stations),ensure_ascii = False))
   
    # 获取电报码字典
    def getCodesDict(self):
        with open(const.stationCodesFilePath, 'r', encoding='utf-8') as file:
            dict = json.load(file)
            return dict

获取到车站电报码,接下来我们就可以查询余票了。细节部分将在代码里进行讲解。

import const
from stationCodes import StationCodes
from utility import Utility
from color import Color
from prettytable import PrettyTable
from login import Login
from APIs import API

class LeftTicket(object):
    def __init__(self):
        self.session = Login.session  # 还是那句话,使用同一个session

    def queryTickets(self):

        StationCodes.getAndSaveStationCodes(self.session) # 先判断电报码文件是否存在,不存在再下载保存
        queryData = self.getQueryData() # 获取trainDate,fromStationCode,toStationCode,fromStation和toStation

        parameters = {
            'leftTicketDTO.train_date'  : queryData['trainDate'],        # 日期,格式为2018-08-28
            'leftTicketDTO.from_station': queryData['fromStationCode'],  # 出发站电报码
            'leftTicketDTO.to_station'  : queryData['toStationCode'],    # 到达站电报码
            'purpose_codes'             : 'ADULT'  # 0X00是学生票
        }
        res = self.session.get(API.queryTicket,params = parameters)
        trainDicts = self.getTrainInfo(res.json(), queryData)
        return queryData, trainDicts  # 返回查询数据和车次信息,便于下单时使用

    def getTrainInfo(self,result,queryData):
        trainDict = {}   # 车次信息字典
        trainDicts = []  # 用于订票
        trains = []      #用于在terminal里打印

        results = result['data']['result']
        maps = result['data']['map']

        for item in results:
            trainInfo = item.split('|')
            # for index, item in enumerate(trainInfo, 0):
            #     print('{}:\t{}'.format(index, item)
            if trainInfo[11] =='Y':

                trainDict['secretStr']       = trainInfo[0]

                trainDict['trainNumber']     = trainInfo[2]  #5l0000D35273

                trainDict['trainName']       = trainInfo[3]    # 车次名称,如D352

                trainDict['fromTelecode']    = trainInfo[6] #出发地电报码

                trainDict['toTelecode']      = trainInfo[7] # 出发地电报码

                trainDict['fromStation']     = maps[trainInfo[6]]  # 上海

                trainDict['toStation']       = maps[trainInfo[7]]  # 成都

                trainDict['departTime']      = Color.green(trainInfo[8])  # 出发时间

                trainDict['arriveTime']      = Color.red(trainInfo[9])    # 到达时间

                trainDict['totalTime']       = Utility.getDuration(trainInfo[10])  # 总用时

                trainDict['leftTicket']      = trainInfo[12]  # 余票

                trainDict['trainDate']       = trainInfo[13]  #20180822

                trainDict['trainLocation']   = trainInfo[15]  # H2

                # 以下顺序貌似也不是一直固定的,我遇到过代表硬座的几天后代表其他座位了
                trainDict[const.businessSeat]     = trainInfo[32]  # 商务座

                trainDict[const.firstClassSeat]   = trainInfo[31]  #一等座

                trainDict[const.secondClassSeat]  = trainInfo[30] #二等座

                trainDict[const.advancedSoftBerth]= trainInfo[21] #高级软卧

                trainDict[const.softBerth]        = trainInfo[23] #软卧

                trainDict[const.moveBerth]        = trainInfo[33]#动卧

                trainDict[const.noSeat]           = trainInfo[26]#无座

                trainDict[const.hardBerth]        = trainInfo[28]#硬卧

                trainDict[const.hardSeat]         = trainInfo[29]#硬座

                trainDict['otherSeat']            = trainInfo[22]#其他

                # 如果值为空,则将值修改为'--',有票则有字显示为绿色,无票红色显示
                for key in trainDict.keys():
                    if trainDict[key] == '':
                        trainDict[key] = '--'
                    if trainDict[key] == '有':
                        trainDict[key] = Color.green('有')
                    if trainDict[key] == '无':
                        trainDict[key] = Color.red('无')

                train = [Color.magenta(trainDict['trainName']) + Color.green('[ID]') if trainInfo[18] == '1' else trainDict['trainName'],
                         Color.green(trainDict['fromStation']) + '\n' + Color.red(trainDict['toStation']),
                         trainDict['departTime'] + '\n' + trainDict['arriveTime'],
                         trainDict['totalTime'], trainDict[const.businessSeat] , trainDict[const.firstClassSeat],
                         trainDict[const.secondClassSeat], trainDict[const.advancedSoftBerth], trainDict[const.softBerth],
                         trainDict[const.moveBerth], trainDict[const.hardBerth], trainDict[const.hardSeat], trainDict[const.noSeat],
                         trainDict['otherSeat']]

                # 直接使用append方法将字典添加到列表中,如果需要更改字典中的数据,那么列表中的内容也会发生改变,这是因为dict在Python里是object,不属于primitive
                # type(即int、float、string、None、bool)。这意味着你一般操控的是一个指向object(对象)的指针,而非object本身。下面是改善方法:使用copy()
                trains.append(train) 
                trainDicts.append(trainDict.copy())# 注意trainDict.copy()

        self.prettyPrint(trains,queryData) # 按照一定格式打印
        return trainDicts

    def getQueryData(self):
        trainDate = Utility.inputTrainDate()                  # 日期
        fromStation = Utility.inputStation('请输入出发地')     # 出发地
        toStation = Utility.inputStation('请输入目的地')       # 目的地
        fromStationCode = Utility.getStationCode(fromStation) # 出发地电报码
        toStationCode = Utility.getStationCode(toStation)     # 目的地电报码

        queryData = {
            'fromStation':fromStation,
            'toStation':toStation,
            'trainDate':trainDate,
            'fromStationCode':fromStationCode,
            'toStationCode':toStationCode
        }
        return queryData

    def prettyPrint(self,trains,queryData):

        header = ["车次", "车站", "时间", "历时", "商务座","一等座", "二等座",'高级软卧',"软卧", "动卧", "硬卧", "硬座", "无座",'其他']
        pt = PrettyTable(header)
        date = queryData['trainDate']
        title = '{}——>{}({} {}),共查询到{}个可购票的车次'.format(queryData['fromStation'],queryData['toStation'],Utility.getDateFormat(date),Utility.getWeekDay(date),len(trains))
        pt.title = Color.cyan(title)
        pt.align["车次"] = "l"  # 左对齐
        for train in trains:
            pt.add_row(train)
        print(pt)
查询结果

订票

这个过程也有很多请求,具体在代码里说明。

import re
from utility import Utility
from urllib import parse
from APIs import API
from queryTicket import LeftTicket
from login import Login



class BookTicket(object):

    def __init__(self):
        self.session = Login.session

    def bookTickets(self,username):
        queryData, trainDicts = LeftTicket().queryTickets()
        # 这个地方座位类型也是不是固定的,如硬卧有时候是3,有时是A3
        seatType = input('请输入车票类型,WZ无座,F动卧,M一等座,O二等座,1硬座,3硬卧,4软卧,6高级软卧,9商务座:\n')
        i = 0
        for trainDict in trainDicts:
            if trainDict[seatType]== Utility.greenColor('有') or trainDict[seatType].isdigit():
                print('为您选择的车次为{},正在为您抢票中……'.format(Utility.redColor(trainDict['trainName'])))
                self.submitOrderRequest(queryData,trainDict)
                self.getPassengerDTOs(seatType,username,trainDict)
                return
            else:
                i += 1
                if i >=len(trainDicts):  # 遍历所有车次后都未能查到座位,则打印错误信息
                    print(Utility.redColor('Error:系统未能查询到{}座位类型存有余票'.format(seatType)))
                continue

    def submitOrderRequest(self, queryData, trainDict):
        data = {
            'purpose_codes'          : 'ADULT',
            'query_from_station_name': queryData['fromStation'],
            'query_to_station_name'  : queryData['toStation'],
            'secretStr'              : parse.unquote(trainDict['secretStr']),
            'tour_flag'              : 'dc',
            'train_date'             : queryData['trainDate'],
            'undefined'              : ''
        }
        dict = self.session.post(API.submitOrderRequest, data=data).json()

        if dict['status']:
            print('系统提交订单请求成功')
        elif dict['messages'] != []:
            if dict['messages'][0] == '车票信息已过期,请重新查询最新车票信息':
                print('车票信息已过期,请重新查询最新车票信息')
        else:
            print("系统提交订单请求失败")


    def initDC(self):
        # step 1: initDc
        data = {
            '_json_att': ''
        }
        res = self.session.post(API.initDc, data=data)
        try:
            repeatSubmitToken = re.findall(r"var globalRepeatSubmitToken = '(.*?)'", res.text)[0]
            keyCheckIsChange = re.findall(r"key_check_isChange':'(.*?)'", res.text)[0]
            # print('key_check_isChange:'+ key_check_isChange)
            return repeatSubmitToken,keyCheckIsChange
        except:
            print('获取Token参数失败')
            return


    def getPassengerDTOs(self,seatType,username,trainDict):

        # step 1: initDc
        repeatSubmitToken, keyCheckIsChange = self.initDC()

        # step2 : getPassengerDTOs

        data = {
            '_json_att': '',
            'REPEAT_SUBMIT_TOKEN': repeatSubmitToken
        }
        res = self.session.post(API.getPassengerDTOs, data=data)
        passengers = res.json()['data']['normal_passengers']

        for passenger in passengers:
            if passenger['passenger_name'] == username:
                # step 3: Check order
                self.checkOrderInfo(seatType, repeatSubmitToken, passenger)
                # step 4:获取队列
                self.getQueueCount(seatType, repeatSubmitToken, keyCheckIsChange, trainDict, passenger)
                return
            else:
                print('无法购票')


    def checkOrderInfo(self,seatType,repeatSubmitToken,passenger):

        passengerTicketStr = '{},{},{},{},{},{},{},N'.format(seatType, passenger['passenger_flag'],
                                                                    passenger['passenger_type'],
                                                                    passenger['passenger_name'],
                                                                    passenger['passenger_id_type_code'],
                                                                    passenger['passenger_id_no'],
                                                                    passenger['mobile_no'])

        oldPassengerStr = '{},{},{},1_'.format(passenger['passenger_name'], passenger['passenger_id_type_code'],
                                                  passenger['passenger_id_no'])
        data = {
            '_json_att'          : '',
            'bed_level_order_num': '000000000000000000000000000000',
            'cancel_flag'        : '2',
            'oldPassengerStr'    : oldPassengerStr,
            'passengerTicketStr' : passengerTicketStr,
            'randCode'           : '',
            'REPEAT_SUBMIT_TOKEN': repeatSubmitToken,
            'tour_flag'          : 'dc',
            'whatsSelect'        : '1'
        }

        res = self.session.post(API.checkOrderInfo, data=data)
        dict = res.json()
        if dict['data']['submitStatus']:
            print('系统校验订单信息成功')
            if dict['data']['ifShowPassCode'] == 'Y':
                print('需要再次验证')
                return True
            if dict['data']['ifShowPassCode'] == 'N':
                return False
        else:
            print('系统校验订单信息失败')
            return False

    def getQueueCount(self,seatType,repeatSubmitToken,keyCheckIsChange,trainDict,passenger):

        data = {
            '_json_att'           : '',
            'fromStationTelecode' : trainDict['fromTelecode'],
            'leftTicket'          : trainDict['leftTicket'],
            'purpose_codes'       : '00',
            'REPEAT_SUBMIT_TOKEN' : repeatSubmitToken,
            'seatType'            : seatType,
            'stationTrainCode'    : trainDict['trainName'],
            'toStationTelecode'   : trainDict['toTelecode'],
            'train_date'          : Utility.getTrainDate(trainDict['trainDate']),
            'train_location'      : trainDict['trainLocation'],
            'train_no'            : trainDict['trainNumber'],
        }

        res = self.session.post(API.getQueueCount,data= data)


        if res.json()['status']:
            print('系统获取队列信息成功')
            self.confirmSingleForQueue(seatType,repeatSubmitToken,keyCheckIsChange,passenger,trainDict)

        else:
            print('系统获取队列信息失败')
            return


    def confirmSingleForQueue(self,seatType,repeatSubmitToken,keyCheckIsChange,passenger,trainDict):

        passengerTicketStr = '{},{},{},{},{},{},{},N'.format(seatType, passenger['passenger_flag'],
                                                             passenger['passenger_type'],
                                                             passenger['passenger_name'],
                                                             passenger['passenger_id_type_code'],
                                                             passenger['passenger_id_no'],
                                                             passenger['mobile_no'])

        oldPassengerStr = '{},{},{},1_'.format(passenger['passenger_name'], passenger['passenger_id_type_code'],
                                               passenger['passenger_id_no'])

        data = {
            'passengerTicketStr': passengerTicketStr,
            'oldPassengerStr': oldPassengerStr,
            'randCode': '',
            'purpose_codes': '00',
            'key_check_isChange': keyCheckIsChange,
            'leftTicketStr': trainDict['leftTicket'],
            'train_location': trainDict['trainLocation'],
            'choose_seats': '',
            'seatDetailType': '000',
            'whatsSelect': '1',
            'roomType': '00',
            'dwAll': 'N',
            '_json_att': '',
            'REPEAT_SUBMIT_TOKEN': repeatSubmitToken,
        }

        res = Login.session.post(API.confirmSingleForQueue, data= data)
        if res.json()['status']['submitStatus'] == 'true':
            print('已完成订票,请前往12306进行支付')
        else:
            print('订票失败,请稍后重试!')

我们现在来订购一张28号上海到成都的二等座车票,在项目里是无法完成支付的,必须到12306官网进行支付!


订单信息.png

我们可以将订票成功的结果以短信或者邮件的方式发送出去,提醒用户。
短信部分我已经写好了,在代码里就不展示了。

推荐阅读更多精彩内容