用Python实现大众点评数据可视化分析实战指南
2026/9/22 16:03:52 网站建设 项目流程

简介:面向Python爬虫与数据分析学习者的一套完整实战项目,围绕大众点评商家数据的采集、清洗、分析与可视化展开。项目综合运用requests、BeautifulSoup等爬虫技术抓取店铺与评论,使用pandas完成数据清洗与统计,再通过matplotlib、seaborn、wordcloud、folium等库输出星级排名、菜品分类、词云图与门店位置分布图,展示不同菜系的评分和消费倾向,覆盖从数据采集到商业洞察的完整链路。压缩包共99个文件,约14.89MB,主要包含8个Python脚本、29个Excel数据表、12个CSV数据、12个Word说明文档、15个HTML可视化页面,并附有词云字体、地图图片、SQL数据备份与配置文件等辅助材料。项目目录在爬虫、数据、结果图像、基础模块和情感词典等方面划分清晰,便于按模块研读和复用。目前已有269人浏览/学习,适合希望通过真实案例掌握数据分析全流程、用于课程设计或毕业设计的初中级学习者。

1. 从一张截图到一条分析链路,大众点评数据可视化到底在做什么

餐饮行业做商圈选址、竞品监控的人,每个月都会干一件笨事:打开大众点评,翻几十页商户列表,把店名、评分、人均价格、评论数手动抄进 Excel。注意力稍一分散就抄错行,更麻烦的是抄完也没法回答"这个商圈整体客单价到底在涨还是跌"这种需要跨时间对比的问题。用 Python 做大众点评数据可视化分析,本质就是把"人工看一眼"升级成"程序定期看一遍":采集页面数据、清洗成结构化表格、按口径算出指标、最后用图表把结论摆出来。整条链路并不依赖某个特定框架,反而是 requests、Pandas、SQLite 和 Matplotlib 这四样基础工具各管一段,串起来就是一套可复用的数据管线。这篇文章的服务对象是已经写过一段时间 Python、但没正经做过采集和分析项目的工程师,目标是读完能按同样的路径跑出自己城市、自己品类的一套分析结果。

2. 数据获取:不急着写爬虫,先按页面结构定字段和接口

2.1 从页面结构反推分析字段,漏了经纬度后面会后悔

写采集代码之前,我一般会先人工把列表页和详情页各打开一遍,在开发者工具里看两个东西:列表数据是从 HTML 里直接渲染的,还是页面加载后才请求接口拿到的。大众点评这类站点的常见做法是列表页在 HTML 里带一份基础数据,包括店名、星级、评论数和人均价格,而经纬度这种更细的信息往往只在详情页或者地图接口里出现。如果把数据采集想成一次副本同步,那字段边界就是要同步的 schema,定义不清楚,后面跑完才发现漏了经纬度,就得整表重采。

字段优先级按分析目标排,我常用的最小集合如下表,这些字段足以支撑店铺评分分布、区域热度对比和价格带分析:

字段名来源页面类型用途说明
shop_id列表页字符串主键,用于去重和增量更新
shop_name列表页字符串展示用
star_rating列表页浮点综合星级,大众点评用半星粒度
review_count列表页整数评论数,代表热度
avg_price列表页整数人均价格,单位元
taste/env/service详情页浮点口味环境服务三项得分
district列表页字符串商圈/行政区,聚类分析用
latitude/longitude详情页浮点可选,做地图热力图才需要

建议用dict按页面结构先声明一个字段映射模板再写解析逻辑,这样页面结构变化时只需要改映射表,不用改入库函数。

2.2 最小可运行的采集代码:请求头、节流与重试缺一不可

第一版代码不用做得花哨,核心目标是把一个列表页的内容完整拿下来,且运行过程不让自己被封。我的做法是每次请求前随机旋转 User-Agent,请求之间强制time.sleep,并对失败请求做指数退避重试。关键点在于把请求头和网络请求封装成两个独立函数,后续不管解析逻辑怎么改,这两段不用动。

