☰
携程景点评论爬虫实战:接口分析、反爬应对与数据落地
2026/9/30 4:24:45 网站建设 项目流程

这几年做景点口碑分析,十次有八次都要跟携程的评论区打交道。印象最深的是前阵子帮一个朋友整理某5A景区的用户评价,手动翻页看到眼睛发酸,半天才收集几百条,而且翻页过程中页面时不时弹个验证码,心态直接崩掉。后来下决心写一套Python爬虫,把目标景区下的评论成批拉取下来,从接口分析到数据落地,前前后后踩了不少坑,也总结出一套比较稳的流程。这篇就把完整思路、核心代码、反爬应对和工程化细节一次性说清楚,适合正在用爬虫做旅游数据分析、舆情监控或者课程设计的同学参考。

1. 这次爬虫的起点:一份景区口碑分析需求

1.1 环境准备与必要依赖

先说环境。我的本机是Windows 11,Python用的3.11版本,项目单独建了虚拟环境,避免把系统Python搞乱。依赖只需要几样:requests(发请求)、pandas(存数据)、openpyxl(Excel读写)、lxml(备用解析方案)。安装命令如下:

pip install requests pandas openpyxl lxml

如果有selenium兜底需求,再补一个selenium和webdriver-manager,后者可以自动管理浏览器驱动版本,省去手动下载的麻烦:

pip install selenium webdriver-manager

这里多说一句:很多人爬虫第一步就习惯性上selenium,其实对于携程这种大量数据通过XHR接口加载的站点,requests加接口调用完全够用,而且速度更快、更不容易被识别。selenium只作为最后兜底方案,后面会详细讲。

1.2 先搞清楚目标页面长什么样

开工第一步不是写代码,而是打开浏览器,手动把携程某个景点页面的评论区翻一遍。以携程移动端页面为例,访问景点详情页后下滑到"用户点评"区域,你会看到评论列表、评分、点评标签、用户头像等元素。

这时候按F12打开开发者工具,切换到Network(网络)面板,勾选Fetch/XHR筛选条件,然后点击评论区的下一页。你会看到页面并没有整页刷新,而是发了一个以getCommentList之类命名的请求,返回的是JSON格式数据。

这个观察非常重要:它告诉你评论内容来自接口而不是HTML源码。如果一上来就requests.get整个页面然后用正则或者xpath去抠数据,你会发现自己辛苦半天的效果远不如直接调接口干净利落。

1.3 动手前必须明确的合规边界

允许我说几句可能在很多教程里看不到的话。爬虫能做,不代表可以乱来。我这个项目出发点是做景区口碑分析,属于个人学习研究范畴。携程这类平台有大量公开的评价数据,但它同样有反爬措施,也有服务条款限制。实际操作时我给自己定了三条规矩:

  • 不碰用户隐私信息,只取评论内容、评分、标签、评论时间这些维度;
  • 控制请求频率,每页之间至少间隔3到5秒,绝不给目标服务器造成压力;
  • 数据仅用于内部学习分析,不做二次转售,不批量发布。

另外,启动采集前最好看一眼目标站点的robots.txt和点评区相关页面,确认哪些路径是明确禁止的。技术能力是一回事,使用方式又是一回事,把合规边界想清楚,后面跑数据才踏实。

2. 观察携程评论的前端加载逻辑:接口远比页面好抓

2.1 为什么先找接口而不是直接解析HTML

携程的景点评论页属于典型的SPA交互结构,页面主体是服务端渲染的,但评论区块是前端异步加载的。如果直接请求HTML源码,你大概率拿不到完整的评论列表,只能拿到一个加载中的空壳。

更麻烦的是,评论区的翻页、排序、筛选都通过JS触发,不同操作对应不同的接口参数组合。与其去HTML里碰运气,不如直接从接口入手。接口返回的JSON结构清晰,字段完整,分页参数直接暴露在请求载荷里,解析成本低一个量级。

我自己的判断标准是:只要目标数据能通过接口拿到,就绝不用解析HTML的笨办法。接口方案的稳定性也更高,因为页面改版往往只是改UI结构,接口路径和参数大概率保持不变。

