简介这是一款基于Python开发的携程景点评论爬虫工具面向旅游数据分析人员、景点运营者及需要采集真实评论数据的研究者。工具可从携程景点详情页自动识别并提取真实POIID进而批量下载该景点的全部用户评论省去手动搜索与复制的繁琐提升数据收集效率与准确性。压缩包共6个文件约45KB包含1个py爬虫主程序、1个md说明文档、1个docx附赠资源、2个txt说明与依赖文件及1个license结构轻量、便于快速上手与二次修改。已有75人学习下载。获取后可直接运行爬虫脚本按说明完成评论抓取并将结果整理为结构化数据包用于游客反馈分析、市场趋势研判与旅游产品策略参考附赠文档还提供使用教程与常见问题解答帮助读者在合法合规前提下扩展抓取任务。1. 携程景点评论爬虫从 POIID 到全量评论的落地路径做旅游数据分析的朋友大概率都遇到过这个场景想评估某个景区的口碑走势打开携程景点详情页评论一条条翻得手酸复制粘贴到 Excel 又丢字段。携程景点评论爬虫工具要解决的就是这件事——输入一个景点页面地址自动解析出真实的 POIID再按这个 ID 批量拉取该景点的全部用户评论落成结构化数据。它适合做舆情监控、竞品分析、旅游选品的数据同学也适合刚学完 requests 和 xpath、想找一个真实站点练手的 Python 爬虫入门者。核心难点不在写循环而在两件事一是 POIID 藏在页面哪里、怎么稳定提取二是评论接口的分页参数和风控节奏怎么拿捏。这两点吃透剩下的就是工程化的事。2. 拆解携程景点页POIID 到底藏在哪一层2.1 为什么不能直接爬 HTML 里的评论列表很多人第一反应是 requests 拿到景点详情页 HTML然后 xpath 提取评论。这个思路在携程上会直接翻车。景点详情页首屏渲染出来的评论通常只有寥寥几条而且是服务端预渲染的占位内容真正的评论列表是页面加载后由 JavaScript 异步请求接口、再动态插入 DOM 的。你用 requests 拿到的 HTML 里根本没有完整评论xpath 自然抓了个空。正确的做法是走接口。打开浏览器开发者工具的 Network 面板筛选 XHR/Fetch 请求往下滚动评论区域你会看到形如https://m.ctrip.com/restapi/...的请求返回的是 JSON。这才是评论数据的真实来源。所以整个工具的架构应该是先请求景点详情页或直接构造详情接口拿到 POIID再用 POIID 去请求评论接口分页拉取。这里有个容易混淆的点携程 PC 端和移动端的接口域名、参数命名不完全一样。移动端接口m 开头通常返回更干净、字段更全我一般优先走移动端。你需要在开发者工具里把设备模拟切成手机或者直接抓移动端页面的请求才能看到这套接口。2.2 从详情页提取 POIID 的三种方式POIID 是携程对每个景点分配的唯一标识评论接口必须带上它。提取方式按稳定性排序第一种从详情页 URL 里正则提取。携程景点详情页 URL 常见形态是https://you.ctrip.com/sight/xxx/数字ID.html那个数字就是 POIID。这种方式最省事但前提是你拿到的是标准详情页链接。第二种从页面内嵌的 JSON 里提取。详情页 HTML 里往往有一段window.__INITIAL_STATE__或类似的初始化数据脚本里面包含poiId字段。用正则或 json 解析都能拿到。第三种直接调详情接口。移动端有景点详情接口返回 JSON 里明确带poiId。这种方式最稳因为不依赖 HTML 结构。我一般用「URL 正则兜底 详情接口优先」的组合先尝试从 URL 抠抠不到再请求详情接口。下面是一个提取 POIID 的最小实现。import re import requests HEADERS { User-Agent: (Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1), Referer: https://m.ctrip.com/, } def extract_poiid_from_url(url: str): 从详情页 URL 中提取 POIID形如 /sight/xxx/12345.html m re.search(r/sight/[^/]/(\d)\.html, url) return m.group(1) if m else None def extract_poiid_from_html(url: str): 请求详情页从内嵌 JSON 中提取 poiId 字段 resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 # 页面里通常有 poiId:12345 或 poiId12345 两种写法 m re.search(rpoiId\s*:\s*?(\d)?, resp.text) if not m: m re.search(rpoiId(\d), resp.text) return m.group(1) if m else None def get_poiid(url: str): poiid extract_poiid_from_url(url) if poiid: return poiid return extract_poiid_from_html(url) if __name__ __main__: test_url https://you.ctrip.com/sight/shanghai2/12345.html print(get_poiid(test_url))逻辑说明extract_poiid_from_url用正则匹配 URL 路径里的数字段这是最快路径extract_poiid_from_html作为兜底请求页面后从文本里搜poiId字段兼容 JSON 和 query 两种写法。参数上HEADERS里的 User-Agent 必须伪装成移动端否则拿到的可能是 PC 端页面字段结构不同timeout设 10 秒避免个别慢响应拖死整个流程。注意正则里的\d要加引号边界否则可能匹配到 URL 里其他数字段。提示携程页面结构会调整正则不是一劳永逸的。上线前先手动跑一遍确认当前页面里poiId的实际写法再决定正则。3. 评论接口分页参数、签名与翻页终止条件3.1 评论接口的请求参数怎么定拿到 POIID 后下一步是构造评论请求。在开发者工具里点开那条评论 XHR看它的 Query String Parameters通常包含这几类参数名含义取值要点poiId景点唯一标识上一步提取的值pageIndex页码从 1 开始递增pageSize每页条数常见 10 或 20别设太大sortType排序方式默认综合可切最新其他 token/签名风控校验从真实请求里复制注意时效pageSize 不建议贪大。设成 100 看着能少翻几页但接口往往有上限超了会被截断或直接拒绝而且单次响应体太大也容易触发风控。我一般用 10 到 20稳。签名参数是这块最玄学的地方。有些接口带_fxpcqlniredt、x-traceid之类的字段它们可能由前端 JS 生成也可能只是透传。实操中先原样复制浏览器里的值试一次能通就说明它不校验或校验宽松如果返回 403 或空数据再考虑用无头浏览器执行页面 JS 拿签名。别一上来就啃 JS 逆向很多接口其实没那么严。3.2 翻页终止与去重逻辑评论接口返回的 JSON 里通常有totalCount总评论数和当前页的items列表。翻页终止条件有三个按优先级判断一是当前页items为空说明到底了二是已抓条数达到totalCount三是连续两页返回的评论 ID 完全重复说明接口在给你返回缓存页再翻也没意义。去重必须做。携程评论有commentId字段用它做唯一键存进 set 里每次插入前判断。不做去重的话排序变化或接口抖动会导致同一条评论被抓多次最后统计口径全乱。import time import requests def fetch_comments(poiid: str, max_pages: int 200): 按页拉取评论返回去重后的评论列表 base https://m.ctrip.com/restapi/soa2/xxxx/getCommentList seen set() results [] for page in range(1, max_pages 1): params { poiId: poiid, pageIndex: page, pageSize: 20, sortType: new, # 按最新排序减少翻页过程中数据变动 } resp requests.get(base, paramsparams, headersHEADERS, timeout10) if resp.status_code ! 200: print(fpage {page} status {resp.status_code}, stop) break data resp.json() items data.get(items) or data.get(commentList) or [] if not items: break new_count 0 for it in items: cid it.get(commentId) if cid and cid not in seen: seen.add(cid) results.append(it) new_count 1 # 整页都是重复的说明接口在返回旧数据终止 if new_count 0: break time.sleep(1.5) # 控制节奏别把对方打疼 return results逻辑说明seen集合负责去重new_count记录本页新增条数为 0 就终止这是防缓存页的关键。sortType选最新而非综合因为综合排序会随热度动态变化翻页时容易漏数据或重复。time.sleep(1.5)是节奏控制具体秒数看接口容忍度1 到 3 秒之间比较安全。max_pages是保险丝防止接口异常时无限翻页。参数上base里的接口路径要以你实际抓到的为准不同时期可能不同。items字段名也可能叫commentList代码里做了兼容。如果返回结构里评论在data.data.comments这种嵌套层级需要按实际调整取值路径。注意time.sleep是单线程下的节奏控制。如果你要上并发别简单套多线程先想清楚目标站点的承受能力和你的合规边界。4. 数据落库与字段清洗把 JSON 变成能分析的表格4.1 该保留哪些字段评论接口返回的 JSON 字段很多全存没必要挑分析用得上的。我一般保留这几类评论 ID、用户昵称脱敏后、评分、评论正文、发布时间、点赞数、回复数、出行类型如家庭游、情侣游。评分和出行类型是做交叉分析的关键维度别丢。发布时间经常是「2024-05-01」或「3天前」这种混合格式需要统一。相对时间要结合抓取日期换算成绝对日期否则后续按时间聚合会出错。4.2 用 pandas 清洗并导出import pandas as pd from datetime import datetime, timedelta def parse_time(raw: str, crawl_date: datetime): 把 3天前 2024-05-01 统一成 YYYY-MM-DD if not raw: return None if 天前 in raw: days int(re.search(r(\d), raw).group(1)) return (crawl_date - timedelta(daysdays)).strftime(%Y-%m-%d) if 小时前 in raw or 分钟前 in raw: return crawl_date.strftime(%Y-%m-%d) return raw.strip() def to_dataframe(comments, crawl_dateNone): crawl_date crawl_date or datetime.now() rows [] for c in comments: rows.append({ comment_id: c.get(commentId), score: c.get(score), content: (c.get(content) or ).replace(\n, ).strip(), publish_date: parse_time(c.get(publishTime), crawl_date), like_count: c.get(likeCount, 0), travel_type: c.get(travelType), }) df pd.DataFrame(rows) df df.drop_duplicates(subsetcomment_id) df df[df[content].str.len() 0] # 丢掉空评论 return df if __name__ __main__: # 假设 comments 来自上一章的 fetch_comments df to_dataframe(comments) df.to_csv(ctrip_comments.csv, indexFalse, encodingutf-8-sig) print(df.shape, df[score].mean())逻辑说明parse_time处理相对时间这是清洗里最容易忽略的一步不处理的话时间维度分析直接废掉。drop_duplicates二次去重防止接口层漏网。过滤空评论是因为有些用户只打分不写字留着会拉低文本分析的信噪比。导出用utf-8-sigExcel 打开不乱码。参数上crawl_date默认取当前时间如果你补抓历史数据要传入实际抓取日期否则相对时间换算会偏。score字段有的接口是字符串必要时加pd.to_numeric转换。5. 避坑与排查那些让我重跑整晚的细节5.1 返回 200 但数据为空现象请求状态码 200JSON 也能解析但items是空数组。原因通常是签名参数过期、Referer 不对或者 pageIndex 从 0 开始而接口要求从 1 开始。解决先用浏览器复现一次请求把完整 headers 和 params 复制过来逐个比对重点看 Referer 和那几个 token 字段。pageIndex 起始值试 0 和 1 各一次。5.2 翻到某页突然全是重复数据现象前几十页正常某一页开始返回的评论和前面完全一样。原因是接口对深分页做了限制或者排序不稳定导致分页错位。解决换sortType为最新排序减少动态变化同时用new_count 0作为终止条件别硬翻。如果确实需要全量考虑按时间区间切片分多次抓。5.3 抓了几百条后被限流现象请求开始返回 403 或连接超时。原因是请求频率过高触发风控。解决加大time.sleep单 IP 场景下 2 到 3 秒比较稳降低并发必要时分时段抓。别用疯狂重试硬刚越试封得越死。5.4 评论正文里混入 HTML 标签现象导出的 CSV 里评论内容带br、a等标签。原因是接口返回的正文是富文本。解决入库前用正则或 BeautifulSoup 去标签只留纯文本。别直接存原始 HTML后续做分词会污染结果。5.5 POIID 提取到了但评论接口报参数错误现象POIID 看着没问题评论接口却提示参数非法。原因是详情页的 POIID 和评论接口要的 ID 不是同一个体系有些景点详情页 URL 里的数字是「sightId」评论接口要的是「poiId」两者可能不同。解决以详情接口返回的poiId字段为准别迷信 URL 里的数字。6. 进阶把单景点脚本改成可批量、可验证的工具单景点跑通只是起点。真实需求往往是给一批景点链接批量出数据。这里分享两个我踩过坑之后固定下来的做法。第一个是任务队列化。别用 for 循环硬串把「景点 URL 列表」写进一个队列文件每条任务记录状态待抓、抓取中、完成、失败脚本每次取一条处理处理完更新状态。这样中断了能续跑失败的可单独重试。用 Python 内置的queue.Queue加一个 JSON 状态文件就够不必上重型框架。第二个是抽样验证。抓完不等于抓对。我习惯随机抽 10 条评论手动打开对应景点页面比对内容、评分、时间是否一致。曾经有一次接口字段改版score从数字变成了{value: 5}这种结构脚本没报错但导出的评分全是空抽样一比才发现。验证这一步省不得。再给一个批量调度的骨架import json import os STATE_FILE tasks.json def load_tasks(): if os.path.exists(STATE_FILE): with open(STATE_FILE, encodingutf-8) as f: return json.load(f) return [] def save_tasks(tasks): with open(STATE_FILE, w, encodingutf-8) as f: json.dump(tasks, f, ensure_asciiFalse, indent2) def run_batch(): tasks load_tasks() for t in tasks: if t[status] done: continue try: poiid get_poiid(t[url]) comments fetch_comments(poiid) df to_dataframe(comments) df.to_csv(fout/{poiid}.csv, indexFalse, encodingutf-8-sig) t[status] done except Exception as e: t[status] failed t[error] str(e) save_tasks(tasks) # 每条都落盘断电也不怕 if __name__ __main__: os.makedirs(out, exist_okTrue) run_batch()逻辑说明状态文件是后悔药每条任务处理完立刻写盘脚本崩了重启能接着跑。out/{poiid}.csv按景点分文件避免大表混在一起难排查。异常捕获后记录 error 字段方便事后定位是网络问题还是解析问题。参数上STATE_FILE和输出目录按你的项目结构调整。如果任务量大可以把save_tasks改成追加写减少全量序列化开销。最后说个习惯每次抓完我会先看三个数——总条数、评分均值、时间跨度。这三个数对不上常识比如一个热门景点只有 20 条评论基本就是接口或解析出了问题先别急着分析回头查数据。这套流程我用了很久稳的时候很稳但每次携程改版都得重新对一遍接口没有一劳永逸的爬虫只有随时能修的脚本。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?