做一个说走就走的短途旅行时被酒店价格坑过之后,我就一直想做一件事:把携程网上那些零散的酒店信息、用户评价、价格区间抓下来,用Python做一轮数据可视化分析,看看同一座城市里,价格和口碑到底有没有关系,哪个片区的酒店性价比最高,用户吐槽最多的点又集中在哪。折腾了几个周末,我总算把“基于python的携程网数据可视化分析”这条链路完整跑通了,从爬虫采集、数据清洗到图表输出,每一步都踩了不少坑。今天就把整个项目的设计思路、核心代码和排错过程整理出来,给同样想拿Python练手数据分析,又不想只对着Kaggle那种现成数据集的朋友做份参考。
这个项目本质上不是什么高深算法,它最吸引人的地方在于数据是真实、动态且带噪声的——酒店评分里有“4.5/5分”这种字符串、价格里有“¥488起”这种带单位带文字的文本、评论量有“1.2万条评价”这种需要换算的计数,这些恰恰是日常工作中最常见的数据形态。处理完这些脏数据,再用matplotlib、pyecharts把它们变成直观的图表,这个过程对Python爬虫、Pandas清洗、可视化三块能力的锻炼非常全面。不管你是刚学完Python基础语法,还是已经在写爬虫但没做过完整数据分析项目,这条项目路径都值得走一遍。
1. 为什么选择携程数据:酒店点评背后隐藏的决策密码
先说说这个项目的出发点。很多人学Python数据分析,第一步往往卡在“找不到合适的数据源”。公开数据集虽然干净,但缺少真实项目那种“需要自己想办法”的感觉。而携程网作为国内头部OTA平台,天然具备几个非常适合练手的特点:数据维度丰富、更新频率高、反爬机制适中、页面结构稳定。
1.1 数据源的价值评估:不是所有平台都适合做分析
携程网的数据覆盖了酒店、机票、景点门票、旅游线路、攻略笔记等多个板块,其中酒店板块的数据对初学者尤其友好。以酒店搜索列表页为例,每条酒店记录至少包含酒店名称、区域位置、用户评分、点评数量、最低价格、酒店图片链接等字段。这些字段组合在一起,能回答非常多实际问题:
- 某个城市不同片区的酒店均价差多少?
- 高评分酒店的起步价通常集中在哪个区间?
- 点评量大的酒店是不是真的意味着“值得住”?
- 用户评论里出现频率最高的词是“干净”“服务好”还是“隔音差”“位置偏”?
对比同类型的飞猪、美团,携程的页面结构相对规整,数据接口的返回格式也比较稳定,对新手来说解析难度适中。而且携程的搜索列表页支持按行政区划分,这为后续做“区域维度对比分析”提供了天然的数据分层逻辑。
1.2 分析目标定义:先想清楚要回答什么问题
动手写代码之前,我建议你先拿出一张纸,把分析目标写下来。很多人做数据分析项目容易犯一个毛病:数据抓了一堆,图表画了一大屏,最后却说不清结论是什么。我在这个项目里明确设定了四个核心分析目标:
- 描述性分析:目标城市酒店的总体价格分布、评分分布是怎样的?
- 关系分析:酒店价格与用户评分之间是否存在相关关系?
- 对比分析:不同行政区/商圈的酒店均价和口碑对比如何?
- 文本分析:用户评论中的高频关键词能反映出哪些共性诉求?
这四个目标分别对应了后面的直方图、散点图、横向条形图和词云图。把问题定义清楚,你才知道每张图该画什么、数据该存成什么格式,而不是抓完数据再对着字段发呆。
1.3 数据使用的合规边界:个人学习项目也要有分寸
这里必须多提醒一句:任何爬虫项目都要有边界意识。我这套代码仅用于个人学习和技术验证,采集频率控制在低频范围,不会对目标站点造成访问压力。如果你是做商业项目或公开发布分析报告,请务必查阅目标平台的Robots协议和用户协议,必要时通过官方API获取数据。爬虫技术本身是中性的,但使用场景必须合规。这个分寸感,是每个Python开发者踏入数据采集领域前就该有的职业素养。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据采集层的架构选择:Requests还是Selenium
数据采集是整个项目的源头,这一层如果设计不好,后面清洗和可视化做得再漂亮都是白搭。我在最初版本里犯过一个典型错误:直接用Requests去请求携程的酒店搜索页,结果拿回来的HTML里根本没有酒店列表数据——因为现在很多页面是异步加载的,列表数据是通过XHR接口动态渲染出来的。
2.1 页面加载方式分析:先搞清楚数据是从哪来的
打开携程酒店搜索页,F12进入开发者工具,切到Network面板,刷新页面,你会看到几十个请求。逐个点开看,会发现酒店列表数据实际来自一个包含大量参数的JSON接口,而不是最初的HTML文档。所以正确做法应该是:找到真正的数据接口,分析它的URL参数和返回结构,再用Requests模拟请求。
我以“杭州”为例,搜索接口的URL参数大致包含这些维度:城市ID、入住日期、离店日期、页码、排序方式、筛选条件等。其中城市ID是关键参数,不同的城市对应不同的数字编码。在代码里,我们可以把这些参数整理成一个字典,方便后续切换城市和分析条件。
python复制import requests
def build_search_url(city_id, page=1, sort_type="recommend"):
base_url = "https://you.ctrip.com/api/hotels/search"
params = {
"cityId": city_id,
"page": page,
"sortType": sort_type,
"checkIn": "2025-03-01",
"checkOut": "2025-03-02",
}
return base_url, params
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://hotels.ctrip.com/",
}
2.2 Requests与Selenium的选型对比:各有各的适用场景
很多教程上来就推荐Selenium,理由是“可视化、不容易被反爬”。但以我跑通这个项目的经验来看,能不用Selenium就不用Selenium,原因有三点:第一,Selenium需要启动一个完整浏览器,资源占用高,并发能力弱;第二,页面渲染等待时间不可控,爬取效率低;第三,一旦页面改版,XPath或CSS选择器需要大改,维护成本高。
相比之下,直接请求JSON接口的方案更加轻量高效,返回的是结构化数据,解析简单,存储方便。适合数据接口比较稳定的场景。Selenium则更适合那种接口加密严重、必须靠真实浏览器环境才能拿到数据的场景。我做了一个对比表,供你快速决策:
| 对比维度 | Requests直接请求接口 | Selenium模拟浏览器 |
|---|---|---|
| 请求效率 | 高,可并发 | 低,单线程为主 |
| 资源占用 | 极低 | 高 |
| 反爬处理 | 需要构造Header/Cookie | 相对简单 |
| 页面改动影响 | 改接口参数 | 改选择器 |
| 适用场景 | 能找到JSON数据接口 | 找不到数据接口 |
2.3 反爬应对策略:不是硬碰硬,是讲规矩
携程虽然页面结构稳定,但毕竟是头部OTA平台,对爬虫还是有一定防护的。我实测下来,最有效的三个措施是:构造合理的请求头、控制请求频率、做好异常重试机制。
首先是User-Agent必须真实,最好把自己浏览器里的UA字符串完整复制过来。其次是Referer字段,很多接口会校验这个字段,少了它可能返回403或空数据。然后是请求频率,我在代码里加了time.sleep()随机延时,每次请求间隔控制在3到8秒之间,实测连续采集数百条数据没有问题。
python复制import time
import random
def safe_request(url, params, headers, retries=3):
for i in range(retries):
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code == 200:
return resp.json()
except requests.RequestException as e:
print(f"[第{i+1}次请求失败] {e}")
time.sleep(5)
return None
# 每次请求前随机延时,模拟真实用户浏览节奏
time.sleep(random.uniform(3, 8))
2.4 解析接口返回的JSON:把列表页数据摊平成表格
接口返回的数据通常是嵌套结构,举例来说,酒店列表在data.hotelList这个层级,每家酒店的评分、点评量、最低价分别在commentScore、commentCount、minPrice这些字段里。直接json.loads()之后,建议用Pandas把这个嵌套结构摊平成我们熟悉的行列表格,这一步对接下来的清洗至关重要。
python复制import pandas as pd
def parse_hotel_list(data):
hotels = []
for item in data.get("data", {}).get("hotelList", []):
hotels.append({
"name": item.get("hotelName"),
"district": item.get("districtName"),
"zone": item.get("zoneName"),
"score": item.get("commentScore"),
"comment_count": item.get("commentCount"),
"min_price": item.get("minPrice"),
"star": item.get("star"),
})
return pd.DataFrame(hotels)
3. 清洗与规整:多源异构数据统一口径的细节
数据抓下来只能算完成了三分之一,剩下的重头戏是清洗。携程接口返回的字段类型五花八门,如果你想直接拿来做可视化,大概率会报TypeError或者画出来的图没法看。这里我把几个最容易出问题的字段清洗过程单独拎出来,这些细节在官方文档里基本找不到,全是实际碰壁碰出来的。
3.1 评分字段:从“4.5/5分”到浮点数
接口里的评分字段有时是浮点数4.5,有时是字符串"4.5/5分",甚至极少数情况下可能是"暂无评分"。如果你直接把它当数值用,Pandas会把它变成object类型,画图时matplotlib根本无法处理。我的处理方案是写一个清洗函数,先判断字段类型,再统一转为浮点数。
python复制def clean_score(value):
if value is None or str(value) in ("暂无评分", "暂无", "-"):
return None
value = str(value).replace("分", "").split("/")[0].strip()
try:
return float(value)
except ValueError:
return None
3.2 价格字段:从“¥488起”到整数
价格字段是另一个经典脏数据案例。接口返回的minPrice有时是纯数字488,有时带货币符号"¥488起",还有可能带小数点。如果你直接pd.to_numeric()必然报错。正确做法是先把非数字字符剔除,再转成int。注意去除字符串中的“¥”和“起”字,同时警惕英文逗号分隔的千分位格式。
python复制def clean_price(value):
if value is None:
return None
# 去除非数字字符(保留小数点)
import re
if isinstance(value, (int, float)):
return int(value)
digits = re.sub(r"[^\d.]", "", str(value))
if not digits:
return None
return int(float(digits))
3.3 评论量字段:从“1.2万条评价”到整数
评论区量的格式同样不省心。少数酒店的评价数可能是"1.2万"这种带单位的写法,也有可能是直接的数字。如果你不处理,“1.2万”会被当成字符串,排序和绘图全乱套。处理逻辑是:判断字符串中是否包含“万”,如果包含就先转成浮点数再乘以10000,最后转成整数。
python复制def clean_comment_count(value):
if value is None:
return None
if isinstance(value, (int, float)):
return int(value)
text = str(value).replace("条评价", "").replace("条点评", "").strip()
if "万" in text:
return int(float(text.replace("万", "")) * 10000)
try:
return int(float(text))
except ValueError:
return None
3.4 缺失值处理策略:宁可保留,不要乱填
清洗过程中还有一个值得说的问题:缺失值怎么办?初学者最容易犯的错是直接dropna()把所有缺失行删掉,结果发现数据少了一大半。我的做法是分字段判断:如果评分、价格这两个核心字段缺失,删除该行;如果只是评论量缺失,可以用该城市的平均值填充,或者直接在分析时忽略。另外,清洗完的数据建议先做一个完整性校验,例如检查价格字段的最小值是否为负数、评分字段是否超出0到5的区间,这些边缘情况看似离奇,真实数据里却真的存在。
python复制def validate(df):
assert df["min_price"].min() >= 0, "发现负价格数据"
assert df["score"].between(0, 5).all(), "发现超出评分范围的数据"
print(f"数据校验通过:共{len(df)}条有效记录")
4. 可视化分析:从价格分布到口碑洞察的核心图表
数据清洗完成,终于到了整个项目最出效果的部分——可视化。我选了四个分析维度,分别用四类图表来呈现。这里有个选型逻辑要提前说清楚:不是所有图表都适合所有数据,直方图适合看分布、散点图适合看相关、条形图适合看排名、词云适合看文本重点。选对图表类型,结论才能一目了然。
4.1 图表选型与数据口径对照表
先把我用到的图表和对应分析目标整理成一张表,方便你对照理解:
| 可视化图表 | 分析目标 | 核心字段 | 适用场景 |
|---|---|---|---|
| 价格分布直方图 | 酒店价格整体分布态势 | min_price | 了解价格集中区间和长尾情况 |
| 评分-价格散点图 | 价格与口碑的关系 | score vs min_price | 判断高价位是否等于高评分 |
| 行政区Top10条形图 | 各区域酒店数量与均价对比 | district, min_price | 做区域横向对比 |
| 评论关键词词云 | 用户关注点与吐槽点 | comment_text | 文本情感与主题提炼 |
4.2 价格分布直方图:一眼看出价格集中区间
直方图是最快摸清数据底细的图表。我当时抓了杭州约500家酒店的价格数据,使用matplotlib绘制直方图,横轴是价格区间,纵轴是酒店数量。代码如下,关键点在于设置合理的bins数量,太少会丢失细节,太多会显得杂乱,我试下来bins=30对酒店类价格数据比较合适。
python复制import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"] # 解决中文乱码
plt.rcParams["axes.unicode_minus"] = False
plt.figure(figsize=(12, 6))
plt.hist(df["min_price"].dropna(), bins=30, color="#4C72B0", edgecolor="white")
plt.xlabel("最低价格(元/晚)")
plt.ylabel("酒店数量(家)")
plt.title("杭州市酒店最低价格分布直方图")
plt.axvline(df["min_price"].median(), color="red", linestyle="--", label=f"中位数 {df['min_price'].median():.0f} 元")
plt.legend()
plt.tight_layout()
plt.savefig("price_distribution.png", dpi=150)
plt.show()
从结果图能明显看出,酒店价格呈现典型右偏分布:大量酒店集中在200到500元区间,中位数大约在350元左右,但尾部拖到2000元以上。这个发现本身就直接回答了“杭州酒店住一晚大概花多少”这个生活化问题。
4.3 评分-价格散点图:验证“贵的就是好的”这个直觉
第二个值得画的图是评分与价格的散点图。做法很简单,横轴是评分(3.0到5.0),纵轴是最低价格,每个点代表一家酒店。如果价格越贵、评分越高,那散点应该呈现从左下到右上的分布趋势。但我实际画出来后,发现情况并不完全是这样。
python复制plt.figure(figsize=(12, 6))
plt.scatter(df["score"], df["min_price"], alpha=0.5, s=10, color="#55A868")
# 用numpy计算线性回归,添加趋势线
import numpy as np
z = np.polyfit(df["score"].dropna(), df["min_price"].dropna(), 1)
p = np.poly1d(z)
x_trend = np.linspace(df["score"].min(), df["score"].max(), 100)
plt.plot(x_trend, p(x_trend), color="red", linewidth=2, label="趋势线")
plt.xlabel("用户评分")
plt.ylabel("最低价格(元/晚)")
plt.title("酒店评分与价格散点图")
plt.legend()
plt.tight_layout()
plt.savefig("score_price_scatter.png", dpi=150)
plt.show()
趋势线的斜率确实是正的,说明总体上看高评分酒店的价格中位数更高。但散点在中低评分区间非常密集,说明同样花了300块,你既可能住到4.8分的酒店,也可能踩到4.0分的雷。这个发现比我预想中更有价值——价格对评分的解释力并没有想象中那么强,真正决定体验的还是具体酒店和位置。看到这个结论的时候,我甚至觉得这一趟数据折腾已经值了。
4.4 行政区Top10条形图:用横向条形图做区域对比
数据里的district字段用来划分行政区,这是做区域对比的天然维度。横向条形图特别适合排名类数据,因为行政区的名称通常比较长,纵向条形图会互相遮挡。我统计了两个维度:每个区域的酒店数量,以及每个区域的平均最低价格。
python复制district_stats = df.groupby("district").agg(
hotel_count=("name", "count"),
avg_price=("min_price", "mean")
).sort_values("hotel_count", ascending=False)
top10 = district_stats.head(10)
plt.figure(figsize=(12, 8))
plt.barh(top10.index[::-1], top10["avg_price"][::-1], color="#DD8452")
plt.xlabel("平均最低价格(元/晚)")
plt.ylabel("行政区")
plt.title("杭州市各区酒店平均最低价格Top10")
plt.tight_layout()
plt.savefig("district_price_barh.png", dpi=150)
plt.show()
这张图的实际分析效果很直接:西湖景区周边的酒店均价明显高于其他区域,而火车东站、下沙等区域的均价更亲民。如果把酒店数量和均价放在同一张图里做双轴图,还能进一步看出“区域酒店供给量”和“区域价格水平”之间的关系。这个视角对安排旅行住宿很有参考意义。
4.5 评论关键词词云:用jieba和WordCloud做文本分析
前面几张图分析的都是结构化字段,而用户评论算是非结构化文本数据。词云图虽然被很多人认为是“花架子”,但在探查性分析阶段非常有用——它能快速暴露用户的核心关注点。我采集了酒店评论中的短评文本,用jieba分词后,用WordCloud生成词云。
python复制from wordcloud import WordCloud
import jieba
# 读取所有评论到文本
comments_text = " ".join(df["comment_text"].dropna().tolist())
words = " ".join(jieba.cut(comments_text))
wordcloud = WordCloud(
font_path="msyh.ttc", # 使用微软雅黑字体,否则中文会变方框
width=800,
height=600,
background_color="white",
max_words=200,
).generate(words)
plt.figure(figsize=(12, 8))
plt.imshow(wordcloud, interpolation="bilinear")
plt.axis("off")
plt.title("酒店用户评论关键词词云")
plt.tight_layout()
plt.savefig("comment_wordcloud.png", dpi=150)
plt.show()
这里有个非常容易踩的坑:WordCloud默认字体不支持中文,如果不指定font_path,生成的图片里全是方框。我最初就卡在这里十分钟,后来换了微软雅黑的字体文件才正常显示。分词阶段还需要注意一个细节:默认的jieba分词会把“酒店”“入住”这类高频但无信息量的词也放进来,建议先用一个自定义停用词表把它们过滤掉,词云才有真正的洞察价值。
5. 跑通全流程后的四个避坑提醒
整个项目从爬虫到可视化,前前后后花了大概两个周末。回头复盘,有四个问题几乎是每个做同类项目的人都可能遇到的,我列在这里,希望能帮你少走弯路。
5.1 请求频率与封IP:数据量越大越要克制
我第一次跑采集脚本时,为了追求速度,把请求间隔设成了0.5秒,结果爬了一百多条数据之后突然开始大量返回空白结果,后来发现是被临时限流了。解决办法是必须把请求频率降下来,并且在代码里加入自动退避机制——遇到连续失败就暂停更长时间。虽然慢,但稳定才是采集的第一原则。另外建议把每次采集结果实时存储到本地CSV里,避免程序中途崩溃导致前功尽弃。
5.2 数据时效性:别把一次采集当作永恒真理
酒店价格随着节假日、大型会展、季节变化波动非常大。我3月份采集的杭州酒店价格分布,放到国庆期间完全不具备参考价值。如果你想让分析结论更有说服力,可以设计一个定时采集脚本,把每周的数据追加存储,做成时间序列分析,观察价格随时间的波动规律。这种增量更新的思路,比单次快照式分析更能体现数据项目的价值。
5.3 接口字段与页面渲染不一致:以接口为准
在开发过程中我发现一个现象:同一个酒店,页面HTML上显示的评分和JSON接口返回的评分偶尔不一致。大部分情况下接口返回的数据更完整、更结构化。所以提醒一句:能解析接口就解析接口,不要拿页面文本做字段提取,那样不仅效率低,还容易引入二次误差。以接口为准,页面展示仅作参考,这个原则能省掉很多不必要的麻烦。
5.4 中文字体乱码:五种图形库的通用解法
中文乱码是Python可视化里最常见的新手杀手。matplotlib默认字体不支持中文,需要设置plt.rcParams["font.sans-serif"];pyecharts默认是支持中文的,但HTML渲染环境里如果缺字体也会出问题;WordCloud需要指定中文字体路径。网络上有很多零碎的解决方案,我这里总结成一个通用思路:任何图形库出现中文乱码,优先检查两件事——系统里是否装了中文字体,代码里是否指定了字体路径。
python复制# 查询系统所有可用中文字体
import matplotlib.font_manager as fm
font_list = [f.name for f in fm.fontManager.ttflist if "Hei" in f.name or "Song" in f.name or "YaHei" in f.name]
print(font_list)
这个项目做下来的最大体会是:数据可视化分析的价值,不在于图表画得多炫,而在于它能不能帮你回答最初的问题。我通过这几张图确实弄明白了杭州酒店价格的分布逻辑——价格由地段、星级、供需关系共同决定,而口碑并不是价格的直接函数。如果你也想动手做类似的项目,我的建议是从你熟悉的城市入手,抓它的酒店或景点数据,先跑通清洗流程,再慢慢加图表、加分析维度。后续还可以往这几个方向扩展:接入评论情感分析判断好评率,采集不同时间段的价格做波动预测,或者把多个城市的指标放在一起做横向对比。用Python做数据可视化分析这条路,一旦跑通第一个真实项目,后面的进阶就会顺畅很多。
