郑重申明:该文章介绍的技术仅供用于学习,不可恶意攻击12306网站。对12306服务器造成的任何损失,后果自负。
导语:由于12306服务器访问量巨大,并且官方为防止黄牛恶意刷票、以及一些非法攻击。12306各模块的 Url地址可能随时会改变。我在看各位前辈代码的时候很多代码已经不合适在现在的时间。所以,重在原理的学习,掌握了原理,不管12306的相关url变成什么样,都可以以不变应万变。
本文将使用以下工具来分析12306购票的过程,然后使用python语言,使用PyQt5实现购票界面的搭建,最终购票。
软件购票视频:
12306.mp4
1、Chrome浏览器(其他的浏览器也可以,都有类似的界面,如Chrome,装了httpwatch的IE浏览器等)+ charles(个人很喜欢)
2、一个可以登录12306网址并且可以购票的12306账号
3. Pycharm
其实在12306软件的实现过程中,我个人认为一般是分为以下三个步骤的:
1。个人账户的登录及授权
2。火车票的余票查询
3。火车票购买
一、个人账户的登录及授权
1.我们首先打开如下地址,进入个人账户的登录界面。网址:https://kyfw.12306.cn/otn/login/init。界面如下图所示
2.我们需要打开谷歌浏览器自带的抓包来对我们请求的各种数据进行获取并分析。(做法:在页面中右键菜单中选择【检查】菜单,打开后,选择【网络】选项卡。如下图所示:)
打开后页面变成二分窗口了,左侧是正常的网页页面,右侧是浏览器自带的控制台,当我们在左侧页面中进行操作后,右侧会显示我们浏览器发送的各种http请求和应答。我们在登录界面输入我们需要登录的个人账号(请注意:我们在点击登录以前要先勾选掉Preserve log,便于我们进行URL的分析)
在我们点击登录后我们回看到出现很多小条目,其实这就是我们在这一过程中发送的各个网络请求。当我们能够拿到我们的个人信息的时候,就表明登录这一块我们已经完全实现了,但是我们使用爬虫的时候需要经历哪些步骤呢?这里就需要引出我用到的charles抓包软件,个人觉得它能够对以上步骤进行层级划分。
1.1 获取验证码并进行验证
我们怎么获取验证码呢? 其实我们在进入网站的时候,验证码已经自动进行加载了,你想知道获取验证码的地址只需要进行刷新一下登录界面即可。通过Charles抓包我们能够获得以下界面。
得到验证码以后,我们就需要将验证码的答案传递给12306的服务器进行检测。(12306怎么知道我们传递的图片是不是正确的呢?–这里就是cookie sessionde原理了,后面讲)检查的地址:https://kyfw.12306.cn/passport/captcha/captcha-check
上图的四个数值是什么意思呢? 其实这里是你所选择的答案在验证码图片上的坐标位置(x,y)
我在UI上的设计思路:创建一个标签用于显示验证码,当我鼠标在我的答案上点击时,产生一个原点,并获取原点的坐标。
```python
from PyQt5.Qt import *
class HfLabel(QLabel):
def clear_points(self):
'''
作用:清除验证码标签上的点
:return:
'''
[child.deleteLater() for child in self.children() if child.inherits("QPushButton")]
def get_result(self):
'''
获取按钮的坐标点
:return:
'''
result = ",".join(["{},{}".format(child.x()+10,child.y()-20) for child in self.children() if child.inherits("QPushButton")]) # 搞清楚为什么这里要分别减10 和20 是由于控件的位置坐标来决定的
# result =",".join(["{},{}".format(btn.x() + 15, btn.y() - 15) for btn in self.children() if btn.inherits("QPushButton")]) # 搞清楚为什么这里要分别减10 和20 是由于控件的位置坐标来决定的
return result
def mousePressEvent(self,evt):
super(HfLabel, self).mousePressEvent(evt)
if evt.x()<0 and evt.y()<=30:
return None
point_btn = QPushButton(self)
point_btn.setStyleSheet("background-image:../Images/yzm_label.png")
point_btn.resize(20,20)
point_btn.move(evt.pos()-QPoint(10,10))
point_btn.show()
point_btn.clicked.connect(lambda x,Btn=point_btn:Btn.deleteLater()) # 双击取消
1.2 登录账号
当我们解决验证码的获取以及验证以后,我们应该获取输入的账号和密码,并发送至12306服务器进行验证。
验证地址:https://kyfw.12306.cn/passport/web/login
有朋友看到 登录成功以为就结束,其实到这一步还是不能哪去到自己的用户信息,我们还需要进行授权有效。
授权网址:https://kyfw.12306.cn/passport/web/auth/uamtk
传递的参数:
上面提到的utmark在我们授权这里会用到,我们请求头的cookies中除开有 utmark以外还有上面验证码的cookies信息(这是12306向比以前改进的地方) 如果只是单纯的使用request中的session对象是不能够完成的。所以在这里我取出其中需要的信息,自己传递cookies.
def Authorclient(self):
# 先向 Uamtk 请求最新的数据
headers = {
"User-Agent": "Mozilla/5.0(WindowsNT10.0;Win64;x64)AppleWebKit/537.36(KHTML,likeGecko) Chrome/80.0.3987.122Safari/537.36",
"Connection": "keep-alive",
"Content-Length": "60",
"Accept": "application/json,text/javascript,*/*;q=0.01",
"Sec-Fetch-Dest": "empty",
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Origin": "https://kyfw.12306.cn",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Referer": "https://kyfw.12306.cn/otn/login/init",
"Accept-Encoding": "gzip,deflate,br",
"Accept-Language": "zh-CN,zh;q=0.9",
}
cookies = {
"Cookie": "{};{};jc_save_wfdc_flag=dc;"
"RAIL_DEVICEID=FYQF27FM-hDGTl8bZAdL0k1DC6z4E7IgqIB6drUkWbGpOzzR3ROgaoV2_QutZUcPDywhQJE7klUQNBMZjX_EjszINQsMa0ftgfA1O2jxWVEjRjZDm4lovBeZj4A-7b2R5jy3DOtkN5uC5s_SnOcK4GG7FVWgYfAa;"
"RAIL_EXPIRATION=1585143263381;_jc_save_fromStation=%u5317%u4EAC%2CBJP;_jc_save_showIns=true;"
"route=495c805987d0f5c8c84b14f60212447d;BIGipServerotn=535298314.24610.0000;"
"BIGipServerpassport=971505930.50215.0000".format(self.Author_list[0], self.Author_list[1])
}
data = {
"appid": "otn"
}
new_tk_resp = self.session.post(API_URL.Uamtk_URL, data=data,headers = headers,cookies = cookies,verify = False)
tk = new_tk_resp.json()["newapptk"]
# 拿到 tk 以后再向另一个网站请求数据
# res2 = requests.request(method="POST",url=API_URL.Uamauthliect_URL, data={"tk":tk},headers = headers,verify = False)
res2 = self.session.post(url=API_URL.Uamauthliect_URL, data={"tk":tk},headers = headers,
verify = False)
下一步就是将新的令牌放在用于客户端授权,通过这一步,我们能够拿到我们需要的用户名信息
请求地址:https://kyfw.12306.cn/otn/uamauthclient
到达这一步,我们怎么才能进入个人中心呢?
请求网址:https://kyfw.12306.cn/otn/index/initMy12306Api
做到这里我们基本是进入个人中心了,我们取出相关信息,并开始跳转进入查票以及订票界面。该部分的授权代码其实是封装在在账号登录函数
```python
```python
def check_login(self):
result = self.yzm_label.get_result()
# 判断验证码是否点击了,返回是否有结果
if len(result) == 0:
self.yzm_num_label.setText("请选择验证码")
return None
if API.check_yzm(result):
account = self.account_le.text()
pwd = self.passeord_le.text()
result = API.Check_account_pwd(account,pwd)
print(result)
if result == "登录名不存在。":
self.refresh()
self.mb.setText("登录名不存在")
self.mb.open()
self.account_le.clear()
elif result == "密码输入错误。如果输错次数超过4次,用户将被锁定。":
self.refresh()
self.mb.setText("密码错误")
self.mb.open()
self.passeord_le.clear()
elif result =="登录失败":
self.refresh()
self.account_le.clear()
self.passeord_le.clear()
self.mb.setText("登录失败")
else:
self.success_login.emit(result)
else:
self.yzm_num_label.setText("验证码是错误的")
self.yzm_label.clear_points()
self.refresh()
二、余票的查询
我们软件的查询页面也是按照12306官网来设计的。
查询余票的网址:https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date=2020-06-16&leftTicketDTO.from_station=BJP&leftTicketDTO.to_station=SHH&purpose_codes=ADULT
如上图所示,我们需要向服务器传递的参数有四个:
leftTicketDTO.train_date 2020-06-16 —出发日期
leftTicketDTO.from_station BJP —出发地车站码
leftTicketDTO.to_station SHH --目的地车站码
purpose_codes ADULT — 乘客信息(普通/学生)
看到上面其实就知道我们需要去找哪些数据了。1.我们需要找到车站和车站码的对应关系,乘客状态码,所有车站的站名
上面这个地址里面即保存的是相关车站和车站码的对应关系。我们需要对信息进行处理(这些信息是固定不变的,我们可以一次请求下来,保存在本地。就可以在UI界面的下拉选择框中查询地址)
def get_all_station(cls):
# 1.不能每一次都发送请求来处理数据和接受站点 所以考虑使用一个缓存机制
# 1.1 检查本地是否有对应的缓存
if os.path.exists(Config_File.get_station_file_path()):
print("读取缓存") #判断有没有这个文件夹,有的话直接读取
with open(Config_File.get_station_file_path(),"r",encoding="utf-8") as f:
result = json.loads(f.read(),encoding="utf-8") #将进行json转译后的字符串读取出来
return result
# 1.2 有 直接跳转 返回出去
else:
print("网路请求下载的站点数据")
# 1.3 没有 发送网络请求,请求结果要保存到本地的文件夹里面
station_dic = {}
headers= {
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc",
"Sec-Fetch-Dest": "script",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36"
}
resp = cls.session.get(API_URL.Station_Name_URL,headers = headers,verify =False)
print(resp.text)
items = resp.text.split("@")
print(items) #数据很乱需要处理
for item in items:
# 但是出现的条目中间会出现错误的信息,怎么过滤掉它呢? 从字符串每一个小元素个数来进行判断
station_list = item.split("|")
if len(station_list) !=6: #不能跳出循环,只需要执行下一次循环就可以啦
continue #break是跳出整个循环,执行与循环体并列的代码,如果是continue 只是跳出本次循环,不会执行本次循环体后面的内容
# print(station_list)
city_name = station_list[1]
city_code = station_list[2]
station_dic[city_name] = city_code #对字典进行填充,【】内放的是key = 后面放的是值
# print(station_dic)
# 将数据保存到文件里面去
with open(Config_File.get_station_file_path(),"w",encoding="utf-8") as f:
json.dump(station_dic,f) #将序列化的字符串放在 f 中间
return station_dic #下载完成以后要保存还要返回给外界调用
经过测试 学生的状态码是:0X00 普通乘客的是:ADULT
下面是我设计的抢票界面(但是这中间其实设计了很多的步骤,比如怎么根据我们的想要的座位 展示车次以及票数。):
我们先看看我们在12306网站上点击查询以后发送的数据,车票信息在响应体的result里面,但是我们可能问车次信息和12306服务器上的表头有什么关系。
我们需要将结果取出来,并对每一个数据进行展示在tablewidget里面,下面代码中的字典即是表示的结果中每一个表示的什么含义(后面订票的过程中会用到)
query_params = {
"leftTicketDTO.train_date":train_date,
"leftTicketDTO.from_station": from_station,
"leftTicketDTO.to_station": to_station,
"purpose_codes": purpose_code
}
resp = cls.session.get(API_URL.Query_Ticket_URL,params =query_params,verify=False,headers = headers)
resp.encoding="utf-8"
# print(resp.json())
result = resp.json()
tickets_list = []
if result["httpstatus"]==200:
items = result["data"]["result"]
for item in items:
ticketInfo = {}
trainInfo= item.split("|") #
if trainInfo[11]=="Y": #先判断有无有票
ticketInfo["Ciphertext"] = trainInfo[0] #订票密文,后期需要
ticketInfo["Train_number"] = trainInfo[2] #目列车编码
ticketInfo["Train_name"] = trainInfo[3] #列车号
ticketInfo["Departure_code"] = trainInfo[4] #出发地电报码
ticketInfo["Destination_code"] = trainInfo[5] #目的地电报码
ticketInfo["Departure_name"] = codetostation[trainInfo[6]] #出发地
ticketInfo["Destination_name"] = codetostation[trainInfo[7]] #目的地
ticketInfo["Departure_time"] = trainInfo[8] #发车时间
ticketInfo["Arrival_time"] = trainInfo[9] #到站时间
ticketInfo["Total_time"] = trainInfo[10] #总时长
ticketInfo["Lefy_tickets"] = trainInfo[12] #余票 是一个字符串
ticketInfo["Date_time"] = trainInfo[13] #火车运行时间
ticketInfo["Train_localtion"] = trainInfo[15] # 后期使用
ticketInfo["Vip_soft_bed"] = trainInfo[21] #高级软卧
ticketInfo["Other_seat"] = trainInfo[22] #其他
ticketInfo["Soft_bed"] = trainInfo[23] #软卧一等卧
ticketInfo["No_seat"] = trainInfo[26] #无座
ticketInfo["Hard_seat"] = trainInfo[29] #硬座
ticketInfo["Hard_bed"] = trainInfo[28] #硬卧二等卧
ticketInfo["Second_seat"] = trainInfo[30] #二等座
ticketInfo["First_seat"] = trainInfo[31] #一等座
ticketInfo["Business_seat"] = trainInfo[32] #一等座
ticketInfo["move_bed"] = trainInfo[33] #动卧
做到上面的步骤其实我们查询余票信息以及做好,接下来的部分就只差我们订票了。
三、订票
我们以上面的G104次列车来开始订票过程的演练
当我们点击预定按钮的时候,他会跳转到如下界面:
我们可以看到,界面上面展示了我们比较私人的乘车人信息,要想拿到刚才的界面信息,我通过charles抓包发现,实际上是通过了四次请求:
第一步:当你点击订票的时候,系统肯定会检查你是否登录,登录成功则继续购买。未登录则需要登录。
检查地址:https://kyfw.12306.cn/otn/login/checkUser
需要传递的参数:
然后我们需要取得响应体中间的状态 flag(上图标红的)
第二步:我们需要体检订票信息
请求地址:https://kyfw.12306.cn/otn/leftTicket/submitOrderRequest
第四步:获取乘车人的信息,并进行选择:
请求地址:https://kyfw.12306.cn/otn/confirmPassenger/getPassengerDTOs
但是我们发现,发送请求的表单数据中有一串字符我们不知道是什么,这个肯定不是任意出现的,经过仔细寻找,我发现是在第三步的html网页中存在,所以我们需要通过正则表达式对它进行获取,并进行传递:
第三步的请求网址:https://kyfw.12306.cn/otn/confirmPassenger/initDc
代码:在这里插入代码片
def initDc(cls):
headers = {
"Connection": "keep-alive",
"Content-Length": "10",
"Cache-Control": "max-age=0",
"Origin": "https://kyfw.12306.cn",
"Upgrade-Insecure-Requests": "1",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36",
"Sec-Fetch-Dest": "document",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-User": "?1",
"Referer": "https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9"
}
resp = cls.session.post(API_URL.Passenger_URL, data={"_json_att=":""}, headers=headers,verify = False)
try:
token = re.findall(r"var globalRepeatSubmitToken = '(.*?)'",resp.text)[0]
key_check_isChange = re.findall(r"'key_check_isChange':'(.*?)'",resp.text)[0]
return (token,key_check_isChange)
except:
print("乘客信息获取错误")
return None
我们现在选择好乘车人、席别等信息以后点击提交订单,看他会出现什么情况。
我们通过抓包软件发现,当我们点击提交订单以后直到我们订票成功会经历如下图所示的过程:
第一步:核查订单信息(座位,乘车人等信息)
地址:https://kyfw.12306.cn/otn/confirmPassenger/checkOrderInfo
需要传递的参数(如下图所示):
解析代码如下:在这里插入代码片
def checkOrder(cls,seatType,passengerDic,token):
headers = {
"Connection": "keep-alive",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Sec-Fetch-Dest": "empty",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "https://kyfw.12306.cn",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9"
}
data = {
"cancel_flag": "2",
"bed_level_order_num": "000000000000000000000000000000",
"passengerTicketStr": "{},{},{},{},{},{},{},N,{}".format(seatType, passengerDic["passenger_flag"],
passengerDic["passenger_id_type_code"],
passengerDic["passenger_name"],
passengerDic["passenger_id_type_code"],
passengerDic["passenger_id_no"],
passengerDic["mobile_no"],
passengerDic["allEncStr"]),
"oldPassengerStr":"{},1,{},{}".format(passengerDic["passenger_name"],passengerDic["passenger_id_no"],
passengerDic["passenger_type"]+"_"),
"tour_flag": "dc",
"randCode": "",
"whatsSelect": "1",
"sessionId": "",
"sig": "",
"scene": "nc_login",
"_json_att": "",
"REPEAT_SUBMIT_TOKEN": token
}
try:
resp = cls.session.post(API_URL.checkOrderinfo_URL,data = data,headers = headers,verify = False)
result = resp.json()
print(result)
if result["status"] and result["data"]["submitStatus"]:
print("检查订单成功")
return True
return False
except:
return None
第二步:获取余票队列
地址:https://kyfw.12306.cn/otn/confirmPassenger/getQueueCount
传递参数:
解析代码如下:
def getQueueCount(cls,Train_dic,token,seatTYpe):
headers = {
"Connection": "keep-alive",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Sec-Fetch-Dest": "empty",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "https://kyfw.12306.cn",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9"
}
data = {
"train_date": TimeTo.getTrainFormat(Train_dic["Date_time"]),
"train_no": Train_dic["Train_number"],
"stationTrainCode": Train_dic["Train_name"],
"seatType":seatTYpe,
"fromStationTelecode":Train_dic["Departure_code"],
"toStationTelecode": Train_dic["Destination_code"],
"leftTicket": Train_dic["Lefy_tickets"],
"purpose_codes": "00", # 固定的值
"train_location": Train_dic["Train_localtion"],
"_json_att": "",
"REPEAT_SUBMIT_TOKEN": token
}
response=cls.session.post(API_URL.getQueueCount_URL,data=data,headers = headers,verify = False)
try:
result= response.json()
if result["status"]:
print("查询队列个数成功", result["data"]["ticket"])
return True
else:
print("查询队列消息失败")
return False
except:
return None
第三步:提交信息,进行购票
地址:https://kyfw.12306.cn/otn/confirmPassenger/confirmSingleForQueue
参数如下图所示:
代码解析如下:
在这里插入代码片 def comfirmSingle(cls, seatType, passengerDic, trainDict, key_check_isChange, token):
headers = {
"Connection": "keep-alive",
"Accept": "application/json,text/javascript,*/*;q=0.01",
# "Sec-Fetch-Dest": "empty",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64;x64) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/80.0.3987.132 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Origin": "https://kyfw.12306.cn",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Referer": "https://kyfw.12306.cn/otn/confirmPassenger/initDc",
"Accept-Encoding": "gzip,deflate,br",
"Accept-Language": "zh-CN,zh;q=0.9"
}
data_dic = {
"passengerTicketStr": "{},{},{},{},{},{},{},N,{}".format(seatType, passengerDic["passenger_flag"],
passengerDic["passenger_id_type_code"],
passengerDic["passenger_name"],
passengerDic["passenger_id_type_code"],
passengerDic["passenger_id_no"],
passengerDic["mobile_no"],
passengerDic["allEncStr"]),
"oldPassengerStr": "{},1,{},{}".format(passengerDic["passenger_name"], passengerDic["passenger_id_no"],
passengerDic["passenger_type"] + "_"),
"randCode": "",
"purpose_codes": "00",
"key_check_isChange": key_check_isChange, # 来源于哪里呢?
"leftTicketStr": trainDict["Lefy_tickets"],
"train_location": trainDict["Train_localtion"],
"choose_seats": "", # 选着的座位
"seatDetailType": "000",
"whatsSelect": "1",
"roomType": "00",
"dwAll": "N",
"_json_att": "",
"REPEAT_SUBMIT_TOKEN": token
}
try:
resp = cls.session.post(url=API_URL.ConfirmSingleForQueue_URL, data=data_dic, headers=headers)
result = resp.json()
if not result["data"]["submitStatus"]:
print("车票都买失败")
return None
return True
except:
print("重新尝试")
return None
结语:该软件其实是春节期间在家做的,也是参考了别人写的博客教程,但是在实际的抓包过程中很多代码都不适用了,原因是12306的反扒措施也是在不断的升级,例如对请求头,session等。但是我觉得万变不离其中—在12306上整个购票的过程是不会更改的。代码确实还有很多完善和可供开发的功能(比如增加乘车人的选择,订票成功的短信或者邮件通知等)