import time import random import requests from requests.adapters import HTTPAdapter UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", ] def get_session() -> requests.Session: s = requests.Session() s.headers.update({ "User-Agent": random.choice(UA_POOL), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3", "Connection": "keep-alive", }) # 同一个 session 复用连接,减少握手次数 s.mount("https://", HTTPAdapter(max_retries=2)) return s def fetch_html(session: requests.Session, url: str, retries: int = 3) -> str: for attempt in range(retries): try: resp = session.get(url, timeout=10) if resp.status_code == 200: return resp.text if resp.status_code in (403, 429): # 被限流时退避时间一次比一次长 wait = 2 ** attempt + random.uniform(0, 1) time.sleep(wait) except requests.RequestException as exc: print(f"[warn] request failed: {exc}") time.sleep(attempt + 1) return ""

这段代码里HTTPAdapter(max_retries=2)只处理连接层错误,业务层拿不到 200 就交给外层重试逻辑,两层的职责是分开的。max_retries不建议设很大,否则一次页面结构改版会让任务卡在重试循环里出不来。真实的采集时长一般由time.sleep决定:单页请求加解析按 2 秒估算,跑 500 页大约 17 分钟,这个量级对个人分析完全可接受,也远没到需要并发提速的程度。

2.3 动态加载内容与字体反爬的判别方法

大众点评的评分和评论数在页面上有个典型特征:HTML 源码里直接能看到数字,但肉眼看到的文字和源码里的字符对不上,这是字体反爬在起作用。具体机制是页面加载时指定一个自定义字体文件,数字 0-9 每个字符被映射到不同的 unicode 编码,浏览器拿到字体文件后按字形渲染出正常数字,而爬虫拿到 HTML 后看到的是乱码。诊断方法很简单:在开发者工具里看响应体,如果评分那一段是这种私有区字符,就是字体反爬。

处理思路不复杂,常见做法是拿到页面引用的 woff/woff2 文件,用fontTools读取字形轮廓,再结合一个已知数字样本做映射。代码层面只需要提取 cmap 表和字形坐标:

from fontTools.ttLib import TTFont def extract_char_map(font_path: str) -> dict: font = TTFont(font_path) cmap = font.getBestCmap() # 输出形如 {0xE60D: ('glyph00001',), 0xE60E: ...} 的映射字典 return {code: name for code, name in cmap.items() if 0xE000 <= code <= 0xF8FF}

拿到code -> glyph_name映射后,再对同一字体文件里每个 glyph 取前几个轮廓点坐标生成指纹,和人工标注过的"0-9 数字指纹表"比对,就能还原出0xE60D = 5这样的映射。这套流程每换一个字体文件就要重算一次,所以实际工程里会把指纹表存成 JSON 缓存,命中直接查表。字体反爬不是不可解的问题,但它的存在提醒我们:页面里任何"看起来像文本"的内容,都可能不是真正的文本,解析前多花一分钟看响应体比写完解析器再返工省时间。

3. 数据预处理:拿到原始数据后先洗出可用表格

3.1 大众点评数据里最容易被坑的三种脏数据形态

采集回来的数据一定不是干净的,大众点评尤其常见三种脏形态。第一种是评论数带单位,"3.2万条"和"580条"混在一个列里,Pandas 直接astype(int)会抛异常。第二种是人均价格字段里混着区间和单位,"¥50/人""60-80元"并存。第三种是评分列存在缺省值,新店还没有评分时页面显示"暂无",这个值在后续相关性分析里会拉低整体质量。处理原则是先在采集侧就统一口径,能清洗的尽量在解析函数里做,而不是全部丢给 Pandas,因为解析阶段还能看到原始上下文,到 Pandas 里就只剩字符串了。

3.2 用 Pandas 做类型转换:把字符串列拆成可计算的数值列

