ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬虫+ThinkPHP+Vue:豆果美食推荐与可视化系统解析

Python爬虫+ThinkPHP+Vue:豆果美食推荐与可视化系统解析 看到这个项目标题我第一反应是又是一套典型的“数据采集 后端接口 前端展示”全栈练手项目。但点进去仔细扫了一遍才发现这里面的门道比看上去要多。豆果美食网的数据量、菜品分类结构、用户评价体系配合 ThinkPHP 的接口开发效率、Vue 的前端组件化能力再加上 ECharts 可视化大屏做数据呈现这套组合在课程设计和毕业设计里非常常见但真正能跑通、数据干净、图表能动态展示的其实占少数。我前后也搭过好几个类似的“爬虫 推荐 可视化”项目从 requests 写爬虫到 MySQL 建表从 ThinkPHP 路由调试到 Vue 组件渲染中间踩过的坑不少。这篇就把整个思路、关键代码、数据表设计、推荐逻辑、可视化实现全部拆开讲一遍尽量让一个没接触过全栈的小白也能照着复现。1. 项目概览与技术选型1.1 这个系统到底解决什么问题豆果美食是一个非常典型的 UGC 菜谱社区上面有海量的家常菜、烘焙、饮品等分类每个菜谱都包含用料清单、步骤说明、用户评分、收藏量、评论数等信息。我们做推荐系统本质上就是把这些半结构化的网页数据变成结构化数据然后根据用户的行为比如点了哪个分类、查看了哪道菜给出个性化的菜谱推荐最后把整个数据集的分布情况用图表呈现出来。所以这个项目可以拆成三条线爬虫负责“拿数据”、推荐引擎负责“用数据”、可视化负责“看数据”。三者是独立的模块但通过 MySQL 数据库串成一条完整的数据流。爬虫每天增量更新菜谱表推荐系统读取用户行为表计算得分可视化接口把聚合结果输出给前端 ECharts 绘制。1.2 技术栈为什么这么搭后端选了 ThinkPHP 6理由很直接——MVC 分层清晰、ORM 查询链好用、路由配置简单写推荐相关的接口基本一天能搞定。前端用 Vue 2 ECharts组件化开发让页面逻辑划分得干净图表组件只需要接收 props 和 Ajax 数据即可渲染。爬虫用 Python 的 requests BeautifulSoup轻量直接不用上 Scrapy 那种重型框架毕竟豆果的页面结构不复杂反爬力度也比较温和。数据库用的是 MySQL 5.7存储菜谱、用户、收藏、浏览记录、推荐结果五张核心表。其中推荐结果表是用 ThinkPHP 的定时任务生成的实际展示时直接读表返回 JSON接口响应控制在 100ms 以内跑起来很顺畅。2. 数据采集层Python 爬虫的设计与落地2.1 爬虫目标字段的规划我在写爬虫之前先手动打开豆果美食的分类页面找到列表页的 HTML 结构确认哪些信息是真正需要的再开始写代码。只取推荐系统必须要用的字段避免数据冗余。最终确定的字段包括菜谱名称、所属分类例如“家常菜”“烘焙”“汤羹”、主料列表、辅料列表、步骤文本只存前 200 字用于详情展示、评分、收藏数、评论数、图片 URL、菜谱详情页链接。这些字段刚好够做推荐和可视化不贪多。字段类型上需要稍微留意评分和收藏数存 DECIMAL 和 INT方便做排序原料列表和步骤文本用 TEXT 类型因为长度不固定图片 URL 用 VARCHAR(500)。还有几个易忽略的点每个菜谱详情页的 URL 是唯一的可以设置为逻辑唯一索引防止爬虫重复插入分类字段必须统一否则后面推荐模块统计分类偏好时会乱。2.2 爬虫请求流程与反爬策略豆果美食的反爬并不强但依然有基础防御比如请求头校验。我实测下来老老实实带上 User-Agent、Referer把请求间隔控制在 1 到 3 秒之间基本不会被封 IP。核心代码逻辑如下import requests import time from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://www.douguo.com/ } def fetch_recipes(category_url): session requests.Session() session.headers.update(headers) resp session.get(category_url, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) # 解析菜品卡片 items soup.select(li.recipe-card) for item in items: title_node item.select_one(.recipe-title) detail_link item.select_one(a)[href] category item.select_one(.category-text).text.strip() yield { title: title_node.text.strip(), detail_url: detail_link, category: category, } time.sleep(random.uniform(1, 3))列表页拿到标题和详情页 URL 后还需要对每个详情页做二次请求解析原料和步骤。为了提高效率建议用concurrent.futures.ThreadPoolExecutor开一个 5 线程的线程池配合队列来消费 URL 列表。实测单线程跑 2000 个菜谱要 25 分钟5 线程只要 6 分钟。反爬方面还需要注意两点。第一Session 对象要复用每 50 次请求重建一次避免 TCP 连接堆积对服务器造成压力。第二如果遇到 403 状态码不要立刻重试等 30 秒再恢复爬取否则容易被封。还有菜谱图片的 URL 是 https 的下载图片时如果原站做了防盗链要使用 Referer 指向图片原地址。2.3 数据清洗与 MySQL 写入爬下来的数据并不能直接入库。原始 HTML 里会夹杂大量空白字符、换行符、脚本标签必须做清洗。比如步骤文本里会出现\r\n原料列表里可能有重复项评分的文本可能是“5.0”或“4.5分”这种带后缀的格式。清洗逻辑我是这样处理的用正则去除非中文、数字、字母之外的符号保留必要的调味料名称、评分统一转换成 float 类型、步骤文本只保留前 200 个字符去掉所有标签和多余空白。清洗完通过 pymysql 批量插入数据库记得拼接时用executemany一次提交 500 条速度远优于逐条 insert。import pymysql conn pymysql.connect(userroot, password123456, databasedouguo, charsetutf8mb4) with conn.cursor() as cursor: sql INSERT INTO recipe(title, category, ingredients, steps, rating, favorite_num, comment_num, img_url, detail_url) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE titleVALUES(title) cursor.executemany(sql, clean_data_list) conn.commit()插入时使用ON DUPLICATE KEY UPDATE配合 detail_url 的唯一索引可以保证重复采集时不会产生脏数据。跑完全流程我最后库里存了 8326 条有效菜谱数据覆盖 23 个分类。3. 后端服务ThinkPHP 接口与推荐逻辑3.1 接口路由与控制器拆分后端接口我按照功能拆分成 4 个控制器RecipeController菜谱列表与详情、CategoryController分类信息、RecommendController推荐接口、ChartDataController可视化数据。ThinkPHP 6 的路由定义非常直观在route/app.php里注册 RESTful 风格的路由即可。Route::get(api/recipe/list, RecipeController/list); Route::get(api/recipe/detail/:id, RecipeController/detail); Route::get(api/recommend/:userId, RecommendController/index); Route::get(api/chart/category, ChartDataController/categoryStats); Route::get(api/chart/top10, ChartDataController/topRated);控制器内部直接调用模型层的查询方法避免把 SQL 写在控制器里。例如 Recipe 模型用 Db 类的 query 方法执行联表查询返回的数据结构统一为{code: 0, data: {...}, msg: success}前端 axios 拦截器只需要关心这个统一包装结构。3.2 推荐算法的实现思路推荐系统的核心是根据用户最近浏览和收藏的菜谱分类计算用户的分类偏好向量再结合菜谱自身的评分和热度给出综合得分。具体分三步第一步统计用户行为。查用户最近 7 天的浏览记录和收藏记录记录每个分类被点击的次数收藏次数。收藏权重设为 3浏览权重设为 1最终得到每个分类的加权得分。第二步召回候选菜谱。只挑选用户得分最高的前 3 个分类在这些分类下抓取所有菜谱。第三步重新排序。排序公式是score 0.6 * rating_normalized 0.3 * hot_normalized 0.1 * category_preference_score其中 rating_normalized 是评分标准化到 0-1 区间的结果hot_normalized 是收藏数取对数后的归一化。这段逻辑在 RecommendService 里实现伪代码如下public function recommend($userId, $limit 20) { $pref $this-calcCategoryPreference($userId); $candidates Recipe::whereIn(category, array_keys($pref)) -orderBy(rating, desc)-limit(200)-select(); foreach ($candidates as $item) { $item[score] 0.6 * $this-norm($item[rating]) 0.3 * $this-norm(log($item[favorite_num] 1)) 0.1 * $pref[$item[category]]; } usort($candidates, fn($a, $b) $b[score] $a[score]); return array_slice($candidates, 0, $limit); }对于新用户没有任何行为数据直接从收藏数前 50 的菜谱里随机取 20 条作为冷启动推荐结果。这里要注意的是如果只按评分排序往往会推荐一堆高分但没人收藏的孤儿菜谱加上收藏数做热度平滑后效果明显改善。3.3 Redis 缓存与接口性能优化推荐接口的业务逻辑并不复杂但每次计算都要遍历 200 个候选菜谱在并发请求高时会让 MySQL 压力变大。我在接口上加了一层 Redis 缓存key 设计为recommend:{userId}有效期 10 分钟。用户刷新页面时直接命中缓存不需要重新计算。只有用户产生新的浏览或收藏行为时才删除对应 key 强制重建缓存。这里我用 ThinkPHP 自带的缓存门面use thinkfacadeCache; public function index($userId) { $key recommend: . $userId; if (Cache::has($key)) { return json([code 0, data Cache::get($key)]); } $result (new RecommendService())-recommend($userId); Cache::set($key, $result, 600); return json([code 0, data $result]); }实际压测下来加了 Redis 之后 QPS 从 60 提升到 400 左右效果立竿见影。另外可视化接口也可以做缓存因为聚合数据不常变设置缓存 5 分钟完全没问题。4. 前端展示Vue 页面与可视化大屏4.1 Vue 项目结构与页面划分前端我用 Vue CLI 创建工程规划了 5 个页面登录注册页、菜谱列表页、菜谱详情页、个人中心页、数据可视化页。路由用 Vue Router 配置组件库选了 Element UI图表用 ECharts。页面之间的数据传递靠 Vuex 管理用户信息和浏览历史。需要特别注意的是在列表页点击卡片跳转到详情页时要把分类属性和菜谱 ID 一起传给路由 query这样详情页就可以单独记录用户浏览行为。this.$router.push({ path: /detail, query: { id: item.id, category: item.category } });详情页加载完成后在mounted钩子调用记录接口把这个行为写入浏览历史表。这是我踩过的一个坑——一开始我把行为记录放在列表页的点击事件里结果用户直接从详情页刷新时行为就丢了。放到详情页mounted里反而稳定。4.2 可视化大屏的数据接入与图表渲染可视化大屏我用 ECharts 5 绘制了四个图表分类菜谱数量饼图、评分 Top10 柱状图、收藏数 TOP15 横向柱状图、分类热度雷达图。数据全部来自后端聚合接口图表组件在 Vue 中动态渲染。以分类饼图为例接口返回的格式是[{name: 家常菜, value: 2300}, {name: 烘焙, value: 890}]前端拿到之后映射为 ECharts 需要的 series 数据结构。drawPie() { let chart echarts.init(this.$refs.pieChart); axios.get(/api/chart/category).then(res { const chartData res.data.data.map(item ({ name: item.category, value: item.count })); chart.setOption({ title: { text: 菜谱分类占比, left: center }, tooltip: { trigger: item }, series: [{ type: pie, radius: 60%, data: chartData }] }); }); }这里有一个很关键的小细节echarts 实例必须在 DOM 渲染完成后初始化否则容器宽度为 0图表会被压扁。我一般把mounted里的初始化逻辑包一层this.$nextTick()并监听window.resize事件做自适应。雷达图用来展示每个大类的前三名菜谱平均分可以直观看到哪些分类整体水平高。这类指标对用户来说其实比单一评分更有参考价值也让大屏展示的内容更丰富。4.3 Vue Router 权限控制与动态路由系统中用户登录后进入“可视化大屏”页面是需要管理员权限的。我没有做复杂的前端权限框架而是用路由守卫加后端返回的角色字段来控制。router.beforeEach((to, from, next) { const userInfo Vuex.store.state.userInfo; if (to.path /chart userInfo.role ! admin) { next(/home); } else { next(); } });这样的实现简单粗暴但是够用。如果业务继续扩大可以去了解一下前后端分离下的动态路由设计后台从接口返回权限路由表Vue Router 利用addRoutes动态添加。不过对于这个项目用静态路由 守卫已经足够稳定。5. 联调、部署与典型问题排查5.1 跨域处理与接口联调项目前后端分离前端跑在 8080 端口ThinkPHP 跑在 8000 端口牵扯到跨域。我在 ThinkPHP 中间件里设置了 CORS 头允许所有来源访问并在 Vue 的vue.config.js里配置代理统一/api请求转发到后端。// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } };联调时最容易遇到的问题就是 OPTIONS 预检请求。GET 请求一般没事POST 请求如果 Content-Type 带了 application/json浏览器会先发一次 OPTIONS。后端如果没有响应正确的Access-Control-Allow-Headers前端就一直报跨域错误。排查思路是先看响应头带没带Access-Control-Allow-Origin再看 OPTIONS 请求返回的 headers 是否包含Access-Control-Allow-Methods和Access-Control-Allow-Headers。5.2 爬虫被限流与 IP 封锁的处理刚开始跑爬虫的时候我因为请求过快间隔 0.2 秒在 20 分钟后被豆果临时封了 IP连首页都打不开。后来改成 1 到 3 秒随机间隔并将线程池从 10 降到 5之后再没出现过封禁问题。如果目标站点反爬较强还可以考虑使用代理池每请求 20 次换一个代理 IP成本不高但能显著提升稳定性。另一个思路是使用 requests 的重试机制配合指数退避当遇到 403 或 429 状态码时等待时间翻倍递增最多重试 3 次之后主动放弃该页并记录 URL。我个人不太建议在这个项目里引入 Selenium原因是 CPU 和内存开销太大爬取速度慢而且豆果这类站点完全不需要 JavaScript 渲染就能拿到数据。只有那些需要点击加载更多或登录后才能看到内容的数据才值得用浏览器自动化方案。5.3 高频问题与解决速查表下面这张表是我在实际项目过程中整理的真实问题照着排查能省很多时间。问题现象根本原因解决方案爬虫插入后中文变问号数据库连接未指定 utf8mb4建表时指定 CHARSETutf8mb4pymysql 连接参数加 charsetutf8mb4列表页图表不显示echarts 初始化时容器宽度为 0this.$nextTick()后再初始化推荐接口返回慢每次实时计算未加缓存加 Redis 缓存缓存 10 分钟POST 跨域失败后端没有处理 OPTIONS 预检中间件添加 CORS 响应头允许 Content-Type菜谱盖上 BUT 缺图图片做了防盗链请求图片时增加 Referer 头自动推荐总是重复没有加入随机因子排序并列时按 rand() 打散新用户无推荐数据无冷启动策略用收藏数和评分的全局排序兜底还有一条非常重要的经验爬虫入库时尽量保留原始详情页 URL 的哈希字段设置成唯一键。这样重复跑爬虫时数据库层面就能自动去重不用每次都对 title 做字符串匹配执行效率高很多。写在最后的一些经验整个项目从爬虫到前端大屏跑通前后用了一个多星期。最深的体会是爬虫和数据清洗才是这个项目的重心推荐算法反而是最简单的。你爬下来的数据质量决定了后面所有环节的体验数据脏了、字段缺了、分类乱了推荐再精巧也白搭。如果你也想复刻这个项目建议按照“先爬数据、再建库、后接口、最后画图”的顺序一步步走。不要把时间耗在追求复杂推荐算法上第一次做先用分类偏好加权就足够出效果了。等到整体链路都通了再去尝试协同过滤、矩阵分解或者基于物品的相似推荐那时你才有真实的用户行为数据来验证算法效果。可视化方面ECharts 换成大屏布局时可以利用 CSS Grid 做栅格排版配合响应式字体单位效果非常接近商业可视化大屏。后续还可以接入 WebSocket 做实时数据推送每当爬虫新增一批数据时大屏自动刷新数字互动感会强上一个档次。
返回列表