2.2 用Chrome DevTools定位评论XHR接口

在Network面板里翻页后,找名称里带comment或者getCommentList的那条请求。点开之后看两个关键地方:一是Request Headers(请求头),二是Request Payload(请求载荷)。

这里贴一份我测试时看到的典型请求头结构(实际字段以你抓包结果为准,但思路通用):

POST https://m.ctrip.com/restapi/soa2/13444/json/getCommentList Content-Type: application/json User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Referer: https://you.ctrip.com/sight/...

请求载荷通常是一个JSON对象,核心参数包括resourceId(景点资源ID)、resourceType(资源类型)、pageIndex(页码)、pageSize(每页条数)、order(排序方式)。把这些参数搞清楚,接口调用的基础就有了。

2.3 接口参数含义与请求头必备字段

在编写代码前,建议建一个表格,把每个参数的含义记录下来。下表是简化版的字段说明,帮助你养成"先整理后编码"的习惯:

参数名含义示例值备注
resourceId景点资源ID68525评论归属的景区标识
resourceType资源类型1固定值,不同站点可能不同
pageIndex当前页码1从1开始递增
pageSize每页条数20最大通常不超过几十
order排序方式3不同值代表默认/最新/好评优先

请求头里,User-Agent、Referer、Content-Type这三个字段建议每次都带上。尤其Referer,很多站点的反爬系统会校验这个字段,少了它可能直接被拒。Cookie一般放在headers里一并提交,登录态不是必须的,但带上之后接口放行的概率更高、封控更慢。

3. 核心代码拆解:请求、解析、清洗、落地一条线

3.1 用curl转requests快速跑通第一版

定位到接口后,最省事的办法是在DevTools的Network面板里找到那条XHR请求,右键选择Copy as cURL,把复制出来的内容粘贴到curlconverter之类的在线工具里,一键转成requests代码。这个方法对新手特别友好,不用手拼请求头和载荷,转出来的代码基本可以直接运行。

我实际跑通的第一版代码长这样(注意resourceId要替换成目标景点ID):

import requests import json url = "https://m.ctrip.com/restapi/soa2/13444/json/getCommentList" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://you.ctrip.com/", "Content-Type": "application/json", } payload = { "arg": { "resourceId": 68525, "resourceType": 1, "pageIndex": 1, "pageSize": 20, "order": 3, "channel": 7 } } resp = requests.post(url, headers=headers, json=payload) print(resp.status_code) print(resp.text[:500])

如果你看到返回的JSON里带了commentList字段,说明第一步已经通了。后面的工作无非是把这个请求循环起来,再对返回数据做解析和清洗。

3.2 JSON数据解析:提取评论内容与评分标签

接口返回的JSON结构通常比较规整,核心数据嵌在data.commentList里。每一条评论对象里常见字段有:评论正文content、综合评分ratingScore、点评标签tagList、点评时间、发布者昵称等。

我的解析思路很直接:先把整个JSON转成Python字典,再定位到评论列表,逐条抽取字段,组装成统一的记录格式。下面是一个解析并打印评论内容的简化版本:

data = resp.json() comment_list = data.get("data", {}).get("commentList", []) for item in comment_list: content = item.get("content", "").strip() score = item.get("ratingScore", "") tags = [tag.get("name", "") for tag in item.get("tagList", [])] publish_time = item.get("publishTime", "") print(score, tags, content[:80], publish_time)

实际跑的时候发现,有些评论内容是空的,需要过滤掉;有些评分字段不在顶层,而是嵌套在子对象里;还有评论标签的key名在不同接口版本下会变化。这些细节不用背,跑一版打印出来对比着调就行。

3.3 评论清洗与分页循环的边界条件

解析完单页数据,紧接着就要考虑两个问题:什么时候停止翻页?重复评论怎么处理?

第一版我只写了一个简单的for循环,从pageIndex=1开始一直加,直到返回的评论列表为空才停。这个逻辑简单粗暴,但有个隐患:如果某页因为网络抖动返回了空列表,程序会提前终止,导致后续数据漏采。

