
简介这是一份面向Python初学者的美食数据全流程实战项目覆盖爬虫、数据清洗、分析与可视化四大环节。项目基于requestsBeautifulSoup抓取菜品与食材信息用pandas/NumPy完成数据预处理与统计再通过Matplotlib/Seaborn绘制分布图、饼图与热力图完整展示从数据采集到结果呈现的方法。压缩包共39个文件包含10个Python脚本、7个编译缓存pyc、5个HTML页面、5个JavaScript文件及配置、模板等配套内容整体仅706KB轻量易部署。目前已有4087人学习下载适合想快速上手数据科学流程、积累真实项目经验的学习者。通过阅读源码与运行示例可深入理解爬虫解析、数据规整及可视化设计的实现细节并掌握多类文件协作的项目组织方式。1. 美食数据爬取数据分析数据可视化这个Python项目能解决什么问题“美食数据爬取、数据分析、数据可视化”这三个词连在一起是一个典型的Python数据分析案例的完整闭环。经常有人问我Python入门之后该做什么练习项目或者说数据分析需要学哪些我都会推荐这个方向——数据源公开易得爬虫难度适中分析维度直观价格、评分、销量、地域可视化做出来有天然的话题性。这套流程跑通一遍等于把requests、Pandas、Matplotlib三件套全部实战了一遍而且每一步的产出都能看得见摸得着。但它也不是没有门槛反爬机制、脏数据、中文字体、坐标轴拥挤这些坑几乎人人都会踩一遍。这篇文章就是我基于这类美食数据项目整理出的一份可复现路径从爬虫选型到分析维度再到图表交付全部按我实际做过的方案来讲。2. 美食数据爬虫的站点分析与字段设计先定需求再写代码2.1 选哪个站点、抓哪些字段先定需求再写代码写代码之前先想清楚要回答什么问题。美食数据最常见的分析需求是什么价位段的餐厅评分最高地域对菜系分布的影响销量或评论量和评分之间有没有相关性。基于这些需求字段设计一般是餐厅名称、菜系类型、人均价格、评分、评论数量、所在城市或商圈、营业状态。这些信息基本分布在列表页和详情页两层。选站点方面常见做法是选大众点评、美团这类垂直平台或者饿了么这类外卖平台。垂直平台的难点在于反爬强度高很多列表页需要登录才能看全外卖平台相对好抓但数据粒度偏粗比如没有评分字段。我一般建议新手先从公开API或者低反爬的站点入手比如政府开放数据平台上的餐饮商户数据或者豆瓣的美食分类——虽然数据量小但能先把流程跑通。等流程熟练了再切换到商业站点配合Cookie、代理池等手段。这里要提醒一句爬虫的合规边界要把握好只爬公开数据、控制请求频率、不绕过登录机制这是底线。提示字段设计决定了后面分析的边界。爬的时候没存经纬度后面想做地域分布就没数据可用没存采集时间后面想做时间维度分析就无从谈起。我通常在字段表里加两个冗余字段采集时间和数据来源URL。成本几乎为零但给后续排查留了余地。比如清洗时发现某批数据评分全为空顺着URL去浏览器里打开看一眼立刻知道是页面改版了还是反爬策略变了而不是对着数据框干瞪眼。2.2 requestsBeautifulSoup实现最小爬虫核心代码与参数说明以静态页面为例最常见的爬虫实现是requests发请求、BeautifulSoup解析HTML。下面这段代码是一个可以直接改改就用的最小方案import requests from bs4 import BeautifulSoup import time import random headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch_page(url, retry3): for i in range(retry): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: return resp.text elif resp.status_code 403: print(f第{i1}次请求被拒绝状态码403) time.sleep(random.uniform(2, 5)) else: print(f状态码异常: {resp.status_code}) except requests.RequestException as e: print(f请求异常: {e}) time.sleep(2) return None html fetch_page(https://example.com/restaurants) if html: soup BeautifulSoup(html, html.parser) items soup.select(.restaurant-item) for item in items[:10]: name item.select_one(.name).text.strip() price item.select_one(.price).text.strip() rating item.select_one(.rating).text.strip() print(name, price, rating)这段代码有三个关键参数需要说明。第一个是headers里的User-Agent很多站点会校验这个字段缺失或者用requests库的默认UA很容易被识别为脚本。第二个是timeout10不设超时的话遇到慢响应或者挂起的连接程序会卡死在resp.text上整个爬虫就停摆了。第三个是retry和sleep的组合遇到403或者网络抖动时退避重试比一次失败就退出稳得多。BeautifulSoup的选择器部分.select接收CSS选择器.select_one返回第一个匹配项。这里的.restaurant-item、.name、.price、.rating是目标站点的实际HTML结构决定的不同站点差异很大。我的习惯是先在浏览器开发者工具里用Elements面板确认选择器能选中元素再写进代码避免爬虫跑了一半才发现选择器失效。新手常犯的错是把class名写错比如把restaurant-item写成restaurant_item下划线和横杠的区别在CSS选择器里是致命的。2.3 数据落盘CSV和SQLite怎么选数据量不大几千条且是一次性采集的话CSV够用。用csv模块写入import csv def save_to_csv(data, filename): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([店铺名, 菜系, 人均价格, 评分, 评论数, 采集时间]) for row in data: writer.writerow(row)需要注意encodingutf-8-sig。如果写成普通的utf-8生成的CSV用Excel打开时中文会乱码因为Excel默认按ANSI解析。加-sig会写入BOM头Excel就能正确识别。这个坑我早期做项目时踩过一次生成的文件在记事本里看是正常的发给同事用Excel打开全乱当时还以为是对方电脑问题其实是编码没选对。如果数据量大、字段多或者想随时按条件查询SQLite更合适。Python内置的sqlite3可以直接用不需要额外安装import sqlite3 conn sqlite3.connect(food_data.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS restaurants ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, cuisine TEXT, price REAL, rating REAL, review_count INTEGER, collected_at TEXT ) ) conn.commit() conn.close()CSV和SQLite的取舍我个人的标准是数据量在1万条以下、后续只用Pandas一次性分析用CSV需要增量更新、跨脚本共享、或者要做条件查询用SQLite。SQLite的好处是不用启动独立数据库服务文件即数据库对爬虫这种轻量场景刚刚好。还有一点SQLite支持并发读写的时候锁粒度也够用比CSV频繁读写要稳。3. 把爬回来的数据变成能分析的样本清洗顺序与特征提取3.1 缺失值、重复值、异常值的处理顺序爬回来的数据一定不是干净的数据。缺字段、重复记录、字段错位是最常见的三类问题。拿到数据之后处理顺序是先去重再处理缺失最后处理异常值。这个顺序别反了因为重复记录的存在会影响缺失值填充时中位数的计算——重复的极端值会把统计量带偏。import pandas as pd df pd.read_csv(food_data.csv) # 去重按关键字段判断 df df.drop_duplicates(subset[店铺名, 地址], keepfirst) # 缺失值处理 print(df.isnull().sum()) df df.dropna(subset[店铺名]) # 核心字段缺失直接删 df[评分] df[评分].fillna(df[评分].median()) # 数值字段用中位数填充 # 异常值处理价格明显偏离常识的 df df[df[人均价格].between(5, 5000)]这里drop_duplicates的subset参数很关键。只按店铺名去重可能会误删同名不同店的情况比如连锁品牌在同一个城市有多家分店只按名字去重会把它们当成同一条记录。加上地址字段会更稳。fillna用中位数而不是均值是因为价格分布通常右偏个别高价餐厅会把均值拉高用中位数填充对整体分布影响更小。between(5, 5000)是我根据这类数据特点设的过滤条件如果你的数据来自外卖平台价格下限可以改成1上限也可以按城市消费水平调整。3.2 文本型字段怎么办评分、价格、销量从字符串到数值爬虫拿到的文本经常带单位、带符号。比如人均价格是“¥128/人”评分是“4.5分”评论数是“1.2万”。这些字段在分析之前必须转成数值型否则Pandas会把整列当成字符串对象做不了排序、求均值、算相关性。import re def parse_price(text): # 提取所有数字处理¥128/人和人均128等格式 nums re.findall(r\d\.?\d*, str(text)) if not nums: return None return float(nums[0]) def parse_count(text): # 处理1.2万这种带单位的文本 if 万 in str(text): return float(str(text).replace(万, )) * 10000 nums re.findall(r\d\.?\d*, str(text)) return float(nums[0]) if nums else None df[price_num] df[人均价格].apply(parse_price) df[count_num] df[评论数].apply(parse_count) df[rating_num] pd.to_numeric(df[评分].str.replace(分, ), errorscoerce)re.findall提取数字的正则\d.?\d*能匹配整数和小数遇到脏文本时最稳。有人喜欢用更复杂的正则去匹配带货币符号的完整模式但在实际数据里你会发现文本格式五花八门统一用数字提取反而更可靠。errorscoerce是pd.to_numeric的推荐用法转换失败会变成NaN而不是直接抛异常把脚本中断。这一步做完之后可以再用df[[price_num, count_num, rating_num]].describe()看一眼分布确认没有明显异常。3.3 用Pandas做聚合与相关性第一张分析结果表清洗完之后开始做真正的数据分析。这一步的核心是用groupby做聚合用corr做相关性探索。这也是整个项目里最容易被低估的一步——很多人急着画图结果画出来的图没有任何信息量就是因为没有先做统计分析。# 按菜系分组看平均价格和平均评分 result df.groupby(菜系).agg({ price_num: [mean, median, count], rating_num: mean, count_num: sum }).round(2) # 排序按评论总数降序找出热门菜系 result result.sort_values((count_num, sum), ascendingFalse) print(result.head(10)) # 相关性矩阵价格、评分、评论数两两之间的关系 corr df[[price_num, rating_num, count_num]].corr() print(corr)groupby之后的agg可以同时对多个字段应用不同聚合函数输出是一个MultiIndex列的数据框。这里用(count_num, sum)做排序是因为MultiIndex取列需要传元组这是Pandas新手最容易卡住的地方。相关性矩阵的取值在-1到1之间如果你发现价格和评分相关性接近0不要慌这本身就是一个有价值的结论——说明贵不一定好吃。分析做了两三组之后你手上就有足够多的中间结果可以用于可视化了。这时候最关键的动作是把结果导出成图表能用的格式比如把groupby结果reset_index()之后存成新的CSVresult.to_csv(analysis_result.csv, encodingutf-8-sig)这一步的意义在于把分析和可视化解耦让后面做图表的时候不用再碰原始数据。做数据分析项目的时候中间产物和最终产物一样重要因为它们能让你在可视化阶段快速试错换一个维度重新分析时不用从头跑。4. 数据可视化用Matplotlib和ECharts把美食数据讲清楚4.1 先想清楚给谁看静态图与交互图的选型可视化不是把数据画出来就完事而是为了让看的人能快速抓到结论。我自己在做这类Python数据分析与可视化项目时会先把图表分成两类一类是给团队或客户看的静态图通常嵌入报告、PPT另一类是给业务方自己探索用的交互图放在网页上看能筛选、能悬停、能看细节。静态图优先用Matplotlib成熟、可控、导出清晰交互图优先用ECharts前端渲染、交互丰富、中文社区资料多。如果你不想写前端页面也可以用Pyecharts库它把ECharts封装成了Python接口几行代码就能输出一个HTML文件。选型的另一个维度是图表类型价格分布用直方图或箱线图评分与价格的关系用散点图菜系分布用柱状图地域分布用地图。先确定要表达的信息再选图表类型最后才选工具这个顺序别颠倒。4.2 Matplotlib画出价格分布和评分散点参数配置要点import matplotlib.pyplot as plt import matplotlib as mpl # 解决中文显示问题 mpl.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, WenQuanYi Zen Hei] mpl.rcParams[axes.unicode_minus] False fig, axes plt.subplots(1, 2, figsize(12, 5)) # 左图人均价格分布直方图 axes[0].hist(df[price_num].dropna(), bins30, edgecolorwhite, alpha0.8) axes[0].set_title(人均价格分布) axes[0].set_xlabel(价格元) axes[0].set_ylabel(餐厅数量) # 右图价格与评分散点图 axes[1].scatter(df[price_num], df[rating_num], alpha0.5, s20) axes[1].set_title(价格与评分的关系) axes[1].set_xlabel(人均价格元) axes[1].set_ylabel(评分) plt.tight_layout() plt.savefig(food_analysis.png, dpi150, bbox_inchestight) plt.show()rcParams[font.sans-serif]这行是中文显示的关键不设置的话所有中文标签都会变成方块。axes.unicode_minus设为False是修复负号显示问题的如果你的图表有负轴数值会用到。散点图的alpha0.5和s20两个参数决定了点的透明度和大小数据量大时要调低alpha防止点叠成一团。savefig的bbox_inchestight会自动裁剪掉多余留白dpi150保证打印清晰。如果你画折线图时发现横坐标标签挤成一团——这是Python画图的高频问题解决方案通常是加一行plt.xticks(rotation45)让标签旋转45度或者用plt.locator_params(axisx, nbins10)限制显示的刻度数量。别硬撑标签重叠的图别人根本看不懂。4.3 用ECharts做交互式可视化数据格式转换与渲染ECharts是前端ECharts数据可视化的标准方案之一这里我用Pyecharts来实现它更适合不熟悉前端的Python用户from pyecharts import options as opts from pyecharts.charts import Bar # 准备数据菜系和平均评分 top10 result.reset_index().head(10) cuisines top10[菜系].tolist() ratings top10[rating_num][mean].tolist() bar Bar() bar.add_xaxis(cuisines) bar.add_yaxis(平均评分, ratings) bar.set_global_opts( title_optsopts.TitleOpts(title十大菜系平均评分), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate15)), yaxis_optsopts.AxisOpts(name评分), ) bar.render(cuisine_rating.html)Pyecharts的核心思路是把Python数据结构转换成ECharts的option配置然后渲染成HTML。add_xaxis和add_yaxis分别接收类目和数值列表这里传中文列表没有问题ECharts前端默认支持UTF-8。rotate15让横轴标签倾斜15度防止菜系名过长互相覆盖——这就是前面提到的“横坐标太密集”的交互式图表解法。纯前端方案也值得了解直接用JSON把数据喂给EChartsfetch(food_data.json) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(chart)); chart.setOption({ xAxis: { type: category, data: data.cuisines }, yAxis: { type: value }, series: [{ type: bar, data: data.ratings }] }); });两种方案的区别在于Pyecharts适合快速出图、不用碰前端代码原生ECharts适合有前端开发经验的人定制能力更强。4.4 输出物管理图片保存、文件命名与交付项目做完整套流程之后产出物一般包括爬虫脚本、清洗脚本、分析脚本、原始CSV、清洗后CSV、分析结果表、静态图、交互式HTML。我习惯建一个清晰的目录结构food_project/ ├── crawler/ # 爬虫脚本 ├── data/ │ ├── raw/ # 原始数据 │ └── cleaned/ # 清洗后数据 ├── analysis/ # 分析脚本和中间结果 ├── output/ │ ├── charts/ # 静态图 │ └── html/ # 交互式报表 └── README.md目录结构本身是给人看的也是给未来的自己看的。三个月后你回来看这个项目能不能三分钟内找到每个文件、看懂每段脚本的用途比代码写得多漂亮更重要。文件名我建议带日期和版本比如food_data_20250115.csv、analysis_v2.py这样回溯的时候能快速定位是哪个版本跑出的结果。5. 美食数据项目避坑指南爬虫、清洗、可视化的5个典型翻车现场5.1 反爬请求头缺失导致403现象爬虫跑了几轮之后突然连续返回403之后所有请求都被拒绝。前面好好的突然就崩了有时候还会收到一封“异常访问提醒”的邮件。原因站点识别到了请求频率异常或者User-Agent太容易被识别为默认值。有些站点还有Cookie校验第一次访问时没种Cookie后续请求直接被拦截。解决加User-Agent并轮换多个UA请求间隔从0.5秒改到2到3秒随机如果还不行用requests.Session()保持会话状态先访问一次首页获取Cookie再请求列表页。session requests.Session() session.get(https://example.com/, headersheaders) # 获取Cookie resp session.get(https://example.com/restaurants, headersheaders)这个问题的根因是请求特征太单一解决办法就是让它看起来像真人操作。间隔时间我一般用time.sleep(random.uniform(2, 5))而不是固定值固定的间隔反而更容易被识别为脚本。爬虫这块有时候会有点玄学同样的代码换个时间跑结果不一样遇到这种问题先把频率和UA换一轮再说。5.2 编码中文乱码现象爬下来的HTML用resp.text解析时中文变成乱码或者打印出来全是乱码字符。存储到CSV后用Excel打开也是乱码。原因响应内容的实际编码不是UTF-8但requests用默认编码解析了。常见的是GBK编码的页面被当成Latin-1或UTF-8解析。解决用resp.apparent_encoding查看推测编码或者直接在代码里指定resp.encoding gbk再取.text。更稳妥的方式是直接拿字节流解码但要注意errors参数。resp requests.get(url, headersheaders) # 方法一让requests自己猜编码 resp.encoding resp.apparent_encoding html resp.text # 方法二手动指定并忽略错误 html resp.content.decode(gbk, errorsignore)两种方法我更喜欢方法二因为apparent_encoding有时候会猜错特别是页面同时包含UTF-8字符和GBK字符时。errorsignore不是完美的方案它可能会漏掉个别字符但对整体分析的影响可以接受。另外把你的CSV文件名或表头编码错误也归到这个坑里——写文件时用utf-8-sig能解决大部分Excel乱码问题。5.3 数据质量某些字段爬回来是空的现象清洗的时候发现“评分”字段有大量NaN而且集中在某几个页面。你以为是数据本来就缺结果打开网页一看评分好好的显示在那里。原因当时爬的时候详情页的评分元素是异步加载的列表页HTML里根本没有评分数据。页面是JS动态渲染的requests拿到的只是空壳HTML。解决这不是代码bug是页面结构问题。排查步骤是打开浏览器开发者工具看Network面板里有没有额外的XHR请求返回评分数据如果有直接请求那个接口。很多站点会在列表页加载后触发一个AJAX请求单独拉取评分和销量数据。xhr_url https://example.com/api/rating?shop_id12345 resp requests.get(xhr_url, headersheaders) rating_data resp.json() # 很多站点直接返回JSON这也解释了为什么我建议在字段表里加“数据来源URL”——顺着URL能很快定位是哪些页面出了问题。数据采集这件事有时候是有后悔药的前提是你把原始URL留下来了。5.4 可视化中文标签显示成方块现象Matplotlib画出的图标题、坐标轴标签全是空心方块。代码没报错图也出来了但所有中文都变成了□□。原因Matplotlib默认字体库不包含中文字符系统里也没有对应字体被注册到Matplotlib。解决设置rcParams[font.sans-serif] [SimHei]如果还是没有检查系统是否安装了中文字体。Linux服务器上是常见问题需要安装fonts-wqy-zenhei或手动指定字体路径。import matplotlib matplotlib.rcParams[font.sans-serif] [WenQuanYi Zen Hei, SimHei, Microsoft YaHei] matplotlib.rcParams[axes.unicode_minus] False这个坑基本每个做Python数据可视化的人都会踩一次而且是纯环境问题和代码逻辑无关。在Windows上SimHei通常能用在macOS上需要换成PingFang SC或Heiti SC在Linux上要装文泉驿。我一般会写一个环境检测的字条先把系统能用的字体列出来再决定用什么字体配置。5.5 分析陷阱样本量太小就下结论现象基于某个菜系只有3家餐厅的数据就得出“这个菜系平均评分最高”的结论。图表上那个柱子的确很高但你看一眼样本量就明白了。原因样本量不足统计结果没有代表性。3家餐厅的平均评分可能只是被一两家高分店拉高了。解决在分析脚本里加一个过滤条件筛掉样本数少于10的菜系再做聚合。做对比图的时候也在图注里写明样本量让看的人知道这个结论的可信度。valid_cuisines df.groupby(菜系).filter(lambda x: len(x) 10) result valid_cuisines.groupby(菜系).agg({ price_num: mean, rating_num: mean, count_num: sum }).round(2)这一步很容易被跳过因为groupby直接做出来的结果看起来是“完整”的不会报错。但分析项目最怕的不是报错而是给出一个看起来很合理、实际上经不起推敲的结论。样本量这个事直接决定了你的分析结论是能写进报告还是只能自己看看。6. 让美食数据项目更有说服力验证结果的三个方法与两个进阶方向当你跑完整条链路——爬虫采集、数据清洗、分析聚合、可视化输出——你手上有一份完整的美食数据分析案例。但项目交付和作业完成之间的差距在于有没有验证过结论的可靠性。我推荐三个验证方法第一随机抽样人工核对。从清洗后的数据里随机抽50条去原始页面人工比对价格、评分、评论数是否一致。这一步能发现爬虫阶段的解析错误。第二时间维度验证。如果你是分几天采集的跑完分析后按采集时间分组对比一下确认数据没有因为站点改版或反爬策略调整而产生系统性偏差。第三交叉验证。把评分最高的10家餐厅手动打开页面核一遍看分析结论是否和实际一致。进阶方向有两个。第一个是把静态分析变成定时监控。比如每周跑一次爬虫把新数据追加到SQLite里用ECharts生成一份价格波动报表挂在公司内网或本地服务器上。这样项目就从“一次性分析”升级成了“持续数据服务”价值完全不同。第二个是引入更丰富的分析维度。比如把餐厅的地理位置转成经纬度做热力图或者把评论内容爬下来做分词和情感分析看评分和情感倾向是否一致。这些方向的技术栈和现有代码完全兼容只是需要新增几个模块而已。我自己在做这类项目时有个习惯也是踩了不少坑之后总结的血泪经验每次跑完一个项目都会把脚本整理成一个可复用的模板下次换一个数据源就能直接套用。爬虫改选择器、清洗改字段名、可视化改标题整个流程半天就能重新跑通。这比每次都从零开始写有效率得多也让我慢慢积累了属于自己的Python数据分析工具集。做这类项目最大的教训是不要一上来就写代码先花半小时想清楚要回答什么问题、需要哪些字段、数据从哪里来。想清楚了后面的每一步都是在执行想不清楚你会在清洗和调试上浪费三倍时间。数据分析这个方向能不能做出价值关键就在这一步的取舍。希望这个思路能帮到你祝你的美食数据项目一次跑通。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。