python

12306火车票查询爬虫全流程

2026-07-06 #python#爬虫

前言

每逢节假日,12306购票难一直是大家头疼的问题。虽然官方提供了完整的购票系统,但有时候我们只需要查询余票信息,而不需要登录和购票。本文将带你从零开始,实现一个完整的12306火车票查询爬虫,支持输入出发地、目的地,自动查询并显示所有可用车次的余票信息。

这个项目经历了多个版本的迭代:从最开始写死URL的简单版本,到支持城市名动态输入的优化版,再到使用代理池和反爬虫对抗的完整版。通过本文,你不仅能学会如何爬取12306的数据,还能掌握Python爬虫开发中的多个核心技巧。

技术背景

本项目涉及以下核心技术和库:

  • requests:Python最流行的HTTP请求库,用于向12306接口发送查询请求
  • json:解析12306返回的JSON格式数据
  • prettytable:在终端以表格形式美观地展示车次信息
  • 面向对象编程:通过TrainInfo类封装列车信息,提升代码可维护性
  • 反爬虫对抗:随机User-Agent、Cookie模拟、代理IP池

12306的查票接口是 https://kyfw.12306.cn/otn/leftTicket/queryO,这个接口不需要登录即可访问,是学习爬虫的绝佳入门案例。

实现思路

整个项目的架构设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
┌─────────────────────────────────────────────────┐
│ 用户输入 │
│ (出发城市、目的城市) │
└──────────────────┬──────────────────────────┘


┌─────────────────────────────────────────────────┐
│ city.json 城市代码映射 │
│ (北京 -> BJP, 上海 -> SHH) │
└──────────────────┬──────────────────────────┘


┌─────────────────────────────────────────────────┐
│ 构造12306查询API请求 │
│ https://kyfw.12306.cn/otn/leftTicket/ │
│ queryO?train_date=... │
└──────────────────┬──────────────────────────┘


┌─────────────────────────────────────────────────┐
│ 发送HTTP请求 │
│ (携带Cookie、随机User-Agent、可选代理) │
└──────────────────┬──────────────────────────┘


┌─────────────────────────────────────────────────┐
│ 解析返回的JSON数据 │
│ data.result 中每条是以 "|" 分隔的字符串 │
└──────────────────┬──────────────────────────┘


┌─────────────────────────────────────────────────┐
│ TrainInfo 类封装数据 │
│ 使用 PrettyTable 格式化输出 │
└───────────────────────────────────────────────┘

核心难点在于理解12306返回数据的格式:车次信息被编码成以 | 分隔的长字符串,每个位置对应不同的字段(车次号、出发时间、到达时间、各席别余票等)。

核心代码解析

1. 城市代码映射(city.json)

12306的API使用的不是城市名,而是三字车站代码。我们需要一个映射文件:

1
2
3
4
5
6
7
8
9
10
11
{
"北京北": "VAP",
"北京南": "VNP",
"上海虹桥": "AOH",
"北京": "BJP",
"上海": "SHH",
"广州": "CAN",
"深圳北": "SZP",
"杭州": "HGH",
"长沙": "CSQ"
}

2. TrainInfo 类——面向对象封装车次信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class TrainInfo:
def __init__(self, train_number, departure_time, time_of_arrival, time_consuming,
premier_class, first_class_seat, second_class, soft_sleeper, hard_sleeper,
soft_seat, hard_seat, without_seat, business_class, first_class_sleeping,
second_class_bedroom, superior_soft_sleeper):
# 车次号,如 G1、D321 等
self.train_number = train_number
# 出发时间,格式 HH:MM
self.departure_time = departure_time
# 到达时间,格式 HH:MM
self.time_of_arrival = time_of_arrival
# 全程耗时,格式 HH:MM
self.time_consuming = time_consuming
# 各类座位余票信息(空字符串表示无该席别或无余票)
self.premier_class = premier_class # 特等座
self.first_class_seat = first_class_seat # 一等座
self.second_class = second_class # 二等座
self.soft_sleeper = soft_sleeper # 软卧
self.hard_sleeper = hard_sleeper # 硬卧
self.soft_seat = soft_seat # 软座
self.hard_seat = hard_seat # 硬座
self.without_seat = without_seat # 无座
self.business_class = business_class # 商务座
self.first_class_sleeping = first_class_sleeping # 一等卧
self.second_class_bedroom = second_class_bedroom # 二等卧
self.superior_soft_sleeper = superior_soft_sleeper # 高级软卧