更稳妥的做法是记录"上次请求是否真正返回数据",并对空列表做二次确认,比如连续三次空列表才退出循环。同时,用评论ID或者"内容+时间"组合做去重,把重复项过滤掉。去重判断放到内存里即可,采集量在几千条规模时完全不担心性能。

page_index = 1 seen = set() results = [] while True: payload["arg"]["pageIndex"] = page_index resp = requests.post(url, headers=headers, json=payload, timeout=10) comment_list = resp.json().get("data", {}).get("commentList", []) if not comment_list: empty_retry += 1 if empty_retry >= 3: break page_index += 1 continue empty_retry = 0 for item in comment_list: cid = item.get("commentId") if cid and cid not in seen: seen.add(cid) results.append({ "评论ID": cid, "评分": item.get("ratingScore", ""), "内容": item.get("content", "").strip(), "标签": "|".join(tag.get("name", "") for tag in item.get("tagList", [])), "时间": item.get("publishTime", "") }) page_index += 1 time.sleep(3.5) print("共采集评论:", len(results))

这段代码里,time.sleep(3.5)是必须写的,既是反爬策略,也是基本的网络礼貌。后面在反爬章节我会再展开聊。

4. 反爬虫交手记录:携程在哪些环节拦截你

4.1 携程反爬的3个典型触发点

跑了大概半小时后,我发现事情没那么简单。前几百条评论很顺利,越往后越频繁出现异常响应。总结下来,携程反爬主要有三个触发点:

第一,请求频率过高。连续以小于1秒的间隔翻页,很快会触发限流,接口返回异常或者跳验证码。第二,请求头不完整。Cookie丢失、Referer错误、User-Agent过于标准,都容易触发风控。第三,行为模式异常。比如翻页速度快到不符合人类浏览规律,一定时间内的请求总量超过阈值,即便频率看起来正常也会被标记。

有一次测试,我按0.5秒一页的速度抓了200页,直接触发了滑块验证码弹窗。后来把间隔调到3秒以上,情况立刻好转。这个对比让我确认了:携程对请求频率的敏感度远高于对请求头细节的敏感度。

4.2 Session、Cookie与请求头的维护技巧

一个很实用的技巧是维护一个requests.Session对象。Session会自动保存服务端下发的Cookie,并在后续请求中自动携带,省去手动管理Cookie环节。

session = requests.Session() session.headers.update(headers) def fetch_comment_page(params): resp = session.post(url, json=params, timeout=10) return resp

把headers初始化时塞进session,后面所有请求都走session,Cookie一致性由模块内部管理。如果你的浏览器已经登录过携程,还可以从DevTools里复制当前Cookie值,手动覆盖一下,这样能延续登录态。实测下来,带Cookie的请求被风控的概率更低一些。

请求头方面,我见过有人刻意伪造一整套浏览器指纹,其实大可不必。保持User-Agent非空且版本合理、Referer指向目标站点、Content-Type正确,这三样就够了。过度伪装反而容易触发异常行为检测。

4.3 遇到滑块验证码时的正确姿势

滑块验证码是全自动采集绕不开的坎。我的原则是:能不碰尽量不碰,碰上了就停下来休息。

触发验证码后,接口返回内容通常不再包含正常JSON,而是跳转到一个验证页。此时继续发请求没有任何意义,只会加重风控标签。正确做法如下:

  • 立即停止请求,等待5到15分钟;
  • 这段时间手动在浏览器里访问一次目标景点页;
  • 重新从浏览器复制Cookie,更新session.headers;
  • 降低后续请求频率,把页间隔从3秒调到5秒以上;
  • 如果频繁触发,换个时间段再采集。

这里强调一点:我没有选择去模拟滑动轨迹或者接入打码平台。原因很简单,项目本身只是拿公开评论做分析,付出大量成本去对抗验证码没有意义,而且容易踩到法律灰线。换个时间、降个频次往往就能解决问题。

4.4 selenium兜底方案的取舍

如果你仔细观察评论区后确认某条信息只能通过页面渲染得到,接口里没有对应字段,这时候才轮到selenium登场。一个轻量级兜底方案是:用selenium打开浏览器翻页,等DOM渲染完成后用find_elements拉评论节点。

