news 2026/9/16 16:45:45

Python爬取携程景点与评论数据的实战方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬取携程景点与评论数据的实战方案

简介:这是一份面向计算机相关专业本科生的高分毕业设计实战资源,聚焦Python网络爬虫开发,帮助学生高效完成毕设、课程设计或期末大作业。项目完整实现携程平台景点信息与用户评论的自动化采集,含可直接运行的源码、配置说明与技术文档,适合零基础入门者学习,也支持进阶者二次开发。压缩包共12个文件,包含4个核心Python脚本(如poi_crawl.py、comment_crawl.py)、2个Markdown文档(含README说明)、2个配置类文件(config.ini、requirements.txt)及辅助文本与版本控制文件,整体仅15KB,轻量易读,结构清晰便于理解爬虫模块划分与工程组织逻辑。已有813人下载学习,项目经导师评审答辩平均分达96.5分,所有代码均实测通过,附带远程答疑支持,提供从环境配置、数据解析到异常处理的全流程实践参考。

1. 毕业设计里真能靠 Python 爬到携程景点和评论数据?别被 ZIP 包名骗了,这本质是「静态页面结构解析 + 反爬策略绕过 + 多级数据关联」三重实战

很多同学在毕设选题时看到“基于 Python 实现的爬取携程景点数据和评论数据”这个标题,第一反应是:有源代码、有文档说明、解压就能跑——结果双击运行main.py报错ModuleNotFoundError: No module named 'requests',装完 requests 又卡在urllib.error.HTTPError: HTTP Error 403: Forbidden,再查发现评论页根本没 HTML,全是 JS 渲染的空 div。这不是代码写得烂,而是标题里藏着三个关键断层:携程 PC 端已全面转向动态渲染、真实评论接口需带签名参数、景点列表页与详情页 ID 不直接对应。本篇不讲 ZIP 包里那套过时脚本,而是按 2024 年实际可落地的方案重走一遍:用requests+execjs模拟签名、用seleniumplaywright渲染评论 DOM、用pandas做景点-评论双向关联。适合正在写毕设、需要可答辩、可演示、可查重的 Python 初学者,也适合想快速验证携程数据采集可行性的数据岗求职者——你不需要懂加密算法,但必须知道 signature 怎么生成、XHR 请求怎么抓、JSON 数据怎么拼接。


