
简介这是一款基于Python开发的携程景点评论爬虫工具面向旅游数据分析人员、景点运营者及爬虫学习者解决手动收集评论效率低、数据零散的问题。工具可从携程景点详情页自动识别真实POIID并批量下载该景点全部用户评论最终输出结构化数据包便于后续统计与文本分析。压缩包共6个文件约45KB包含py爬虫主程序、md与txt说明文档、docx附赠资源及license授权文件覆盖源码、运行指引与扩展参考。已有75人学习下载。使用者可获取完整可运行的爬虫脚本理解POIID提取与评论批量抓取的核心逻辑并借助说明文档快速上手、按需修改代码以适配不同抓取任务附赠文档还提供额外教程与常见问题解答帮助排查运行问题、规范数据处理流程。需注意使用时应遵守携程服务条款与相关法律法规在合法合规前提下使用数据。1. 携程景点评论爬虫从 POIID 到全量评论的落地路径做旅游数据分析的同行大概率都遇到过这个场景老板丢过来一个 Excel里面列着几十个热门景区的名字要求两天内交出这些景点的评论情感分析报告。手动复制一个景点动辄几千条评论翻页翻到手抽筋。这时候一套能自动提取 POIID、批量拉取评论的 Python 脚本就成了刚需。这份携程景点评论爬虫工具的核心价值就在于此——它把「景点详情页 → 真实 POIID → 全量评论数据」这条链路串了起来省去了人工找 ID 和逐页翻评论的重复劳动。适合做旅游舆情监控、竞品分析、学术研究的数据从业者也适合刚接触 requests 爬虫、想找一个真实项目练手的 Python 学习者。需要说明的是工具本身只负责数据采集环节后续的清洗、存储、可视化需要自己按需接上。2. 拆解爬虫链路POIID 提取与请求构造的底层逻辑2.1 为什么 POIID 是整个流程的钥匙携程的景点评论接口不是靠景点名称来定位的而是靠一个叫 POIID 的数字标识。你可以把它理解成景点在携程数据库里的身份证号——同一个景点在不同城市可能有重名但 POIID 是唯一的。很多新手一上来就想着用景点名称去拼 URL结果要么请求不到数据要么拿到的是别的景点的评论这就是没搞清楚 POIID 的作用。POIID 通常藏在景点详情页的 HTML 源码里常见的位置有几个页面头部的window.__INITIAL_STATE__变量、某个script标签里的 JSON 配置、或者页面 URL 的 query 参数中。不同页面模板的存放位置可能不一样所以提取逻辑不能写死一个正则就完事得做多模式匹配。我一般会按这个优先级来找先看 URL 里有没有明显的数字 ID没有再去 HTML 里搜poiId、poi_id、sightId这几个关键词最后兜底用正则从整页源码里捞长数字串。下面是一个提取 POIID 的参考实现import re import requests 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/, } def extract_poi_id(detail_url: str) - str | None: 从景点详情页 HTML 中提取 POIID支持多种页面模板 resp requests.get(detail_url, headersHEADERS, timeout15) resp.raise_for_status() html resp.text # 模式一URL 路径中直接带数字 ID如 /sight/xxx/12345.html url_match re.search(r/sight/[^/]/(\d)\.html, detail_url) if url_match: return url_match.group(1) # 模式二页面内嵌 JSON 中的 poiId 字段 json_match re.search(rpoiId\s*:\s*?(\d)?, html) if json_match: return json_match.group(1) # 模式三兜底从 script 标签里捞 6 位以上的数字串 fallback re.search(rpoi[_-]?id[\]?\s*[:]\s*[\]?(\d{5,}), html, re.I) if fallback: return fallback.group(1) return None这段代码的逻辑说明extract_poi_id接收景点详情页的完整 URL先发一次 GET 请求拿到 HTML。然后按三种模式依次尝试匹配——URL 路径匹配最快最准内嵌 JSON 匹配覆盖面广兜底正则用来应对页面改版后的意外情况。参数方面timeout15是防止某个页面卡住导致整个批量任务挂起HEADERS里的Referer建议带上部分接口会校验来源。提示如果三种模式都返回 None大概率是页面结构变了或者触发了验证码这时候不要硬跑先把 HTML 存下来人工看一眼。2.2 评论接口的请求参数怎么拼拿到 POIID 之后下一步是构造评论列表的请求。携程的评论数据一般通过一个独立的接口返回返回格式是 JSON分页由pageIndex或page参数控制。常见的请求参数包括参数名含义典型值注意事项poiId景点唯一标识从详情页提取必须为纯数字字符串pageIndex当前页码1, 2, 3...从 1 开始不是 0pageSize每页条数10 或 20部分接口上限为 20sortType排序方式1最新不同值影响返回顺序channelType渠道标识固定值不同端可能不同实际请求时接口地址和参数名可能随携程前端版本变化。我一般会先用浏览器开发者工具抓一次真实请求把 URL 和参数原样复制到代码里跑通之后再改成循环翻页。下面是一个翻页拉取评论的骨架import time import json def fetch_comments(poi_id: str, max_pages: int 50) - list[dict]: 按页拉取指定景点的评论返回评论列表 api_url https://m.ctrip.com/restapi/soa2/13444/json/getCommentCollapseList all_comments [] for page in range(1, max_pages 1): payload { poiId: poi_id, pageIndex: page, pageSize: 20, sortType: 1, channelType: 2, } try: resp requests.post(api_url, jsonpayload, headersHEADERS, timeout15) data resp.json() except Exception as e: print(f[page {page}] 请求异常: {e}) break items data.get(result, {}).get(items, []) if not items: print(f[page {page}] 无更多数据停止翻页) break all_comments.extend(items) print(f[page {page}] 获取 {len(items)} 条累计 {len(all_comments)} 条) time.sleep(1.5) # 控制频率避免触发风控 return all_comments逻辑说明fetch_comments接收 POIID 和最大页数循环构造 POST 请求。每页拿到数据后检查items是否为空为空说明已经到底了直接 break。time.sleep(1.5)是必须的——不加延迟连续请求轻则被限流返回空数据重则 IP 被临时封禁。参数方面max_pages建议设一个上限防止死循环pageSize如果接口支持 20 就用 20能减少请求次数。2.3 数据落盘JSON 还是 CSV评论拿到手之后落盘格式的选择取决于后续用途。如果要做情感分析、关键词提取JSON 保留字段最完整如果要丢进 Excel 给非技术同事看CSV 更友好。我一般两个都存——原始 JSON 做备份清洗后的 CSV 做交付。import csv def save_comments(comments: list[dict], poi_id: str): 将评论数据分别保存为 JSON 和 CSV # 原始 JSON 备份 with open(fcomments_{poi_id}_raw.json, w, encodingutf-8) as f: json.dump(comments, f, ensure_asciiFalse, indent2) # 清洗后的 CSV fields [commentId, content, score, publishTime, userNick] with open(fcomments_{poi_id}.csv, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnamesfields, extrasactionignore) writer.writeheader() for c in comments: writer.writerow(c) print(f已保存 {len(comments)} 条评论到 comments_{poi_id}.csv)这里有个细节CSV 的编码用utf-8-sig而不是utf-8原因是 Excel 打开纯 UTF-8 的 CSV 时中文会乱码加 BOM 头能解决这个问题。extrasactionignore的作用是当评论字典里有 fields 之外的键时自动忽略不会报错。3. 批量任务编排多景点循环与断点续跑3.1 用配置文件管理待爬景点列表单景点跑通之后下一步就是批量。最忌讳的做法是把景点 URL 硬编码在脚本里改一个就得动代码。常见做法是单独维护一个配置文件把景点名称和详情页 URL 列进去脚本读取后逐个处理。import yaml def load_sights(config_path: str sights.yaml) - list[dict]: 从 YAML 配置文件读取待爬景点列表 with open(config_path, r, encodingutf-8) as f: data yaml.safe_load(f) return data.get(sights, []) # sights.yaml 示例结构 # sights: # - name: 西湖 # url: https://you.ctrip.com/sight/hangzhou14/12345.html # - name: 故宫 # url: https://you.ctrip.com/sight/beijing1/67890.html用 YAML 而不是 JSON 做配置主要是因为 YAML 支持注释团队协作时可以在每个景点旁边标注优先级或备注。load_sights返回一个列表每个元素包含name和url两个字段主流程遍历这个列表即可。3.2 断点续跑别让一个失败拖垮整批任务批量任务最怕的就是跑到第 30 个景点时网络抖动整个脚本崩了前面 29 个的数据虽然存了但重新跑又得从头来。解决办法是维护一个进度文件记录哪些景点已经完成下次启动时跳过。import os PROGRESS_FILE progress.txt def load_progress() - set: 读取已完成的景点标识 if not os.path.exists(PROGRESS_FILE): return set() with open(PROGRESS_FILE, r, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) def mark_done(poi_id: str): 标记某个景点已完成 with open(PROGRESS_FILE, a, encodingutf-8) as f: f.write(poi_id \n) def run_batch(sights: list[dict]): 批量执行爬取任务支持断点续跑 done load_progress() for sight in sights: poi_id extract_poi_id(sight[url]) if not poi_id: print(f[跳过] {sight[name]} 未提取到 POIID) continue if poi_id in done: print(f[跳过] {sight[name]} ({poi_id}) 已完成) continue print(f[开始] {sight[name]} ({poi_id})) comments fetch_comments(poi_id) save_comments(comments, poi_id) mark_done(poi_id) print(f[完成] {sight[name]} 共 {len(comments)} 条)这套机制的核心是progress.txt文件每完成一个景点就追加一行 POIID。下次运行时load_progress把已完成的 ID 读进集合遍历时直接跳过。注意mark_done用的是追加模式a不是覆盖模式否则每次都会把之前的记录冲掉。3.3 异常重试与日志记录网络请求失败是常态关键是要有重试机制和清晰的日志。我一般用requests配合urllib3的重试适配器或者自己写一个简单的重试装饰器。import logging from functools import wraps logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(spider.log, encodingutf-8), logging.StreamHandler(), ], ) logger logging.getLogger(__name__) def retry(times: int 3, delay: float 2.0): 简单的重试装饰器 def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times 1): try: return func(*args, **kwargs) except Exception as e: logger.warning(f{func.__name__} 第 {attempt} 次失败: {e}) if attempt times: raise time.sleep(delay * attempt) return wrapper return decoratorretry装饰器的参数times控制最大重试次数delay是基础等待时间实际等待时间按delay * attempt递增避免短时间内反复冲击目标接口。日志同时输出到文件和控制台排查问题时直接翻spider.log就行。4. 避坑与排查那些跑起来才知道的坑4.1 返回数据为空但状态码是 200现象请求评论接口HTTP 状态码 200但items字段是空数组翻到第二页还是空。原因大概率是触发了携程的风控机制。携程对评论接口有频率限制短时间内大量请求会被静默限流——不返回 403但也不给你数据。解决降低请求频率time.sleep从 1.5 秒加到 3 秒以上如果还是空换一个时间段再跑。另外检查HEADERS里的User-Agent和Referer是否完整缺Referer有时也会被判定为异常请求。4.2 POIID 提取到了但评论接口返回 404现象POIID 明明提取成功但请求评论接口时返回 404 或参数错误。原因POIID 提取对了但接口地址或参数格式变了。携程的评论接口有多个版本PC 端和移动端的接口地址不同参数名也可能有差异。解决用浏览器开发者工具重新抓一次真实请求对比接口 URL、请求方法GET 还是 POST、参数名是否一致。特别注意channelType这个参数不同渠道的值不一样填错了接口会拒绝。4.3 翻页到某一页突然中断现象前 10 页正常返回第 11 页开始报连接超时或返回非 JSON 格式的内容。原因可能是目标接口对翻页深度有限制超过一定页数后不再返回数据也可能是网络抖动导致单次请求失败。解决在翻页循环里加 try-except单页失败不中断整个任务记录失败页码后续补跑。同时设一个合理的max_pages上限一般景点评论超过 50 页的情况很少如果确实需要全量考虑按时间分段拉取。4.4 CSV 中文乱码现象用 Excel 打开生成的 CSV 文件中文全部变成乱码。原因Excel 默认按 GBK 编码打开 CSV而 Python 写文件时用的是 UTF-8。解决写 CSV 时编码参数用utf-8-sig加 BOM 头后 Excel 能正确识别。如果已经生成了乱码文件用记事本打开另存为 ANSI 编码也能救回来但不如一开始就写对。4.5 脚本跑着跑着内存暴涨现象批量爬取几十个景点后Python 进程占用内存从几十 MB 涨到几个 GB。原因所有评论数据都堆在all_comments列表里没释放景点越多内存占用越大。解决每个景点处理完后及时落盘并清空列表不要等所有景点跑完再统一保存。如果确实需要汇总用生成器逐条 yield或者分批写入数据库。5. 进阶技巧评论数据去重与增量更新5.1 基于 commentId 的去重策略同一批评论数据重复拉取时最直接的去重依据是commentId。每条评论在携程系统里都有唯一 ID用集合做去重是最快的。def dedup_comments(comments: list[dict], seen_ids: set) - list[dict]: 基于 commentId 去重返回新增评论 new_comments [] for c in comments: cid str(c.get(commentId, )) if cid and cid not in seen_ids: seen_ids.add(cid) new_comments.append(c) return new_commentsseen_ids集合可以持久化到本地文件或数据库每次增量爬取时先加载历史 ID只保留新增部分。这样重复跑同一个景点时不会产生冗余数据。5.2 增量更新只拉最新评论如果只是想做舆情监控不需要每次全量拉取可以只拉第一页或前几页的最新评论和本地已有数据做对比。具体做法是记录上次爬取时间翻页时遇到发布时间早于上次爬取时间的评论就停止。from datetime import datetime def fetch_incremental(poi_id: str, last_crawl_time: str) - list[dict]: 增量拉取遇到早于上次爬取时间的评论就停止 new_comments [] last_dt datetime.fromisoformat(last_crawl_time) for page in range(1, 10): payload {poiId: poi_id, pageIndex: page, pageSize: 20, sortType: 1} resp requests.post(API_URL, jsonpayload, headersHEADERS, timeout15) items resp.json().get(result, {}).get(items, []) if not items: break stop False for c in items: pub_time c.get(publishTime, ) try: pub_dt datetime.fromisoformat(pub_time.replace(Z, 00:00)) except ValueError: new_comments.append(c) continue if pub_dt last_dt: stop True break new_comments.append(c) if stop: break time.sleep(2) return new_comments这段代码的关键在于sortType1按最新排序然后逐条比对发布时间。一旦遇到早于last_crawl_time的评论就停止翻页避免做无用功。last_crawl_time建议存在配置文件或数据库里每次增量跑完后更新。5.3 验证数据完整性的小技巧爬完之后怎么确认数据没漏我一般会做两个检查一是对比接口返回的总条数和实际保存的条数是否一致二是随机抽几条评论去页面上人工核对。接口返回的 JSON 里通常有一个totalCount字段拿它和len(all_comments)对比差距超过 5% 就说明翻页逻辑有问题得回去查。从那以后我每次跑批量任务前都会先用一个景点做全流程验证确认 POIID 提取、评论拉取、落盘、去重四个环节都正常再放开跑全量。这个习惯帮我省了很多事后补数据的麻烦。希望帮到你。本文还有配套的精品资源点击获取