
简介这份资源是面向餐饮行业市场调研与竞品分析场景的数据工具包聚焦北京市奶茶店铺这一细分市场适合具备一定Python基础、希望快速上手网页数据采集与统计分析的学习者与从业者。包内共36个文件以27张png分析图表、2份csv原始数据集、2个html交互地图、1个py爬虫脚本、1个ipynb分析笔记及md、txt、docx说明文档为主压缩包约12.58MB结构清晰便于按模块查阅。爬虫代码支持自定义爬取地址与美食种类可灵活调整采集范围原始数据集涵盖店铺名称、地址、评分、评论数、人均消费等字段可直接用于建模。ipynb文件则串联了直方图、相关性热力图、DBSCAN聚类、层次聚类树状图与回归诊断等完整分析流程帮助读者理解从数据获取到结论输出的全链路。目前已有114人学习适合作为市场调研与竞品分析的实操参考。1. 大众点评奶茶店铺数据从网页端到分析闭环的完整路径北京奶茶市场有多卷一条街上七八个品牌贴身肉搏喜茶、奈雪、蜜雪冰城、茶百道、沪上阿姨挤在同一个商圈里抢客流。做餐饮行业市场调研和竞品分析的人如果还靠手工翻大众点评一家一家截图记录效率低到让人崩溃。这个方案要解决的就是这件事用 Python 爬取大众点评网页端北京市奶茶店铺信息把店铺名称、评分、人均消费、评论数、地址、口味/环境/服务分项评分等字段结构化落成原始数据集再通过 ipynb 文件做数据分析和可视化最终支撑选址评估、竞品定价、品类空白点识别等决策。适合有基础 Python 能力、想做餐饮赛道数据化调研的从业者也适合拿它当数据分析项目实践来练手。整个链路分三层爬取层负责拿数据存储层负责清洗落表分析层用 ipynb 做聚类和可视化。下面按这个顺序拆开讲。2. 爬取层请求构造、字段解析与翻页策略2.1 目标页面结构拆解与请求头配置大众点评网页端的店铺列表页本质是一个带查询参数的搜索结果页。以北京奶茶为例URL 里通常包含城市标识、品类关键词、页码等参数。常见做法是先用浏览器打开目标页面用开发者工具切到 Network 面板筛选 XHR 或 Doc 请求找到返回店铺列表 HTML 的那条请求把它的 URL 模板和请求头完整复制下来。请求头里最关键的是 User-Agent、Referer 和 Cookie。User-Agent 要伪装成正常浏览器Referer 要指向站内来源页Cookie 里携带的会话信息决定了你能不能拿到完整列表。这三者缺一个返回的可能就是空列表或者验证页面。import requests import time import random HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://www.dianping.com/beijing/ch10/g3416, # 站内来源页 Cookie: 你的Cookie字符串, # 从浏览器开发者工具复制 Accept-Language: zh-CN,zh;q0.9, } def fetch_page(url, retry3): 带重试的页面请求失败后指数退避 for i in range(retry): try: resp requests.get(url, headersHEADERS, timeout15) if resp.status_code 200: return resp.text print(f状态码 {resp.status_code}第 {i1} 次重试) except requests.RequestException as e: print(f请求异常{e}第 {i1} 次重试) time.sleep(2 ** i random.uniform(0, 1)) return None这段代码做了三件事封装请求头、带重试的 GET 请求、指数退避加随机抖动。参数上timeout 设 15 秒是经验值太短容易误判超时太长会拖慢整体节奏。retry 设 3 次足够再多说明目标站点已经在限制你了继续重试只会加重封禁风险。随机抖动是为了打散请求间隔避免固定频率被识别。2.2 列表页字段提取与详情页补全列表页能拿到的字段有限通常只有店铺名、评分、评论数、人均、地址和详情页链接。分项评分口味、环境、服务和推荐菜往往在详情页里。所以策略是两步走先从列表页批量抓取基础字段和详情页 URL再逐个请求详情页补全。解析用 BeautifulSoup 或 lxml 都行关键是定位到正确的 CSS 选择器。大众点评的 class 名经常带混淆后缀比如shop-name-1a2b3c这种不能硬编码要用部分匹配或者层级关系来定位。from bs4 import BeautifulSoup import re def parse_list_page(html): 解析列表页返回店铺基础信息列表 soup BeautifulSoup(html, lxml) shops [] for item in soup.select(div.shop-list div.shop-item): name_tag item.select_one(a.shop-name) if not name_tag: continue shop { name: name_tag.get_text(stripTrue), detail_url: name_tag.get(href, ), score: extract_float(item, span.shop-score), review_count: extract_int(item, a.review-count), avg_price: extract_int(item, span.avg-price), address: extract_text(item, span.shop-address), } shops.append(shop) return shops def extract_float(parent, selector): 提取浮点数找不到返回 None tag parent.select_one(selector) if tag: m re.search(r[\d.], tag.get_text()) return float(m.group()) if m else None return None def extract_int(parent, selector): 提取整数处理1234条这类文本 tag parent.select_one(selector) if tag: m re.search(r\d, tag.get_text().replace(,, )) return int(m.group()) if m else None return None def extract_text(parent, selector): tag parent.select_one(selector) return tag.get_text(stripTrue) if tag else None这里把提取逻辑拆成独立函数好处是列表页和详情页可以复用同一套解析工具。extract_float和extract_int用正则从文本里抠数字因为页面上的评分可能写成「4.8分」评论数可能写成「1,234条」直接取 text 再转类型会报错。detail_url拿到后要补全域名前缀大众点评列表页里的链接有时是相对路径。详情页解析重点是分项评分和推荐菜。分项评分一般在div.comment-score下面用span的 title 属性或者文本内容区分口味、环境、服务。推荐菜在div.recommend-dish里取前 5 个就够了太多对后续分析帮助不大。2.3 翻页控制与请求频率管理翻页有两种方式一种是改 URL 里的 page 参数另一种是点击下一页按钮触发异步加载。网页端通常第一种更稳定。北京奶茶店铺按品类筛选后一般能翻到 50 页左右每页 15 条总量在 700 到 800 条之间。实际能拿到多少取决于 Cookie 有效性和请求频率。请求频率是爬取层最需要克制的地方。我的血泪经验是间隔低于 3 秒连续请求 20 页左右就会触发验证间隔拉到 5 到 8 秒配合随机抖动能稳定跑完整个列表。下面是一个翻页控制循环的骨架def crawl_all_pages(base_url, max_pages50): 翻页爬取带频率控制和中断保护 all_shops [] for page in range(1, max_pages 1): url f{base_url}page{page} html fetch_page(url) if not html: print(f第 {page} 页请求失败跳过) continue shops parse_list_page(html) if not shops: print(f第 {page} 页无数据可能已到末尾或触发限制) break all_shops.extend(shops) print(f第 {page} 页完成累计 {len(all_shops)} 条) time.sleep(random.uniform(5, 8)) # 核心频率控制 return all_shopsmax_pages设 50 是保守值实际到 40 页左右数据就重复了。time.sleep的 5 到 8 秒随机区间是经过多次翻车后定下来的低于这个范围封禁概率明显上升。中断保护体现在if not shops那个分支一旦某页返回空列表大概率是触发了限制或者已经翻到末尾直接 break 比继续硬跑更安全。3. 存储层数据清洗、去重与落盘格式选择3.1 原始数据清洗规则爬下来的原始数据不能直接进分析环节至少要做四类清洗去重、空值处理、类型转换、异常值过滤。去重按店铺名称加地址组合判断因为同一品牌不同分店名称可能相同但地址不同。空值处理上评分和评论数为空的记录直接丢弃人均消费为空的可以用同品类中位数填充。类型转换确保评分是 float、评论数和人均是 int。异常值方面人均超过 500 的奶茶店大概率是数据错误或者品类误判需要人工复核。import pandas as pd import numpy as np def clean_shops(raw_list): 清洗原始爬取数据 df pd.DataFrame(raw_list) # 去重名称地址组合 df.drop_duplicates(subset[name, address], inplaceTrue) # 丢弃核心字段为空的行 df.dropna(subset[name, score, review_count], inplaceTrue) # 类型转换 df[score] pd.to_numeric(df[score], errorscoerce) df[review_count] pd.to_numeric(df[review_count], errorscoerce).astype(Int64) df[avg_price] pd.to_numeric(df[avg_price], errorscoerce) # 人均空值用中位数填充 median_price df[avg_price].median() df[avg_price].fillna(median_price, inplaceTrue) # 异常值过滤 df df[(df[avg_price] 0) (df[avg_price] 500)] df df[(df[score] 1) (df[score] 5)] df.reset_index(dropTrue, inplaceTrue) return dfdrop_duplicates的 subset 参数选名称加地址比只按名称去重更准确。astype(Int64)用大写 I 是为了支持空值整数列普通 int 遇到 NaN 会报错。人均中位数填充是折中方案如果空值比例超过 30%建议直接丢弃这些行而不是填充否则会引入偏差。3.2 CSV 与 Excel 的落盘选择原始数据集存成 CSV 还是 Excel取决于后续怎么用。CSV 体积小、读写快、兼容性好适合程序化处理Excel 方便人工查看和分享但行数超过 10 万会变慢而且中文编码容易出问题。我的习惯是两份都存CSV 作为分析主数据源Excel 作为交付和人工复核的副本。def save_dataset(df, csv_pathbeijing_milk_tea.csv, excel_pathbeijing_milk_tea.xlsx): 双格式落盘 df.to_csv(csv_path, indexFalse, encodingutf-8-sig) df.to_excel(excel_path, indexFalse, engineopenpyxl) print(f已保存 {len(df)} 条记录)encodingutf-8-sig是关键参数带 BOM 的 UTF-8 能让 Excel 直接打开 CSV 时不乱码。不加这个参数Windows 上双击 CSV 打开大概率是乱码这个坑我踩过不止一次。engineopenpyxl是写 xlsx 的默认引擎如果数据量特别大可以换xlsxwriter写入速度更快。3.3 数据集字段说明与质量校验落盘后的数据集应该包含以下字段每个字段的含义和取值范围需要在分析前确认清楚字段名类型含义取值范围namestr店铺名称非空scorefloat综合评分1.0-5.0review_countint评论数≥0avg_pricefloat人均消费元1-500addressstr店铺地址非空taste_scorefloat口味评分1.0-5.0env_scorefloat环境评分1.0-5.0service_scorefloat服务评分1.0-5.0recommend_dishesstr推荐菜逗号分隔可空质量校验至少跑一遍检查各字段空值比例、评分分布是否合理、人均消费是否有极端值、地址是否都在北京范围内。地址校验可以用关键词匹配包含「北京」「朝阳」「海淀」「东城」「西城」「丰台」等区名的记录标记为有效。4. 分析层ipynb 里的聚类、可视化与竞品对比4.1 ipynb 环境准备与数据加载ipynb 文件的好处是代码、图表、文字说明可以混排适合做探索性分析和结果汇报。打开 ipynb 的方式有两种命令行jupyter notebook启动后浏览器打开或者用 VS Code 直接打开.ipynb文件。环境依赖主要是 pandas、numpy、matplotlib、seaborn、scikit-learn 这几个库。# ipynb 第一个 cell环境准备 import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns from sklearn.preprocessing import StandardScaler from sklearn.cluster import DBSCAN # 中文字体配置否则图表中文显示为方块 plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False df pd.read_csv(beijing_milk_tea.csv, encodingutf-8-sig) print(f数据量{len(df)} 条) print(df.describe())中文字体配置是必须的不设的话 matplotlib 图表里的中文全是方块这个坑新手几乎必踩。SimHei是 Windows 自带黑体Mac 上可以换成Arial Unicode MS或PingFang SC。df.describe()先看一眼数值分布确认清洗后的数据没有明显异常。4.2 DBSCAN 聚类识别商圈热度DBSCAN 适合做奶茶店铺的空间聚类因为它不需要预先指定簇数量而且能识别噪声点。核心参数有两个eps是邻域半径min_samples是核心点最小邻居数。对北京奶茶数据来说经纬度需要先从地址解析出来或者用地址里的商圈关键词做近似分组。from sklearn.cluster import DBSCAN # 用评分、评论数、人均三个维度做聚类 features df[[score, review_count, avg_price]].copy() scaler StandardScaler() features_scaled scaler.fit_transform(features) db DBSCAN(eps0.5, min_samples5) df[cluster] db.fit_predict(features_scaled) # 统计各簇特征 cluster_summary df.groupby(cluster).agg( count(name, count), avg_score(score, mean), avg_price(avg_price, mean), avg_reviews(review_count, mean), ).round(2) print(cluster_summary)eps0.5和min_samples5是初始值实际调参要看聚类结果。如果噪声点太多cluster-1 占比超过 30%说明 eps 太小适当调大到 0.7 或 0.8如果所有点都归到一个簇说明 eps 太大。StandardScaler标准化是必须的因为评论数动辄几千评分只有个位数不标准化的话评论数会主导距离计算。聚类结果可以这样解读高评分高评论低人均的簇是「口碑走量型」适合参考它们的选址和定价低评分高人均的簇是「高价低质型」是竞品分析里的反面教材噪声点往往是独立小店或者数据异常需要单独看。4.3 可视化评分分布、价格带与品牌对比可视化是 ipynb 的核心价值。至少做三张图评分分布直方图、人均消费箱线图、品牌评论数对比条形图。fig, axes plt.subplots(1, 3, figsize(18, 5)) # 评分分布 axes[0].hist(df[score], bins20, color#4C72B0, edgecolorwhite) axes[0].set_title(北京奶茶店铺评分分布) axes[0].set_xlabel(评分) axes[0].set_ylabel(店铺数) # 人均消费箱线图 sns.boxplot(ydf[avg_price], axaxes[1], color#DD8452) axes[1].set_title(人均消费分布) axes[1].set_ylabel(人均元) # 品牌评论数对比取评论数前10的品牌 df[brand] df[name].str.extract(r^([\u4e00-\u9fa5]{2,4})) top_brands df.groupby(brand)[review_count].sum().nlargest(10) axes[2].barh(top_brands.index, top_brands.values, color#55A868) axes[2].set_title(评论数 Top10 品牌) axes[2].set_xlabel(总评论数) plt.tight_layout() plt.savefig(analysis_overview.png, dpi150) plt.show()品牌提取用正则从店铺名开头截取 2 到 4 个中文字符这个方法对「喜茶」「奈雪」「蜜雪冰城」这类命名规律比较准但遇到「茶百道朝阳店」这种带括号的也能处理。nlargest(10)取评论数前 10 的品牌横向条形图比纵向更适合展示品牌名。dpi150保存的图片清晰度足够放进报告。从这三张图能快速读出几个结论评分集中在 4.0 到 4.8 之间低于 4.0 的店铺要么是新店要么有硬伤人均消费中位数在 18 到 25 元区间超过 35 元的店铺评论数明显偏低评论数 Top10 品牌里蜜雪冰城和喜茶通常占据前两位但两者的评分和人均差异很大这就是竞品分析要展开的点。5. 避坑与排查爬取和分析中最容易翻车的五个地方5.1 现象请求返回 200 但列表为空原因Cookie 过期或者请求头不完整大众点评对未登录或异常会话返回的是空列表页而不是 403。解决重新从浏览器复制最新 Cookie确认 Referer 和 User-Agent 与 Cookie 来源浏览器一致。如果换了 IP 或长时间未操作Cookie 会失效需要重新获取。5.2 现象翻到第 10 页左右开始返回重复数据原因大众点评对翻页深度有限制超过一定页数后返回的是第一页内容或者随机推荐内容。解决在解析前先判断当前页第一条记录的 URL 是否与上一页重复重复则停止翻页。另外可以按商圈或品类拆分查询条件每个条件只翻前 10 页用多个条件组合覆盖全量。5.3 现象详情页解析报 NoneType 错误原因部分店铺没有分项评分或推荐菜字段页面结构与其他店铺不同。解决所有提取函数都要做空值判断select_one返回 None 时直接返回 None 而不是继续调用.get_text()。在清洗阶段统一处理这些空值不要指望爬取阶段能拿到完整字段。5.4 现象DBSCAN 聚类结果全是 -1原因eps设得太小所有点都被判定为噪声。解决先对特征做标准化然后用 k-距离图辅助确定 eps。简单做法是从 0.3 开始逐步增大 eps观察噪声点比例降到 20% 以下即可。min_samples一般设为特征维度的 2 倍加 1三维特征设 7 左右比较合理。5.5 现象ipynb 里图表中文显示为方块原因matplotlib 默认字体不支持中文。解决在第一个 cell 里设置plt.rcParams[font.sans-serif]Windows 用SimHeiMac 用Arial Unicode MSLinux 用WenQuanYi Micro Hei。设置后需要重启 kernel 才生效改完不重启是另一个常见翻车点。6. 进阶技巧用 Spark 做大规模数据分析的衔接思路单机 pandas 处理七八百条数据绰绰有余但如果要把分析范围扩展到全国多个城市、多个品类数据量上到几十万条pandas 就会开始吃力。这时候可以把存储层和分析层做一次升级原始数据落成 Parquet 格式用 Spark 做分布式聚合和聚类ipynb 里通过 PySpark 连接 Spark 集群读取结果。from pyspark.sql import SparkSession from pyspark.ml.feature import VectorAssembler, StandardScaler from pyspark.ml.clustering import KMeans spark SparkSession.builder.appName(MilkTeaAnalysis).getOrCreate() df_spark spark.read.parquet(beijing_milk_tea.parquet) assembler VectorAssembler(inputCols[score, review_count, avg_price], outputColfeatures) df_vec assembler.transform(df_spark) scaler StandardScaler(inputColfeatures, outputColscaled_features) scaler_model scaler.fit(df_vec) df_scaled scaler_model.transform(df_vec) kmeans KMeans(k5, seed42, featuresColscaled_features) model kmeans.fit(df_scaled) df_result model.transform(df_scaled) df_result.groupBy(prediction).count().show()Spark 的 KMeans 和 sklearn 的 DBSCAN 定位不同KMeans 适合已知簇数量的场景速度快DBSCAN 适合发现任意形状的簇和噪声点但计算复杂度高。数据量小的时候用 DBSCAN 探索数据量大且需要定期跑批的时候用 KMeans 更实际。Parquet 格式比 CSV 压缩率高、读取快Spark 读 Parquet 是列式扫描只取需要的列比全量读 CSV 效率高一个量级。验证分析结果是否靠谱我一般用两个方法一是抽样人工复核从每个聚类里随机抽 5 家店去大众点评上核对评分和人均是否与数据集一致二是换参数重跑把 DBSCAN 的 eps 调大调小各跑一次看聚类结论是否稳定如果结论随参数剧烈变化说明数据特征不够明显需要换维度或者补充数据。做这类项目最大的教训是爬取阶段省的时间清洗阶段要加倍还回来。我现在的习惯是爬完先不急着分析花半小时把数据质量报告跑一遍空值率、重复率、异常值比例都看清楚再往下走。这个习惯帮我省掉了无数次返工。希望帮到你。本文还有配套的精品资源点击获取