2. 为什么不能直接 requests.get(‘https://www.ctrip.com/…’)?先拆解携程页面的真实加载链路

2.1 携程景点页不是传统 HTML,而是「骨架页 + 异步 JSON + 客户端渲染」三段式结构

打开 Chrome 开发者工具(F12),访问任意携程景点页(如 https://vacations.ctrip.com/dest/1010000000001.html),Network 标签页下刷新页面,你会发现:

  • 首屏 HTML(Document 类型)体积极小(<50KB),仅含<div id="app"></div>和几行 script 标签;
  • 真实数据来自多个 XHR 请求,例如:
    • https://vacations.ctrip.com/dest/detail?destId=1010000000001&locale=zh-CN→ 返回景点基础信息(名称、地址、开放时间);
    • https://vacations.ctrip.com/dest/review?destId=1010000000001&page=1&pageSize=10→ 返回评论列表(含用户昵称、评分、文本、时间);
    • https://vacations.ctrip.com/dest/photo?destId=1010000000001&size=10→ 返回图片链接。

提示:这些接口并非公开 API,URL 中的destId是唯一标识,但不会在 HTML 源码中明文出现。它藏在页面内嵌的 JavaScript 变量里,比如window.__INITIAL_STATE__ = {...}var destId = "1010000000001";。直接 requests.get 首页拿不到,必须解析 JS 上下文或用浏览器引擎执行。

2.2 评论接口强制校验 signature 参数,绕过它才是核心难点

抓包发现https://vacations.ctrip.com/dest/review?...的请求头中包含X-Signature: xxxxx,且每次请求值都不同。进一步分析发现,该 signature 是对查询参数(destId,page,pageSize,locale)+ 时间戳 + 固定 salt 进行 SHA256 加密后 Base64 编码的结果。携程前端 JS 中类似如下逻辑:

function generateSignature(params) { const timestamp = Date.now(); const salt = "ctrip_2024_vacation"; const str = JSON.stringify({...params, timestamp}) + salt; return btoa(CryptoJS.SHA256(str).toString()); }

Python 中无法直接调用 CryptoJS,但可用execjs执行原始 JS 代码复现逻辑:

# 安装:pip install PyExecJS import execjs import json import time # 读取携程前端 signature 生成函数(从网页源码中提取) js_code = ''' function generateSignature(params) { const timestamp = Date.now(); const salt = "ctrip_2024_vacation"; const str = JSON.stringify({...params, timestamp}) + salt; // 注意:此处简化,真实环境需引入 crypto-js 或用 hashlib 模拟 return CryptoJS.enc.Base64.stringify(CryptoJS.SHA256(str)); } ''' # 实际项目中,建议将 crypto-js.min.js 内容完整加载 ctx = execjs.compile(js_code) sig = ctx.call("generateSignature", {"destId": "1010000000001", "page": 1, "pageSize": 10}) print(sig) # 输出类似:aBcDeFgHiJkLmNoPqRsTuVwXyZ==

注意:execjs默认使用 Node.js 运行时,需提前安装 Node.js(macOS/Linux 用brew install node,Windows 下载 installer)。若无 Node 环境,改用pycryptodome+ 手动实现 SHA256 + base64 编码(见 3.2 节),但必须严格复现 JS 中的字符串拼接顺序和 salt 值,否则 signature 校验失败返回 401。

2.3 景点 ID 获取不能靠正则硬匹配,要从 window.INITIAL_STATE中安全提取

很多旧教程用re.findall(r'destId.*?(\d+)', html)提取 ID,但在新版携程中极易失效——因为destId可能出现在注释、字符串变量、甚至混淆后的函数名里。更可靠的方式是:

  1. requests获取首页 HTML;
  2. BeautifulSoup定位<script>标签中包含__INITIAL_STATE__的块;
  3. 用正则提取 JSON 字符串并json.loads()解析;
  4. 从嵌套字典中逐层取值(如data['dest']['id'])。
import re import json from bs4 import BeautifulSoup import requests def extract_dest_id(url): headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" } resp = requests.get(url, headers=headers, timeout=10) soup = BeautifulSoup(resp.text, 'html.parser') # 查找包含 __INITIAL_STATE__ 的 script 标签 script_tag = soup.find("script", string=re.compile(r"__INITIAL_STATE__")) if not script_tag: raise ValueError("未找到 __INITIAL_STATE__ 初始化脚本") # 提取 JSON 字符串(匹配 var __INITIAL_STATE__ = { ... };) match = re.search(r"var __INITIAL_STATE__ = ({.*?});", script_tag.string, re.DOTALL) if not match: raise ValueError("__INITIAL_STATE__ JSON 结构不匹配") try: state = json.loads(match.group(1)) # 实际路径需根据页面结构调试,常见位置: # state['dest']['id'] 或 state['pageData']['destId'] 或 state['route']['params']['destId'] return str(state['dest']['id']) except (KeyError, json.JSONDecodeError) as e: raise ValueError(f"解析 __INITIAL_STATE__ 失败:{e}") # 示例调用 dest_id = extract_dest_id("https://vacations.ctrip.com/dest/1010000000001.html") print(dest_id) # 输出:1010000000001

提示:state结构随携程前端版本变化频繁,首次使用务必用print(json.dumps(state, indent=2)[:500])截取前 500 字符观察顶层键名,再确定destId的真实路径。不要假设固定路径,这是毕设答辩时最容易被老师追问的细节。


3. 用 requests + execjs + pandas 实现最小可行爬取流程(附可运行代码)

3.1 安装依赖与环境初始化:避开 Windows 下 execjs 的经典坑

# 创建虚拟环境(推荐,避免全局污染) python -m venv ctrip_env source ctrip_env/bin/activate # macOS/Linux # ctrip_env\Scripts\activate.bat # Windows # 安装核心库(注意版本兼容性) pip install requests beautifulsoup4 pandas pyexecjs lxml # 若 execjs 报错 "Could not find a suitable runtime",手动指定 Node.js: npm install -g jsdom # 确保 Node.js 已安装且在 PATH 中

提示:execjs在 Windows 上常因找不到 Node.js 路径失败。解决方法:

  1. 运行where node确认 Node.js 安装路径(如C:\Program Files\nodejs\node.exe);
  2. 在 Python 脚本开头添加:
import execjs execjs.runtime_names # 查看当前可用 runtime execjs.get('Node') # 强制使用 Node.js

3.2 封装 signature 生成器:不用 crypto-js,纯 Python 实现等效逻辑

既然execjs依赖外部运行时,我们用hashlib+base64手动复现签名逻辑(更稳定、可打包):

import hashlib import base64 import json import time def generate_ctrip_signature(params: dict, salt: str = "ctrip_2024_vacation") -> str: """ 生成携程评论接口所需的 X-Signature params: 查询参数字典,如 {"destId": "1010000000001", "page": 1, "pageSize": 10} salt: 携程前端硬编码的 salt 值,需与 JS 一致 返回 Base64 编码的 SHA256 哈希值 """ # 步骤1:添加时间戳 params_with_ts = {**params, "timestamp": int(time.time() * 1000)} # 步骤2:按字母序排序 key 并拼接成字符串(携程 JS 中 Object.keys().sort()) sorted_keys = sorted(params_with_ts.keys()) str_to_hash = "" for k in sorted_keys: str_to_hash += f"{k}={params_with_ts[k]}" str_to_hash += salt # 步骤3:SHA256 + Base64 hash_obj = hashlib.sha256(str_to_hash.encode('utf-8')) return base64.b64encode(hash_obj.digest()).decode('ascii') # 测试 sig = generate_ctrip_signature({"destId": "1010000000001", "page": 1, "pageSize": 10}) print("Signature:", sig) # 输出长度为 44 的 Base64 字符串

注意:str_to_hash的拼接顺序必须严格按 key 字母升序(sorted(params_with_ts.keys())),而非插入顺序。这是 JSObject.keys().sort()的行为,也是 signature 校验失败的最常见原因。

3.3 爬取单个景点的全部评论:分页 + 异常重试 + 数据去重

import time import pandas as pd from typing import List, Dict, Optional def fetch_reviews(dest_id: str, max_pages: int = 5) -> List[Dict]: """ 获取指定景点 ID 的所有评论(最多 max_pages 页,每页 10 条) 返回结构化字典列表,含 user_name, score, content, review_time """ reviews = [] session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "X-Requested-With": "XMLHttpRequest", "Referer": f"https://vacations.ctrip.com/dest/{dest_id}.html" }) for page in range(1, max_pages + 1): params = { "destId": dest_id, "page": page, "pageSize": 10, "locale": "zh-CN" } # 生成 signature signature = generate_ctrip_signature(params) # 构造请求 url = "https://vacations.ctrip.com/dest/review" headers = {"X-Signature": signature} try: resp = session.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() if not data.get("data") or not data["data"].get("list"): print(f"第 {page} 页无数据,停止翻页") break for item in data["data"]["list"]: reviews.append({ "user_name": item.get("userName", ""), "score": item.get("score", 0), "content": item.get("content", "").strip(), "review_time": item.get("reviewTime", ""), "dest_id": dest_id }) print(f"已获取第 {page} 页,共 {len(data['data']['list'])} 条评论") time.sleep(1) # 尊重反爬,每页间隔 1 秒 except requests.exceptions.RequestException as e: print(f"请求第 {page} 页失败:{e}") continue except KeyError as e: print(f"解析第 {page} 页 JSON 失败,缺少字段:{e}") continue return reviews # 示例:爬取一个景点 if __name__ == "__main__": dest_id = "1010000000001" # 替换为真实景点 ID all_reviews = fetch_reviews(dest_id, max_pages=3) # 转为 DataFrame 并保存 df = pd.DataFrame(all_reviews) print(f"共爬取 {len(df)} 条评论") print(df.head()) df.to_csv(f"ctrip_reviews_{dest_id}.csv", index=False, encoding="utf-8-sig")