def __str__(self):
"""重写__str__方法,方便直接print输出"""
return (f"车次: {self.train_number}, 出发时间: {self.departure_time}, "
f"到达时间: {self.time_of_arrival}, 耗时: {self.time_consuming}, "
f"特等座: {self.premier_class}, 一等座: {self.first_class_seat}, "
f"二等座: {self.second_class}")

为什么要用类封装? 直接将12个参数 disperse 在代码里很难维护,用类封装后,新增字段或修改字段顺序时只需修改类定义,解析处的代码无需变动。

3. 解析12306返回的管道符分隔数据

这是整个爬虫最核心的部分。12306返回的 result 是一个字符串列表,每个字符串形如:

1
"book|240000G1010C|G1|北京南|VNP|上海虹桥|AOH|06:00|13:28|07:28|...(共38+个字段)"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 将响应内容解析为JSON格式
json_data = response.json()

# 获取查询结果:data -> result 是车次信息列表
result = json_data.get('data', {}).get('result', [])

for i in result:
# 按 '|' 分割字符串,得到各字段
index = i.split('|')

# 关键字段索引(需要对照12306实际返回数据):
# index[3] = 车次号(如 G1)
# index[8] = 出发时间
# index[9] = 到达时间
# index[10] = 耗时
# index[23] = 软卧余票
# index[26] = 无座余票
# index[28] = 硬卧余票
# index[29] = 硬座余票
# index[30] = 二等座余票
# index[31] = 一等座余票
# index[32] = 特等座余票
# index[33] = 软座余票
# index[35] = 商务座余票
# index[36] = 二等卧余票
# index[37] = 高级软卧余票

train_info = TrainInfo(
train_number=index[3],
departure_time=index[8],
time_of_arrival=index[9],
time_consuming=index[10],
premier_class=index[32],
first_class_seat=index[31],
second_class=index[30],
soft_sleeper=index[23],
hard_sleeper=index[28],
soft_seat=index[33],
hard_seat=index[29],
without_seat=index[26],
business_class=index[35],
first_class_sleeping=index[34],
second_class_bedroom=index[36],
superior_soft_sleeper=index[37]
)

4. 使用 PrettyTable 美化输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from prettytable import PrettyTable

tb = PrettyTable()
tb.field_names = ['序号', '车次', '出发时间', '到达时间', '耗时',
'特等座', '一等座', '二等座', '软卧', '硬卧',
'软座', '硬座', '无座', '商务座', '一等卧', '二等卧', '高级软卧']

# 遍历所有车次,逐行添加到表格
for i in result:
index = i.split('|')
tb.add_row([
page, # 序号
index[3], # 车次
index[8], # 出发时间
index[9], # 到达时间
index[10], # 耗时
index[32], # 特等座
index[31], # 一等座
index[30], # 二等座
index[23], # 软卧
index[28], # 硬卧
index[33], # 软座
index[29], # 硬座
index[26], # 无座
index[35], # 商务座
index[34], # 一等卧
index[36], # 二等卧
index[37] # 高级软卧
])
page += 1

print(tb) # 直接打印,PrettyTable会自动格式化

运行后终端输出效果类似:

1
2
3
4
5
6
+------+--------+----------+----------+------+--------+--------+--------+------+------+------+------+------+--------+--------+--------+------------+
| 序号 | 车次 | 出发时间 | 到达时间 | 耗时 | 特等座 | 一等座 | 二等座 | 软卧 | 硬卧 | 软座 | 硬座 | 无座 | 商务座 | 一等卧 | 二等卧 | 高级软卧 |
+------+--------+----------+----------+------+--------+--------+--------+------+------+------+------+------+--------+--------+--------+------------+
| 1 | G1 | 06:00 | 13:28 | 07:28| -- | 有 | 有 | -- | -- | -- | -- | -- | 2 | -- | -- | -- |
| 2 | G3 | 07:00 | 14:36 | 07:36| -- | 有 | 有 | -- | -- | -- | -- | -- | 5 | -- | -- | -- |
+------+--------+----------+----------+------+--------+--------+--------+------+------+------+------+------+--------+--------+--------+------------+

5. 代理IP池的使用

为了对抗12306的频率限制,项目还实现了代理IP池功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import json
import random

# 读取 proxies.json 文件(存储多个代理IP)
with open('proxies.json', 'r', encoding='utf-8') as f:
proxies_list = json.load(f)

