☰
Python爬虫实战:实时抓取飞猪机票折扣信息
2026/10/7 4:13:37 网站建设 项目流程

今天聊一个实战项目:用 Python 爬虫实时抓取飞猪旅行上面的机票折扣信息。很多人看到"实时抓取"四个字就觉得门槛高,其实拆开看就是"定时发起请求 + 解析航班价格 + 增量存储"三个环节。这个项目能解决的实际问题很具体——比如你经常飞某条航线,想知道什么时候放低价票,或者想对比几个日期的折扣力度,手动刷网页不仅累,而且容易错过放票窗口。这篇文章把整个实现过程掰开揉碎讲清楚,从技术选型到代码实现再到踩坑记录,适合有 Python 基础但没做过完整爬虫项目的读者,也适合想把自己的查票流程自动化的人。

先说明一件事:本文只讨论对公开页面数据的合理抓取,频率控制在正常访问范围内,不涉及绕过任何技术保护措施,抓下来的数据也仅用于个人学习与分析。爬虫的边界比技术本身更重要,这一点我会在后面的部分反复提到。

1. 项目概述:拆需求、定边界、选架构

1.1 这个项目到底在解决什么问题

飞猪上的机票价格不是固定不变的,同一个航班在一天内可能调整好几次价格,折扣票更是放出一批就少一批。手动盯盘的痛点有三个:第一,你不可能整天开着页面刷新;第二,即使刷新了,也很难记住价格变化趋势,不知道现在是高是低;第三,多日期多航线对比时,人工操作效率极低。

这个项目就是要做一个"盯盘机器人":每隔一段时间自动去抓取指定航线的航班列表,解析出航班号、起降时间、原始价、折扣价、折扣比例这些字段,把结果写入本地数据库。积累几天数据之后,你就可以清楚看到哪些日期段折扣力度大,哪个时间段放票最频繁,甚至可以反过来推算航司的调价节奏。

从技术角度看,这个需求可以拆成四个子任务:数据源接入、页面解析、差异检测、执行调度。数据源接入解决"怎么拿到数据",页面解析解决"数据长什么样",差异检测解决"价格变了没有",执行调度解决"什么时候去抓"。四个部分各管一段,后续扩展或替换都非常灵活。

1.2 整体架构与数据流向

整个系统的数据流向很清晰:调度器触发抓取任务,请求模块带上合理的请求头访问目标页面,拿到响应后交给解析模块提取字段,然后经过数据清洗写入存储。另外加一个告警模块,当检测到某个航班折扣比例超过设定阈值时,立刻推送通知。

用图来表示大概是这样的流程(文字版):

调度器(定时触发)→ 请求模块 → 响应内容 → 解析模块 → 清洗与结构化 → 存储模块 ↓ 差异检测 → 告警通知

实际落地的时候,我建议先用最简单的同步方案跑通,不要一上来就上 Scrapy、Celery、Redis 那一套。原因很简单:机票数据量不大,一个 flights 表几千条记录完全够用,同步请求加简单循环就能满足需求。等确实遇到性能瓶颈,再考虑分布式也不迟。

1.3 合规边界:动手之前先想清楚

爬虫项目的合规问题必须放在最前面说。飞猪的服务条款里对数据抓取有明确限制,所以这个项目有两个硬性原则:第一,只抓取公开页面能直接看到的数据,不登录、不绕过验证、不突破频率限制;第二,抓取频率要温和,我实际操作中把请求间隔设置在 5 到 10 秒之间,绝不对同一接口做并发轰炸。

另外,robots.txt 协议也值得看一下。虽然它不是法律文件,但遵守它是从业者的基本素养。如果你连目标站点的 robots 规则都不查就直接开爬,那这个项目做得越成功,风险反而越大。

2. 技术选型:把合适的工具放到合适的位置

2.1 请求库与解析库的选择逻辑

Python 生态里做请求的库很多,requests、httpx、aiohttp 各有优势。这个项目我选的是 requests,理由很直接:它足够稳定,文档齐全,遇到问题随便一搜就有答案。异步方案 aiohttp 在并发量大的时候确实快,但我们的场景是低频定时抓取,异步带来的复杂度远大于收益。

解析库方面,lxml 的 XPath 是首选。有些文章推荐 BeautifulSoup,但我个人更习惯 XPath,因为它对页面结构的表达更精准,尤其在处理复杂的 DOM 嵌套时,XPath 的路径语法比 find() 链式调用直观得多。正则表达式则用来做最后的字段提取兜底,比如从一段混合文本里抠出价格数字。

下面是我常用的一套组合:

