美食数据分析系统实战:Python爬虫+Django+DeepSeek可视化全解 最近把一个“美食数据分析系统”从零到一完整跑了一遍想清楚了很多之前没注意的细节。整体流程是用Python爬虫把某旅游平台的本地美食频道点评数据抓下来清洗成结构化数据再接入deepseek大模型做标签抽取、情感判断和推荐语生成最后用Django把分析结果做成可视化Web系统页面可以看区域口味分布、餐厅排行榜、菜系热度这些内容。这篇文章不是只贴代码。我会把项目设计思路、数据链路、爬虫落库、大模型调用、Django可视化、常见排坑这几个核心环节全部拆开讲每个关键选择都说明为什么这样做。无论你是想入门爬虫数据分析还是想搞明白“大模型到底怎么接进实际项目”这套流程都值得存一份对照着看。1. 项目整体拆解这套美食数据分析系统到底在解决什么问题1.1 为什么盯上旅游平台的本地美食频道市面上美食数据来源很多外卖平台、点评类App、社区种草内容各有各的调性。我最终选择旅游平台的本地美食频道最重要的原因是它的数据带有明显“探店”属性用户点评场景更贴近真实消费决策游客会写“第一次来这个城市吃什么”本地用户会写“这家店从小吃到大”。这种文本比单纯外卖打分信息密度高很多非常适合做大模型语义分析。从技术角度讲这类平台的数据结构也比较友好。一家餐厅通常包含餐厅名、菜系分类、区域、人均价格、口味/环境/服务三项评分、评论内容、评论时间。一个热门城市的“美食榜单”入口下少则几千家餐厅多则上万评分数据评论量轻松到数万条。这个量级刚好是“普通机器能处理、但手工统计会崩溃”的范围用“大数据”来形容不算夸张但它又不是企业级分布式那种量级个人开发者一台电脑就能跑完。拿它做项目还有一个隐性优势数据维度齐全。区域、价格、评分、评论四类字段组合起来能回答的问题非常多。比如“哪个商圈的口味得分中位数最高”“人均100到200这个区间哪类餐厅好评率最好”“某个菜系在点评里被提到最多的正面标签是什么”。这些分析结果做出来以后不是死在报表里而是能直接展示成交互页面适合做作品集或练手。1.2 技术栈选型的三个关键判断这套系统的技术栈是标题里那串关键词的组合Python、爬虫、Django、deepseek、可视化。选型时我做了三个判断。第一爬虫和数据清洗用Python。原因不需要多解释requests、BeautifulSoup、pandas这套组合太成熟了。就算遇到需要处理动态渲染的页面也可以补上Selenium这类无头浏览器工具生态足够厚。Python在这条链路里的优势不是某个库多强而是从抓取到清洗再到后续喂给大模型几乎无缝衔接。第二Web端选择Django而不是Flask或其他轻量框架。很多人会觉得一个分析展示系统用Flask就够了没必要上Django。但实际项目跑起来以后Django的ORM和Admin后台价值很大。爬下来的原始数据在入库前很脏我需要在后台直接查看、修改、筛选数据Admin可以零成本提供这个入口。ORM也让“把清洗后的DataFrame写回数据库”变成几行代码的事。项目一旦涉及多张表关联和后台管理Django的“重”就变成了优势。第三语义分析部分交给deepseek大模型而不是尝试训练一个传统NLP模型。我最初也考虑过用情感词典或者自训练分类器但这类方案对标签体系是封闭的只能输出“正/负/中”这种粗粒度结果。而大模型可以基于一条评论灵活抽取“环境安静”“适合约会”“上菜慢”“偏咸”这类开放标签还能直接生成一句适合放在榜单页的推荐语。这个能力差异决定了整个分析层的设计方向。1.3 deepseek大模型在系统里的真实定位把大模型放进项目最容易犯的错误是“什么都让模型做”。做这套系统时我给deepseek的定位非常克制只让它承担三类语义任务。一类是点评标签抽取。给定一条评论让模型输出它提到的维度标签比如口味、环境、服务、性价比、位置便利度等。二类是情感倾向判断。判断整条评论的情感极性是正面、中性还是负面顺便给一个0到10之间的细粒度分数。三类是生成区域或餐厅的推荐语。比如把某个商圈评分最高的10家餐厅的聚合标签交给模型让它生成一段“如果来这一片逛吃推荐你怎么安排”的文案。这个能力很出效果也是整个系统看起来“智能”的直观来源。为什么不让大模型做所有的事因为成本和稳定性。每个请求都有延迟和token费用如果让模型去统计某个菜系的平均评分它既算不准又费钱这种事情交给Django ORM加上pandas一条SQL就完成了。所以我的分工原则是数值计算走数据库规则清洗走正则语义理解走大模型各管一段。这个经验在后面实现时帮了大忙希望你能保留这个思路。2. 爬虫设计与数据采集实现2.1 先把目标站点的页面链路拆清楚爬虫最忌讳上来就写代码。目标站点是一个旅游平台的本地美食频道我第一步做的是打开浏览器开发者工具把从“城市美食首页”到“某家餐厅评论区”的完整链路摸清楚。典型链路是城市页 - 商圈/榜单分组 - 餐厅列表 - 餐厅详情 - 评论区列表。每一个层级背后都有一个数据接口。拿首页的“按口味排序”榜单来看点击下一页时URL并不会跳转而是前端在请求一个带page、sort、cityId这类参数的XHR接口。这个接口返回的是JSON里面直接包含餐厅列表的字段。找到它以后爬虫的复杂度就降了一大半不需要去解析动态渲染的HTML。这里有一个重要操作把浏览器Network面板里那个返回JSON的请求复制成cURL然后丢到Python里先复现一遍。这样可以确认两件事第一是这个接口是不是真的不需要复杂登录态第二是服务端是否校验了User-Agent、Referer这些请求头。我实际测试时发现接口本身不校验签名但会校验UA和Cookie如果缺少UA会直接返回空列表。这个排查过程最好自己亲手做一遍以后遇到任何站点都能举一反三。2.2 核心爬虫代码与落库方案接口确认可用后我写了一个基于requests.Session的采集模块。Session的好处是可以在多次请求之间保持Cookie同时可以把请求头和重试策略统一挂上去。下面这段代码是整个爬虫的骨架它只做一件事翻页请求餐厅列表接口把数据一条条塞进生成器。import time import requests from requests.adapters import HTTPAdapter session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://target.example.com/food/city, }) # 挂上重试策略遇到网络抖动会自动重试最多3次 adapter HTTPAdapter(max_retries3) session.mount(http://, adapter) session.mount(https://, adapter) def fetch_restaurant_list(city_id, page): url https://target.example.com/api/food/restaurant_list params {cityId: city_id, page: page, size: 20, sort: taste} resp session.get(url, paramsparams, timeout10) if resp.status_code ! 200: return None return resp.json().get(list, []) def crawl_restaurants(city_id): for page in range(1, 500): items fetch_restaurant_list(city_id, page) if not items: break for item in items: yield item time.sleep(2) # 频率控制宁慢勿快采集下来的数据不建议直接用ORM一条条写入那样太慢。我通常在爬虫侧先把所有原始记录收集成一个列表然后一次性转换成pandas DataFrame做初步字段规整再批量写入数据库。这一步的写法很固定先把字典列表转DataFrame挑选需要保留的字段统一字段名之后执行df.to_sql。如果用的是Django也可以用批量create。import pandas as pd def save_raw_restaurants(raw_list): df pd.DataFrame(raw_list) df df[[id, name, area, category, avgPrice, tasteScore, envScore, serviceScore, commentCount]] df.columns [shop_id, name, area, category, price, taste_score, env_score, service_score, comment_count] df.to_sql(restaurant_raw, sqlite:///food.db, if_existsappend, indexFalse)这种落库方式非常实用它的好处是让清洗阶段和爬虫阶段解耦。爬虫只负责把原材料搬回来哪怕字段名不规范也没关系清洗时再做标准化。如果你把两步混在一起后面字段调整一次就要重新跑数据会浪费很多时间。2.3 反爬控制与封禁自救清单做爬虫不是和站点作对而是“规范采集”。我在这套系统里没有使用任何对抗手段而是把重点放在控制请求频率和数据完整性上。这里有一个核心原则请求频率降下来被封概率指数级下降。单线程每页请求间隔2到3秒一个城市几千家餐厅跑完也就一两小时完全可接受。实际采集过程中最容易遇到的现象有三个第一某一次请求返回了200但JSON里的list为空第二请求返回403第三响应内容里出现一个验证页面。遇到这些情况时不要立刻调整代码继续猛抓应该先把采集暂停检查Cookie和UA是否过期或者换一个入口。我这里把常见的反爬现象整理成了排查清单。现象常见原因处理方式返回200但list为空登录态/Cookie失效或当前城市无数据重新获取Cookie或换城市测试请求返回403UA被识别或缺少Referer更新UA池补齐Referer头响应变成验证码页请求频率过高立即停止请求间隔拉大到5秒以上评论数据只有第一页评论区做了登录限制优先采集餐厅聚合评分评论只做抽样另外还有一个经验值得单独说一定要做断点续采。给每个店铺ID增加一个crawled状态字段重新运行时只采集状态为未完成的记录。这样做的好处是即使中途断网、程序崩溃或被人为终止重启后不需要从头再来。我第一次踩坑就是没做这个爬到一半断了结果只能清掉库里的数据重来。3. 数据清洗与智能分析从原始点评到结构化洞察3.1 数据质量治理哪些脏数据必须优先处理采集下来的原始数据要多脏有多脏。比如人均价格字段里面可能混着“人均78元”这种带单位字符串评分字段有正常数字也有“暂无评分”占位符评论内容里有大段的表情符号、网址、各种“这家店真的真的真的太好吃了”这类重复文本。清洗这步做不好后面所有分析都是建立在垃圾之上的。我的清洗流程分三层。第一层是去重和缺失值处理。餐厅级别的记录以店铺ID去重评论级别的记录以“店铺ID评论文本”去重。评分缺失的直接删除人均价格缺失的用同菜系价格中位数填充。第二层是文本清理。评论里的URL、emoji、连续重复的感叹号和语气词全部去掉这一步既提升后续大模型的分析效果也直接减少token消耗。第三层是无效数据过滤。小于10个字符的短评、纯表情评论、带“转让”“广告”这类营销词的内容直接丢弃因为这种数据对情感分析基本没有贡献还会干扰标签聚合。文本清洗有一段很实用的pandas写法处理完以后干净很多。import re import pandas as pd def clean_text(s): if not isinstance(s, str): return s re.sub(rhttps?://\S, , s) s re.sub(r[\U0001F000-\U0001FAFF\u2600-\u27BF], , s) # 去掉emoji s re.sub(r(.)\1{2,}, r\1\1, s) # 压缩连续重复字符 return s.strip() df pd.read_sql(select * from review_raw, sqlite:///food.db) df df.drop_duplicates(subset[shop_id, content]) df[content] df[content].apply(clean_text) df df[df[content].str.len() 10]注意压缩连续重复字符这步很多入门教程会忽略但它对后续词频统计影响很大。“好吃好吃好吃好吃”如果不压缩在词云里会被重复计数导致“好吃”这个词的权重被严重放大。压缩成“好吃好吃”以后既保留了用户强烈的正面情绪又不会让统计结果失真。3.2 deepseek标签抽取与情感归纳的Prompt实践数据清洗完毕以后就到了大模型发挥作用的环节。我的做法是把每条评论和一个强约束的Prompt一起发给deepseek要求它只输出JSON不准输出解释性文字。这个约束非常重要因为模型默认会在回答里夹带“好的我来分析一下”这类废话影响解析效率。下面是实际使用的Prompt模板我建议你直接保存。import json import requests def analyze_review(text): prompt f 你是一个美食点评分析助手。请阅读下面这条用户点评输出一个JSON对象不要输出任何解释。 JSON字段说明 - taste_tags: 从点评中提取提到的维度标签只能从[口味, 环境, 服务, 性价比, 位置便利]中选可以多个 - sentiment: 整条评论的情感只能是 positive / neutral / negative - score: 0到10之间的数字表示这条评价的综合得分允许一位小数 - one_line: 用一句话总结这条点评的亮点或槽点 点评内容 {text} 输出JSON {{ taste_tags: [口味], sentiment: positive, score: 8.5, one_line: 招牌菜很惊艳环境适合朋友聚会 }} resp requests.post( https://your-api-base.example/v1/chat/completions, headers{Authorization: Bearer YOUR_TOKEN}, json{ model: deepseek-chat, messages: [{role: user, content: prompt}], temperature: 0.1, max_tokens: 300, }, timeout30, ) content resp.json()[choices][0][message][content] # 去掉模型偶尔会输出的Markdown代码块标记 content content.replace(json, ).replace(, ).strip() return json.loads(content)这里有几个细节。温度参数降到0.1是为了让输出更稳定避免同样一句话这次判positive下次判negative。max_tokens控制在300因为标签和一句话总结用不了太多token太长反而容易让模型自由发挥。对于特别长的评论我会先截断到前200个字符再送入模型实验下来信息损失很小但响应速度和成本都明显改善。模型输出的JSON不是100%可解析的偶尔会带着换行问题或者尾逗号。我在代码里先剥掉Markdown标识再取第1个{到最后一个}之间的内容最后才交给json.loads。如果解析失败就把这条评论标记为analyze_failed后续统一重试而不是让整个流程崩掉。3.3 传统机器学习和大模型如何分工协作deepseek负责语义但不负责数值。我在系统里保留了传统机器学习模块用来做两件大模型不擅长的事评分归因和用户偏好聚类。评分归因这步我训练了一个简单的随机森林回归模型目标是预测餐厅的口味评分。特征是评论文本长度、平均情感分数、评论中提到的标签数量和种类、人均价格、评论总数。这个模型的意义在于它能把非结构化数据转化成可解释的特征重要性比如模型告诉我“平均情感分数”和“口味标签提及率”对口味评分影响最大这就形成了比单纯看评分更细的洞察。聚类分析这步我用KMeans把餐厅按价格、评分、标签分布分成五类左右再结合deepseek生成的标签为每一类手动起名字。比如某类餐厅的特征是“人均150以上、环境标签占比高、性价比标签占比低”我就把它命名为“宴请型”另一类“人均60以下、性价比和口味标签突出”命名为“本地人日常食堂”。这些名字直接出现在Web页面的“餐厅风格”栏里用户一看就懂。大模型和传统模型各干各的结果完全可以做交叉验证。我试过把聚类结果和deepseek生成的推荐语对照发现凡是模型判定为“环境突出”的餐厅大部分也确实落在了聚类里的“宴请型”分组说明两条技术路线并非互相矛盾而是在分析体系里互为校验。4. Django后端与可视化展示实现4.1 工程结构与核心模型定义Django项目不是从零搭建脚手架开始而是先把分析目标映射成数据表。我的核心模型就三张餐厅表、评论表、评论分析结果表。餐厅表负责展示基础信息评论表保存原始内容和情感判断分析结果表存deepseek返回的标签和一句话总结。模型设计并不复杂但有几个字段值得注意。评论分析结果表里面加了一个唯一约束UniqueConstraint(fields[review])确保一条评论只分析一次。餐厅表里面给area和category都加了db_index因为后面可视化页面的榜单查询基本都是按区域或菜系过滤的不加索引后面会慢到怀疑人生。from django.db import models class Restaurant(models.Model): name models.CharField(max_length128) area models.CharField(max_length64, db_indexTrue) category models.CharField(max_length32, db_indexTrue) price models.FloatField(default0) taste_score models.FloatField(default0) env_score models.FloatField(default0) service_score models.FloatField(default0) comment_count models.IntegerField(default0) class Review(models.Model): restaurant models.ForeignKey(Restaurant, on_deletemodels.CASCADE, related_namereviews) user models.CharField(max_length64, blankTrue) content models.TextField() rating models.FloatField(nullTrue) created_at models.DateTimeField(nullTrue) sentiment models.CharField(max_length16, blankTrue) class ReviewAnalysis(models.Model): review models.OneToOneField(Review, on_deletemodels.CASCADE, related_nameanalysis) taste_tags models.JSONField(defaultlist) sentiment models.CharField(max_length16, blankTrue) score models.FloatField(nullTrue) one_line models.CharField(max_length255, blankTrue) analyzed_at models.DateTimeField(auto_nowTrue) class Meta: constraints [ models.UniqueConstraint(fields[review], nameuniq_review_analysis) ]Django Admin在开发阶段帮了大忙。我把这三张表注册进Admin后可以随时在后台查看某家餐厅被聚类到哪个风格、某条评论为什么被判为负面。这个“可观察性”在模型输出不稳定的调试期极其重要。没有Admin的话每排查一条异常评论都要写一句查询效率差很多。4.2 大模型API接入封装与成本优化接入deepseek的代码不能散落在视图函数里我单独做了一个llm_client.py模块把调用、重试、缓存、结果落库全部封装在一起。这样Web视图里只需要调用一个analyze_and_save(review)函数它内部会判断这条评论是否已经分析过没有才真正请求API。import time import logging import requests logger logging.getLogger(__name__) def call_deepseek(prompt, retries3): payload { model: deepseek-chat, messages: [{role: user, content: prompt}], temperature: 0.1, max_tokens: 300, } for attempt in range(retries): try: resp requests.post( https://your-api-base.example/v1/chat/completions, headers{Authorization: Bearer YOUR_TOKEN}, jsonpayload, timeout30, ) if resp.status_code 200: return resp.json()[choices][0][message][content] except Exception as exc: logger.warning(deepseek call failed: %s, exc) time.sleep(2 ** attempt) return 成本优化来自两个机制。第一个是缓存机制已经分析过的评论直接命中数据库不重复请求。第二个是截断机制评论超过200字先截断而不是把全文丢给模型。这两个机制加起来能把全量分析的成本压到很低的水平。比如一个城市两万条有效评论平均每条输入加输出约400 token全量分析量级也就在800万token左右个人学习项目完全在可接受范围内。要控制成本核心思路就是不要浪费请求在“已经有结论”或“明显无价值”的数据上。4.3 可视化图表接口与前端渲染衔接前后端我用的是“半分离”方式Django模板负责页面框架图表数据全部走JSON接口前端用ECharts渲染。这样做的原因是分析结果经常要加新的筛选条件如果图表HTML在后端拼好每次改需求都要动模板很痛苦。JSON接口则灵活得多前端拿到的就是结构化数据怎么展示由前端决定。典型的JSON接口是这样的接收area和category参数返回该筛选条件下的Top餐厅排行。视图函数里用Django ORM完成聚合再把结果序列化成列表。from django.http import JsonResponse from django.db.models import F, Avg from .models import Restaurant, ReviewAnalysis, Review def api_top_restaurants(request): area request.GET.get(area, ) qs Restaurant.objects.all() if area: qs qs.filter(areaarea) rows qs.order_by(-taste_score)[:30] data [{ name: r.name, area: r.area, category: r.category, price: r.price, taste: r.taste_score, env: r.env_score, service: r.service_score, suggest: _get_one_line(r), } for r in rows] return JsonResponse(data, safeFalse)前端页面引入ECharts以后用fetch请求这个接口然后把数据映射成条形图。这块有一个很容易踩的坑返回JSON里的Decimal和datetime类型不能直接被json.dumps序列化。我一般在视图里先把它们转成float和字符串。ECharts只关心数字所以价格、评分这类字段必须转成float再返回不然前端拿到的可能是未定义或解析失败。可视化不能只做一张榜。我做了四个核心图表区域平均口味得分柱状图、价格带与好评率关系散点图、菜系标签词云、餐厅口味/环境/服务雷达图。雷达图是最出效果的一个因为一家餐厅的综合实力一目了然比单独看数字直观太多。5. 常见问题与排查技巧实录5.1 爬虫拿不到数据时的排查顺序爬虫最气人的情况是代码写得很顺跑起来却永远返回空数据。我总结出一套固定排查顺序遇到问题按这个顺序走能省大量时间。先看请求是否真正到达了目标接口。在代码里打印status_code和body前500个字符确认返回的是JSON还是HTML。如果是HTML大概率是被重定向到了登录页或验证页。第二步检查请求头和浏览器Network里的cURL是否完全一致重点比对User-Agent、Referer、Cookie这三样。第三步检查参数很多接口对page、size、sort的取值有限制传入非法值会静默返回空list。第四步确认断点续爬的状态也许当前店铺确实已经抓过了是业务逻辑跳过了它而不是接口问题。我曾经遇到一个情况请求返回200、JSON结构也对但列表里永远只有同一个商圈的餐厅。后来发现是接口默认的sort参数只针对当前城市热门榜单生效换城市后需要重置排序字段默认值反而限制了数据范围。这种“看起来没报错但其实数据不完整”的问题比直接报错难查最好定期统计一下采集到的餐厅区域分布一旦发现某个商圈占比异常低就要怀疑是不是参数配置有问题。5.2 大模型输出不稳的容错处理deepseek这类大模型API接进生产流程后最烦人的问题不是准确率而是“输出格式不稳定”。明明Prompt里说了只输出JSON它偶尔还是会输出Markdown代码块、额外解释、甚至在一行JSON结尾加个逗号。我处理这个问题分三层。第一层是在调用函数里清洗返回文本剥掉代码块标记提取从第一个{到最后一个}的子串。第二层是解析失败时重试重试前把原始回复存进日志表方便后续分析为什么失败。第三层是仍然失败则标记该条评论为待人工复核而不是让整个分析任务中断。实际跑下来大部分失败都能靠第一层解决真正需要重试的比例很低。情感判断的稳定性也要注意。同一家店“味道很棒但上菜慢”这条评论如果模型温度设置太高可能这次判positive下次判negative。我实测把temperature调到0.1以后结果基本稳定了。另外如果需要更稳定的评分可以对同一条评论请求两次取平均但成本会翻倍个人项目一般不需要这么严格。5.3 数据量大以后页面慢怎么办刚开始展示页面还很快等全城餐厅数据全部入库、评论分析全部跑完以后接口响应时间会明显上升。这个问题主要出在“图表数据实时聚合”。如果前端每次请求都让Django去遍历几万条评论做聚合数据库撑不住。我的解决思路是“预计算”。单独建一张聚合统计表跑一个定时任务把每个区域、每个菜系、每个价格带的结果提前算好页面只查预计算结果而不是实时去扫原始评论。这张表很小最多几百上千行查询毫秒级图表渲染也就流畅了。另外前端不要一次拿全部数据。比如词云图只需要Top 100高频标签柱状图最多展示30家餐厅这些在前端请求时就用limit参数限制住。Needs to的数据量级远大于展示所需的精度别拿大数据打图表这是可视化项目里最容易被忽略的性能教训。6. 几次踩坑后的真实体会做这个项目最大的体会是一套“爬虫大模型Django可视化”的系统真正的难点不在某个单独环节而在环节之间的衔接。爬虫阶段攒的脏数据会让清洗阶段多花一倍时间清洗不干净又会让大模型分析结果方差变大分析结果不稳可视化的信息价值就打了折扣。所以做数据类项目时前期的数据质量治理值得投入最多精力它是整个链路的地基。另一个体会有必要单独提接入大模型API时一定要先做好缓存和失败重试再去做花哨的功能。我第一次跑全量分析时没有缓存机制某条Prompt因为网络超时反复重发白白浪费了请求次数。后来加入“已分析状态”标记以后整个流程才真正变成增量式可以随时中断再续跑。最后再分享一个小技巧这套流程不需要一开始就从整个城市几万家餐厅起步。我的建议是先挑一个商圈大约三五十家餐厅把爬虫、清洗、分析、可视化、部署这个闭环完整跑通确认每一步输出都符合预期再扩展数据范围。这样试错成本最低而且每一步都能看到真实业务效果做起来也更有动力。