# 随机选择一个代理
selected_proxy = random.choice(proxies_list)
proxy = {
'http': f"http://{selected_proxy['IP地址']}:{selected_proxy['端口']}",
'https': f"https://{selected_proxy['IP地址']}:{selected_proxy['端口']}"
}

# 发起GET请求时使用代理
response = requests.get(url=API_URL, headers=headers, proxies=proxy)

proxies.json 中存储了多个代理IP的信息,包括IP地址、端口、位置、HTTPS支持情况等,爬虫每次请求时随机选择一个代理,有效降低被封禁的风险。

6. 完整优化版代码(支持用户输入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import json
import random
import requests
from prettytable import PrettyTable
from train_info import TrainInfo

# 读取 city.json,获取城市与车站代码的映射
with open('city.json', 'r', encoding='utf-8') as f:
city_data = json.load(f)
fromStation = input('请输入出发的城市:')
toStation = input("请输入目的地:")

# 根据用户输入获取对应的车站代码
from_station = city_data[fromStation]
to_station = city_data[toStation]
print(f"出发站代码:{from_station},到达站代码:{to_station}")

# 构造完整的API URL
train_date = "2024-12-19"
purpose_codes = "ADULT"
API_URL = "https://kyfw.12306.cn/otn/leftTicket/queryO"
full_api_url = f"{API_URL}?leftTicketDTO.train_date={train_date}&leftTicketDTO.from_station={from_station}&leftTicketDTO.to_station={to_station}&purpose_codes={purpose_codes}"

# 随机User-Agent列表
user_agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36'
]

headers = {
'User-Agent': random.choice(user_agents),
'Cookie': '_uab_collina=173449375088168679444499; JSESSIONID=60CD7C21677293CF3570C9946BE42019; ...'
}

try:
response = requests.get(url=full_api_url, headers=headers, timeout=10)
response.raise_for_status()
json_data = response.json()

result = json_data.get('data', {}).get('result', [])
if not result:
print("没有找到相关车次信息。")
else:
# 使用PrettyTable格式化输出
tb = PrettyTable()
tb.field_names = ['序号', '车次', '出发时间', '到达时间', '耗时',
'特等座', '一等座', '二等座', '软卧', '硬卧',
'软座', '硬座', '无座', '商务座']

page = 1
for i in result:
index = i.split('|')
tb.add_row([
page, index[3], index[8], index[9], index[10],
index[32], index[31], index[30], index[23], index[28],
index[33], index[29], index[26], index[35]
])
page += 1
print(tb)

except requests.exceptions.RequestException as e:
print(f"请求失败: {e}")
except ValueError as e:
print(f"JSON解析失败: {e}")

运行效果

程序运行后,终端交互过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
请输入出发的城市:北京
请输入目的地:上海
出发站代码:BJP,到达站代码:SHH
正在查询...

+------+--------+----------+----------+--------+
| 序号 | 车次 | 出发时间 | 到达时间 | 耗时 |
+------+--------+----------+----------+--------+
| 1 | G1 | 06:00 | 13:28 | 07:28 |
| 2 | G3 | 07:00 | 14:36 | 07:36 |
| 3 | G5 | 07:28 | 14:36 | 07:08 |
| 4 | G7 | 08:00 | 15:28 | 07:28 |
| ... | ... | ... | ... | ... |
+------+--------+----------+----------+--------+
共找到 35 趟车次

总结与优化方向

通过本文的12306爬虫项目,我们学习了:

  1. HTTP请求与JSON解析:使用requests向API发起GET请求,解析JSON响应
  2. 数据解析技巧:处理管道符分隔的字符串格式
  3. 面向对象设计:用类封装数据,提升代码可维护性
  4. 终端美化:用PrettyTable输出整齐的表格
  5. 反爬虫基础:随机User-Agent、Cookie携带、代理IP池

可优化方向:

  • 增加日期输入:目前日期是写死的,可以让用户输入任意日期
  • 增加.filter过滤:按出发时间段、只看高铁、只看有票等条件过滤
  • 数据持久化:将查询结果保存为Excel或JSON文件
  • 异步请求:使用aiohttphttpx提升大量查询时的性能
  • 自动抢票:结合12306登录接口,实现自动下单(注意:实际使用需遵守12306服务条款)
  • 站点自动补全:输入”北”自动提示”北京”、”北京南”等,提升用户体验

免责声明:本项目仅供学习交流使用,请勿用于商业目的或恶意刷票。使用本代码产生的任何后果由使用者自行承担。

评论
分享