场景工具选择理由
HTTP 请求requests生态成熟、API 简洁、调试方便
页面解析lxml + XPath路径表达精准、性能好
文本提取re处理不规则字段时兜底
数据存储sqlite3零配置、单文件、适合个人项目
定时调度schedule / 系统 crontab轻量级实现,不依赖外部服务

2.2 存储设计:为什么用 SQLite 而不是 CSV

很多新手喜欢把数据存成 CSV,省事是省事,但后续查询和去重非常痛苦。这个项目我用 SQLite,它有四个天然优势:支持 SQL 查询,可以按日期、航线、折扣率做筛选排序;自带去重能力,通过唯一索引避免重复记录;单文件存储,备份迁移都方便;Python 内置 sqlite3 模块,不用额外安装数据库服务。

建表的时候,我给每一条记录设计了一个唯一键,由"航班号 + 出发日期 + 抓取批次"拼接而成。这样同一航班同一出发日期即使被抓取多次,也能保留每次的历史价位,而不是被新数据覆盖掉。价格历史本身就是数据分析的原料,这个设计直接决定了后续能不能做趋势分析。

2.3 反爬机制的常见思路与应对原则

任何有点流量的网站都会做反爬,飞猪这种体量的平台更不例外。常见的反爬手段包括请求头校验、IP 频率限制、JS 动态渲染、数据接口混淆等。我们的原则不是"破解",而是"模拟真实用户"。

具体做法上,我会把 User-Agent 设置成真实浏览器的完整值,带上 Accept-Language、Referer 等常规请求头,让请求看起来像从一个正常的 Chrome 浏览器发出的。同时控制访问频率,每次抓取结束后强制 sleep 几秒。这样做的逻辑很简单:我们不是在做数据轰炸,而是在模拟一个普通用户偶尔刷新页面,只要行为合理,触发风控的概率就会低很多。

如果页面内容是通过 JavaScript 动态加载的,requests 直接拿到的 HTML 里可能没有数据。这时候有两个选择:一是抓包找底层的数据接口,直接请求 JSON 数据;二是用 Selenium 或 Playwright 模拟浏览器。优先选前者,因为接口返回的 JSON 结构化程度高、解析成本低,而且请求量更小。

3. 核心代码实现与关键环节拆解

3.1 环境准备与请求模块封装

开始写代码之前,先把环境准备好。我用的是 Python 3.8 以上版本,依赖库只有 requests、lxml、schedule 这三个,装起来非常快:

pip install requests lxml schedule

请求模块我封装了一个简单的函数,统一设置请求头、超时时间,并把请求间隔的控制也放在这一层。这样主程序逻辑不会被请求细节干扰,后续想增加代理、调整头信息也只需要改这一个文件。

import requests import time HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://www.fliggy.com/", } last_request_time = 0 def fetch_page(url, max_retries=3): """带基本重试和礼貌限速的页面请求""" global last_request_time for attempt in range(max_retries): # 控制请求间隔,至少 5 秒一次 wait_time = max(0, last_request_time + 5 - time.time()) if wait_time > 0: time.sleep(wait_time) try: resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() last_request_time = time.time() return resp.text except requests.RequestException as e: print(f"[{attempt + 1}/{max_retries}] 请求失败: {e}") time.sleep(3 * (attempt + 1)) return None

注意这段代码里的限速逻辑:last_request_time记录了上一次请求的时间戳,每次发起请求前先算一下距上次是否已满 5 秒,不够就主动 sleep。这是整个项目里最重要的一行代码,它保证了我们的程序无论如何都不会变成对目标站点的请求风暴。

3.2 解析航班列表:从杂乱 HTML 中提取结构化字段

拿到页面 HTML 之后,下一步就是解析。机票列表页的 HTML 结构通常非常复杂,各种嵌套的 div、span、class 命名也不够语义化。我的做法是先找一个目标元素,比如整个航班卡片区域的 XPath,然后把卡片逐个遍历,对每个卡片提取需要的字段。

from lxml import html def parse_flight_cards(page_text): """从页面 HTML 中提取航班基础信息""" if not page_text: return [] doc = html.fromstring(page_text) cards = doc.xpath('//div[contains(@class, "flight-card")]') results = [] for card in cards: try: flight_no = card.xpath('.//span[contains(@class, "flight-no")]/text()') dep_time = card.xpath('.//span[contains(@class, "dep-time")]/text()') arr_time = card.xpath('.//span[contains(@class, "arr-time")]/text()') price_raw = card.xpath('.//span[contains(@class, "price-now")]/text()') if not flight_no or not price_raw: continue results.append({ "flight_no": flight_no[0].strip(), "dep_time": dep_time[0].strip() if dep_time else "", "arr_time": arr_time[0].strip() if arr_time else "", "price": extract_price(price_raw[0]), }) except Exception as e: print(f"单卡片解析失败: {e}") continue return results

