用Python爬虫爬取飞猪旅行酒店套餐信息,这项目听起来挺唬人,但真做起来其实就是三件事:找到数据接口、伪装得像正常用户、把返回的数据清洗落地。我前后花了两天时间把它跑通,中间踩了不少坑,今天把这套完整思路和代码逻辑整理出来,给想做类似采集项目的朋友一个可参考的样板。
这不是一段复制就能跑的“一键脚本”,而是把整个分析过程摊开讲清楚:页面怎么分析、接口怎么找、XPath怎么抽数据、遇到反爬怎么处理。适合有点Python基础、想完整走一遍真实爬虫项目,而不是停在教程demo层面的读者。如果只是为了临时抓一两个价格,本文同样能帮你少走弯路。
1. 项目整体拆解与合规边界
1.1 需求拆解:这个项目到底在爬什么
“爬取飞猪旅行酒店套餐信息”这个标题拆开看,有四个关键点:Python是工具栈,爬虫是采集手段,飞猪旅行是目标站点,酒店套餐信息是最终要拿的数据对象。
很多人上手就想着去分析接口签名、抓加密参数,这是搞错顺序了。第一步应该是搞清楚“酒店套餐信息”到底包含哪些字段。我最终确定的目标字段是:套餐标题、酒店名称、适合人数、套餐价格、原价、包含权益(早餐/房型/景点门票)、所在城市、销量。这些字段分布在列表页上,不需要点进详情就能拿到大部分,这就把采集难度降了一个量级。
页面数据结构也要先摸清楚。飞猪的酒店套餐页,搜索后展示的是卡片式信息流,每张卡片一个套餐。这种页面通常不是服务端直接渲染的,而是前端通过XHR请求接口拿JSON数据再渲染。这就引出一个关键选择:直接抓页面HTML解析,还是找底层接口请求。
两个方案我都试过。纯HTML解析的问题在于页面结构随版本更新容易变,而且列表页有懒加载,滚动才有新数据。走接口则稳定不少,数据直接是结构化JSON,解析成本低。前提是你能从浏览器开发者工具里找到那个真正返回套餐数据的请求。怎么看?打开Network面板,刷新页面,按XHR过滤,找返回内容里能看到价格和酒店名的请求,一个一个点,两三分钟就能定位。
1.2 合规边界:写爬虫前必须先想清楚的事
这年头爬虫出事的新闻不少,所以在动手前,合规边界必须先立好。合规不是空话,是保护自己。具体到这个项目,有几条红线我建议死死守住。
第一,只采集公开可见的信息,不碰需要登录才能看的内容,更别尝试绕过任何登录鉴权。飞猪酒店套餐搜索页不强制登录,这就够了。第二,严格遵守robots协议,虽然飞猪的robots.txt不会写“欢迎爬虫”,但至少明确告诉你了哪些路径不允许访问,别去踩。第三,控制采集频率和总量,你要做的是比价和观察市场,不是把人家整个数据库搬走。第四,采集到的数据只用于个人合理参考,不做商业转售,不对外提供批量查询服务。
还有一条很容易被忽略:不要构造恶意请求。比如用高并发去压接口、用大量随机参数去遍历数据,这些行为已经不是“爬虫学习”了,是攻击。爬虫实战项目做完,代码怎么写不重要,边界感才是真正重要的东西。我个人习惯是:采集间隔至少3秒起步,每天总量控制在几百条以内,触发风控立刻停手,绝不硬刚。
1.3 应用场景分析:这事做完到底能拿来干嘛
做完这个项目,你能拿它做不少实际的事。个人旅行规划是最直接的场景——收集多家酒店套餐的价格和权益,横向对比哪家划算,而不是一家一家切页面人工看,看得眼花还容易漏。第二种场景是做小范围的酒店市场观察,比如观察某个节假日前后套餐价格浮动趋势,这些数据用Excel拉个透视表就能看出规律。
如果你想把数据长期攒着,还能做定时采集,每天跑一次,持续积累价格曲线,观察有没有大促前的调价动作。这属于后续扩展,我在第六部分会展开说。但无论哪种用法,前提都是数据质量要过关——字段完整、去重干净、价格是纯数字格式而不是带了一堆促销文案的字符串,这些功夫都花在解析和清洗上。
2. 环境准备与依赖安装
2.1 Python运行环境:版本选择与虚拟环境隔离
这个项目基于Python 3.8完全能跑,但建议用3.10以上版本,兼容性更好。我自己用的是Python 3.11,主要是为了lxml和pandas的预编译wheel包下载方便,省去编译的麻烦。
新项目一律建议建虚拟环境,别直接装到全局环境里。项目依赖越整越乱的时候,虚拟环境就是你后悔药。具体操作:
python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activateWindows用户有个常见坑:命令行里输入python,可能打开的是Microsoft Store的安装引导页。这是因为Windows应用商店的别名拦截了命令。解法是到Python官网下载安装包,安装时务必勾选“Add Python to PATH”,装完在cmd里输入python --version确认版本号能正常输出。
2.2 核心依赖安装:requests、lxml、pandas
本项目用到四个核心库:requests(HTTP请求)、lxml(XPath解析)、pandas(数据清洗)、fake_useragent(生成随机UA)。安装一次搞定:
pip install requests lxml pandas fake_useragent这里说明一下为什么用lxml而不是BeautifulSoup。BeautifulSoup的find系列方法写起来确实顺手,但面对飞猪这种层级复杂的卡片结构,XPath的优势就出来了:能用一条路径直接从根节点定位到目标节点,还能用contains匹配动态class名。而这些页面里的class名经常带随机后缀,比如“product-card--s3k2f”,如果用精确class匹配,页面一改就崩。contains模糊匹配就是为此准备的。
fake_useragent的作用是每次请求随机生成一个浏览器UA,避免所有请求都顶着同一个Python-requests默认UA,那种一眼就是机器的指纹很容易被识别。
2.3 辅助工具清单:浏览器开发者工具和调试技巧
除了Python环境,另一个核心工具是浏览器开发者工具(F12)。平时大家用它看报错,爬虫场景下它至少承担三个任务:定位XHR请求、查看请求头参数、验证XPath语法。
定位XHR请求的方法前面讲过。查看请求头参数,主要是确认哪些Header是服务端必校验的,最简单的方式就是自己写代码只带UA去请求,被403了再逐步补Referer、Origin、Cookie,直到恢复200。这个过程在开发者工具里对比每个请求的Header,很快能圈出关键字段。
XPath语法验证有个小技巧:在开发者工具Console里直接调用$x("//div[contains(@class, 'title')]"),这比反复写Python脚本试错快太多。定位逻辑在浏览器里验证通过后,再搬到Python里跑,一次成功率非常高。
3. 页面分析与反爬机制拆解
3.1 飞猪酒店套餐页面的请求链路分析
飞猪搜索酒店套餐,选择城市和日期后,页面上看到数据是靠多个XHR请求撑起来的。其中最主要的一个接口返回套餐列表数据,包含了价格、销量、卖点标签这些核心字段。
怎么在浏览器里找到它?刷新页面后打开Network面板,按XHR过滤,会看到一批名叫hotel、search、list之类的请求。逐个点开看Response预览,哪个请求返回的数据里能搜到“酒店名”或“价格”,哪个就是目标。定位后,右键这个请求复制为cURL,拿到完整的URL、请求方式和Headers。
接着看参数。搜索版的接口URL上通常带city、checkIn、checkOut、pageIndex等参数,翻页时pageIndex从1递增,这就构成了爬虫的翻页基础。这里有个很重要的建议:先不要急着写代码,直接在浏览器里手改URL参数测试,确认真实参数含义。很多参数传错了服务端不会报错,而是忽略它返回默认数据,容易造成采集数据全是同一页的假象。
3.2 常见反爬手段与应对策略
飞猪这级别的站点,反爬不是单一的,而是几道关叠加。我把实操中能摸到的关卡和应对思路整理成表:
| 反爬手段 | 典型表现 | 应对思路 |
|---|---|---|
| User-Agent校验 | 非法UA直接403 | 伪造浏览器UA,用fake_useragent轮换 |
| Referer/Origin校验 | 请求无Referer被拦 | 请求头补齐Referer和Origin |
| 请求频率限制 | 连续请求后出现418/验证码 | 控制间隔3-5秒,随机睡眠 |
| Cookie校验 | 缺少特定Cookie被302 | 从浏览器复制基础Cookie字段 |
| 字体反爬 | 页面价格显示正常但爬下来是乱码 | 优先找接口获取原始数据 |
| JS加密参数 | 接口参数经过混淆签名 | 不做破解,只处理明文参数的页面版本 |
这里需要重点说明的是JS加密参数。部分接口的请求参数里有签名值,比如sig、token之类的字段,这种参数是在页面JS里动态生成的。破解它需要逆向JavaScript,投入产出比很低,我不建议新手碰。务实的做法是找不带签名参数的页面版本或接口,比如一些活动页、列表页的早期版本。这不是投机取巧,而是采集公开信息本来就应该选阻力最小的通道。
3.3 数据动态加载判断方法:HTML解析还是直接请求接口
判断页面数据是静态的还是动态加载的,有个非常简单的方法:在浏览器里禁用JavaScript再刷新页面,看内容有没有渲染出来。内容在就说明服务端直接吐HTML,用XPath解析即可;内容空了,说明必须走接口。
两种方式各有利弊。纯HTML方式的好处是请求少且不需要关心参数签名,坏处是页面结构经常变,class名带随机后缀,需要靠contains模糊匹配来兜底。接口方式数据规范、字段稳定,但要处理Headers、动态参数、分页逻辑。飞猪这个项目,两者其实能搭配用:接口拿JSON数据,HTML页面用来兜底校验,防止接口参数变动后直接抓瞎。
我在实操中遇到过接口突然返回空数据的情况,排查半天发现是参数里少了某个城市代码。这种暗坑很难在代码层面完全规避,只能靠异常监控和日志记录,抓不到数据时输出请求URL便于排查。这个经验后面会细讲。
4. 爬虫核心实现与数据抽取
4.1 请求会话配置:模拟真实浏览器的访问上下文
requests.Session能维持Cookie和相关状态,是爬虫请求的标准姿势。配合Header配置,请求伪装到这一步就够用了。
import requests import fake_useragent import time import random ua = fake_useragent.UserAgent() session = requests.Session() session.headers.update({ "User-Agent": ua.random, "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://www.fliggy.com/", "Origin": "https://www.fliggy.com", }) session.cookies.update({ # 从浏览器复制的基础Cookie,用于维持基础会话 # "cookie_name": "cookie_value", })Cookie这里多说一句:如果只是采集公开搜索结果,基础Cookie通常不是必需的;但带上浏览器里的Cookie字段能显著减少被风控的概率。操作方式是登录飞猪网页版,在开发者工具Application面板里找到当前页面的Cookie,把关键的几个字段复制进代码。注意这里只建议带自己的账号Cookie,不做任何账号破解或绕过操作。
请求间隔的控制我用的是随机睡眠,核心是让请求节奏接近真人浏览:
def safe_request(url, params, max_retry=3): for attempt in range(max_retry): try: resp = session.get(url, params=params, timeout=10) if resp.status_code == 200: return resp elif resp.status_code in (403, 418): time.sleep(random.uniform(8, 15)) continue except requests.RequestException: time.sleep(5) return None随机区间选定8-15秒是针对触发风控后的冷却策略,符合“看出异常就停手”的原则。正常频率下间隔3-5秒即可,不需要为了快而牺牲稳定性。
4.2 列表页接口与翻页逻辑实现
确定了目标接口之后,翻页参数通常比较直观。以搜索列表接口为例,需要构造的参数包括城市、入住日期、离店日期、页码。翻页循环要注意边界条件:接口返回一个总数totalCount,按每页pageSize条数算出总页数,超过页数后停止翻页,避免死循环打爆服务端。
city = "杭州" check_in = "2025-04-20" check_out = "2025-04-22" page_size = 20 base_params = { "city": city, "checkIn": check_in, "checkOut": check_out, "pageSize": page_size, } all_items = [] for page in range(1, 6): # 这里限定最多5页,属于个人使用的合理范围 params = {**base_params, "pageIndex": page} resp = safe_request("https://example.fliggy-api.com/search", params) if not resp: print(f"第{page}页请求失败,停止翻页") break data = resp.json() items = data.get("data", {}).get("items", []) if not items: break all_items.extend(items) time.sleep(random.uniform(3, 5))这里有个实操细节:每页请求成功后、发起下一页请求前,必须等待。等待时间建议做成带随机波动的,固定时间间隔反而容易被识别为脚本行为。
4.3 XPath抽取套餐核心字段:text函数的正确用法
如果你拿到的响应不是JSON而是HTML,XPath就是主力解析工具。以列表页套餐卡片为例,定位逻辑如下:
from lxml import html doc = html.fromstring(page_source) cards = doc.xpath("//div[contains(@class, 'product-card') and contains(@class, 'item')]") for card in cards: title_node = card.xpath(".//div[contains(@class, 'title')]")[0] title = "".join(title_node.xpath(".//text()")).strip() price_node = card.xpath(".//span[contains(@class, 'price')]") price = "".join(price_node[0].xpath(".//text()")).strip() if price_node else "" tags = card.xpath(".//div[contains(@class, 'tag') or contains(@class, 'label')]//text()") tags = [t.strip() for t in tags if t.strip()]XPath里最容易被带偏的就是text()的用法。很多人看到.//text()拿不到完整文本就懵了,其实是没搞清楚text()返回的是当前节点下的直接文本节点,而不是所有后代文本。如果标题里嵌套了span、em这些子标签,直接取text()只能拿到外层的一点文本,剩下的散落在子节点里。
解决办法有几种。一是像我上面那样,用join(节点.xpath('.//text()'))把所有后代文本节点拼起来,这种方式最通用。二是用string()函数,它会把整个节点及其后代的文本全部取出来,等价于浏览器里看到的内层文本。区别在于text()是返回节点列表,string()是返回拼接后的字符串,用的时候注意别混。
contains的用法是另一个高频坑。class名精确匹配非常脆弱,因为前端框架经常在class后面追加哈希后缀。contain匹配时最好连类名主干一起写,比如contains(@class, 'product-card'),不要只匹配一个单词,防止误匹配无关节点。
4.4 价格清洗与数据标准化
接口返回的数据字段通常相对规范,但价格字段经常带着文本符号,比如“¥599起”,HTML版本里还可能混入“直减30元”这类促销文案。标准化处理是必须的:
import re def clean_price(raw): if not raw: return None # 提取数字和小数点,去掉其他字符 match = re.search(r"\d+(\.\d+)?", raw.replace(",", "")) return float(match.group()) if match else None同样需要清洗的还有销量字段,常见格式为“已售1.2万”,需要把“万”换算成12000。日期字段建议统一成YYYY-MM-DD格式,方便后续比较价格时做时间轴排序。
所有字段清洗完毕后,组装成结构化记录并去重。去重逻辑我按“酒店名称+套餐标题+价格”组合作为唯一键,因为同一酒店可能卖多个套餐,标题和价格能准确定位一条记录。pandas在这里很顺手:
import pandas as pd df = pd.DataFrame(all_items) df = df.drop_duplicates(subset=["hotel_name", "title", "price"]) df = df.dropna(subset=["title"])4.5 数据落地:CSV导出与MySQL存储
清洗后的数据存储方案,按使用场景选。临时采集、个人比价,CSV足够;需要长期增量采集,建议落MySQL。CSV导出的坑大家踩得最多的是中文乱码,解决方案是编码用utf-8-sig而不是utf-8:
df.to_csv("fliggy_hotel_packages.csv", index=False, encoding="utf-8-sig")MySQL存储时先建表,字段设计按套餐信息的关键维度来:
CREATE TABLE hotel_package ( id INT AUTO_INCREMENT PRIMARY KEY, hotel_name VARCHAR(255), title VARCHAR(255), price DECIMAL(10, 2), original_price DECIMAL(10, 2), city VARCHAR(50), suitable_people VARCHAR(50), sales INT, tags VARCHAR(500), crawl_date DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_hotel_title_price (hotel_name(100), title(200), price) ) DEFAULT CHARSET=utf8mb4;插入数据用executemany批量提交,效率和稳定性都好:
import pymysql conn = pymysql.connect( host="localhost", user="root", password="yourpass", database="hotel_crawler", charset="utf8mb4" ) sql = """INSERT INTO hotel_package (hotel_name, title, price, original_price, city, suitable_people, sales, tags, crawl_date) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE price = VALUES(price)""" rows = [ (item["hotel_name"], item["title"], item["price"], item["original_price"], item["city"], item["suitable_people"], item["sales"], item["tags"], crawl_date) for item in cleaned_items ] with conn.cursor() as cur: cur.executemany(sql, rows) conn.commit() conn.close()这里有个经验:建表时唯一索引不是拍脑袋加的,是防止定时任务重复采集时产生脏数据。同一条记录重复抓取时,价格如果变了就更新,否则跳过,这一招让增量采集的数据干净不少。
5. 常见问题与排查实录
5.1 请求状态码异常:403、418、302
这三个状态码是爬虫路上最常见的拦路虎,含义和处理思路完全不同。
403表示服务端拒绝请求,通常是请求头不全或者UA被识别。先补Header,重点加Referer和Origin;还不行就把浏览器请求的完整Header照搬进代码。418是典型的反爬拦截响应,说明服务端判断你的请求是机器人,最常见原因是请求频率过高。遇到418别硬重试,最好的做法是停掉任务,隔半小时再跑,同时把请求间隔调大一倍。302跳转则要检查Cookie,可能是缺少了必要的会话字段导致被重定向到登录页或验证页。
我实测经历过从403一路改到200的过程,最后发现卡在一个很不起眼的Accept字段上。浏览器的请求头看起来一堆冗余字段,但某些后端框架对Header的完整性校验很严格,缺一个就拒绝。所以排查Header问题的时候,最佳实践是:在开发者工具里复制cURL,导入到Postman能正常请求,再从Postman生成的代码里对比你的Header差异,效率很高。
5.2 XPath取不到数据:三个高概率原因
XPath匹配不到节点,大多数时候不是代码问题,而是拿到的页面结构和你想的不一样。常见原因有三个。
第一,页面是动态渲染的,直接GET返回的HTML里根本没有对应节点。判断方法前面说过,禁用JS刷新页面看内容是否还在。如果内容靠接口渲染,老老实实找XHR请求。
第二,class名匹配写死了。飞猪页面的class名经常带哈希后缀,我今天看到的是product-card--abc123,明天可能就成了product-card--xyz789。写死精确匹配就废了。正确做法是把只有变动部分去掉,用contains匹配主干。
第三,iframe嵌套。有些页面内容在iframe里,直接在文档里抓不到。先看响应HTML里有没有iframe标签,有的话要单独请求iframe的src地址再解析。这类问题在比较老的页面里多见,飞猪现在基本不用。
排查XPath问题,我的习惯是在浏览器Console里先用$x()验证路径,验证通过再搬到Python里。这个方法能省掉大量“路径写错但在Python里反复试”的时间。
5.3 中文乱码问题:编码探测和强制指定
requests默认会猜编码,猜错就乱码。飞猪页面的响应头通常带charset信息,但也有不带的时候。标准做法是先看响应头声明,没声明就用apparent_encoding探测:
resp.encoding = resp.apparent_encoding这个探测也不是百分百准,中文页面最稳妥的是直接指定UTF-8:
resp.encoding = "utf-8"另外注意lxml解析HTML时,如果原始内容是bytes类型,lxml会自动按页面声明的编码解析;如果你手动把响应内容转成str再传,就会丢掉编码信息,导致乱码。最佳实践是直接传resp.content给html.fromstring,让它自己识别编码。
5.4 被封与限流:判断标准和处理策略
所有反爬对策最终都指向一个问题:被封了怎么办。被封之前通常有征兆,最常见的信号是:连续三次请求状态码从200变成418,或者返回的数据突然被截断,每条都缺字段。
我的处理策略很保守:一旦连续两次出现非200状态码,立即终止任务,不重试,不换参数硬闯。等一段冷却期后再跑,同时把请求间隔从3秒拉长到10秒以上。这种“怂一点”的策略看似慢,实际总耗时反而更短,因为无限重试才会把时间浪费在没有意义的对抗上。
另外一个容易被忽视的风险点:不要多线程并发抓取同一接口。这个项目规模根本用不上并发,单线程配合合理延时,既稳定又不容易触发风控。真追求速度的数据采集项目,那是另一个量级的架构问题,和本文的场景完全不同。
6. 后续扩展思路与个人实操体会
6.1 定时增量采集与价格趋势分析
如果想让这个项目产生长期价值,最值得做的扩展就是定时增量采集。思路很简单:用APScheduler或系统cron每天定时跑一次脚本,数据通过唯一索引更新到MySQL,积累一个月后,你就有了一张酒店套餐价格变化表。
在此基础上,可以统计每家酒店套餐价格在节假日前后的变动幅度,观察哪些酒店“先涨后降”,哪些酒店一直坚挺。这类分析在地理位置、数据量层面不会涉及任何敏感信息,完全作为个人消费决策参考。
from apscheduler.schedulers.blocking import BlockingScheduler scheduler = BlockingScheduler() scheduler.add_job(main_crawl_func, "cron", hour=9, minute=30) scheduler.start()定时任务还需要考虑异常重试。每天跑的时候可能会因为网络波动失败,常见做法是失败后延迟半小时重试,最多重试两次。三次都失败就跳过当天,不积压任务。
6.2 数据可视化:套餐价格分布可视化面板
采集的数据攒到一定量级后,可以做一个简单的可视化面板。用pyecharts画城市酒店均价对比、按月价格趋势、酒店套餐销量Top20,这些图对个人决策很有参考价值。可视化部分不复杂,pandas统计后直接调pyecharts接口就能出图。
这里有一个坑:酒店名称在飞猪上可能有好几种写法,比如“杭州西溪宾馆”和“西溪宾馆(杭州)”,这不是同一家酒店但字符串不同,会导致统计重复。清洗时需要做一个简单的归一化——去掉括号和城市前缀,再去做统计。这一步在数据分析时比爬虫本身更花时间,别忽略。
6.3 我的实操体会:稳定推进比强硬突破重要
最后分享几个做这个项目沉淀下来的体会。
代码层面,最让我意外的是,整个项目里最耗时间的不是写爬虫逻辑,而是字段清洗和数据校验。接口返回的JSON数据哪怕结构再规范,价格字段里也会混入各种不可控的字符串,标签字段可能多个套餐共用一套文案,导致去重失败。新手做爬虫项目容易把注意力全放在“怎么把数据抓下来”上,但真正决定项目质量的是“怎么把数据洗干净”。
反爬层面,我的体会是不要抱着“破解”的心态去做采集。遇到418就停,看到参数加密就绕道走公开页面,这种不硬刚的策略反而让项目跑得最久。爬虫是数据获取手段,不是攻击工具,这个认知能帮你避开绝大多数风险。
最后一个实用建议:代码里每层请求都加日志,记录请求URL、状态码、耗时。这个习惯在你排查“为什么今天抓到的数据比昨天少一半”这种问题时,能直接帮你定位是接口参数变了、被限流了还是页面结构改了。没有日志的爬虫项目,出问题就像在黑暗里摸开关,效率极低。
做采访自动化的过程中,这套“找接口优先、解析兜底、清洗为重”的思路同样适用:先用接口拿结构化数据,拿不到再退到HTML解析,最终目标始终是拿到干净、可用、合规的数据。也希望这篇飞猪酒店套餐采集项目的完整复盘,能帮你跨过从demo到实战的那道坎。