去哪儿网旅游数据爬取与可视化分析实战 简介本资源是一份完整的本科毕业设计项目文档面向Python爬虫与数据分析初学者及旅游大数据实践者聚焦于真实平台去哪儿网的旅游数据采集、清洗、分析与可视化全流程。文档涵盖反爬应对策略、MySQL/CSV双存储方案、Pandas数据清洗技巧、NumPy统计分析方法以及matplotlib/seaborn/plotly多维可视化实现特别包含景点区域与月销量、等级与评分、价格与销量等7类交叉分析图表及解读逻辑。资源为1个979KB的PDF文件结构清晰含任务分析、技术选型、分步实施含数据库连接、数据导入、异常值处理、可视化结果展示及作品总结共四大模块目录层级完整适合作为课程设计参考或自学复现范本。目前已有2767人学习下载内容详实、步骤可追溯是少有的兼顾工程落地性与教学指导性的旅游数据实战资料。1. 去哪儿网旅游数据爬取可视化分析不是“抓完就跑”的脚本而是能复用、可验证、经得起业务追问的轻量级分析闭环你手头有个需求想快速摸清某类旅游产品的价格带分布、热门出发地集中度、用户评论情感倾向——不是等运营甩Excel也不是靠截图拼凑。这时候“去哪儿网旅游数据爬取可视化分析”就不是一句空话而是一套从真实页面结构出发、绕过前端渲染陷阱、保留关键业务字段、用最小依赖生成可读图表的落地路径。它不追求全站镜像也不依赖黑盒API核心是用 Requests BeautifulSoup 稳定提取结构化商品卡片含价格、出发地、天数、评分、评论数再用 Pandas 清洗后接入 Plotly 或 Matplotlib 输出带业务注释的折线图、热力图与词云。适合一线产品助理做竞品快扫、市场岗做节前备货预判、教学场景中带学生走通“数据获取→清洗→洞察”完整链路。注意这不是教你怎么绕过反爬而是告诉你——当目标页面已知稳定、字段明确、无登录强依赖时如何用最朴素的工具链在2小时内产出一份能放进周会PPT的分析页。2. 用 Requests BeautifulSoup 在本地跑通去哪儿网搜索结果页的最小解析命令2.1 确认目标URL结构与请求头伪装策略去哪儿网搜索结果页URL具备明显规律例如目的地为“三亚”的自由行产品页https://vacations.qunar.com/search?keyword三亚searchTypefreefromhome但直接用requests.get()会返回空内容或跳转首页——因为服务端校验User-Agent和Referer。常见做法是构造一个浏览器级请求头而非简单加UA。我一般会复用 Chrome 最新版本的典型头非动态生成避免引入 seleniumheaders { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Referer: https://vacations.qunar.com/, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, Connection: keep-alive, }提示Accept-Encoding: gzip很关键——去哪儿网默认返回 gzip 压缩HTML漏掉这行会导致response.text解析失败或乱码但response.content仍可用。若用.text报 UnicodeDecodeError优先检查此项。2.2 解析商品列表DOM结构定位真实数据容器而非“伪静态”div去哪儿网PC端搜索结果页采用“服务端渲染局部JS补丁”混合模式。商品卡片主结构在初始HTML中存在但部分字段如实时价格浮动、库存状态由JS注入。我们只取服务端直出的稳定字段产品标题、出发城市、行程天数、标价、评分、评论数、链接。通过浏览器开发者工具Network → HTML响应体确认商品卡片包裹在div classproduct-item中每个字段有明确子class标题h3 classproduct-title...h3出发地span classdep-city北京/span天数span classdays5天4晚/span价格span classprice-num¥1980/span注意可能含空格或换行需.get_text(stripTrue)评分span classscore-num4.8/span评论数span classcomment-num326条评论/span关键逻辑在于不依赖>import requests from bs4 import BeautifulSoup import time def fetch_qunar_page(url: str, headers: dict, timeout10) - BeautifulSoup: try: resp requests.get(url, headersheaders, timeouttimeout) resp.raise_for_status() # 显式指定编码避免chardet误判 resp.encoding utf-8 return BeautifulSoup(resp.text, html.parser) except requests.exceptions.RequestException as e: print(f请求失败: {e}) return None def parse_product_list(soup: BeautifulSoup) - list: if not soup: return [] items [] for card in soup.find_all(div, class_product-item): try: title card.find(h3, class_product-title).get_text(stripTrue) if card.find(h3, class_product-title) else dep_city card.find(span, class_dep-city).get_text(stripTrue) if card.find(span, class_dep-city) else days card.find(span, class_days).get_text(stripTrue) if card.find(span, class_days) else price_text card.find(span, class_price-num).get_text(stripTrue) if card.find(span, class_price-num) else # 提取纯数字价格匹配 ¥ 后连续数字支持千分位逗号 import re price_match re.search(r¥\s*([\d,]\.?\d*), price_text) price float(price_match.group(1).replace(,, )) if price_match else 0.0 score_elem card.find(span, class_score-num) score float(score_elem.get_text(stripTrue)) if score_elem and score_elem.get_text(stripTrue).replace(., ).isdigit() else 0.0 comment_elem card.find(span, class_comment-num) comment_text comment_elem.get_text(stripTrue) if comment_elem else comment_count int(re.search(r(\d), comment_text).group(1)) if re.search(r(\d), comment_text) else 0 link card.find(a, class_product-link)[href] if card.find(a, class_product-link) else items.append({ title: title, dep_city: dep_city, days: days, price: price, score: score, comment_count: comment_count, url: link }) except Exception as e: # 单条解析失败不中断整体记录日志即可 continue return items # 示例调用 url https://vacations.qunar.com/search?keyword三亚searchTypefreefromhome soup fetch_qunar_page(url, headers) products parse_product_list(soup) print(f成功解析 {len(products)} 条商品)这段代码的核心价值不在“能跑”而在可控性每一步都可打断调试比如打印card.find(span, class_price-num)看原始HTML片段、每处异常都隔离处理、价格正则明确适配“¥1,980”和“¥1980”两种格式。新手可逐行粘贴测试熟手可快速替换正则或class名适配新页面结构。3. Pandas数据清洗把原始爬虫输出转成带业务含义的分析就绪表3.1 构建DataFrame并标准化字段类型与空值逻辑爬虫输出的products是字典列表直接转pd.DataFrame会遗留大量object类型字段。必须显式转换否则后续分组统计会报错或结果失真。重点处理三类字段价格price强制float64空值填np.nan非0因0元产品在旅游场景极罕见填0会污染均值评分scorefloat64但需过滤掉0.0代表无评分——业务上应视为缺失非低分评论数comment_countInt64pandas nullable integer支持pd.NA避免用-1或0混淆“无评论”和“0条评论”。import pandas as pd import numpy as np df pd.DataFrame(products) # 类型强转与空值归一 df[price] pd.to_numeric(df[price], errorscoerce) # 错误值转nan df[score] pd.to_numeric(df[score], errorscoerce) df[comment_count] pd.to_numeric(df[comment_count], errorscoerce).astype(Int64) # 过滤无效记录标题为空、价格≤0、出发地为空的行直接丢弃非填充 df df.dropna(subset[title, dep_city]) df df[df[price] 0] df df[df[score] 0] # 排除占位用的0分 # 衍生字段价格区间标签业务常用分段 def price_band(x): if x 1000: return 经济型1000 elif x 3000: return 舒适型1000-3000 else: return 高端型≥3000 df[price_band] df[price].apply(price_band) # 行程天数标准化提取数字如“5天4晚”→5“周末游”→2业务约定 def extract_days(text): if not text: return 2 match re.search(r(\d)天, text) if match: return int(match.group(1)) return 2 # 默认周末2天 df[days_num] df[days].apply(extract_days) print(清洗后数据概览) print(df[[title, dep_city, price, score, comment_count, price_band, days_num]].head()) print(f\n总记录数{len(df)}价格均值{df[price].mean():.0f}元评分均值{df[score].mean():.2f})这段清洗逻辑的“业务感”体现在所有转换都有现实依据。比如price_band分段不是随意切分而是参考旅游行业常规定价带days_num对“周末游”硬设为2天是因为业务方明确告知该词在去哪儿网语境下即指周六日两天行程。这种约定优于技术完美却极大降低后续分析歧义。3.2 处理高频脏数据出发地模糊匹配与多城市合并去哪儿网的dep_city字段常含干扰信息“北京[首都机场]”、“上海虹桥”、“广州-深圳联运”。若直接按字符串分组会导致“北京”、“北京[首都机场]”被算作两个城市失真严重。正确做法是构建城市白名单 模糊前缀匹配# 常见出发城市标准名按业务需求增删 CITY_STANDARD [ 北京, 上海, 广州, 深圳, 成都, 杭州, 西安, 重庆, 武汉, 南京, 长沙, 青岛, 厦门, 昆明, 三亚, 哈尔滨, 乌鲁木齐, 拉萨 ] def standardize_dep_city(raw: str) - str: if not raw: return 未知 # 移除括号及内部内容北京[首都机场] → 北京 clean re.sub(r\[.*?\]|\(.*?\)|.*?, , raw) # 移除空格和连接符 clean re.sub(r[\s\-—–], , clean) # 前缀匹配只要开头匹配标准名即采纳 for city in CITY_STANDARD: if clean.startswith(city): return city return 其他 df[dep_city_std] df[dep_city].apply(standardize_dep_city) print(出发地标准化后分布) print(df[dep_city_std].value_counts().head(10))这个函数解决的是真实业务痛点市场同事说“北京出发最多”结果你图表里显示“北京[首都机场]”第一、“北京”第二还得手动合并。而standardize_dep_city用前缀匹配而非全等兼容了“北京T3”“北京首都”等变体且不依赖外部库如 fuzzywuzzy部署零依赖。4. 避坑去哪儿网爬取与分析中5个血泪经验换来的具体问题排查清单4.1 现象response.text返回空字符串或乱码但response.content可见HTML原因服务端返回 gzip 压缩响应但requests未自动解压因Accept-Encoding头缺失或response.text强制用错误编码解码。解决确保请求头含Accept-Encoding: gzip, deflate解析时优先用response.content并显式解码BeautifulSoup(response.content, html.parser, from_encodingutf-8)。4.2 现象BeautifulSoup找不到product-item但浏览器审查元素可见原因页面实际由 JS 动态渲染初始HTML中该class不存在或URL被重定向到登录页未带Cookie。解决先检查response.url是否等于请求URL若跳转至login.qunar.com说明触发风控此时应放弃改用人工确认的稳定搜索URL如带fromhome参数的首页入口并确保 headers 中Referer正确。4.3 现象价格字段解析出¥1,980但float()报错could not convert string to float原因正则未覆盖全如遇到¥ 1980空格在¥后或¥1980起含中文。解决升级价格正则为r¥\s*([\d,]\.?\d*)\s*[起|元]*并用re.search(..., flagsre.IGNORECASE)增强鲁棒性。4.4 现象pandas分组统计时comment_count列报错TypeError: unsupported operand type(s) for : str and int原因该列含字符串如暂无评论pd.to_numeric(..., errorscoerce)已将其转为np.nan但后续groupby().sum()遇Int64类型的pd.NA会报错。解决分组前统一转为float并用min_count1df.groupby(dep_city_std)[comment_count].sum(min_count1).astype(Int64)或直接用df.groupby(dep_city_std)[comment_count].count()统计有评论的产品数。4.5 现象Plotly 词云图显示方块□而非中文或Matplotlib图表标题中文乱码原因默认字体不支持中文Linux/macOS尤甚且未设置全局字体。解决Matplotlib 加载系统中文字体如Noto Sans CJK并设为默认import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [Noto Sans CJK SC, SimHei, DejaVu Sans] plt.rcParams[axes.unicode_minus] False # 正常显示负号Plotly 则在fig.update_layout()中显式指定font_familyNoto Sans CJK SC。5. 用 Plotly WordCloud 输出三张高信息密度图表价格分布直方图、出发地热力图、评论关键词云5.1 价格分布直方图叠加业务分位线与均值标注旅游产品价格常呈右偏分布单纯看均值易误导。Plotly 直方图应同时呈现实际分布histogram25%/50%/75%分位线vlines当前均值线红色虚线带label每个bin顶部显示频次texttemplateimport plotly.express as px import plotly.graph_objects as go # 计算分位数 q25 df[price].quantile(0.25) q50 df[price].quantile(0.50) q75 df[price].quantile(0.75) mean_price df[price].mean() fig_hist px.histogram( df, xprice, nbins40, title三亚自由行产品价格分布单位元, labels{price: 标价元, count: 产品数量}, histnormprobability density ) # 添加分位线 fig_hist.add_vline(xq25, line_dashdash, line_colorgreen, annotation_text25%, annotation_positiontop left) fig_hist.add_vline(xq50, line_dashsolid, line_colororange, annotation_text中位数, annotation_positiontop right) fig_hist.add_vline(xq75, line_dashdash, line_colorgreen, annotation_text75%, annotation_positiontop right) fig_hist.add_vline(xmean_price, line_dashdot, line_colorred, annotation_textf均值{mean_price:.0f}, annotation_positiontop left) # 显示频次文本需计算bins counts, bins np.histogram(df[price], bins40) fig_hist.update_traces(texttemplate%{y}, textpositionoutside) fig_hist.show()这张图的价值在于一眼识别价格主力区间如1500-2500元与长尾高价产品5000元占比业务方能据此判断是否需补充中端SKU或清理滞销高价款。5.2 出发地热力图用px.density_heatmap替代地理图规避坐标精度难题去哪儿网不提供经纬度强行用folium或plotly.express.scatter_geo需手动维护城市坐标且小城市定位不准。更务实的做法是用出发地 × 价格区间交叉频次生成二维热力图。既反映地域热度又关联价格带。# 构建交叉表 pivot_table pd.crosstab(df[dep_city_std], df[price_band], dropnaFalse) # 转为长格式供plotly heatmap_df pivot_table.reset_index().melt(id_varsdep_city_std, var_nameprice_band, value_namecount) fig_heat px.density_heatmap( heatmap_df, xprice_band, ydep_city_std, zcount, title各出发地在不同价格带的产品数量热力图, labels{price_band: 价格区间, dep_city_std: 出发城市, count: 产品数} ) fig_heat.update_layout(yaxis{categoryorder:total ascending}) # 按总数升序排城市 fig_heat.show()此图揭示真实业务信号例如“上海”在“高端型”产品中数量断层领先而“成都”集中在“经济型”提示区域用户价格敏感度差异。5.3 评论关键词云从标题与评论文本中提取高信息量词去哪儿网商品标题含大量业务关键词如“亲子”“潜水”“免税店”“海景房”。直接用jieba分词 WordCloud生成词云但需过滤停用词与低频干扰词import jieba from wordcloud import WordCloud import matplotlib.pyplot as plt # 合并所有标题文本 all_titles .join(df[title].dropna().tolist()) # 自定义停用词旅游领域特有 stopwords {自由行, 跟团, 纯玩, 特价, 限时, 预售, 套餐, 含, 送, 赠, 起} # jieba精准分词 过滤 words jieba.lcut(all_titles) filtered_words [w for w in words if len(w) 2 and w not in stopwords and w.isalpha()] # 统计词频 from collections import Counter word_freq Counter(filtered_words) top_100 dict(word_freq.most_common(100)) # 生成词云指定中文字体路径Linux下可选 /usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf wc WordCloud( font_path/System/Library/Fonts/PingFang.ttc, # macOS # font_path/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf, # Linux width1200, height600, background_colorwhite, max_words100, colormapviridis ).generate_from_frequencies(top_100) plt.figure(figsize(15, 8)) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.title(三亚自由行产品标题高频关键词去停用词, fontsize16, pad20) plt.show()词云中若出现“亲子”“潜水”“免税”说明该目的地核心卖点已沉淀为用户搜索语言若“雨季”“台风”高频则提示需加强天气风险提示——这才是数据驱动的业务洞察起点。6. 把分析流程封装成可复用函数3个参数控制全链路支持单次运行与批量调度6.1 定义主函数run_qunar_analysis()用字典传参替代硬编码把前述所有步骤请求、解析、清洗、绘图封装为一个函数输入仅需3个参数keyword: 目的地关键词如“三亚”search_type:free自由行或group跟团output_dir: 结果保存路径自动生成子目录这样同一份代码可一键分析“三亚”“丽江”“敦煌”多个目的地无需改任何URL或class名。import os from datetime import datetime def run_qunar_analysis( keyword: str, search_type: str free, output_dir: str ./qunar_output ): 执行去哪儿网旅游数据爬取与可视化分析全流程 Args: keyword: 搜索关键词如三亚 search_type: free自由行或group跟团游 output_dir: 输出目录将创建 keyword_时间戳 子目录 # 1. 构造URL与请求头 base_url https://vacations.qunar.com/search params { keyword: keyword, searchType: search_type, from: home } url base_url ? .join([f{k}{v} for k, v in params.items()]) # 2. 请求与解析 soup fetch_qunar_page(url, headers) if not soup: print(页面获取失败退出) return products parse_product_list(soup) if not products: print(未解析到商品检查页面结构是否变更) return # 3. 清洗与分析 df pd.DataFrame(products) df[price] pd.to_numeric(df[price], errorscoerce) df[score] pd.to_numeric(df[score], errorscoerce) df[comment_count] pd.to_numeric(df[comment_count], errorscoerce).astype(Int64) df df.dropna(subset[title, dep_city]).query(price 0 and score 0) df[price_band] df[price].apply(price_band) df[days_num] df[days].apply(extract_days) df[dep_city_std] df[dep_city].apply(standardize_dep_city) # 4. 创建输出目录 timestamp datetime.now().strftime(%Y%m%d_%H%M%S) full_output_dir os.path.join(output_dir, f{keyword}_{search_type}_{timestamp}) os.makedirs(full_output_dir, exist_okTrue) # 5. 保存原始数据与清洗后数据 df.to_csv(os.path.join(full_output_dir, raw_data.csv), indexFalse, encodingutf-8-sig) df.to_excel(os.path.join(full_output_dir, cleaned_data.xlsx), indexFalse) # 6. 生成图表并保存 # 此处插入前述 fig_hist, fig_heat, wc 的生成与 savefig 代码 # 例fig_hist.write_html(os.path.join(full_output_dir, price_distribution.html)) print(f分析完成结果已保存至{full_output_dir}) # 单次运行示例 run_qunar_analysis(keyword三亚, search_typefree) # 批量运行示例遍历目的地列表 for dest in [三亚, 丽江, 敦煌, 长白山]: run_qunar_analysis(keyworddest, search_typefree)这个函数的设计哲学是不追求一次写完所有功能而确保每次调用都产生可追溯、可复现、可归档的结果。full_output_dir命名含时间戳避免覆盖raw_data.csv保留原始爬取结果方便回溯cleaned_data.xlsx用Excel格式业务方双击即可查看无需Python环境。6.2 部署建议用 cron 或 GitHub Actions 实现周度自动更新若需长期跟踪价格趋势可将run_qunar_analysis()封装为独立脚本qunar_analyze.py配合定时任务Linux0 9 * * 1 /usr/bin/python3 /path/to/qunar_analyze.py --keyword 三亚 --search_type free每周一上午9点执行GitHub Actions用schedule: 0 0 * * 1触发上传结果到gh-pages分支生成静态报告页注意去哪儿网反爬策略会随时间调整务必在定时任务中加入失败告警如邮件或企业微信机器人而非静默失败。我的习惯是每次运行后检查len(products)是否低于历史均值的70%若是则触发告警——这是比任何UA轮换都可靠的风控信号。最后说句实在话这套方案不是为了“爬得更多”而是为了“用得更稳”。当业务方突然问“上个月三亚5天产品的平均价格涨了多少”你不用翻聊天记录找截图而是打开终端敲一行命令30秒后把带时间水印的对比图发过去。这种确定性才是工程师最该交付的价值。希望帮到你。本文还有配套的精品资源点击获取