清洗动作集中在三个函数里,全部用 Pandas 向量化操作完成,不写 for 循环。review_count需要先判断是否有"万"字再乘 10000;avg_pricestr.extract提取第一组数字;评分列把"暂无"替换成pd.NA,后面分析时统一剔除。这里正好是 Python 类型转换的典型场景:Pandas 的to_numeric搭配errors="coerce"参数可以把脏数据转成 NaN,而astype只在确定数据已经干净时使用。

import pandas as pd def clean_review_count(raw: pd.Series) -> pd.Series: # 3.2万条 -> 32000 def _parse(val): if isinstance(val, str): val = val.replace(",", "").replace("条", "") if "万" in val: return int(float(val.replace("万", "")) * 10000) try: return int(float(val)) except (TypeError, ValueError): return 0 return raw.map(_parse) def clean_avg_price(raw: pd.Series) -> pd.Series: # "¥50/人" / "60-80元" -> 只保留第一个数字 nums = raw.astype(str).str.extract(r"(\d+)").iloc[:, 0] return pd.to_numeric(nums, errors="coerce") def clean_rating(raw: pd.Series) -> pd.Series: return pd.to_numeric(raw.replace("暂无", pd.NA), errors="coerce") df["review_count"] = clean_review_count(df["review_count"]) df["avg_price"] = clean_avg_price(df["avg_price"]) df["star_rating"] = clean_rating(df["star_rating"])

errors="coerce"的语义是解析失败的位置填入NaN,而不是中断整个流程,这在大规模清洗任务里远比raise安全。有一条经验值得记住:清洗函数返回的 Series 索引要和传入的raw保持一致,如果在map里做了sort_values这类操作,索引错位会造成整列数据张冠李戴。最稳妥的做法是全程不改变 index,清洗完成后统一reset_index(drop=True)

3.3 写入 SQLite 时如何避免重复数据越攒越多

采集任务不是只跑一次的脚本,后面大概率每周都会重跑,所以入库时去重必须由数据库约束保证,而不是靠应用层先查一遍。SQLite 的INSERT OR IGNORE配合shop_id唯一索引是最省事的方案:重复写入时直接跳过,不需要在 Python 里维护一个已存在 ID 的集合。

CREATE TABLE IF NOT EXISTS shops ( shop_id TEXT PRIMARY KEY, shop_name TEXT NOT NULL, star_rating REAL, review_count INTEGER, avg_price INTEGER, taste_score REAL, env_score REAL, service_score REAL, district TEXT, latitude REAL, longitude REAL, crawled_at TEXT DEFAULT (datetime('now', 'localtime')) );

写 SQLite 时有个经常被忽略的细节:crawled_at默认值用数据库时间,但采集任务跨天执行时,入库时间可能和页面数据的实际更新时间差出几个小时。更严谨的做法是采集时把页面里的"更新时间"字段一并存储,分析时效性时用那个字段而不是数据库入库时间。写入逻辑用executemany批量提交,2000 条的数据量毫秒级完成,不需要用 ORM。

import sqlite3 conn = sqlite3.connect("dianping.db") records = df.to_dict(orient="records") conn.executemany( "INSERT OR IGNORE INTO shops " "(shop_id, shop_name, star_rating, review_count, avg_price, " " taste_score, env_score, service_score, district, latitude, longitude) " "VALUES (:shop_id, :shop_name, :star_rating, :review_count, :avg_price, " ":taste_score, :env_score, :service_score, :district, :latitude, :longitude)", records, ) conn.commit()

这里to_dict(orient="records")把 DataFrame 转成字典列表,:字段名占位符和键一一对应,键名必须是字符串且不能有多余字段,否则 SQLite 会直接报 "You did not supply a value for binding parameter" 之类的错。遇到这类报错,第一步检查 DataFrame 列名是否比 INSERT 语句里的列表多了或少了一个。

4. 分析指标与可视化:从清洗好的表到一张看得懂的图

4.1 先定口径再画图:三个值得优先计算的指标

