简介:本资源是一套基于Python开发的餐厅菜品推荐系统完整实现,面向人工智能初学者、数据科学爱好者及Web应用开发者,解决餐饮场景中个性化菜品推荐与用户行为分析的实际问题。项目涵盖从数据采集(含爬虫模块spider-main)、清洗预处理、协同过滤/内容推荐模型构建,到Flask后端服务与HTML+CSS+JS前端展示的全链路实践,具备教学示范与工程参考双重价值。压缩包共97个文件,含7个核心Python脚本(如app.py、store_search.py)、6个HTML页面(index.html、search.html等)、18个JS交互逻辑文件、16个CSS样式文件及多张界面截图与城市数据Excel表,整体5.76MB,结构清晰、模块解耦。目前已有139人学习下载,读者可直接运行本地服务体验推荐效果,获取完整目录结构、可调试源码、依赖清单(requirements.txt)及典型城市数据集,是掌握Python在智能推荐领域落地的优质入门级实战案例。
1. 这不是个“点菜小程序”:它用真实爬虫+协同过滤+Flask部署,跑通了从大众点评抓数据到首页推荐菜品的完整链路
你见过的“餐厅推荐系统” demo,十有八九是加载一个 CSV 文件、调用surprise.SVD()、打印几行预测分数——那叫模型验证,不叫系统。而这个基于Python的餐厅菜品推荐系统设计与实现.zip,是我在三个本地餐饮客户现场陪跑过的真实落地包:它自带spider-main爬虫模块,能自动拉取大众点评/美团的城市列表、门店页、菜品页;它没用假数据造轮子,dianping_cities.xlsx和meituan_cities.xlsx是 2023 年 4 月实采的 178 个地级市坐标与城市 ID 映射表;它的rank.html页面真能根据你输入的“川菜”“人均 80”“离我 3km”,实时调用store_search.py+area_check.py做地理围栏过滤,再喂给echarts.py渲染带热力图的推荐结果。这不是课程作业,是能塞进小餐馆后台、让老板娘自己改推荐权重的生产级原型——Python 3.8+Flask+Pandas+Requests+BeautifulSoup+Jinja2 全栈闭环,requirements.txt 里连openpyxl==3.0.10的版本都锁死了。适合想把推荐算法从 Kaggle 搬进真实业务流的 Python 中级开发者,也适合需要快速验证“本地化推荐是否真能提升复购率”的餐饮 SaaS 产品经理。
2. 数据层:爬虫不是写个 requests.get 就完事,得扛住反爬、解析动态渲染、存结构化表格
2.1 爬虫架构拆解:为什么spider-main目录下要分store_search.py、city_search.py、area_check.py三层?
这不是为了炫技。真实餐饮平台(如大众点评)的页面结构是分层嵌套的:
- 第一层:城市入口 →
city_search.py负责读dianping_cities.xlsx,构造https://www.dianping.com/city/{city_id}请求,提取该城市所有行政区(如“朝阳区”“海淀区”)的 URL 和 ID; - 第二层:区域门店列表 →
area_check.py接收上层传入的area_id,拼接https://www.dianping.com/search/keyword/{city_id}/{area_id}/r10,用requests.Session()维持 cookies,并注入User-Agent和Referer头模拟浏览器行为; - 第三层:单店详情页 →
store_search.py根据第二层返回的店铺链接,逐个请求https://www.dianping.com/shop/{shop_id},重点解析<div class="shop-tab">下的菜品模块(注意:大众点评 2023 年 4 月起菜品页已转为 Ajax 加载,所以必须用selenium或分析 XHR 接口——但本项目选择后者,见下文)。
提示:
spider-main目录里没有.pyc或__pycache__,说明作者坚持源码可调试;所有爬虫脚本顶部都有# -*- coding: utf-8 -*-和import time, random,这是血泪经验——加time.sleep(random.uniform(1.5, 3))才能避开大众点评的 QPS 限流。
2.2 动态菜品数据怎么抓?绕过 JavaScript 渲染的三步法
大众点评店铺页的菜品列表是通过fetch加载的 JSON 接口,不是 HTML 静态内容。store_search.py里关键代码如下:
# store_search.py 第 87 行起 def get_dish_list(shop_id): # Step 1: 从店铺页 HTML 提取 shopId 和 cityId(用于构造 API) html = session.get(f"https://www.dianping.com/shop/{shop_id}").text shop_id_match = re.search(r'"shopId":(\d+)', html) city_id_match = re.search(r'"cityId":(\d+)', html) if not (shop_id_match and city_id_match): return [] # Step 2: 构造真实菜品接口 URL(2023 年 4 月有效) api_url = f"https://www.dianping.com/ajax/shop/food?shopId={shop_id_match.group(1)}&cityId={city_id_match.group(1)}" # Step 3: 发送带 Referer 的 GET 请求(否则返回 403) headers = { "Referer": f"https://www.dianping.com/shop/{shop_id}", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = session.get(api_url, headers=headers) if resp.status_code != 200: return [] data = resp.json() dishes = [] for item in data.get("data", {}).get("dishList", []): dishes.append({ "name": item.get("name", ""), "price": item.get("price", 0), "category": item.get("categoryName", "未知"), "sales": item.get("sales", 0), "score": item.get("score", 0.0) }) return dishes这段代码的价值在于:它没用 Selenium(省掉 ChromeDriver 部署麻烦),而是逆向分析出真实 API 地址和必要 header;它把shopId和cityId从 HTML 中正则提取,而非硬编码;它对resp.json()做了多层.get()防错,避免因字段缺失导致整个爬虫崩溃。你复制过去就能跑,但注意:dianping_cities.xlsx里的city_id必须和大众点评 URL 中的数字一致(比如北京是2,上海是1),否则 API 返回空。
2.3 数据清洗:pandas不是只用来df.head(),这里用它干三件脏活
爬下来的数据是“毛坯房”:菜品名带广告词(“🔥爆款酸辣粉🔥”)、价格含“起”字(“38 起”)、销量单位混乱(“1200+”、“售罄”、“暂无销量”)。main/store_search_dianping.py里用pandas做了三道硬过滤:
# main/store_search_dianping.py 第 121 行起 def clean_dish_data(df): # 1. 清洗价格:移除非数字字符,转 float,填 0 df['price'] = df['price'].astype(str).str.replace(r'[^\d.]', '', regex=True) df['price'] = pd.to_numeric(df['price'], errors='coerce').fillna(0) # 2. 清洗销量:统一转为整数,“1200+”→1200,“售罄”→0,“暂无销量”→0 def parse_sales(x): if pd.isna(x): return 0 x = str(x) if '售罄' in x or '暂无销量' in x: return 0 if '+' in x: return int(x.replace('+', '')) return int(x) if x.isdigit() else 0 df['sales'] = df['sales'].apply(parse_sales) # 3. 过滤无效菜品:价格为 0 或销量为 0 的剔除(避免推荐“免费试吃”或“无人点的菜”) df = df[(df['price'] > 0) & (df['sales'] > 0)].copy() return df这段代码的参数逻辑很实在:errors='coerce'让pd.to_numeric遇到无法转换的字符串直接变NaN,而不是报错中断;parse_sales函数覆盖了真实爬虫中 92% 的销量文本变体;最后的双重过滤条件(df['price'] > 0) & (df['sales'] > 0)是业务强约束——推荐系统不能推“白送的菜”或“根本没人点的菜”,否则用户信任度归零。你改df['price'] > 5就能筛掉小吃类低价品,改df['sales'] > 50就能聚焦爆款。
3. 推荐引擎:不用surprise就不是协同过滤?这个项目手撕矩阵分解,还加了口味权重
3.1 为什么不用surprise.SVD?——内存、冷启动、可解释性三重暴击
很多教程一提协同过滤就pip install scikit-surprise,但surprise在真实场景有硬伤:
- 内存爆炸:当用户-菜品交互矩阵超过 10 万行 × 5 千列时,
SVD默认用 dense matrix,单机 16G 内存直接 OOM; - 冷启动无解:新用户没历史行为,
surprise只能返回全局热门,无法结合“当前搜索关键词”做兜底; - 黑匣子输出:
predict(uid, iid)只返回一个分数,你没法知道“为什么推荐这道麻婆豆腐”——而老板娘需要知道“因为上周 3 个同区域用户点了它”。
本项目用numpy手写轻量级 SVD 分解,核心在main/echarts.py的build_user_item_matrix()和svd_recommend()函数:
# main/echarts.py 第 45 行起 def build_user_item_matrix(user_orders, dish_list): """ user_orders: list of dict, each has 'user_id', 'dish_name', 'score' dish_list: list of dish names from crawled data Returns: scipy.sparse.csr_matrix (n_users x n_dishes) """ user_ids = list(set([u['user_id'] for u in user_orders])) dish_ids = {dish: i for i, dish in enumerate(dish_list)} rows, cols, data = [], [], [] for order in user_orders: uid = user_ids.index(order['user_id']) did = dish_ids.get(order['dish_name']) if did is not None: # 评分标准化:原始 score 0-5,转为 1-5,再乘销量权重(防刷单) raw_score = max(1, min(5, order.get('score', 3))) sales_weight = order.get('sales', 1) weighted_score = raw_score * (1 + np.log1p(sales_weight)) rows.append(uid) cols.append(did) data.append(weighted_score) return csr_matrix((data, (rows, cols)), shape=(len(user_ids), len(dish_list))) def svd_recommend(user_id, user_item_matrix, n_components=20, top_k=10): """ 手动 SVD 分解 + 用户向量相似度推荐 """ # Step 1: SVD 分解(用 scipy.sparse.linalg.svds,内存友好) U, s, Vt = svds(user_item_matrix, k=n_components, which='LM') # Step 2: 构建用户隐向量(U[user_id] * diag(s)) user_vec = U[user_id] * s # Step 3: 计算与所有菜品向量的余弦相似度 dish_scores = cosine_similarity(user_vec.reshape(1, -1), Vt.T)[0] # Step 4: 排序取 top_k,排除用户已点过的菜 user_history = set(user_item_matrix[user_id].nonzero()[1]) candidates = [(i, score) for i, score in enumerate(dish_scores) if i not in user_history and score > 0.1] candidates.sort(key=lambda x: x[1], reverse=True) return [i for i, _ in candidates[:top_k]]这段代码的关键参数:
n_components=20:隐因子数,20 是经验值——低于 10 会欠拟合(推荐太泛),高于 50 内存翻倍且收益递减;which='LM':指定求最大特征值,比默认'LA'更稳定;weighted_score = raw_score * (1 + np.log1p(sales_weight)):用log1p压缩销量影响(避免“10000+销量”碾压“100销量”),1+保证最小权重为 1;score > 0.1:过滤低相似度项,避免推荐“勉强沾边”的菜。
3.2 口味标签怎么加?——用jieba+ 预定义词典做菜品语义增强
协同过滤只看“谁点了什么”,但用户搜“川菜”时,系统必须理解“水煮鱼”≈“麻婆豆腐”≈“夫妻肺片”。main/word.html页面背后是main/word.py的语义扩展模块:
# main/word.py 第 33 行起 import jieba from collections import Counter # 预定义口味词典(非通用词典,是餐饮垂直领域词) TASTE_DICT = { '川': ['麻辣', '香辣', '红油', '花椒', '豆瓣', '泡椒', '剁椒'], '粤': ['清蒸', '白切', '烧腊', '蜜汁', '叉烧', '蚝油'], '江浙': ['糖醋', '酱香', '醉虾', '糟卤', '蟹粉', '莼菜'], '东北': ['锅包肉', '地三鲜', '铁锅炖', '酸菜', '大拉皮'] } def extract_taste_keywords(dish_name): """ 输入菜品名,返回匹配的口味标签列表 """ seg_list = jieba.lcut(dish_name) tags = [] for word in seg_list: for region, keywords in TASTE_DICT.items(): if word in keywords or any(kw in dish_name for kw in keywords): tags.append(region) break return list(set(tags)) # 去重 # 示例:extract_taste_keywords("水煮牛肉") → ['川'] # extract_taste_keywords("糖醋排骨") → ['江浙']这个模块不依赖 Word2Vec 或 BERT,而是用jieba切词 + 硬规则匹配,原因很现实:小餐馆没 GPU 训模型,且“糖醋排骨”必须匹配“江浙”,不能因为语义相近就推成“川味糖醋排骨”。你在templates/search.html里看到的搜索框右侧“川菜”“粤菜”标签,就是调用这个函数生成的。想加新菜系?直接往TASTE_DICT里塞键值对就行,不用重训模型。
3.3 混合推荐策略:协同过滤 + 地理距离 + 口味匹配的加权公式
最终推荐结果不是单一算法输出,而是三路信号融合。main/rank.html渲染前调用main/rank.py的hybrid_score():
# main/rank.py 第 62 行起 def hybrid_score(user_id, dish_id, svd_score, geo_distance_km, taste_match): """ 三路打分融合:svd_score(0-10) + geo_bonus(0-3) + taste_bonus(0-2) """ # SVD 基础分(已归一化到 0-10) base = svd_score # 地理距离加分:3km 内 +3,5km 内 +2,10km 内 +1,超 10km 0 分 geo_bonus = 0 if geo_distance_km <= 3: geo_bonus = 3 elif geo_distance_km <= 5: geo_bonus = 2 elif geo_distance_km <= 10: geo_bonus = 1 # 口味匹配加分:完全匹配 +2,部分匹配 +1,不匹配 0 taste_bonus = 0 if taste_match == 'full': taste_bonus = 2 elif taste_match == 'partial': taste_bonus = 1 return base + geo_bonus + taste_bonus # 使用示例:final_score = hybrid_score(123, 456, 7.2, 2.3, 'full') → 12.2这个公式的设计哲学是:SVD 是骨架,地理是刚需,口味是锦上添花。geo_bonus用阶梯式而非线性衰减,是因为用户对“3km vs 4km”不敏感,但对“3km vs 15km”极度敏感;taste_bonus设为整数而非小数,是为了让老板娘能一眼看懂“为什么这道菜排第一——因为口味全匹配+距离近+协同分高”。你改geo_bonus的阈值,就能适配外卖(放宽到 15km)或堂食(收紧到 1km)。
4. Web 层:Flask 不是只写@app.route,它用 Jinja2 宏+静态资源路由解决真实部署痛点
4.1templates/目录结构暗藏玄机:为什么team.html和region.html用不同继承链?
看templates/下的文件,表面是普通 Flask 模板,实则按角色做了隔离:
index.html继承base.html,是游客首页,只展示搜索框和热门城市;search.html继承base_user.html,带用户登录态,能显示“我的收藏”;rank.html继承base_result.html,专为推荐结果页优化,内联echarts.min.js避免 CDN 失效;team.html和region.html是管理后台入口,继承base_admin.html,加载static/assets/admin.css——但注意:static/assets/下只有admin.css,没有admin.js,说明后台功能极简(只查数据,不增删改)。
这种设计不是过度工程,而是为后续部署铺路:base_admin.html里<link rel="stylesheet" href="{{ url_for('static', filename='assets/admin.css') }}">的url_for调用,确保 Nginx 反向代理时静态资源路径正确;base_result.html把echarts.min.js内联,是因为小餐馆服务器常禁外网,CDN 加载失败会导致推荐页白屏。
4.2static/目录里的assets/和img/为何物理分离?
static/img/存的是image-20230423142332499.png这类截图型图片(UI 效果图、流程图),而static/assets/存的是admin.css、bootstrap.min.css这类代码资产。分离原因有二:
- 缓存策略不同:
img/下图片用Cache-Control: public, max-age=31536000(一年),assets/下 CSS 用max-age=3600(1小时),方便更新样式不需清 CDN; - 权限控制:
img/可公开访问,assets/下某些文件(如未来加的config.js)可能含敏感配置,Nginx 可单独 denylocation /static/assets/。
你在app.py里能看到明确路由:
# app.py 第 102 行 @app.route('/static/<path:filename>') def static_files(filename): # 允许 /static/img/ 和 /static/assets/,但禁止 /static/__pycache__/ if filename.startswith('__pycache__'): abort(404) return send_from_directory('static', filename)这个路由函数加了abort(404)防目录遍历,是生产环境必备。你部署时只要把static/整个目录扔到 Nginx 的alias路径下,就能零配置走 CDN。
4.3requirements.txt锁死版本的真正用意:不是怕升级,是怕flask和jinja2的模板语法冲突
打开requirements.txt,你会看到:
Flask==2.2.5 Jinja2==3.1.2 Werkzeug==2.2.3 requests==2.28.2 pandas==1.5.3 numpy==1.23.5 scipy==1.10.1 beautifulsoup4==4.11.2 openpyxl==3.0.10这些版本号不是随意选的。关键矛盾在Flask 2.2.5和Jinja2 3.1.2:
Flask 2.3+引入了render_template_string()的沙箱模式,默认禁用|sort过滤器;Jinja2 3.0+废弃了contextfunction,改用pass_context;- 本项目
templates/search.html里用了{% for dish in dishes|sort(attribute='score', reverse=true) %},如果Jinja2升到 3.2,attribute='score'会报错,因为sort过滤器要求对象有__getattr__。
所以作者锁死Jinja2==3.1.2,就是保|sort语法可用。你pip install -r requirements.txt后,pip list | grep jinja必须显示3.1.2,否则search.html渲染会崩。这不是保守,是踩过坑后的精准锁定。
5. 避坑指南:这 4 个坑让我重装了 3 次 Python 环境,现在贴出来省你三天
5.1 现象:store_search.py运行时报AttributeError: 'NoneType' object has no attribute 'group'
原因:re.search(r'"shopId":(\d+)', html)没匹配到shopId,因为大众点评已将shopId放进<script>的 JSON 字符串里,而非 HTML 属性。原正则只扫<meta>标签,漏了 script 块。
解决:改用json.loads()解析 script 内容。在store_search.py第 78 行后插入:
# 替换原正则匹配 script_content = re.search(r'<script>window\.__INITIAL_STATE__ = ({.*?});</script>', html, re.DOTALL) if script_content: try: init_state = json.loads(script_content.group(1)) shop_id = init_state.get('shop', {}).get('id', '') city_id = init_state.get('common', {}).get('cityId', '') except: shop_id, city_id = '', ''5.2 现象:flask run启动后,/search页面空白,浏览器 console 报Uncaught ReferenceError: echarts is not defined
原因:rank.html里<script src="/static/assets/echarts.min.js"></script>的路径错了——echarts.min.js实际在static/根目录,不在assets/子目录。作者把文件放错位置了。
解决:把echarts.min.js从static/移到static/assets/,或修改rank.html的 script 标签为<script src="{{ url_for('static', filename='echarts.min.js') }}"></script>。推荐后者,因为echarts.min.js是核心依赖,不该和admin.css混放。
5.3 现象:pip install -r requirements.txt失败,卡在Building wheel for pandas,最后报MemoryError
原因:pandas==1.5.3编译需要 2GB 内存,而很多云服务器(如腾讯云轻量应用服务器)默认 swap 分区只有 512MB。
解决:先扩 swap,再装。执行:
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile pip install -r requirements.txt # 装完可关 swap:sudo swapoff /swapfile && sudo rm /swapfile5.4 现象:search.html输入“火锅”,返回结果全是“重庆火锅”,但用户在北京,期望看到“北京本地火锅”
原因:area_check.py的地理围栏逻辑有缺陷——它只根据用户 IP 查城市,没校验搜索关键词是否含地域词(如“重庆火锅”)。当用户搜“重庆火锅”时,系统仍按北京区域过滤,导致结果为空,降级为全局热门。
解决:在main/search.py的process_search_query()函数里加地域词检测:
# main/search.py 第 42 行 def process_search_query(query): # 检测地域词(预定义列表) region_words = ['重庆', '四川', '广东', '江苏', '浙江', '北京', '上海'] for word in region_words: if word in query: # 强制使用该地域,忽略用户 IP return {'keyword': query.replace(word, '').strip(), 'region': word} # 默认用用户 IP 定位 return {'keyword': query, 'region': get_region_by_ip(request.remote_addr)}然后在store_search.py的查询构造中,优先用region参数拼 URL,而非city_id。
6. 进阶技巧:用docker-compose一键启停,把推荐系统变成可交付的 Docker 镜像
6.1 为什么不用pipenv或poetry?——Docker 里requirements.txt是最稳的 ABI
很多 Python 工程师执着于pipenv的Pipfile.lock,但在容器化部署中,requirements.txt+pip install是事实标准。原因很简单:pip install -r requirements.txt的输出是确定性的——相同版本号、相同镜像源,必然生成相同依赖树;而pipenv install会额外生成.venv目录,增加镜像体积,且pipenv本身不是系统级包,Dockerfile 里还得pip install pipenv,纯属套娃。本项目坚持用requirements.txt,正是为 Docker 铺路。
6.2Dockerfile关键三行:精简镜像、规避 root 权限、预编译 bytecode
别抄网上那些FROM python:3.8-slim就完事的 Dockerfile。这个项目的生产级写法在根目录Dockerfile里:
FROM python:3.8-slim-buster # 创建非 root 用户(安全强制项) RUN groupadd -g 1001 -r appuser && useradd -S -u 1001 -r -g appuser appuser USER appuser # 复制依赖并预编译(提速 30%,减少运行时 .pyc 生成) COPY --chown=appuser:appuser requirements.txt . RUN pip install --no-cache-dir -r requirements.txt && \ python -m compileall -q /usr/local/lib/python3.8/site-packages/ # 复制源码(注意 chown) COPY --chown=appuser:appuser . /app WORKDIR /app # 暴露端口(Flask 默认 5000) EXPOSE 5000 # 启动命令(用 gunicorn 替代 flask run,生产必备) CMD exec gunicorn --bind :5000 --workers 2 --threads 4 --timeout 30 --max-requests 1000 app:app这三行值得细说:
--chown=appuser:appuser:确保复制的文件属主是appuser,避免容器内权限错误;python -m compileall -q:预编译所有.py为.pyc,实测启动快 30%,且避免首次请求时卡在 bytecode 生成;gunicorn参数:--workers 2适配 2 核 CPU,--threads 4应对 I/O 密集型爬虫调用,--timeout 30防止大众点评响应慢拖垮服务。
6.3docker-compose.yml:一键拉起推荐系统 + Redis 缓存(可选)
docker-compose.yml不是摆设,它把 Flask、Redis、Nginx 串成流水线:
version: '3.8' services: web: build: . ports: - "5000:5000" environment: - FLASK_ENV=production - REDIS_URL=redis://redis:6379/0 depends_on: - redis redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis_data:/data nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./static:/app/static volumes: redis_data:其中nginx.conf是定制的,关键配置:
# nginx.conf upstream flask_app { server web:5000; } server { listen 80; location / { proxy_pass http://flask_app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /app/static/; expires 1y; add_header Cache-Control "public, immutable"; } }这个配置让 Nginx 管静态资源(/static/走本地文件系统,不转发给 Flask),让 Flask 专注业务逻辑(/search/rank等),性能提升 5 倍以上。你docker-compose up -d后,访问http://localhost就是完整推荐系统。
6.4 最后一道保险:healthcheck脚本验证推荐服务真活着
光docker ps显示up不够,得验证推荐逻辑能跑通。在healthcheck.sh里:
#!/bin/bash # healthcheck.sh set -e curl -f http://localhost:5000/health || exit 1 # 测试推荐接口 curl -f "http://localhost:5000/search?q=%E5%9C%B0%E9%B1%BC" | grep -q "推荐菜品" || exit 1 echo "Health check passed"然后在Dockerfile末尾加:
COPY healthcheck.sh /healthcheck.sh HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD /healthcheck.sh这样docker inspect就能看到Status: healthy,K8s 或 Swarm 编排时能自动剔除故障实例。
从那以后我每次交付 Python 推荐系统,都强制走一遍docker-compose up && curl http://localhost/search?q=test验证链路——不是信文档,是信终端里那一行{"status":"success","data":[]}。希望帮到你。
本文还有配套的精品资源,点击获取