from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) driver = webdriver.Chrome(options=options) driver.get("https://you.ctrip.com/sight/68525.html") for _ in range(3): cards = driver.find_elements(By.CSS_SELECTOR, ".commentItem") for card in cards: content = card.find_element(By.CSS_SELECTOR, ".commentContent").text print(content) next_btn = driver.find_element(By.CSS_SELECTOR, ".nextPage") next_btn.click() time.sleep(4)

这套代码能跑,但效率和稳定性都比接口方案差一截,主要是页面渲染慢、元素定位依赖CSS类名,类名一旦改动就得跟着改代码。所以我的建议是:先花时间在DevTools里找接口,实在找不到接口再用selenium,不要一上来就上重型武器。

5. 批量采集多景点时,必须处理的三个工程问题

5.1 多景点ID的批量获取思路

如果只是爬一个景点,前文的代码已经够用了。但真实需求往往是分析同一区域多个景区的口碑,或者对比不同城市的同类景区。这时候就涉及景点ID的批量获取。

携程景点详情页的URL里通常带一串数字ID,比如sight/68525.html中的68525就是景点资源ID。人工逐个复制当然可以,但效率太低。借助景点搜索接口,搜关键词后返回的结果JSON里往往包含景点ID列表。可以先请求搜索接口,提取ID列表,再遍历调用评论接口。

这个思路其实暴露了一个核心能力:爬虫不只是一段请求代码,而是一套围绕目标数据的处理器流程。拿到ID列表后,建议写入一个CSV文件,便于断点续跑和排查问题。

5.2 断点续爬与异常重试机制

采集中最常见的意外是网络超时和反爬触发。几千条数据采集过程中,如果跑到一半程序崩了,从头再来非常浪费。我会做两件事:一是把所有请求放进一个循环里,单条请求失败时捕获异常并重试;二是定期把已采集的数据写入本地文件,而不是等到全部跑完再落盘。

def safe_fetch(params, retry=3): for i in range(retry): try: resp = session.post(url, json=params, timeout=10) if resp.status_code == 200: return resp.json() except requests.RequestException as e: print("请求异常, retry:", i + 1, e) time.sleep(2) return None

Cycle结构再加入"每采集50页就写一次Excel"的机制。这样即便中途报错,最多损失最近50页的数据,重跑成本非常小。

5.3 数据落地Excel与后续分析

最后一批数据落地,最简单的做法是用pandas把列表转DataFrame,再输出到Excel。

import pandas as pd df = pd.DataFrame(results) df = df.drop_duplicates(subset=["评论ID"]) df = df[df["内容"].str.len() > 0] df.to_excel("scenic_comments.xlsx", index=False) print(df.shape)

落地之后,做统计就非常方便了。比如计算不同评分档位的占比,用标签类目统计游客最关注的体验维度,按时间维度看评论趋势。这些分析直接基于Excel即可完成,不需要建数据库。如果数据量超过几万条,再考虑换成SQLite。

顺便提醒一句:Excel单元格有字符数限制,超长评论可能被截断。如果发现评论内容不完整,可以把评论内容单独存成CSV或者JSON行,Excel只放汇总统计表。

6. 跑完上千条评论后的几点体会

整套流程跑下来,印象最深的一件事就是:爬虫的瓶颈几乎永远不在代码,而在对目标站点的理解程度。你花十分钟抓包找到接口,比花两小时写一个完美的HTML解析器有价值得多;你把请求频率控制在一个"礼貌"的区间,比研究各种花哨的隐藏技巧重要得多。

最终数据量是1287条有效评论,清洗后拿来做情感分析和标签统计,置信度基本够用了。耗时大概四十分钟,其中很大一部分是等待请求间隔。如果你要爬的景点评论量特别大,可以把间隔时间适当压缩到2秒左右,同时关注响应码变化,在触发风控前及时刹车。

后续要扩展的话,可以考虑接入代理池做分布式采集,或者把数据落在数据库里做增量更新。但这些属于锦上添花,对大部分个人分析和学习场景来说,Requests加pandas这套组合已经足够解决问题。

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

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

立即咨询