数据清洗完成后,最忌讳的事是打开 Matplotlib 把所有列两两画一遍散点图,画完发现没有一个结论能用。我现在会先想清楚"要回答什么问题",再反推需要算什么指标。大众点评数据里信息量最大的三个口径分别是:商圈平均人均价格评分分布密度评分与人均价格的相关性。第一个回答"这个区域消费力如何",第二个回答"头部商铺有没有拉开差距",第三个回答"贵是否等于好"。

计算时注意聚合口径要统一。商圈的划分以爬虫拿到的 district 字段为准,不推荐用坐标点做聚类再命名,因为聚类出来的一团点没法在报告里说清楚"这是哪个商圈"。

指标计算公式图表类型分析价值
商圈人均价格按 district 分组取 avg_price 中位数横向条形图抗离群值,比均值稳
评分标准差按 district 分组取 star_rating 标准差箱线图看商圈内商家质量差异
价格-评分相关系数整体或分商圈算 Spearman 相关系数散点图检验"贵=好"的直觉

中位数而不是均值的原因是大众点评的价格带呈长尾分布,一个商圈里 800 元的日料店会把均值拉高几十块,中位数能更真实地反映大多数店铺的价格水位。

4.2 Matplotlib 画分布与相关性,注意处理横坐标刻度过密

Matplotlib 画直方图时一上来最容易遇到"横坐标太密集"的问题:评分列是 3.0 到 5.0 之间带半星粒度的浮点,如果不设置bins,默认刻度会挤成一团黑色。常见做法是规定bins=np.arange(3.0, 5.5, 0.5)让每一星半成一个柱子,再用MaxNLocator限制坐标轴刻度数量。

import numpy as np import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator def plot_rating_distribution(df: pd.DataFrame, district: str | None = None): subset = df if district is None else df[df["district"] == district] fig, ax = plt.subplots(figsize=(10, 5)) bins = np.arange(3.0, 5.5, 0.5) ax.hist(subset["star_rating"], bins=bins, edgecolor="white", alpha=0.8) ax.xaxis.set_major_locator(MaxNLocator(nbins=6)) ax.set_xlabel("star_rating") ax.set_ylabel("shop count") ax.set_title(f"Rating Distribution - {district if district else 'All'}") fig.tight_layout() return fig

MaxNLocator(nbins=6)的含义是坐标轴最多出 6 个刻度,Matplotlib 会自行决定取哪些整数刻度。如果想让刻度更符合人的阅读习惯,可以显式传入tick_positions = [3.0, 3.5, 4.0, 4.5, 5.0],效果更可控。注意过滤空商圈时会得到一个空 DataFrame,ax.histbins非空时不会报错,但图会没有柱子,脚本继续跑下去不影响其他商圈出图。

4.3 相关性分析要选对方法,评分和人均价格之间用 Spearman 更合理

评分和人均价格都是带顺序性质的连续变量,但评分是半星粒度,其实是离散化的数据,直接算 Pearson 相关系数会被粒度和离群值干扰。Spearman 把两组数据分别转成排名再算相关性,对大众点评这种数据形态更合适。scipy.stats.spearmanr一行出结果,使用前一章清洗好的表,注意把pd.NA剔除干净。

from scipy.stats import spearmanr clean = df.dropna(subset=["star_rating", "avg_price"]) corr, p_value = spearmanr(clean["star_rating"], clean["avg_price"]) print(f"Spearman r = {corr:.3f}, p = {p_value:.2e}")

很多地区跑出来的结果显示评分和人均价格呈弱负相关,或者相关性不显著。这本身就是一个值得写进报告的反直觉结论:消费者愿意为环境付费的占比比想象中小,高分店集中在人均 50 到 120 元这个区间。分析时如果只看散点图的整体趋势而忽略区间特征,很容易得出完全相反的结论,所以可视化图上我们下一步要分层配色。

fig, ax = plt.subplots(figsize=(8, 6)) scatter = ax.scatter( clean["avg_price"], clean["star_rating"], c=clean["review_count"], cmap="viridis", s=14, alpha=0.7, ) ax.set_xlim(0, 400) # 截掉极端高价店,只看主体分布 ax.set_xlabel("avg_price (yuan)") ax.set_ylabel("star_rating") plt.colorbar(scatter, label="review_count") fig.tight_layout()

