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 年实际可落地的方案重走一遍用requestsexecjs模拟签名、用selenium或playwright渲染评论 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.htmlNetwork 标签页下刷新页面你会发现首屏 HTMLDocument 类型体积极小50KB仅含div idapp/div和几行 script 标签真实数据来自多个 XHR 请求例如https://vacations.ctrip.com/dest/detail?destId1010000000001localezh-CN→ 返回景点基础信息名称、地址、开放时间https://vacations.ctrip.com/dest/review?destId1010000000001page1pageSize10→ 返回评论列表含用户昵称、评分、文本、时间https://vacations.ctrip.com/dest/photo?destId1010000000001size10→ 返回图片链接。提示这些接口并非公开 APIURL 中的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.jsmacOS/Linux 用brew install nodeWindows 下载 installer。若无 Node 环境改用pycryptodome 手动实现 SHA256 base64 编码见 3.2 节但必须严格复现 JS 中的字符串拼接顺序和 salt 值否则 signature 校验失败返回 401。2.3 景点 ID 获取不能靠正则硬匹配要从 window.INITIAL_STATE中安全提取很多旧教程用re.findall(rdestId.*?(\d), html)提取 ID但在新版携程中极易失效——因为destId可能出现在注释、字符串变量、甚至混淆后的函数名里。更可靠的方式是用requests获取首页 HTML用BeautifulSoup定位script标签中包含__INITIAL_STATE__的块用正则提取 JSON 字符串并json.loads()解析从嵌套字典中逐层取值如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, headersheaders, timeout10) soup BeautifulSoup(resp.text, html.parser) # 查找包含 __INITIAL_STATE__ 的 script 标签 script_tag soup.find(script, stringre.compile(r__INITIAL_STATE__)) if not script_tag: raise ValueError(未找到 __INITIAL_STATE__ 初始化脚本) # 提取 JSON 字符串匹配 var __INITIAL_STATE__ { ... }; match re.search(rvar __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, indent2)[: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 路径失败。解决方法运行where node确认 Node.js 安装路径如C:\Program Files\nodejs\node.exe在 Python 脚本开头添加import execjs execjs.runtime_names # 查看当前可用 runtime execjs.get(Node) # 强制使用 Node.js3.2 封装 signature 生成器不用 crypto-js纯 Python 实现等效逻辑既然execjs依赖外部运行时我们用hashlibbase64手动复现签名逻辑更稳定、可打包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 # 步骤3SHA256 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: fhttps://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, paramsparams, headersheaders, timeout10) 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_pages3) # 转为 DataFrame 并保存 df pd.DataFrame(all_reviews) print(f共爬取 {len(df)} 条评论) print(df.head()) df.to_csv(fctrip_reviews_{dest_id}.csv, indexFalse, encodingutf-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, paramsparams, headersheaders, timeout10) 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, indexFalse, encodingutf-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 时指定 howinner 避免空值validateone_to_many 确保一对多关系 df_joined pd.merge( df_dest, df_reviews, ondest_id, howinner, validateone_to_many ) # 添加衍生字段便于分析 df_joined[review_date] pd.to_datetime(df_joined[review_time], errorscoerce) 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, indexFalse, encodingutf-8-sig)注意pd.merge的validate参数是关键——它会在运行时检查dest_id在df_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(ascendingFalse) 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而是分步演示打开 Chrome访问https://vacations.ctrip.com/dest/1010000000001.html→ 指出页面右上角“写点评”按钮说明“这就是我们要爬的评论入口”切换到 Network → XHR → 刷新 → 找到/dest/review?destId...请求→ 右键 → “Copy as cURL”粘贴到终端验证curl -v [复制的命令] | jq .是否返回 JSON证明接口可达回到 PyCharm打开fetch_reviews.py修改dest_id 1010000000001运行→ 展示终端输出已获取第 1 页共 10 条评论打开生成的ctrip_reviews_1010000000001.csv用 Excel 打开滚动查看真实评论内容→ 强调“数据可读、可验证、可溯源”。提示提前录好 3 秒 GIF 动图放在 PPT 里作为“接口调用成功”证据。避免现场网络波动导致演示失败。5.2 回答「为什么不用 Scrapy」聚焦毕设场景的轻量级优势当老师问“Scrapy 更专业你为什么只用 requests”回答要点开发效率Scrapy 需配置settings.py、items.py、pipelines.py三文件而本方案 1 个.py文件搞定全部逻辑符合毕设“快速验证核心功能”的定位调试友好requests 错误直接抛异常print 调试即可Scrapy 的异步机制让断点调试困难对初学者不友好部署简单Scrapy 依赖 TwistedWindows 下编译易失败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 天注意最后一行不要总结。本文还有配套的精品资源点击获取