1. 从"退出码0"说起:那次爬虫程序什么都没爬到的排查经历
先讲个真实的场景。你在PyCharm里写了一个爬虫,辛辛苦苦把requests库的代码敲完,点下运行,控制台只显示一行:
code复制Process finished with exit code 0
没了。什么输出都没有,没有报错,没有异常,程序"成功地"什么都没干。这个情况在热搜词里反复出现,说明踩中的人不在少数。我当时爬机票价格的时候也遇到过,而且是在连续爬了十几个航班数据之后突然发生的——程序没有报错,但抓回来的数据是空的。
那次的排查过程给我留下了很深的印象,因为它暴露出来的问题不止一个,而是层层叠加的:第一个是目标网站改了接口参数,第二个是我的爬虫还在用旧的UA伪装,第三个是我连最基本的响应码检查都没做。 三层问题叠在一起,程序按旧逻辑正常跑完,但拿到的页面里已经没有机票价格了。
所以这篇文章不打算从一个"从零开始写爬虫"的角度来讲。你需要的不是"如何安装requests"这种基础内容,而是真正在爬机票价格走势这个具体场景下,怎么把数据稳定地抓下来、解析出来、存起来、最后画成趋势图。我会把整个过程拆开,每一步把为什么这么做讲透,包括那些文档里不会写的坑。
另外说一句,文中的所有代码基于Python 3.9+和requests库,目标站点我会用模拟的API地址代替。具体是哪家航司或者哪个聚合平台不重要,重要的是这套思路换到任何机票类站点上都能用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么爬机票价格比爬普通网页麻烦:先搞清数据藏在哪
机票价格数据和普通文章页面的最大区别在于:它几乎全部是动态加载的。 你打开一个机票查询页面,看到的价格列表、航班号、起降时间,这些信息不是在HTML源码里写死的,而是页面加载完成后通过JavaScript发请求,从后端API拿到JSON数据,再渲染出来的。
这意味着什么?意味着你用requests直接get那个查询页面,得到的HTML源码里根本没有价格数据,只有一堆JavaScript代码和页面骨架。很多爬虫新手在这里卡住:明明浏览器里能看到价格,requests却什么都拿不到。
2.1 先通过开发者工具确认真实数据接口
这个步骤不能跳。打开目标机票网站的查询页面,按F12进入开发者工具,切到Network(网络)面板,勾选Fetch/XHR过滤条件,然后手动在页面上发起一次机票搜索。观察Network面板里出现的请求,找到返回内容里包含价格数据的那个请求。
判断标准很简单:点中某个请求,看右侧的Response(响应)标签页,如果里面出现了你刚才在页面上看到的航班号和价格,那就说明这就是数据的真实来源。绝大多数情况下,这个请求的URL会带有一长串参数,包含出发地、目的地、日期这些查询条件。
一个常见的反直觉情况是:你可能会看到十几个XHR请求,不要把时间浪费在逐个试上。按时间顺序找最早发起的那个请求,它往往是查询入口,后面的请求多半是二次补充数据或者埋点上报。我实战中遇到的多数情况是第一个请求就直接返回了价格JSON。
2.2 分析请求参数和响应结构
拿到真实的API地址之后,接下来要做的是分析它的参数结构和返回数据的JSON层次。这个工作没有捷径,就是逐个字段看。
以我当时的做法为例。假设真实接口是:
python复制https://api.example-air.com/flight/search
它的GET参数大概是这样的:
python复制params = {
"departureCity": "BJS", # 出发城市代码
"arrivalCity": "SHA", # 到达城市代码
"date": "2025-06-15", # 出发日期
"adultNum": "1", # 成人数量
"childNum": "0", # 儿童数量
"infantNum": "0", # 婴儿数量
"cabinType": "Y", # 舱位类型 Y=经济舱
"queryType": "普通查询"
}
而我最初踩坑的地方就在最后这个queryType参数上。一开始我以为是固定值,结果某天这个值变了,从"普通查询"变成了"normal",导致接口返回空数据。
这就是为什么我一再强调:即使拿到了真实接口,也要给请求响应加校验,不要假设参数永远不变。
响应JSON的结构通常是嵌套的,需要耐心地一层层剥开。典型结构:
json复制{
"status": 0,
"data": {
"flightList": [
{
"flightNo": "CA1234",
"departureTime": "08:00",
"arrivalTime": "10:30",
"price": 1250,
"tax": 50,
"cabin": "经济舱"
}
]
}
}
拿price这个字段举例,第一层是status和data,第二层是flightList数组,第三层才是每条航班信息里的价格。用Python解析的时候,就是一层一层地索引进去:resp_json["data"]["flightList"][0]["price"]。
3. 明确爬取目标:不是爬一次,而是持续监测价格
很多人的思路是:写个脚本,跑一次,拿到今天的价格,结束了。但"价格走势"这个词决定了你要做的不是一次性抓取,而是在一个时间周期内,对同样的航线、同样的日期,反复抓取并记录价格变化。
这个差异直接决定了你的技术选型和代码架构。
3.1 数据模型怎么设计:把价格变化存成时间序列
如果只爬一次,你用一个字典或者列表就能存下来。但要爬价格走势,数据模型就不能这么简单了。
核心数据表需要一个时间维度。对于每条记录,至少要包含这些字段:
| 字段名 | 示例值 | 说明 |
|---|---|---|
| date | 2025-06-15 | 航班日期 |
| flight_no | CA1234 | 航班号 |
| collect_time | 2025-05-20 08:30:00 | 抓取时间 |
| price | 1250 | 含税总价 |
| currency | CNY | 币种 |
| source | example-air | 数据来源 |
这里的关键是把collect_time当成一个独立的字段存下来,而不是把当天的价格直接覆盖到旧记录上。 这样每跑一次爬虫,就会在数据库里追加一批新记录。等你跑了一个月,去看某个航班的价格变化,只需要按flight_no筛选,再按collect_time排个序,价格走势就出来了。
我当时用的是SQLite,因为单文件、零配置、Python自带sqlite3库,特别适合这种轻量的个人数据采集项目。不需要装MySQL,不需要维护服务,直接一个.db文件就搞定了。
3.2 定时执行的两种姿势:Cron和循环休眠
采集频率取决于你想分析什么。如果你想看一天之内的价格波动(有没有可能在凌晨放特价票),那采集频率应该以小时甚至分钟计。如果只是记录每天的价格变化,一天跑一次就够了。
我当时用的是比较折中的方案:工作日每6小时跑一次,因为实测中发现部分航司会在上午和下午各调一次价。
实现方式有两种。如果你用的是Linux服务器,直接写Cron定时任务最省事:
bash复制# 每6小时执行一次
0 */6 * * * cd /path/to/project && /usr/bin/python3 crawl.py >> crawl.log 2>&1
如果你只是在自己电脑上跑,Windows的计划任务或者写个while循环休眠也行。但说实话,我建议有条件的话还是扔到一台长期开机的服务器上跑,毕竟个人电脑会休眠、会关机,采集链路一旦断了,价格走势就出现空档期,分析的时候很麻烦。
4. 一套完整的采集链路:从请求到入库的实战代码
前面铺垫了这么多,现在给出一个完整的、可以直接改改就跑的采集脚本。这个脚本以requests为主,配合SQLite存储,代码结构上把请求、解析、存储拆分成三个独立的部分。
4.1 请求层:带上必要的请求头和超时控制
python复制import requests
import json
import time
import random
def fetch_price_data(departure="BJS", arrival="SHA", flight_date="2025-06-15"):
# 真实接口地址请替换为你从开发者工具里抓到的URL
url = "https://api.example-air.com/flight/search"
params = {
"departureCity": departure,
"arrivalCity": arrival,
"date": flight_date,
"adultNum": "1",
"childNum": "0",
"infantNum": "0",
"cabinType": "Y",
"queryType": "normal"
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://www.example-air.com/",
"Origin": "https://www.example-air.com"
}
try:
resp = requests.get(
url,
params=params,
headers=headers,
timeout=10
)
# 关键:不要假设请求一定成功
if resp.status_code != 200:
print(f"[WARN] 请求失败,状态码: {resp.status_code}")
return None
# 有些接口即使返回200,也可能返回业务错误码
resp_json = resp.json()
if resp_json.get("status") != 0:
print(f"[WARN] 业务错误: {resp_json.get('message', '未知错误')}")
return None
return resp_json
except requests.exceptions.Timeout:
print("[ERROR] 请求超时")
return None
except requests.exceptions.ConnectionError:
print("[ERROR] 连接失败")
return None
except json.JSONDecodeError:
print("[ERROR] 响应不是合法JSON")
return None
这段代码里最值得注意的两个点是timeout和返回值校验。在爬虫实战里,网络超时和连接失败是最常见的情况,不处理这些异常的话,程序会在运行中突然崩溃,你的定时任务也就断在了那里。而返回值校验,就是我前面提到的"请求看似成功但实际没数据"的防线。
给所有做爬虫的朋友一个建议:永远不要假设远端服务是可靠的。凡是主动返回None的函数,后面调用方都至少要做一个判空处理。
4.2 解析层:从JSON中提取航班价格
python复制def parse_price_data(resp_json, flight_date):
"""从API响应中提取航班价格列表"""
if not resp_json:
return []
flight_list = resp_json.get("data", {}).get("flightList", [])
result = []
for flight in flight_list:
flight_no = flight.get("flightNo", "")
price = flight.get("price", 0)
tax = flight.get("tax", 0)
# 有些平台把价格放在子对象里,比如flightPriceInfo.totalPrice
if price == 0 and "flightPriceInfo" in flight:
price = flight["flightPriceInfo"].get("totalPrice", 0)
total_price = price + tax
item = {
"date": flight_date,
"flight_no": flight_no,
"price": total_price,
"collect_time": time.strftime("%Y-%m-%d %H:%M:%S"),
"source": "example-air"
}
result.append(item)
return result
解析层的容错设计值得多说一句。同一个航班的信息,不同平台的返回结构有时差异很大。有的把价格直接放在顶层,有的放在一个叫flightPriceInfo的子对象里。我的建议是写多级降级解析:先取顶层字段,取不到就取子对象字段,再取不到就记为0。这样不会因为某一层结构变化而崩溃。
4.3 存储层:用SQLite记录价格快照
python复制import sqlite3
def init_db(db_path="flight_price.db"):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS price_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT,
flight_no TEXT,
price REAL,
collect_time TEXT,
source TEXT
)
""")
# 避免重复采集同一条数据:加一个唯一索引
cursor.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_collect
ON price_snapshots (date, flight_no, collect_time, source)
""")
conn.commit()
conn.close()
def save_price_data(items, db_path="flight_price.db"):
if not items:
print("[INFO] 无新数据需要保存")
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
inserted = 0
for item in items:
try:
cursor.execute("""
INSERT INTO price_snapshots
(date, flight_no, price, collect_time, source)
VALUES (?, ?, ?, ?, ?, ?)
""", (
item["date"],
item["flight_no"],
item["price"],
item["collect_time"],
item["source"]
))
inserted += 1
except sqlite3.IntegrityError:
# 唯一索引冲突,说明这条数据已经采集过
pass
conn.commit()
conn.close()
print(f"[INFO] 本次采集新增 {inserted} 条记录")
def main():
init_db()
# 可以在这里扩展成多航线、多日期的循环
flights = [
("BJS", "SHA", "2025-06-15"),
("SHA", "BJS", "2025-06-18"),
("BJS", "SHA", "2025-06-20"),
]
for dep, arr, date in flights:
resp_json = fetch_price_data(dep, arr, date)
items = parse_price_data(resp_json, date)
save_price_data(items)
# 控制爬取频率,避免请求过快被封
time.sleep(random.uniform(3, 8))
if __name__ == "__main__":
main()
唯一索引的设计是我在实际修改中加上的。第一次不加索引的时候,脚本因为某些原因在同一个采集周期内重复执行了一次,导致数据库里出现大量完全相同的数据行。加了这个唯一索引,虽然不能完全替代去重逻辑,但至少能兜底,保证不会重复入库。
5. 对抗反爬的关键:数据有了之后,接下来怎么办
数据链路跑通之后,你很快就会迎来新的问题:爬的次数多了,网站开始不给你返回数据了,或者返回的状态码变成了403、418。这就是反爬机制介入的信号。
这个环节是整个机票爬虫实战中最考验经验的部分。我分几个典型的反爬手段来说。
5.1 IP频率限制与请求节奏控制
最基础的反爬策略是限制单个IP的请求频率。你短时间内发太多请求,网站就可能临时封禁你的IP,表现为:请求返回200,但JSON里的status变成了错误码,或者直接返回一段验证页面。
应对方式最有效的是控制请求频率。我实测下来,单次查询之间隔3到8秒是比较稳妥的节奏。如果需要采集的航线数量特别多,可以把单次任务拆成更小的批次,比如一次只查5条航线,然后休息30秒再继续。
如果你的采集规模更大,就需要考虑代理IP池了。但说实话,对于个人采集价格走势这种需求,频率本来就不会太高,一天查几十次对目标网站来说压力很小,用代理池反而增加了成本和复杂度。先调整频率,解决不了再上代理,不要一上来就上重武器。
5.2 Cookie失效与会话保持
有些机票网站会先要求你访问首页,种下一些Cookie,然后才允许你调用查询API。这种情况下直接用requests.get是拿不到数据的,因为你的请求没有携带正确Cookie。
解决办法是先用requests.Session()保持会话:
python复制session = requests.Session()
# 先访问首页获取初始Cookie
session.get("https://www.example-air.com/", headers=headers)
# 然后带着会话去请求API
resp = session.get(url, params=params, headers=headers)
Session对象会自动保存服务器返回的Cookie,并在后续请求中带上。用Session替换裸的requests请求,是很多机票类网站能爬通的第一步。
这里有个很容易踩的坑:Session里的Cookie也会有有效期,特别是当你间隔好几个小时才跑一次任务的时候,上次保存的Cookie可能已经失效了。所以我的建议是每次任务开始的时候,都先重新访问一次首页刷新Cookie,不要复用旧Session。
5.3 验证码挑战与处理策略
当IP频率限制和Cookie策略都拦不住你的时候,网站可能放出验证码。机票网站最常见的验证码形式是滑块验证或者点选文字。
遇到验证码,我的态度是:承认它的存在,但不要试图绕过它。 这不是在说教,而是从成本和稳定性角度考虑。滑块验证的算法对抗是个无底洞,你花三天时间写一个通过率90%的滑块脚本,网站的检测算法可能一天就更新了。
更务实的策略是降低触发频率。验证码的出现往往源于请求频率过高或者请求特征与真人差异过大。你在请求头里加入真实的浏览行为特征(比如正确的Accept-Language、Referer),把请求频率控制在每分钟不超过10次,绝大多数情况下不会触发验证码。
如果真的被频繁验证码卡住,我的建议是:停一停,让IP冷静几个小时,同时检查你的请求头,特别是User-Agent是不是被识别成了爬虫特征明显的值。
6. 从数据到结论:价格走势的可视化分析
数据有了,接下来就是最后一步:把价格走势呈现出来。这一步是很多人容易敷衍的部分,但实际上,它才是你爬数据的目的——不是为了存数据而爬,而是为了看价格变化规律。
6.1 用Python直接清洗查询数据
从SQLite里查询某条航线的价格走势,SQL很简单:
python复制import sqlite3
import pandas as pd
conn = sqlite3.connect("flight_price.db")
df = pd.read_sql_query("""
SELECT date, flight_no, price, collect_time
FROM price_snapshots
WHERE date = '2025-06-15'
ORDER BY collect_time ASC
""", conn)
conn.close()
# 按天聚合,看每天的最低价格
daily_min = df.groupby(df["collect_time"].str[:10])["price"].min()
print(daily_min)
这段代码用pandas把数据库里的数据读进来,然后按天聚合出最低价格。为什么要按天聚合?因为一天内价格可能波动多次,你真正想做决策时,需要知道的是"某天的最低价是多少""某天的价格为什么突然涨了"。
6.2 用matplotlib画走势图
python复制import matplotlib.pyplot as plt
# 设置中文字体,避免乱码
plt.rcParams["font.sans-serif"] = ["SimHei", "Arial Unicode MS"]
plt.rcParams["axes.unicode_minus"] = False
dates = daily_min.index.tolist()
prices = daily_min.values.tolist()
plt.figure(figsize=(12, 6))
plt.plot(dates, prices, marker="o", linestyle="-", linewidth=2)
plt.title("某航线价格走势")
plt.xlabel("日期")
plt.ylabel("最低价格(元)")
# 标注最低点
min_idx = prices.index(min(prices))
plt.annotate(f"最低价: {prices[min_idx]}",
xy=(dates[min_idx], prices[min_idx]),
xytext=(dates[min_idx], prices[min_idx] + 100),
arrowprops=dict(arrowstyle="->"))
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig("price_trend.png", dpi=150)
plt.show()
matplotlib画图的几个参数值得说明。中文字体设置是个高频坑,Windows下用SimHei一般能解决,macOS用Arial Unicode MS,如果都不行,可以考虑指定系统里已有的其他字体。标注最低点的annotate用法,可以让你一眼看到价格最低的那个日期,这个洞察就是你爬数据最终想要的东西。
6.3 一个我实际观察到的价格规律
用这套系统跑了大概三周后,我发现了一条航线的价格有一种规律:工作日上午查询的价格普遍比周末低。 具体来说,同一航班,周六上午查到的价格比周三上午高出10%到15%。
原因并不玄妙:周末是出行需求高峰,航司的调价算法会根据实时搜索量和出票量动态调价,搜索的人多,价格就涨上去了。这个规律如果不做持续监测是根本发现不了的——你偶尔看一次机票价格,只会觉得"票价好贵",但不知道它在周中其实有回落。
这就是做价格趋势监测的意义:不是预测未来,而是看清楚过去。 当你对某条航线过去三周的价格走势了如指掌,你就知道什么时间点出手下单最划算。
7. 写在最后的几个经验建议
7.1 关于"退出码0"问题的最终解决方案
回到文章开头提到的那个问题。如果你在PyCharm里运行爬虫,只看到Process finished with exit code 0而没有输出,按照下面的顺序排查:
- 确认代码里有没有print语句。如果没有,程序正常运行完自然没有任何输出。
- 确认if name == "main"下面的代码是否被执行。有时候你定义了main函数,但忘了调用它。
- 确认你有没有把requests爬到的数据保存到文件或者数据库。只爬不存,程序运行完数据就丢了,自然看不到结果。
- 确认目标网站返回的内容是否非空。在代码里加上状态码和内容长度的校验,打印出来看看到底有没有拿到数据。
这四个步骤覆盖了90%以上的"退出码0"问题。前两个是你自己的代码逻辑问题,后两个是爬虫与目标网站交互的问题。
7.2 爬虫的伦理边界
说实话,写爬虫的人最需要警惕的不是技术难点,而是"能不能爬"的边界问题。机票价格数据属于公开数据,但你用高频请求去抓取,实际上是在给目标网站的服务器制造额外负担。
我的建议有三条:
- 控制频率,把自己当成一个正常的、有耐心的用户,而不是一个无情的请求机器。
- 只爬你真正需要的数据,不要把所有页面都抓回来存着,那是浪费自己的存储也是浪费别人的带宽。
- 如果只是个人学习和分析,尽量选择提供官方API的数据源。 有些航司和聚合平台提供开发者接口,虽然可能需要申请,但稳定性和合法性都有保障。
最后再分享一个小技巧:把爬虫日志也存下来。 我第一次运行这套系统的时候没有记录日志,后来某天发现数据断档了,怎么也查不到原因。后来加了logging,记录每次请求的URL、状态码、数据量,排查问题就变得简单多了。这个习惯让我在后面几次目标网站接口调整的时候,都能快速定位到问题所在,而不是靠猜。