c映射到评论数后,图中每个点的颜色深浅代表了热度,能直接看出"高分高热度"和"高分低热度"两类店铺在坐标系里分布在不同的价格区间。散点图点很多时alpha=0.7是为了叠加区域不至于黑成一团,点少时可以调回 1.0。

4.4 用 PyECharts 代替静态图,做一份能给领导看的可交互报告

Matplotlib 适合自己分析阶段快速出图,但要交付一份能交互的 HTML 报告,PyECharts 是常见选择。它在接口设计上比 Matplotlib 更贴近图表语义,add_yaxis一句就能加一个系列,render()输出独立 HTML 文件,双击就能在浏览器打开。

from pyecharts.charts import Bar from pyecharts import options as opts district_price = df.groupby("district")["avg_price"].median().sort_values(ascending=False) bar = ( Bar(init_opts=opts.InitOpts(width="900px", height="600px")) .add_xaxis(district_price.index.tolist()) .add_yaxis("商圈人均价格(中位数)", district_price.round(0).tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="商圈人均价格中位数对比"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=30)), ) ) bar.render("district_price_bar.html")

axislabel_opts=opts.LabelOpts(rotate=30)专门解决商圈名过长互相遮挡的问题,和 Matplotlib 里的rotation=30是同一个思路。PyECharts 默认输出的是带完整 JavaScript 依赖的 HTML,文件体积比静态图大几十倍,但如果后续要嵌入公司内部看板,这个格式比图片灵活得多。用.render_notebook()方法可以在 Jupyter 里直接内嵌渲染,适合分析阶段的快速验证。

PyECharts 做交互报告时有一个实用技巧:多个页面共享同一个配色风格时,把colors定义成一个全局常量,在各图表里通过InitOpts传入,避免不同图表间颜色语义不一致,比如"红色代表高价格"必须在整个报告里保持统一。

5. 验证采集结果:交叉核对和增量重跑保证可视化不骗人

图表画得再好看,数据源头错了结论就是错的。数据分析完成后,第一件事不是写总结报告,而是做三轮结果核验。第一轮是人工抽样:从数据库里随机抽 10 家店铺,打开大众点评对应页面,比对店名、评分、人均价格三项,任何一项对不上就要定位是采集版本的字段偏移还是页面结构变了。第二轮是聚合核验:用 SQL 独立算一遍商圈平均价格,与 Python 里 Pandas 聚合结果对,误差应该是 0。第三轮是时间一致性:检查crawled_at的日期分布,如果同一天采集的数据跨了两天,说明任务执行时长超过了预期,需要检查是否有请求卡在重试循环里。

增量重跑是保数据新鲜度的关键。我一般会在表里加一个crawled_at字段,第二次运行前先查一次最新采集时间,只抓最近三天内评分或评论数有变化的店铺,这样每次运行量从几千页降到几百页。具体做法是把采集入口函数拆成crawl_all()crawl_incremental()两个版本,前者用于首次全量,后者接收一个since_date参数。大众点评没有公开的"更新时间"接口,常见替代方案是定期全量重跑列表页,靠INSERT OR IGNORE去重;列表页总量不大时,全量重跑比增量逻辑更可靠。

还有一个容易被忽略的验证点:采集到的经纬度不能直接用,因为地图坐标在国内需要经过坐标系转换才能落到高德或百度地图上。转换逻辑通常是一个固定偏移公式,没转换前画热力图会偏移几百米,但不算致命错误。如果要做大屏展示,建议先用散点图在底图上抽查 20 个点,确认都在对应商圈后再接热力图。

最后再提醒一个小技巧,为了不让人工核验和增量重跑互相干扰,数据处理和分析的脚本文件建议分开维护:fetch.py只管产出原始 JSON,clean.py负责入库,analyze.py里只读数据库不写数据。这样哪天页面改版了,需要重跑的只有fetch.py,分析代码一行都不用动。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询