这里有一个很容易被忽略的点:XPath 的text()方法返回的是字符串列表,所以即使取到内容也要记得[0]取值。另外页面上可能混着促销标签、划线价、满减信息等各种噪声,直接用第一个匹配结果经常会抓错。我建议在提取价格时先拿到整段文本,再用正则把数字抠出来。

import re def extract_price(raw_text): """从混合文本中提取价格数字,兼容"¥890起"这类格式""" match = re.search(r'(\d+(?:\.\d+)?)', raw_text.replace(",", "")) return float(match.group(1)) if match else None

3.3 折扣率计算与历史数据对比

单次抓取只是拿到了"当下的价格快照",真正的价值在于和昨天的数据做对比。我在存储层设计了一个price_history表,每次抓取都会插入一条新记录,标记这次抓取的批次号。这样同一个航班就有了一条价格随时间变化的曲线,折扣率也能算得更准确。

折扣率的计算逻辑是这样的:从页面里同时提取"原价"和"当前价",折扣率就是当前价除以原价。这个值越小说明折扣力度越大。有些页面不直接显示原价,而是只显示"XX折"或者"已减XX元",那就要反向推断。

def calc_discount(current_price, original_price): """计算折扣率,返回百分比数值""" if not current_price or not original_price or original_price == 0: return None return round(current_price / original_price * 100, 1) def check_big_discount(history_row, threshold=40.0): """判断是否触发低价告警""" discount = history_row.get("discount") if discount is not None and discount <= threshold: send_alert(history_row["flight_no"], discount)

阈值 40 的意思是"四折以下才告警"。实际使用中可以根据航线热度调整:热门商务航线放四折票已经很罕见,但旅游航线经常出现两折三折的票,同一个阈值不一定适用所有情况。

3.4 定时任务:让程序自己"醒来"去抓数据

实时抓取必须解决"什么时候抓"的问题。我的做法是在 Python 脚本内部用 schedule 库写定时逻辑,每天跑固定几个时段。为什么不用 crontab?因为 crontab 在 Windows 上的体验一般,而 schedule 是纯 Python 实现,改起来直观,而且可以在任务之间共享内存变量。

import schedule import time def job(): print("开始抓取最新机票折扣信息...") page_text = fetch_page(TARGET_URL) flights = parse_flight_cards(page_text) saved_count = save_to_database(flights) print(f"本次抓取完成,新增 {saved_count} 条有效记录") check_alert_threshold() # 每天抓取四个时段:早中晚和凌晨 schedule.every().day.at("06:30").do(job) schedule.every().day.at("12:00").do(job) schedule.every().day.at("18:30").do(job) schedule.every().day.at("23:50").do(job) while True: schedule.run_pending() time.sleep(30)

选择这四个时间点有讲究:早上六点半是部分航司放特价票的高频时段,中午十二点适合观察上午的价格波动,晚上六点半是下班后的查询高峰,凌晨十一点五十则能抓住一天最后的调价。这些时间点是根据我的长期观察总结出来的,你可以根据自己的目标航线调整,但建议至少保证每天两次抓取,密度太低的话价格曲线就不连贯了。

3.5 告警推送:用最简单的方式通知自己

数据抓下来之后,怎么让人知道"便宜票出现了"?我用的方案是 Server酱(一种通过微信接收消息的推送服务),只要发一个 HTTP 请求就能把消息推到微信上。如果你不想依赖第三方服务,也可以写一个简单的邮件通知,或者干脆把告警信息写进日志文件,每天手动看一眼。

def send_alert(flight_no, discount): """通过 Server酱发送低价提醒""" title = f"航班 {flight_no} 折扣预警" content = f"当前折扣已低至 {discount} 折,请及时查看!" try: requests.post( "https://sctapi.ftqq.com/YOUR_SENDKEY.send", data={"title": title, "desp": content}, timeout=10 ) except requests.RequestException as e: print(f"推送失败: {e}")

注意这里的 SENDKEY 要从环境变量里读取,不要硬编码在源码里。把密钥写进代码再传到 GitHub 上的教训,我相信不需要重复讲了。

4. 实战中遇到的典型问题与排查技巧

4.1 页面结构变化:昨天能抓到今天抓不到

网页改版是爬虫项目的家常便饭。有一次我照常运行任务,突然所有航班都抓不到了,页面返回正常但解析结果为空。排查的时候我先把页面保存到本地,用浏览器打开,发现原来 class 名从flight-card改成了ticket-item,整个 DOM 结构都调整了。

