简介:本资源是一套完整的Python数据科学实战项目,面向编程初学者与数据分析入门者,聚焦美食领域从数据获取到可视化呈现的全流程实践。项目涵盖网络爬虫(requests+BeautifulSoup)、数据清洗分析(pandas+NumPy)及多维可视化(Matplotlib+Seaborn)三大核心能力训练,适用于课程设计、毕业设计或自学进阶场景。压缩包共39个文件,含10个核心Python脚本(如manager.py、models.py、api_1_0模块)、5个HTML/JS前端展示页、5个XML配置与IDE工程文件(.idea/.gitignore等),以及README和数据库迁移相关文件,整体706KB,结构清晰、模块分离明确,便于理解MVC架构与项目组织逻辑。已有4087人学习下载,读者可直接运行调试、复现完整流程,获得可执行代码、典型网页解析技巧、结构化数据处理范式及美观图表生成方案,是少有的兼顾工程规范与教学实用性的轻量级全栈数据项目。
1. 为什么你爬了三天的美食网站,最后只导出一个空Excel?——Python实现美食数据爬取+数据分析+数据可视化的真实落地路径
这不是一个“教你怎么写for循环”的Python入门课。这是我在给本地餐饮连锁做菜品热度分析时,被门店运营反复追问“上周哪道菜被退单最多”“抖音爆款菜在我们32家店的复购率差异在哪”之后,硬生生踩着三类坑、重写了四版脚本、最终跑通的完整链路:从真实网页结构里稳稳拿下带图片URL、价格、评分、评论数、营业时间、人均消费、地理位置标签的结构化美食数据;用Pandas清洗掉“人均¥88(含锅底)”这种玄学字段;用Matplotlib+Seaborn做可解释的分布图,再用Plotly嵌入内部BI看板——全程不碰Excel手动拖拽,不依赖任何SaaS平台API,所有代码打包即跑。适合两类人:刚学完requests和pandas想找个有业务意义的练手项目的新手;或正在为市场部临时要的“区域热门菜TOP10”报表焦头烂额的数据岗同事。标题里的.zip不是噱头,是真正能解压、改两行城市名、3分钟跑出带交互图表HTML报告的生产级最小可行包。
2. 爬取:绕过反爬不是靠“加headers”,而是理解目标网站的请求生命周期
美食类网站(如大众点评、美团商户页、小红书探店笔记)的反爬机制已远超“User-Agent轮换”层级。直接requests.get()返回403或空白div是常态。必须拆解其真实数据加载路径——绝大多数并非静态HTML渲染,而是前端JS调用后端API动态注入。盲目抓包只会拿到一堆加密参数或token过期报错。
2.1 先定位真实数据接口:用浏览器Network面板锁定XHR/Fetch请求
打开一家典型川菜馆页面(例如:https://www.dianping.com/shop/xxxxx),按F12 → Network → XHR → 刷新页面。重点观察:
review_all?开头的请求:含用户评论、评分、评论数shopinfo或basicInfo请求:含人均消费、营业时间、电话、地址坐标photos请求:含菜品图、环境图URL列表
提示:关闭“Preserve log”,否则历史请求干扰判断;右键某条请求 → “Copy” → “Copy as cURL (bash)” 可直接粘贴到终端验证是否返回JSON。
2.2 处理关键障碍:Referer校验、Cookie会话、动态加密参数
以大众点评为例,其review_all接口需同时满足:
Referer必须为该店铺首页URL(非空或通用域名)Cookie中需包含有效的_lxsdk_s(会话标识,有效期约2小时)uuid参数为16位小写hex字符串(可伪造,但需与Cookie中_lxsdk前缀匹配)
# ✅ 正确curl示例(替换your_shop_id和cookie_value) curl 'https://www.dianping.com/ajax/json/shopDynamic/reviewAll?shopId=your_shop_id&cityId=20' \ -H 'Referer: https://www.dianping.com/shop/your_shop_id' \ -H 'Cookie: _lxsdk_s=your_cookie_value; _lxsdk=your_lxsdk_value;' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'2.3 Python实现稳定请求:Session管理 + 动态Referer注入
import requests from urllib.parse import urljoin class DianPingCrawler: def __init__(self, cookie_str: str): self.session = requests.Session() # 强制设置基础headers,避免每次重复 self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) # 注入Cookie(从浏览器复制,注意去掉引号外空格) self.session.cookies.set("_lxsdk_s", cookie_str.split("_lxsdk_s=")[1].split(";")[0]) def get_shop_reviews(self, shop_id: str, city_id: str = "20") -> dict: # 动态构造Referer,确保与目标店铺页一致 referer_url = f"https://www.dianping.com/shop/{shop_id}" api_url = f"https://www.dianping.com/ajax/json/shopDynamic/reviewAll" params = { "shopId": shop_id, "cityId": city_id, } # 关键:每次请求都显式设置Referer self.session.headers["Referer"] = referer_url try: resp = self.session.get(api_url, params=params, timeout=10) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException as e: print(f"❌ 请求失败 {shop_id}: {e}") return {"code": -1, "msg": str(e)} # 使用示例 crawler = DianPingCrawler("your_cookie_string_here") data = crawler.get_shop_reviews("123456789")逻辑说明:
Session复用TCP连接并自动管理Cookie,比单次requests.get()更稳定;referer_url动态生成,避免硬编码导致跨店铺失效;timeout=10防止DNS卡死,raise_for_status()主动抛出HTTP错误便于捕获;- 返回
dict而非str,后续可直接用data.get("reviewCount")取值,无需json.loads()。
3. 数据清洗:别让“人均¥128(含锅底)”毁掉你的统计结果
爬到的原始数据充满业务语义噪声:价格字段混杂单位、括号备注、斜杠分隔;评分可能是“4.5分(238条评论)”;地址含“地铁10号线虹桥路站1号口步行300米”。Pandas能处理,但必须针对美食领域定制规则。
3.1 定义核心字段清洗函数:价格、评分、评论数、营业时间
import re import pandas as pd from datetime import datetime def clean_price(price_str: str) -> float: """提取纯数字价格,支持'¥88'、'人均¥128(含锅底)'、'¥68/人'等格式""" if not isinstance(price_str, str): return 0.0 # 移除所有非数字、点、括号、斜杠、中文字符 cleaned = re.sub(r'[^\d./()\u4e00-\u9fa5]', '', price_str) # 提取第一个连续数字(兼容'¥68/人' → 68) match = re.search(r'(\d+\.?\d*)', cleaned) return float(match.group(1)) if match else 0.0 def clean_rating(rating_str: str) -> float: """提取评分数字,如'4.5分(238条评论)' → 4.5""" if not isinstance(rating_str, str): return 0.0 match = re.search(r'(\d+\.\d+|\d+)', rating_str) return float(match.group(1)) if match else 0.0 def clean_review_count(review_str: str) -> int: """提取评论数,如'238条评论' → 238""" if not isinstance(review_str, str): return 0 match = re.search(r'(\d+)', review_str) return int(match.group(1)) if match else 0 def parse_business_hours(hours_str: str) -> dict: """解析营业时间字符串,返回{'open': '11:00', 'close': '22:00'}""" if not isinstance(hours_str, str): return {"open": "", "close": ""} # 匹配"11:00-22:00"或"11:00~22:00"或"11:00至22:00" pattern = r'(\d{1,2}:\d{2})[~\-至](\d{1,2}:\d{2})' match = re.search(pattern, hours_str) if match: return {"open": match.group(1), "close": match.group(2)} return {"open": "", "close": ""} # 应用清洗到DataFrame df = pd.read_json("raw_data.json") df["price_clean"] = df["price"].apply(clean_price) df["rating_clean"] = df["rating"].apply(clean_rating) df["review_count"] = df["review_text"].apply(clean_review_count) df["business_hours"] = df["hours"].apply(parse_business_hours) df["open_time"] = df["business_hours"].apply(lambda x: x["open"]) df["close_time"] = df["business_hours"].apply(lambda x: x["close"])参数说明:
clean_price()用正则先过滤干扰字符,再精准捕获数字,避免float("¥128")报错;parse_business_hours()返回字典而非字符串,方便后续计算营业时长(如pd.to_datetime(close) - pd.to_datetime(open));- 所有函数加
isinstance类型检查,防止空值或None传入导致AttributeError。
3.2 处理地理坐标:将“上海市静安区南京西路XXX号”转为经纬度
高德/百度地图API需申请Key,但免费额度足够测试。这里用高德地理编码API(https://restapi.amap.com/v3/geocode/geo):
import time def address_to_coords(address: str, amap_key: str) -> tuple: """将地址转为高德坐标(lng, lat)""" if not address or len(address) < 5: # 过短地址跳过 return (0.0, 0.0) params = { "address": address, "key": amap_key, "city": "上海" # 指定城市提升精度 } try: resp = requests.get( "https://restapi.amap.com/v3/geocode/geo", params=params, timeout=5 ) data = resp.json() if data["status"] == "1" and data["count"] != "0": loc = data["geocodes"][0]["location"].split(",") return (float(loc[0]), float(loc[1])) except Exception as e: print(f"📍 地址解析失败 {address}: {e}") return (0.0, 0.0) # 批量处理(加延时防限流) amap_key = "your_amap_api_key_here" df["lng"], df["lat"] = zip(*df["address"].apply( lambda x: address_to_coords(x, amap_key) )) time.sleep(0.1) # 每次请求间隔100ms避坑点:高德API对QPS敏感,无延时易触发"infocode":"10007"(访问超限)。生产环境建议用concurrent.futures.ThreadPoolExecutor控制并发数≤3。
4. 避坑:爬取与清洗阶段最常翻车的5个血泪现场
这些不是理论问题,是我在调试时对着日志一行行grep出来的真问题。每一条都附带现象→原因→解决,照着改立竿见影。
4.1 现象:爬虫跑着跑着突然全部返回空数据,但浏览器手动访问正常
原因:目标网站检测到请求频率过高,返回了带验证码的HTML(状态码200但内容含<div class="verify">),而代码未识别直接当JSON解析。
解决:在get_shop_reviews()中增加验证码检测逻辑:
if "verify" in resp.text.lower() or "captcha" in resp.text.lower(): raise Exception("Detect CAPTCHA page - need manual intervention")4.2 现象:clean_price()把“¥128(含锅底)”解析成128,但“¥68/人”却变成68.0,导致后续分组统计时类型不一致
原因:float()转换后部分值为int(如128.0 → 128),部分为float(如68.5),Pandas列类型变为object,df.groupby().mean()报错。
解决:强制统一为float64:
df["price_clean"] = df["price_clean"].astype("float64")4.3 现象:地理编码批量调用时,前100个成功,后面全返回{"status":"0","info":"INVALID_USER_KEY"}
原因:高德Key被误填为百度地图Key(格式不同),且错误响应未被resp.raise_for_status()捕获(因HTTP状态码仍是200)。
解决:严格校验API返回的status字段:
if data.get("status") != "1": raise ValueError(f"AMap API error: {data.get('info')}")4.4 现象:parse_business_hours()对“周一至周五 11:00-22:00,周末 10:00-23:00”只提取了第一个时间段
原因:正则r'(\d{1,2}:\d{2})[~\-至](\d{1,2}:\d{2})'只匹配首次出现,未考虑多时段。
解决:改用re.findall()提取所有时段,取第一个有效区间:
matches = re.findall(r'(\d{1,2}:\d{2})[~\-至](\d{1,2}:\d{2})', hours_str) return {"open": matches[0][0], "close": matches[0][1]} if matches else {"open": "", "close": ""}4.5 现象:df.to_csv("output.csv")导出后,Excel打开显示乱码(中文变问号)
原因:Windows系统默认用GBK编码读取CSV,而Pandas默认UTF-8保存。
解决:显式指定编码:
df.to_csv("output.csv", encoding="utf_8_sig", index=False) # utf_8_sig兼容Excel5. 可视化:用Plotly做出能放进周报的交互式图表,而不是“好看但看不懂”的炫技图
Matplotlib画饼图、柱状图是基础,但业务方真正需要的是:点击某个商圈,自动筛选出该区域所有餐厅的评分分布;鼠标悬停显示“川菜-人均¥98-评分4.6-评论数1287”。这必须用Plotly——它生成的HTML文件双击即可打开,无需服务器,且天然支持筛选、缩放、悬停。
5.1 用Plotly Express快速生成地理热力图:直观定位高密度美食区
import plotly.express as px # 确保经纬度字段存在且非零 valid_df = df[(df["lng"] != 0) & (df["lat"] != 0)].copy() fig = px.density_mapbox( valid_df, lat="lat", lon="lng", z="review_count", # 热度权重:评论数越多越亮 radius=10, # 点半径 center=dict(lat=31.2304, lon=121.4737), # 上海中心坐标 zoom=10, mapbox_style="carto-positron", # 无干扰底图 title="上海美食店铺评论热度分布(基于爬取数据)" ) # 添加交互:悬停显示店铺名、评分、人均 fig.update_traces( hovertemplate="<b>%{customdata[0]}</b><br>" "评分: %{customdata[1]:.1f}<br>" "人均: ¥%{customdata[2]}<br>" "评论数: %{customdata[3]}<extra></extra>", customdata=valid_df[["name", "rating_clean", "price_clean", "review_count"]] ) fig.write_html("shanghai_food_heatmap.html")效果说明:
density_mapbox自动聚类邻近点,避免上海人民广场区域密密麻麻一堆点;hovertemplate定制悬停文案,%{customdata[0]}对应customdata列表第一项(店铺名);zoom=10适配上海城区范围,过大则看不清细节,过小则显示全国;- 导出HTML后双击打开,可拖拽、缩放、悬停,直接发给老板看。
5.2 用Plotly Subplots对比不同菜系的性价比:评分 vs 人均消费
import plotly.graph_objects as go from plotly.subplots import make_subplots # 按菜系分组(需提前清洗出cuisine字段) cuisine_groups = ["川菜", "粤菜", "本帮菜", "日料", "西餐"] fig = make_subplots( rows=1, cols=2, subplot_titles=("各菜系平均评分", "各菜系人均消费分布"), specs=[[{"type": "bar"}, {"type": "box"}]] ) # 左图:平均评分柱状图 avg_rating = df.groupby("cuisine")["rating_clean"].mean().reindex(cuisine_groups) fig.add_trace( go.Bar(x=avg_rating.index, y=avg_rating.values, name="平均评分"), row=1, col=1 ) # 右图:人均消费箱线图(显示中位数、四分位、异常值) for cuisine in cuisine_groups: subset = df[df["cuisine"] == cuisine]["price_clean"] fig.add_trace( go.Box(y=subset, name=cuisine, boxpoints='outliers'), row=1, col=2 ) fig.update_layout(height=500, title_text="上海主流菜系性价比分析", showlegend=False) fig.write_html("cuisine_comparison.html")关键参数:
boxpoints='outliers'只标出异常值(如某家粤菜人均¥888),避免图表拥挤;reindex(cuisine_groups)强制按指定顺序排列,避免Pandas自动排序打乱业务逻辑;showlegend=False去除图例,因横轴已标注菜系名,更简洁。
5.3 用Plotly实现“点击筛选”联动:选中商圈,下方图表自动更新
Plotly原生不支持跨图联动,但可用dash轻量封装(无需Flask部署):
# 安装:pip install dash import dash from dash import dcc, html, Input, Output, callback import plotly.express as px app = dash.Dash(__name__) # 假设df已加载,且含"district"字段(如"静安区"、"徐汇区") districts = df["district"].unique() app.layout = html.Div([ html.H1("上海美食数据分析看板"), dcc.Dropdown( id="district-filter", options=[{"label": d, "value": d} for d in districts], value=districts[0], placeholder="选择行政区" ), dcc.Graph(id="rating-distribution"), dcc.Graph(id="price-scatter") ]) @callback( [Output("rating-distribution", "figure"), Output("price-scatter", "figure")], Input("district-filter", "value") ) def update_graphs(selected_district): filtered = df[df["district"] == selected_district] # 图1:评分分布直方图 fig1 = px.histogram(filtered, x="rating_clean", nbins=20, title=f"{selected_district}评分分布") # 图2:价格-评分散点图(带趋势线) fig2 = px.scatter( filtered, x="price_clean", y="rating_clean", trendline="ols", # OLS线性拟合 title=f"{selected_district}价格与评分关系" ) return fig1, fig2 # 运行:app.run_server(debug=True, port=8050) # 访问 http://127.0.0.1:8050 即可交互落地价值:
dash启动的是本地Web服务,不暴露公网,符合企业安全要求;trendline="ols"自动生成回归线,直观回答“贵的餐厅评分一定高吗?”;- 整个看板代码不足50行,比用ECharts手写JavaScript事件绑定快10倍。
6. 进阶技巧:如何让这份代码从“能跑”升级为“敢交差”的生产级方案
我最初交出去的版本,被运营同事问倒三次:“为什么‘外婆家’的数据比大众点评APP少200条评论?”“为什么‘静安嘉里中心’的坐标偏到隔壁楼?”“为什么导出的HTML在IE里打不开?”——直到我把以下四件事补全,才真正敢把它放进周报附件。
6.1 加入数据可信度校验:用交叉验证堵住源头漏洞
爬取数据必然有误差。与其事后解释,不如在代码里埋下自检开关:
def validate_data_integrity(df: pd.DataFrame) -> dict: """返回数据质量报告""" report = {} # 1. 价格合理性:剔除明显异常值(>¥5000或<¥10) price_outliers = df[(df["price_clean"] > 5000) | (df["price_clean"] < 10)] report["price_outliers_ratio"] = len(price_outliers) / len(df) if len(df) else 0 # 2. 评分有效性:0-5分之外的值视为脏数据 invalid_ratings = df[~df["rating_clean"].between(0, 5, inclusive="both")] report["invalid_rating_ratio"] = len(invalid_ratings) / len(df) if len(df) else 0 # 3. 地理坐标有效性:上海经纬度范围(经度120.8-121.8,纬度30.7-31.5) valid_coords = df[ df["lng"].between(120.8, 121.8) & df["lat"].between(30.7, 31.5) ] report["coord_valid_ratio"] = len(valid_coords) / len(df) if len(df) else 0 return report # 使用 quality_report = validate_data_integrity(df) print("📊 数据质量报告:", quality_report) # 若price_outliers_ratio > 0.05,则触发告警 if quality_report["price_outliers_ratio"] > 0.05: print("⚠️ 警告:价格异常值超5%,请检查清洗逻辑")为什么重要:运营不会看代码,但会看报告。把price_outliers_ratio写进HTML报告页脚,就是最好的背书。
6.2 生成可交付的HTML报告:用Jinja2模板拼接动态内容
不要让老板下载zip解压看HTML。用模板生成一份“开箱即用”的报告:
from jinja2 import Template html_template = """ <!DOCTYPE html> <html> <head><title>上海美食数据分析报告</title> <style>body{font-family:Arial,sans-serif;margin:40px}</style> </head> <body> <h1>📊 {{ report_title }}</h1> <p><strong>生成时间:</strong>{{ gen_time }}</p> <p><strong>数据来源:</strong>爬取自大众点评({{ city }},共{{ total_shops }} 家店铺)</p> <h2>📈 核心指标</h2> <ul> <li>平均评分:{{ avg_rating:.2f}} 分(满分5分)</li> <li>平均人均:¥{{ avg_price:.0f}}</li> <li>最高评论数店铺:<strong>{{ top_shop }}</strong>({{ top_reviews }} 条)</li> </ul> <h2>🗺️ 热力图</h2> <iframe src="{{ heatmap_path }}" width="100%" height="500" frameborder="0"></iframe> <h2>🔍 详细分析</h2> <p>点击下方链接查看交互式图表:</p> <ul> <li><a href="{{ cuisine_path }}">菜系对比分析</a></li> <li><a href="{{ district_path }}">行政区深度分析</a></li> </ul> </body> </html> """ template = Template(html_template) report_html = template.render( report_title="上海美食数据洞察报告(2024Q2)", gen_time=datetime.now().strftime("%Y-%m-%d %H:%M"), city="上海", total_shops=len(df), avg_rating=df["rating_clean"].mean(), avg_price=df["price_clean"].mean(), top_shop=df.loc[df["review_count"].idxmax(), "name"], top_reviews=int(df["review_count"].max()), heatmap_path="shanghai_food_heatmap.html", cuisine_path="cuisine_comparison.html", district_path="district_dashboard.html" ) with open("final_report.html", "w", encoding="utf-8") as f: f.write(report_html)效果:生成的final_report.html是一个独立文件,含所有文字摘要、指标卡片、iframe嵌入图表,双击即开,转发即用。
6.3 自动化调度:用cron(Linux)或Task Scheduler(Windows)每日凌晨更新
数据价值在于时效性。手动运行是技术债的起点:
# Linux下添加每日3:00执行任务 # crontab -e 0 3 * * * cd /path/to/project && python crawl_and_analyze.py >> /var/log/food_crawl.log 2>&1# crawl_and_analyze.py 最后加入 if __name__ == "__main__": # 1. 爬取新数据 crawler = DianPingCrawler(get_cookie_from_file()) # 从文件读Cookie,避免硬编码 new_data = crawl_shops_in_city("上海", crawler) # 2. 合并历史数据(去重) if os.path.exists("history.csv"): history = pd.read_csv("history.csv", encoding="utf_8_sig") combined = pd.concat([history, new_data]).drop_duplicates(subset=["shop_id"]) combined.to_csv("history.csv", encoding="utf_8_sig", index=False) else: new_data.to_csv("history.csv", encoding="utf_8_sig", index=False) # 3. 生成新报告 generate_final_report(combined) print("✅ 报告已更新:final_report.html")关键习惯:
get_cookie_from_file()将Cookie存单独文件(.env或cookie.txt),.gitignore排除,避免泄露;drop_duplicates(subset=["shop_id"])用唯一ID去重,而非整行,防止同店价格微调被误删;- 日志重定向到
/var/log/,便于运维排查。
6.4 给非技术人员的“后悔药”:一键回滚到昨日数据
当新爬取数据出错(如某天反爬策略突变导致全空),运营急需昨日数据救急。我在脚本里加了这个功能:
import shutil from datetime import datetime, timedelta def backup_today_data(): """备份今日数据为带日期的文件""" today = datetime.now().strftime("%Y%m%d") shutil.copy2("history.csv", f"history_{today}.csv") print(f"💾 已备份:history_{today}.csv") def restore_yesterday_data(): """恢复昨日备份(若存在)""" yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") backup_file = f"history_{yesterday}.csv" if os.path.exists(backup_file): shutil.copy2(backup_file, "history.csv") print(f"⏪ 已恢复昨日数据:{backup_file}") else: print("❌ 无昨日备份,请检查备份是否开启") # 在主流程开头调用 if "--restore-yesterday" in sys.argv: restore_yesterday_data() exit(0)使用方式:
- 正常运行:
python crawl_and_analyze.py - 紧急回滚:
python crawl_and_analyze.py --restore-yesterday
这行命令,曾让我在凌晨2点被电话叫醒后,30秒内把正确数据发给运营总监——技术的价值,就藏在这种“不用解释、立刻生效”的细节里。
希望帮到你。
本文还有配套的精品资源,点击获取