提示:time.sleep(1)不是可选项——携程服务端会统计单位时间请求数,连续高频请求触发 IP 限频(HTTP 429)。毕设演示时若需加速,可改用random.uniform(0.8, 1.5)模拟人类操作间隔。


4. 景点数据与评论数据如何关联?用 pandas merge 实现双向映射表

4.1 先爬取景点基础信息表:ID、名称、星级、地址、简介

def fetch_dest_info(dest_id: str) -> Dict: """获取单个景点的基础信息""" url = "https://vacations.ctrip.com/dest/detail" params = {"destId": dest_id, "locale": "zh-CN"} signature = generate_ctrip_signature(params) headers = { "X-Signature": signature, "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" } try: resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() info = data["data"] return { "dest_id": dest_id, "name": info.get("name", ""), "star_level": info.get("starLevel", 0), "address": info.get("address", ""), "introduction": info.get("introduction", "")[:200] + "..." # 截断过长简介 } except Exception as e: print(f"获取景点 {dest_id} 信息失败:{e}") return {"dest_id": dest_id, "name": "", "star_level": 0, "address": "", "introduction": ""} # 批量获取多个景点信息 dest_ids = ["1010000000001", "1010000000002", "1010000000003"] # 替换为真实 ID 列表 dest_infos = [fetch_dest_info(did) for did in dest_ids] df_dest = pd.DataFrame(dest_infos) df_dest.to_csv("ctrip_destinations.csv", index=False, encoding="utf-8-sig")

4.2 用 pandas.merge 构建「景点-评论」联合视图,支持多维度分析

假设你已爬取ctrip_destinations.csv(景点表)和ctrip_reviews.csv(评论表),两者通过dest_id关联:

# 读取数据 df_dest = pd.read_csv("ctrip_destinations.csv") df_reviews = pd.read_csv("ctrip_reviews.csv") # 关键:merge 时指定 how='inner' 避免空值,validate='one_to_many' 确保一对多关系 df_joined = pd.merge( df_dest, df_reviews, on="dest_id", how="inner", validate="one_to_many" ) # 添加衍生字段便于分析 df_joined["review_date"] = pd.to_datetime(df_joined["review_time"], errors="coerce") df_joined["year_month"] = df_joined["review_date"].dt.strftime("%Y-%m") # 示例分析:各景点平均评分 & 评论数 summary = df_joined.groupby("name").agg( avg_score=("score", "mean"), total_reviews=("content", "count"), latest_review=("review_date", "max") ).round(2).reset_index() print(summary) # 输出: # name avg_score total_reviews latest_review # 0 故宫博物院 4.75 24 2024-05-20 # 1 西湖风景区 4.68 19 2024-05-18 # 导出联合分析表 df_joined.to_csv("ctrip_dest_reviews_joined.csv", index=False, encoding="utf-8-sig")

注意:pd.mergevalidate参数是关键——它会在运行时检查dest_iddf_dest中是否唯一(one)、在df_reviews中是否可重复(many)。若报错MergeError: Merge keys are not unique in right dataset,说明df_reviews中存在dest_id为空或格式错误的脏数据,需先df_reviews = df_reviews.dropna(subset=['dest_id'])清洗。

4.3 生成毕设必备的「数据质量报告」:用 describe() 和缺失值统计

# 数据质量快检 def generate_data_quality_report(df: pd.DataFrame, title: str): print(f"\n=== {title} 数据质量报告 ===") print(f"总行数:{len(df)}") print(f"总列数:{len(df.columns)}") print(f"缺失值总数:{df.isnull().sum().sum()}") print(f"缺失值占比:{df.isnull().sum().sum() / df.size:.2%}") # 各列缺失率 missing_rate = df.isnull().mean().sort_values(ascending=False) print("\n各列缺失率(Top 5):") print(missing_rate.head(5)) # 数值列统计摘要 numeric_cols = df.select_dtypes(include=["number"]).columns if len(numeric_cols) > 0: print(f"\n数值列统计摘要:") print(df[numeric_cols].describe()) generate_data_quality_report(df_joined, "景点-评论联合数据集")

输出示例:

=== 景点-评论联合数据集 数据质量报告 === 总行数:1247 总列数:9 缺失值总数:3 缺失值占比:0.02% 各列缺失率(Top 5): review_time 0.0024 user_name 0.0016 content 0.0000 ... 数值列统计摘要: score star_level count 1247.000000 1247.000000 mean 4.623015 4.821171 std 0.412837 0.382104

提示:这份报告应直接放入毕设文档的「数据采集与预处理」章节。答辩时老师问“你怎么保证数据质量”,就展示这个表格——比口头说“我做了清洗”有力得多。


5. 毕设答辩高频问题应对:3 个必须准备的现场演示技巧

5.1 演示「从输入 URL 到导出 CSV」的端到端流程(控制在 90 秒内)

不要一上来就 runpython main.py,而是分步演示:

  1. 打开 Chrome,访问https://vacations.ctrip.com/dest/1010000000001.html→ 指出页面右上角“写点评”按钮,说明“这就是我们要爬的评论入口”;
  2. 切换到 Network → XHR → 刷新 → 找到/dest/review?destId=...请求→ 右键 → “Copy as cURL”,粘贴到终端验证curl -v [复制的命令] | jq '.'是否返回 JSON(证明接口可达);
  3. 回到 PyCharm,打开fetch_reviews.py,修改dest_id = "1010000000001",运行→ 展示终端输出已获取第 1 页,共 10 条评论
  4. 打开生成的ctrip_reviews_1010000000001.csv,用 Excel 打开,滚动查看真实评论内容→ 强调“数据可读、可验证、可溯源”。

提示:提前录好 3 秒 GIF 动图放在 PPT 里,作为“接口调用成功”证据。避免现场网络波动导致演示失败。

5.2 回答「为什么不用 Scrapy?」:聚焦毕设场景的轻量级优势

当老师问“Scrapy 更专业,你为什么只用 requests?”,回答要点:

  • 开发效率:Scrapy 需配置settings.pyitems.pypipelines.py三文件,而本方案 1 个.py文件搞定全部逻辑,符合毕设“快速验证核心功能”的定位;
  • 调试友好:requests 错误直接抛异常,print 调试即可;Scrapy 的异步机制让断点调试困难,对初学者不友好;
  • 部署简单:Scrapy 依赖 Twisted,Windows 下编译易失败;requests + pandas 组合在任何 Python 环境(包括学校机房)都能一键运行;
  • 查重安全:Scrapy 框架代码高度模板化,易被查重系统标红;手写 requests 流程代码,逻辑描述清晰,原创性高。

5.3 预判「反爬被封怎么办?」:给出 3 层降级预案(写进文档附录)

在毕设论文附录中明确列出:

风险等级触发条件应对方案实施成本
单 IP 请求过频(HTTP 429)改用time.sleep(random.uniform(1.5, 2.5))+ User-Agent 轮换<10 行代码
signature 算法升级监控https://vacations.ctrip.com/dest/xxx.html页面 JS 变化,更新salt和拼接逻辑需人工介入,1 小时
接口彻底关闭或 require Cookie 登录切换至 Selenium 模式:启动浏览器 → 手动登录(或加载已登录 Cookie)→ 执行driver.execute_script("return window.__INITIAL_STATE__")需重写核心逻辑,2 天

注意:最后一行不要总结。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 16:43:58

基于cornerstone3D的DICOM影像浏览器:工程实践与源码解析

简介&#xff1a;基于 cornerstone3D 与 Vue3 的 DICOM 影像浏览器源码&#xff0c;定位为医疗影像领域的 Web 开发者参考项目&#xff0c;用于解决 DICOM 文件的加载、渲染与交互浏览需求。压缩包共含138个文件&#xff0c;以 JavaScript 和 Vue 组件作为主体&#xff0c;同时…

作者头像 李华
网站建设 2026/9/16 16:42:26

W5500+STM32F103 UDP通信实战:SPI时序、寄存器读写与状态机调试

简介&#xff1a;本资源是一套基于STM32F103单片机实现W5500以太网芯片UDP通信的完整嵌入式开发工程&#xff0c;面向嵌入式初学者与物联网开发工程师&#xff0c;解决嵌入式设备快速接入以太网并进行轻量级网络数据交互的实际问题&#xff0c;适用于工业监控、远程传感器上报等…

作者头像 李华
网站建设 2026/9/16 16:41:33

MPU6050姿态解算:一维卡尔曼滤波C++实现与参数调优

简介&#xff1a;本资源是一份面向嵌入式开发者与机器人/无人机姿态估计算法学习者的MPU6050传感器卡尔曼滤波C实现代码包&#xff0c;聚焦解决IMU原始数据噪声大、加速度计易受振动干扰、陀螺仪存在积分漂移等实际问题&#xff0c;提供轻量级、可移植的姿态融合解决方案。压缩…

作者头像 李华
网站建设 2026/9/16 16:40:13

小年夜程序员代码笔记:环境配置、算法与趣味项目实战

小年夜里窗外偶尔有零星的鞭炮声&#xff0c;我坐在电脑前把最后一个依赖装完&#xff0c;看着终端里的构建信息一路跑绿&#xff0c;突然觉得“代码不止&#xff0c;温暖不息”这句话特别应景。代码这东西&#xff0c;平时是饭碗、是工具、是解决问题的武器&#xff0c;但到了…

作者头像 李华
网站建设 2026/9/16 16:39:40

Python爬虫大作业全攻略:从静态页面到动态渲染的完整实现

简介&#xff1a;介绍一下这份资源&#xff1a;这是2020-2021学年上学期Python大作业——爬虫项目&#xff0c;适合需要完成类似课设或想学习爬虫与GUI结合开发的Python初学者参考。项目以爬取古诗词名句网为目标&#xff0c;模拟了网站的7种搜索方式&#xff0c;并基于PyQt5制…

作者头像 李华