这种现象告诉我们两件事:第一,XPath 写得太细是脆弱的,id 和嵌套层级一换就失效,尽量用稳定的属性定位,比如包含稳定关键词的 class 模糊匹配;第二,程序要预留"解析失败"的告警机制,连续两次抓取结果为零时,应该通知开发者去检查页面结构,而不是沉默地一直跑下去。

4.2 请求被限流:从 IP 被封到动态休眠

限流是另一个高发问题。我的账号和 IP 都经历过被临时限制访问的情况,表现是请求能返回 200,但内容变成了验证页面。后来我把请求间隔从 3 秒加到了 5 到 8 秒,并且加上指数退避的逻辑——一旦发现返回内容里出现验证码关键词,立刻停止当前批次任务,等待更长时间再试。

def detect_blocked(page_text): """检测页面是否出现验证或拦截标志""" markers = ["验证", "captcha", "滑动", "访问过于频繁"] return any(m in page_text for m in markers)

这种做法比硬着头皮重试更聪明:如果你的程序检测到被拦截还继续高频请求,只会加重限制。正确姿势是"遇到拦截立刻停止,进入冷静期"。

4.3 动态渲染内容:requests 拿不到数据怎么办

有些页面把航班数据放在 JavaScript 变量里,通过 Ajax 请求后端接口再渲染到页面。直接用 requests 抓 HTML,拿到的可能只是一个首屏框架。这时候我有两个办法,优先用第一个:

第一个是抓包分析接口。用浏览器的开发者工具切到 Network 面板,翻页操作时观察发出的 XHR 请求,通常能找到返回 JSON 的数据接口。直接请求这个接口,解析效率比解析 HTML 高一个量级。

第二个是切到 Playwright 方案。如果接口做了签名校验,分析成本太高,那就用无头浏览器直接渲染页面,等 JS 执行完后再读取 DOM。代价是内存占用更高、速度更慢,所以能走接口就尽量走接口,Playwright 是最后的兜底方案。

# Playwright 兜底方案示例(仅在接口方案不可行时使用) from playwright.sync_api import sync_playwright def fetch_page_with_browser(url): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, timeout=30000) page.wait_for_selector("div.flight-card", timeout=10000) html_content = page.content() browser.close() return html_content

4.4 数据脏乱:价格字段里的隐形陷阱

机票价格的格式远比想象中复杂。页面上可能出现"¥890起"、"券后 ¥860"、"含税总价¥980"等不同形式,直接截取数字很容易把"起"字带进来,或者把优惠券前的价格当成最终价格。我的处理方式是通过小样本观察页面规律,先手动统计所有价格出现的上下文,再写针对性的清洗规则。

另一个坑是价格单位。飞猪的国际机票有时显示的是外币价格,如果直接把"USD 200"当成人民币来算折扣率,结果就会完全失真。清洗逻辑里一定要带上币种判断,发现非人民币单位时单独标记,不参与人民币折扣计算。

5. 常见问题速查表与扩展方向

5.1 问题速查表

现象可能原因处理方式
请求返回 200 但内容为空页面结构变动或动态渲染保存页面检查结构;尝试浏览器方案
返回验证码页面请求频率过高停止任务,拉长间隔至 60 秒以上
解析结果数量明显变少部分航班卡片结构不同抓取页面样本,补充新的 XPath 规则
价格数值为 None页面格式调整更新正则规则,补充新格式样本
定时任务不触发电脑休眠或脚本退出检查系统电源设置;部署到云服务器
数据库记录重复唯一索引缺失为关键字段添加 UNIQUE 约束

5.2 后续还能怎么玩:从"抓价格"到"预测价格"

这个项目跑通两到三周后,数据库里就有了一条完整的价格曲线,这个时候可以做的事情就多了。比如用简单的时间序列分析,找出每周的低价窗口规律;或者爬取多个航司平台的价格做横向对比,判断当前价格在不同平台间是否划算;更进一步,可以用历史数据训练一个轻量回归模型,给出"未来三天该航线价格大概率下降/上涨"的参考信号。

我个人操作中的体会是,爬虫项目的价值曲线并不是线性的,前期花在解析和处理反爬上的时间看起来很长,但等到数据积累起来了,后面每一个分析需求都会变得极其顺手。这也是为什么我一直强调"存储设计要留历史、留批次",没有历史数据,所有关于趋势的判断都是空中楼阁。

最后再分享一个细节:抓下来的每一条数据,不管价格有没有变化,都值得保留。很多爬虫项目只在乎"当前值",但价格变化本身就是信息。今天的票价比昨天贵了 30 块,这可能只是正常的浮动,但如果同一航班连续三天都在同一时间涨价,那背后多半有规律可循。数据攒得越久,你能看到的规律就越多,这也是实时抓取项目中"存储"和"抓取"同等重要的原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询