
最近把一个美食推荐系统完整整理了一遍从数据采集到算法实现再到可视化展示整个项目用到的技术正好是 Python 岗位需求里最常见的组合爬虫、Echarts 可视化、协同过滤推荐算法和 Django 框架。项目核心是围绕“店铺推荐”做个性化推荐不是简单按照评分排序而是根据用户的历史行为猜你可能喜欢哪家店同时用可视化大屏把店铺评分分布、美食分类占比、用户口味偏好这些数据全部展示出来。适合正在学 Python 想做实战项目的同学也适合准备往数据分析或推荐算法方向转的朋友参考。1. 项目整体设计与技术选型1.1 需求拆解美食推荐系统到底要解决什么问题我最早拿到这个需求时的第一反应是这玩意儿到底该怎么拆。市面上的推荐系统资料很多但真正从数据、算法、展示端到端串起来的项目不多。很多教程只讲一个孤立算法或者只给一个爬虫脚本很难在简历上讲清楚“我能独立做完整项目”。所以我把项目目标拆成三个层次第一层是数据层至少要把美食店铺的基础信息采集下来第二层是算法层要根据用户的历史交互行为给出店铺推荐结果第三层是展示层要有一个可用的 Web 界面和可视化大屏让别人一眼看懂系统在做什么。拆完需求之后我发现整个系统可以分成四条相互独立又能串联的线。第一是数据采集线负责把店铺名称、分类、评分、人均价格、地址这些字段抓取入库第二是算法线负责从用户行为记录里构建评分矩阵训练协同过滤模型生成 TopN 店铺推荐第三是 Web 应用线负责用户注册、登录、店铺浏览、评分收藏和个人推荐页第四是可视化线负责用 Echarts 把统计数据和推荐结果变成大屏图表。每条线之间用数据库解耦任何一条挂了其他线还能独立开发调试。这个拆法帮我省了很多时间因为不用盯着一个巨大的 views.py 来回改。1.2 技术栈选型为什么是 Django 而不是 Flask技术选型上语言当然选 Python生态太成熟了爬虫、数据分析、算法都有现成库。真正纠结的是 Web 框架我在 Django 和 Flask 之间犹豫过。最后选了 Django不是因为它比 Flask 高级而是因为本项目需要用户系统、后台管理、多张数据表之间的关联Django 自带 ORM、Admin 后台和认证体系开发效率高一大截。Flask 灵活适合纯 API 或特别小规模的项目但要自己拼一堆扩展在这个场景下属于增加工作量。对比项DjangoFlask自带ORM有模型关系清晰无需要自己选 SQLAlchemy用户认证内置 auth 系统直接用需要扩展Admin后台内置可快速维护数据需要自己写适合场景数据模型多、完整Web应用轻量API、微服务爬虫部分我直接用 requests lxmlrequests 负责拿 HTMLlxml 负责用 XPath 解析这两个库组合足够应对大多数静态页面。可视化层选了 Echarts不用 Highcharts 的最主要原因是 Echarts 开源免费、中文文档全、社区案例多而且图表配置方式对前端新手很友好。数据库在开发阶段其实可以用 SQLite但表关系复杂以后我更推荐 MySQL因为联表查询、并发写入都会更稳。Redis 可以作为可选项用来缓存相似度矩阵和热门店铺聊性能优化时很有话题度。1.3 数据流与系统架构设计整个系统的数据流可以用一句话概括爬虫脚本采集店铺数据入库超市用户在 Web 端产生评分和收藏行为行为数据进入行为表推荐算法离线读取行为表生成相似度矩阵Django 接口根据当前用户行为拿到推荐结果并返回给前端页面展示同时统计数据接口给 Echarts 大屏使用。我没有把推荐计算放在用户请求的同步链路里而是做成了离线计算加在线查询的结构。相似度矩阵是推荐算法里最重的计算部分如果每次请求都重新算一遍用户和物品的矩阵那数据库再快也扛不住。所以我用一个定时任务去训练模型并落盘线上接口只负责读矩阵、查邻居、排序这样响应时间能控制在几十毫秒级别。这个设计的核心思路是把“计算密集”和“交互密集”分离也是我在实际项目里最常用的一套套路。1.4 协同过滤算法选型解析推荐算法方面我没有一上来就套深度学习模型。美食推荐这个场景数据量级通常在一万店铺以下做复杂模型既没有数据支撑也难以解释结果上线之后都是黑盒出了问题都不好排查。协同过滤是解释性强、实现成本低、效果又稳定的算法很适合这个项目。协同过滤分两类。UserCF 的核心是先找到和你口味相似的用户再把这些用户喜欢的店铺推荐给你ItemCF 的核心是先找到和你已经去过、喜欢的店铺相似的店铺再推荐给你。美食推荐场景里用户数量往往远大于店铺数量而且用户口味随时会变UserCF 计算代价大反过来店铺更新频率不高用户对店铺的行为数据比较稳定所以最终在线推荐我用了 ItemCF。UserCF 也没有完全扔掉新用户冷启动的时候我会用它做候选补充。简单说项目里两种算法都写了主力是 ItemCFUserCF 作为辅助策略。2. 数据采集爬虫模块设计与实现2.1 目标站点分析与爬虫方案设计爬虫第一步不是急着写代码而是先确定目标页面。我找的是一个允许普通用户公开浏览的本地美食频道没有登录墙也没有明显的反爬声明。这里要提醒一句如果你要抓取别人的网站一定要先看网站的 robots.txt 和相关条款爬虫只适合学习和技术验证不要对生产网站做高频采集。我测试时用的就是一个公开可访问的列表页并且把请求频率控制到一秒一次数据量不大不会给对方服务器造成压力。拿到目标页面后先用浏览器开发者工具检查页面结构。我主要确认店铺名称、评分、人均价格、地址这几个字段分别在哪个区块里。这类列表页通常结构比较规律每个店铺都是一个卡片区块字段用固定的 class 包裹。分析清楚了之后用 requests 发送 get 请求然后把响应文本交给 lxml 去解析。先写一个最简版本确认能拿到 HTML再去写解析逻辑。这一步最忌讳的就是直接从网上复制一段 XPath 就开始抓因为页面一改就全崩。import requests from lxml import etree HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp requests.get(https://example.com/shops, headersHEADERS, timeout15) print(resp.status_code, len(resp.text))2.2 基于 XPath 解析字段与 text 函数使用拿到 HTML 文本后我用etree.HTML()把它转成可查询的节点树然后用 XPath 提取字段。这里特别容易踩一个坑XPath 返回的是列表不是单个字符串。很多新手写name html.xpath(//h3/a/text())之后直接对 name 调用 strip()结果报错说列表没有 strip 方法。正确做法是先取出列表再判断长度最后对每个元素做清理。另一个常见问题是text()的用法。//span[classscore]/text()只取当前节点的直接子文本如果评分是被一层 span 嵌套包着的比如span classscorespan classnum4.8/span分/span直接取 text() 会返回空或者只剩一个“分”字。这时候可以用string()函数它会取当前节点的全部后代文本如果页面里匹配多个节点就要用//span[classscore]//text()然后手动拼接。我在项目里写了一个小函数专门负责对每个 xpath 表达式的结果做提取和清理。html etree.HTML(resp.text) shop_names html.xpath(//div[classshop-info]//h3/a/text()) scores html.xpath(//div[classshop-info]//span[classscore]/text())这里的逻辑看起来简单实际调试时却是最花时间的。有些页面里的店铺名称不在 a 标签里而是单独放在 div 节点中有些评分是“4.8”而不是“4.8分”清洗时我就直接把非数字字符替换掉再转成 float。清洗规则我单独写了一个方法先 strip 空白再按字段类型做转换空值填成 0 或空字符串尽量避免脏数据进数据库因为后面推荐算法对数据质量非常敏感。2.3 动态页面与请求频率控制有些美食网站并不是服务端直接渲染完 HTML 的而是先返回一个空壳再通过 JavaScript 请求 JSON 接口把列表渲染出来。如果遇到这种情况直接 requests 只能拿到一堆空的 divxpath 什么都匹配不到。我的处理原则是先别急着上 Selenium打开浏览器开发者工具里的 Network 选项卡刷新页面看 XHR 请求列表找到真正返回店铺数据的那个 JSON 接口。之后用 requests 直接请求那个接口再用resp.json()解析数据效率和稳定性都远高于模拟浏览器。如果遇到登录验证或者验证码通常说明请求频率太高或者目标站点本身不允许采集。正确做法是降低频率、检查请求头、换数据源而不是强行绕过。我在爬虫脚本里对每个页面请求之间加了time.sleep(1)整个采集过程大概几分钟不会对目标站点造成明显影响。做爬虫项目一定要把握好边界学技术没问题但别把自己做成骚扰别人服务器的工具。2.4 数据入库与店铺字段设计数据入库之前我把表结构先定好因为表结构直接决定后面 Django 页面和推荐算法怎么写。店铺表shop我设计了 7 个字段id、name、category、avg_score、price、address、reviews。其中avg_score是浮点数price是整数reviews是评论数量用于后续排序和可视化统计。用户行为表rating更关键字段至少是id、user_id、shop_id、rating、created_at。这张表可以看作推荐算法的“原料”用户对店铺的打分越高说明偏好越强。项目里我造了一批模拟用户行为数据给 200 个用户和 1000 家店铺生成随机的 1 到 5 分评价这样算法有足够的输入可以跑。真实用户后续产生的评分行为也会写入同一张表冷启动问题就没有那么严重了。3. 协同过滤推荐算法核心实现3.1 构建评分矩阵与数据准备推荐算法第一步是把行为表转换成评分矩阵。我习惯用 pandas 的 pivot_table 来实现。行为表里有user_id、shop_id、rating三列pivot 之后就变成了行是用户、列是店铺的二维矩阵每个格子代表该用户对该店铺的评分没有评分就填 0。数据准备阶段有个细节不能直接拿原始评分矩阵去算物品相似度因为不同用户的打分尺度不一样。有人习惯打 4 分保底有人偏好打极端分如果不处理相似度会被“打分习惯”而不是“真实偏好”主导。解决办法是先对每个用户做中心化也就是把每个用户的所有评分减去该用户的平均分。这样处理后评分大于 0 表示比该用户平时喜欢的店更喜欢小于 0 表示相对不喜欢物品向量在不同用户之间才有可比性。3.2 ItemCF 物品相似度计算ItemCF 的核心是算店铺两两之间的相似度。我先定义两个店铺的相似度等于“共同给它们打过分的用户向量”的余弦相似度。公式看着复杂实际代码里就是一个矩阵乘法加归一化。用 Python 写出来如下import pandas as pd import numpy as np def compute_item_sim(bhv_df): pivot bhv_df.pivot_table( indexuser_id, columnsshop_id, valuesrating ).fillna(0) user_mean pivot.mean(axis1) pivot_adjusted pivot.sub(user_mean, axis0) item_matrix pivot_adjusted.T norm np.sqrt((item_matrix ** 2).sum(axis1)) norm[norm 0] 1e-10 sim item_matrix.dot(item_matrix.T) / np.outer(norm, norm) sim sim.clip(lower-1, upper1) return pd.DataFrame(sim, indexitem_matrix.index, columnsitem_matrix.index)这段代码里最容易忽略的是分母为零的情况。如果某家店铺在矩阵里的行为向量全为零或者调整后所有维度都为零norm就会是 0除出来全是 NaN。我先用1e-10替换掉零避免警告后面再用clip把极端值收进来。实际计算出来的相似度矩阵是一个 1000 乘 1000 的方阵对角线是 1表示每家店铺和自身完全相似这个矩阵保存下来之后在线推荐阶段直接查表就行。3.3 生成 TopN 店铺推荐有了相似度矩阵推荐过程就变得很直接。假设当前用户曾经给店铺 A 打过分A 在相似度矩阵里有十个最相似的邻居店铺那么这十个邻居店铺就会进入候选池得分等于“用户对 A 的评分 × A 与该邻居的相似度”。用户有多家历史店铺时候选店铺的得分就是所有来源的累加最后按照总分排序取前 N 家。def recommend_for_user(user_id, sim_matrix, bhv_df, top_n20): user_items bhv_df[bhv_df[user_id] user_id] if user_items.empty: return [] user_shop_ids set(user_items[shop_id]) scores {} for _, row in user_items.iterrows(): shop_id row[shop_id] rating row[rating] if shop_id not in sim_matrix.index: continue sim_series sim_matrix.loc[shop_id].sort_values(ascendingFalse) for candidate, sim_score in sim_series.head(10).items(): if candidate shop_id or candidate in user_shop_ids: continue scores[candidate] scores.get(candidate, 0) sim_score * rating sorted_shops sorted(scores.items(), keylambda x: x[1], reverseTrue) return [shop_id for shop_id, _ in sorted_shops[:top_n]]这里有一个必须处理的逻辑用户已经去过的店绝对不能出现在推荐结果里。我刚写的时候没加这个过滤结果推荐结果里总出现用户已经打分的店铺看起来非常傻。另一个细节是邻居数量 K 的选择我默认取 10K 太大会把相似度很低的店铺也拉进来K 太小则推荐结果不够丰富。如果数据量变大可以把head(10)改成按相似度阈值过滤只保留相似度大于 0.3 的邻居效果更稳。3.4 冷启动与推荐多样性优化推荐算法跑通之后我马上遇到冷启动问题。新注册用户没有任何行为user_items是空的推荐函数直接返回空列表。这个体验太差了。我的方案是没有行为时回退到“热门店铺 Top10”按平均分高、评论数多排序先把页面撑起来用户有过一次评分行为后系统立刻切换成个性化推荐。还有一个问题是推荐结果同质化严重。用户喜欢过一家串串店算法会把周围所有串串店全推出来品类极端单一。虽然从相似度角度看没错但用户的真实需求往往是“今天想吃个不一样的”只看相似度会把用户圈在固定口味里。我加了一个分类打散策略生成推荐结果后按店铺分类分组每个分类最多保留 3 到 4 家如果不够 20 家再从剩余候选中补足。这样推荐列表里既有用户偏好的品类也有其他高频分类明显更贴近现实选择场景。3.5 在 Django 中集成推荐服务算法写好后不能留在 notebook 里要把它接入 Web 系统。我单独建了一个recommend/services.py文件把评分矩阵的读取、相似度矩阵的加载和推荐函数都封装起来。Django 视图只需要调用recommend_for_user(user_id)拿到的店铺 id 列表再查一次数据库就可以渲染页面了。相似度矩阵我避免在每次请求时重复计算。项目启动时通过AppConfig.ready()把矩阵加载到全局变量或者保存成 pickle 文件需要更新时才重新训练。平时线上请求只做查表和排序性能完全够用。这个“离线训练、在线服务”的分层思想即使以后把算法换成深度学习整体架构也不用推翻。4. Echarts 数据可视化大屏4.1 大屏需求与图表选型可视化大屏是这个项目里最直观的部分。我主要想表达三类信息第一类是店铺基础数据比如美食分类占比、评分分布、人均价格区间第二类是用户行为数据比如最受欢迎店铺 Top10第三类是推荐系统效果比如各分类推荐命中率。这些数据全部来自 Django 接口前端用 Echarts 渲染。图表选型上分类占比用饼图评分分布用柱状图热门店铺用横向柱状图人均价格区间用折线图。大屏整体采用深色背景卡片式布局方便后续投到展示屏幕上。Echarts 的好处是配置项丰富官方示例可以直接改参数我花了一个上午就把五张图全部搭起来了比从零写 SVG 不知道快多少。4.2 Django 返回统计数据接口大屏数据不能直接写在模板里最合理的做法是提供一个 JSON 接口。我在 dashboard app 里写了一个视图查询店铺表统计各分类数量、评分区间数量、价格区间数量和热门店铺列表然后通过JsonResponse返回。前端页面用 fetch 请求这个接口拿到数据后更新图表。from django.http import JsonResponse from django.db.models import Count, Avg def dashboard_data(request): shops Shop.objects.all() category_counts shops.values(category).annotate(numCount(id)) score_ranges [ {name: 0-3.5, value: shops.filter(avg_score__lt3.5).count()}, {name: 3.5-4.0, value: shops.filter(avg_score__gte3.5, avg_score__lt4.0).count()}, {name: 4.0-4.5, value: shops.filter(avg_score__gte4.0, avg_score__lt4.5).count()}, {name: 4.5, value: shops.filter(avg_score__gte4.5).count()}, ] hot_shops list(shops.order_by(-reviews)[:10].values(name, reviews)) return JsonResponse({ category: list(category_counts), score: score_ranges, hot: hot_shops, })4.3 Echarts 核心图表实现前端部分我在模板里放了一个大屏容器然后引入 echarts.min.js 和自定义的 dashboard.js。每个图表都有一个容器div必须显式设置高度否则 Echarts 初始化后会是空白。这一步是新手踩坑重灾区很多人图表不显示最后发现是容器高度为 0。fetch(/api/dashboard/) .then(res res.json()) .then(data { const scoreChart echarts.init(document.getElementById(scoreChart)); scoreChart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: data.score.map(item item.name) }, yAxis: { type: value }, series: [{ name: 店铺数, type: bar, data: data.score.map(item item.value) }] }); window.addEventListener(resize, () scoreChart.resize()); });这段代码看起来简单但需要注意一点fetch 是异步的一定要在then里再来执行setOption不能在页面加载时直接同步调用。如果你把初始化写在 fetch 之前图表就会先拿到空数据后面数据到了不去刷新自然白屏。我还给每个图表加了resize监听浏览器窗口变化时自动重绘这样投屏到不同分辨率的屏幕上时不会变形。4.4 前端接口安全与防爬关于前端防爬我自己的经验是不要指望通过混淆页面源码来彻底防住爬虫因为任何数据只要在浏览器里展示出来就一定有办法被别人拿到。更现实的做法是第一敏感接口必须要求登录用户未登录时只返回汇总数据不返回原始行为明细第二对接口做频率限制用 Django 中间件记录每个 IP 的请求次数超过阈值直接返回 429第三统计接口只返回聚合结果不在 JSON 里携带太多原始数据。做到这几点已经能挡掉大部分脚本采集者。5. Django 框架整合与系统功能实现5.1 项目目录结构与模块划分整个项目我按 app 做了模块化拆分而不是把所有功能堆在同一个 Django app 里。目录大致是这样的config/ # 项目配置 apps/ accounts/ # 用户登录注册 shops/ # 店铺模型与列表页 recommend/ # 推荐算法服务 dashboard/ # 可视化接口与页面 static/ css/ js/ templates/这种拆分方式带来的好处非常明显。我第一次做这个项目时把推荐代码、视图、爬虫解析全写在views.py里结果改一个功能就要重启整个项目测试一个接口要把日志翻半天。拆分成 app 之后每个模块的边界清晰了维护成本立刻降下来。推荐算法相关的函数都放在recommend/services.py爬虫脚本则放在独立目录不进 Web 项目的 app 目录。5.2 用户注册登录与行为数据采集用户系统直接用 Django 自带的auth应用实现注册、登录、退出都能在半个小时内搞定。用户登录后我在店铺详情页增加了收藏和评分按钮前端通过 post 请求把shop_id和rating传到后端。后端视图里用request.user.id获取当前登录用户然后检查行为表里是否已有记录有则更新评分没有则新增记录。这个逻辑虽然简单但却是推荐系统最重要的数据来源。这里有个细节用户评分行为不能只记录分数还应该记录时间。推荐结果中加入时间衰减后可以做到“最近喜欢的店影响更大几个月前的口味逐渐淡出”这样更符合真实用户心理。不过时间因子会引入更多调参我把它作为扩展点留着了基础版先不做。5.3 店铺列表、搜索与个性化推荐页店铺列表页需要支持分类筛选和关键词搜索。Django ORM 的icontains就能解决搜索需求用filter(name__icontainsquery)对店铺名做模糊匹配。列表页还需要分页我用 Django 内置的 Paginator一页 12 家切换页码时分页参数会带到 URL 里。推荐页和列表页结构相似但数据来源不同。登录用户进入“猜你喜欢”页面视图会调用recommend_for_user拿到推荐店铺 id 列表然后查询店铺表最后渲染模板。如果用户没有登录就展示热门店铺。这里要注意 N1 查询问题Django 查询店铺列表时如果用到了关联表字段记得用select_related或prefetch_related否则页面一大数据库查询次数会爆炸接口响应速度直接掉到几秒。5.4 定时爬虫与性能优化爬虫不能放在 Web 请求线程里执行否则用户点一下页面就要等好几秒体验极差。我单独做了一个定时任务模块用 APScheduler 每小时执行一次增量爬虫。增量爬虫只请求最近热度出现变化的店铺比如评论数有变化的店铺而不会每天全量抓取。Web 端性能优化主要集中在缓存上。热门店铺 Top10 的查询频率非常高但数据变化不频繁我用 Redis 缓存了结果缓存 10 分钟。相似度矩阵也做了缓存文件版本存在本地Redis 版本适合多实例部署。这些优化做完后本地开发环境接口响应基本都在 100ms 以内部署到普通服务器也能流畅跑。6. 常见问题与排错实录6.1 爬虫解析结果为空或字段错位爬虫踩坑最多的地方是 XPath 解析结果为空。遇到这种情况我一般按照三条路径排查先打印resp.text前 1000 个字符确认请求到的不是登录跳转页或者验证页再看 XPath 表达式的 class 属性是否带多余空格比如classshop list不能匹配classshop-list最后用etree.tostring(html, pretty_printTrue)打印局部 HTML确认解析目标结构没有变化。字段错位通常是因为页面里某些店铺卡片缺了某个字段导致后续元素频繁错位。解决办法是每个字段都做独立 XPath 提取而不是用“按索引对齐整个卡片”这种脆弱的方案。6.2 Echarts 白屏、tooltip 不显示怎么办Echarts 白屏九成是容器高度问题。echarts.init执行时容器需要有明确的高度不能用 height:auto。我惯用的做法是给大屏容器设置一个固定高度比如 300px或者用 CSS Grid 布局给每个图表卡片分配min-height: 240px。Tooltip 不显示通常是配置 trigger 不对柱状图上挂了tooltip: {trigger: item}但坐标轴数据是从 xAxis 来的应该改成trigger: axis。如果图表还是没反应打开浏览器开发者工具看 Console 有没有报错多半是 JSON 数据结构没对齐。6.3 Django 静态文件加载失败本地开发时静态文件 404先检查settings.py里的STATICFILES_DIRS是否包含了项目 static 目录。部署到服务器上就会遇到另一个问题DEBUGFalse之后 Django 默认不提供静态文件服务必须执行python manage.py collectstatic把所有静态文件收集到一个目录然后交给 Nginx 或其他 Web 服务器去托管。如果这两个都没问题记得清浏览器缓存再刷新页面。6.4 推荐结果太同质化、全是大热门我最早跑通协同过滤时推荐出来的 20 家店几乎全是连锁火锅店因为热门店铺在行为矩阵里和大量用户都有交集相似度天然偏高算法会给它们更高的候选得分。解决方式有三个第一过滤掉用户已经访问过的店第二对热门店铺做降权比如相似度乘以一个惩罚系数第三引入多样性重排按分类限制推荐数量。我最终把分类打散逻辑集成进了推荐函数效果好了很多用户反馈也明显更丰富。6.5 数据量变大后推荐计算变慢当行为数据到几万条时pandas 矩阵乘法还能跑但如果到几十万用户、几十万店铺二维矩阵点积的内存开销就没办法忽视了。这个项目的应对思路是离线计算、增量更新。相似度矩阵每天离线算一次结果导出成 pickle 文件线上接口只查矩阵。如果以后数据量继续扩大再考虑用 Faiss 做近似最近邻搜索或者直接把行为数据放到 Spark 里做分布式计算。这些扩展方向都不影响现有架构换算法只是换一个服务实现。最后分享一个我做项目时最深的体会一开始我急着写爬虫把店铺数据抓了一堆才开始做算法结果发现用户行为数据根本不够推荐效果一直不好。后来先构造了一份干净的用户模拟行为数据把“数据采集 → 推荐算法 → 接口 → 页面”这条主链路完整跑通再回过头去完善真实采集和真实行为效率一下子提上来了。如果你也要复现这个项目强烈建议先把一条最小链路走通再逐步加功能。另一个小技巧是固定一份样本数据集跑回归测试这样不管爬虫代码怎么改推荐算法都不会被意外变化搞挂。