
豆果美食推荐系统这类项目我前前后后做了不少也帮几个朋友改过类似的毕设和练手项目。今天就把这套“ThinkPHP Vue 爬虫 可视化”的完整思路和实操过程拆开揉碎讲一遍。如果你正打算做美食推荐、菜谱分享、甚至电商导购类的数据项目这篇应该能帮你少踩不少坑从数据采集、清洗入库到推荐算法落地再到前端可视化和部署一条龙说清楚。1. 项目整体设计与思路拆解1.1 为什么选 ThinkPHP Vue 这套组合先说后端ThinkPHP是目前国内用得最多的PHP框架之一特别是ThinkPHP 5和6系列上手门槛低、文档齐全、生态成熟。很多学校课程和企业内部系统都在用所以拿它来做后端接口和数据处理非常稳。搜索热词里还有一条“thinkphp 3.2版本兼容php8”很现实我早几年做过一个3.2的老项目要迁到PHP 8环境折腾了一整天改兼容这里也提醒一下如果你是新项目直接上ThinkPHP 6或者8千万别用3.2这种古董版本除非你是在维护老系统。前端选Vue理由也很直接Vue对后端转前端的开发者非常友好模板语法简单组件化开发效率高配合Element UI或者Ant Design Vue后台管理界面半天就能搭出来。而且Vue生态里的ECharts插件、Axios请求库都极其成熟做数据可视化大屏、图表展示、路由管理都很顺手。这套组合的核心价值在于前后端分离后端专门出接口、管数据前端专心做页面交互和图表展示开发和联调都清爽。像美食推荐这种数据量不算特别大、但功能点比较多的项目——用户登录、菜谱列表、搜索、推荐、收藏、数据统计——用ThinkPHP出RESTful接口Vue这边按页面模块来组织组件整个工程结构非常清晰。1.2 核心功能模块划分我在做这个项目时把整个系统拆成了五个核心模块模块职责技术要点数据采集爬取豆果美食公开菜谱数据PHP curl 正则/XPath或Python脚本数据处理清洗、去重、格式化入库定时任务 批量写入MySQL推荐引擎基于用户行为的推荐协同过滤简化为基于用户/基于物品接口服务前后端数据交互ThinkPHP路由 RESTful API数据可视化平台运营数据大屏ECharts Vue 可视化大屏布局每个模块之间用接口连接比如爬虫抓到的数据先存到MySQL里推荐引擎读取用户行为数据跑算法计算完的结果写回数据库最后前端通过接口调出来展示。1.3 数据流与系统架构从数据流的角度看整个系统一端是数据源豆果美食官网一端是终端用户。爬虫定时从豆果美食抓取菜谱、食材、分类信息清洗后入库。用户在这个系统上浏览菜谱、收藏、评分这些行为数据沉淀下来推荐引擎基于这些行为计算“你可能还喜欢”。同时后台统计模块把用户行为、菜品热度、分类占比等等做成可视化图表运营方或者你自己能一目了然看出平台情况。我画过一张部署图大致是[爬虫脚本] --数据-- [MySQL数据库] --读写-- [ThinkPHP后端API] | | HTTP JSON v [Vue前端 ECharts可视化]实操时还可以加一层Redis缓存比如热门的菜谱列表、推荐结果这类读多写少的数据用Redis缓存能明显降低MySQL的压力。热搜词里的“redis可视化客户端”就是指用Redis Desktop Manager之类的工具来看缓存数据调试时方便不少。2. 核心细节解析与实操要点2.1 爬虫部分数据采集的完整思路爬虫是这类推荐系统的数据源头没有数据后面一切都是空谈。在网上搜“python爬虫”、 “爬虫技术抓取网站数据”这些热词的人特别多但用PHP做爬虫其实也完全可行特别是在你已经选了ThinkPHP的情况下直接用PHP写爬虫脚本能省掉一套技术栈。我平时两种方式都用开发效率上Python更高但部署运维上PHP脚本能直接挂在ThinkPHP的定时任务里跑非常省事。核心实现要点第一步确定采集目标和分析页面结构我当时定了四个采集维度菜谱名称、分类家常菜 / 凉菜 / 汤羹粥 / 烘焙等、主要食材、烹饪步骤。打开豆果美食的菜谱列表页用Chrome开发者工具F12看页面结构找到列表项的链接规则和翻页参数这个一定要先手工确认别上来就写代码。第二步抓取列表页并提取详情页链接这是爬虫最容易出错的地方页面结构一变选择器就失效了。我在写的时候先抓一页打印出来确认解析结果确认没问题再全量跑。第三步抓取详情页解析菜谱信息详情页能拿到完整步骤、食材用量、用户评分、收藏数这些。采集频率要控制好别对目标站点造成压力我一般设置每次请求间隔1到3秒随机一下更稳。第四步数据清洗与结构化存储原始解析出来的数据往往很脏比如“盐 适量”“清水 500克”这种字符串需要把食材名称和用量拆开存成两个字段评分可能带“分”字也需要清洗成纯数字还有重复数据同一个菜谱可能在不同分类下出现多次必须做去重。2.2 爬虫代码示例ThinkPHP 环境下怎么抓下面贴一部分我用过的核心代码思路很直白基于curl抓取页面再用DOMDocument做解析。有两点必须提醒一定要设置User-Agent伪装成浏览器一定要控制抓取间隔。?php namespace app\common\lib; use think\Exception; class Spider { // 抓取网页内容 public static function getContent($url) { $ch curl_init(); $options [ CURLOPT_URL $url, CURLOPT_RETURNTRANSFER true, CURLOPT_FOLLOWLOCATION true, CURLOPT_TIMEOUT 15, CURLOPT_SSL_VERIFYPEER false, CURLOPT_SSL_VERIFYHOST false, CURLOPT_USERAGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, CURLOPT_HTTPHEADER [ Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.5,en;q0.3, ], ]; curl_setopt_array($ch, $options); $content curl_exec($ch); $httpCode curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode ! 200) { throw new Exception(请求失败HTTP状态码 . $httpCode); } return $content; } // 解析列表页提取菜谱链接 public static function parseList($html) { $links []; $doc new \DOMDocument(); // 用libxml抑制HTML不标准产生的警告 libxml_use_internal_errors(true); $doc-loadHTML($html); libxml_clear_errors(); $xpath new \DOMXPath($doc); // 这里的xpath表达式需要根据实际页面结构调整 $nodeList $xpath-query(//div[contains(class,recipe)]//a); foreach ($nodeList as $node) { $href $node-getAttribute(href); if (strpos($href, /recipe/) ! false) { $links[] https://www.douguo.com . $href; } } // 去重 return array_values(array_unique($links)); } }注意豆果美食的页面结构改过很多次上面的XPath表达式只是示例思路实际用的时候一定要按当时的页面结构去写别照抄。我踩过最大的坑就是页面改版后解析逻辑瞬间失效所以一定要做好日志记录和异常监控。2.3 数据表设计与推荐算法实现说句实在话很多人在推荐算法上想得太复杂了。对这类中小型项目你不需要上深度学习、图神经网络一个简化版的协同过滤就完全够用。热搜词里有一条“协同过滤算法旅游推荐系统”思路一模一样只是领域不同。推荐逻辑我用的是经典的“基于用户的协同过滤”用户A对菜谱X有浏览/收藏行为记分。找到跟A行为最相似的若干用户比如都喜欢川菜、都收藏了麻婆豆腐。把这些相似用户喜欢、但A还没看过的菜谱推荐给A。这里有一个关键行为要量化。浏览记1分收藏记3分评分1-5星直接按分数算。用户-菜谱的评分矩阵用数据库表来实现然后PHP里跑相似度计算。?php namespace app\common\lib; class Recommend { // 计算两个用户之间的余弦相似度 public static function cosineSimilarity($userRatingsA, $userRatingsB) { $common array_intersect(array_keys($userRatingsA), array_keys($userRatingsB)); if (count($common) 0) { return 0; } $dot 0; $normA 0; $normB 0; foreach ($common as $itemId) { $dot $userRatingsA[$itemId] * $userRatingsB[$itemId]; } foreach ($userRatingsA as $rating) { $normA $rating * $rating; } foreach ($userRatingsB as $rating) { $normB $rating * $rating; } if ($normA 0 || $normB 0) { return 0; } return $dot / (sqrt($normA) * sqrt($normB)); } }表结构方面核心三张表recipe菜谱表id、title、category、ingredients、steps、image_url、source_url、create_timeuser_behavior用户行为表id、user_id、recipe_id、behavior_typeview/favorite/rate、score、create_timeuser_similarity用户相似度表id、user_id、similar_user_id、similarity菜谱表字段里的source_url一定要加唯一索引用于去重。用户行为表则要建联合索引(user_id, recipe_id)因为推荐算法的SQL要频繁按用户去查。3. 实操过程与核心环节实现3.1 ThinkPHP 后端接口开发后端接口我采用RESTful风格路由定义在route/app.php中。我自己用ThinkPHP 6写过完整的一套这里列几个接口设计供参考接口方法功能说明/api/recipe/listGET菜谱列表支持关键词、分类、分页/api/recipe/detailGET菜谱详情/api/recipe/recommendGET个性化推荐/api/user/behaviorPOST上报用户行为/api/stat/overviewGET可视化大屏统计数据接口统一返回JSON格式结构是code message data。控制器里不写业务逻辑业务都放到Service层控制器只负责参数接收、调用服务、返回结果。这样后期维护、加功能都方便。一个推荐接口的示例?php namespace app\api\controller; use app\BaseController; use app\common\lib\Recommend; use think\facade\Db; class Recipe extends BaseController { // 获取个性化推荐列表 public function recommend() { $userId $this-request-param(user_id); if (!$userId) { return json([code 0, message 参数错误]); } // 简化方案如果用户行为数据少于5条返回热门菜谱 $behaviorCount Db::name(user_behavior)-where(user_id, $userId)-count(); if ($behaviorCount 5) { $recipes Db::name(recipe) -order(collect_count, desc) -limit(10) -select(); return json([code 1, data $recipes]); } // 读取用户的评分向量 $ratings Db::name(user_behavior) -where(user_id, $userId) -column(score, recipe_id); // 查找相似用户简化版全量计算数据量大时应该离线预计算 $allUsers Db::name(user_behavior) -distinct(true) -column(user_id); $similarUsers []; foreach ($allUsers as $otherUserId) { if ($otherUserId $userId) continue; $otherRatings Db::name(user_behavior) -where(user_id, $otherUserId) -column(score, recipe_id); $sim Recommend::cosineSimilarity($ratings, $otherRatings); if ($sim 0.3) { $similarUsers[$otherUserId] $sim; } } // 根据相似用户的偏好召回菜谱排除用户已经看过的 // ...省略具体SQL组装 return json([code 1, data $recommendRecipes]); } }提示推荐这块如果用户量涨上来全量实时计算会越来越慢我的经验是把相似度表提前离线算好存到user_similarity表里线上接口只做查询和排序。热搜词里“ai是爬虫技术的更深层次运用吗”其实点了一个更深的方向——真正的AI推荐会把爬来的数据向量化再用向量数据库做语义匹配但这不是这个项目要覆盖的范围中小型场景用协同过滤已经够了。3.2 Vue 前端搭建与核心页面Vue这块我建议直接用Vue CLI或者Vite来初始化项目。拿到项目后第一步是配置Vue Router路由和Axios请求封装。# 创建Vue3项目 npm create vitelatest douguo-frontend -- --template vue # 进入项目目录 cd douguo-frontend # 安装路由和请求库 npm install vue-router axios # 安装UI组件库和图表库 npm install element-plus echarts这里踩过的坑提醒一下热搜词里提过“vue安装及环境配置”“vue安装依赖”最容易出的问题就是Node版本不匹配新版Vite要求Node 18我一开始用Node 14跑一直报错。升级Node之后一切顺利。前端页面我分了四个核心视图首页推荐流展示推荐菜谱的卡片墙菜谱详情页展示食材、步骤、用户评分用户中心管理收藏和浏览历史数据可视化大屏展示平台运营数据其中可视化大屏是亮点也是热词里“可视化大屏”、“3d地区地图可视化大屏样式”提到最多的。我用ECharts的折线图展示每日新增用户和新增菜谱趋势饼图展示菜谱分类占比柱状图展示热门菜谱TOP10。大屏布局用flex grid实现分左右两栏中间放核心KPI数字顶部设计成深色渐变背景。ECharts示例配置// 分类占比饼图 const chartDom document.getElementById(categoryChart) const chart echarts.init(chartDom) chart.setOption({ tooltip: { trigger: item }, legend: { orient: vertical, left: left }, series: [ { name: 菜品分类占比, type: pie, radius: 50%, data: [ { value: 324, name: 家常菜 }, { value: 185, name: 凉菜 }, { value: 245, name: 汤羹粥 }, { value: 128, name: 烘焙 }, { value: 96, name: 饮品 } ], emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: rgba(0, 0, 0, 0.5) } } } ] })3.3 可视化大屏的数据统计实现大屏要想有东西可看后端统计接口必须算得准。我当时统计了六组数据累计用户数、累计菜谱数、今日新增菜谱数、今日访问量、分类占比TOP5、热门菜谱TOP10。统计接口的核心SQL思路// 分类占比 $categoryStat Db::name(recipe) -field(category, COUNT(*) as count) -group(category) -order(count, desc) -select(); // 每日新增 $dailyNew Db::name(recipe) -field(DATE(create_time) as date, COUNT(*) as count) -where(create_time, , date(Y-m-d, strtotime(-30 days))) -group(date) -select(); // 热门TOP10 $hotRecipes Db::name(recipe) -order(collect_count, desc) -limit(10) -select();这里有个坑GROUP BY DATE(create_time) 这种写法在MySQL的ONLY_FULL_GROUP_BY模式下会报错解决办法是确保select的字段都在group by里或者用ANY_VALUE()包一下。我第一次上线就折在这上面排查了半天才发现是sql_mode的问题。前端大屏的异步请求我统一封装了一层axios实例配置好baseURL和超时时间每个图表组件都在mounted生命周期里发起请求响应完成后setOption渲染图表。4. 常见问题与排查技巧实录4.1 爬虫采集中最常遇到的坑页面结构变更、解析失效。这个无解只能定期检查。我的做法是每天定时任务跑完之后比对采集数量如果连续两天都是0就邮件告警提醒人工检查页面结构。请求频率过高被封IP。解决方式我在前面说过请求之间随机sleep 1到3秒再不行就做一个简单的重试机制遇到429或403错误后等30秒重试一次重试3次还是失败就跳过并记日志。中文编码乱码。豆果美食页面是UTF-8编码一般不会乱但如果你爬其它网站很有可能会遇到GBK编码的页面用mb_convert_encoding($html, UTF-8, GBK)转换一下就好。4.2 ThinkPHP 的版本与安全坑热词里“thinkphp漏洞”一直是搜索热点因为历史版本爆出过不少安全问题。在代码层面我能分享几条非常实际的生产环境一定要关闭debug模式设置APP_DEBUG为false否则报错信息会暴露服务器路径和数据库配置。数据库连接信息放.env文件里并且确认.env不会通过web访问到。控制器入口对参数做严格校验特别是id这类数字参数用intval或强制类型约束。SQL注入和越权漏洞往往就出在不校验参数上。ThinkPHP有一个很实用的命令行指令 php think run 用于本地开发但千万不要在服务器上用这种方式跑要用Nginx或Apache的伪静态配置指向public目录。新版ThinkPHP 6/8自动带了依赖注入和中间件权限验证这块用中间件实现起来也很顺手。如果你是从老项目迁移到新框架的尤其要注意路由定义格式和数据库链式操作的差异光这两项就够改一整天。4.3 Vue 前端调试的实用技巧Vue项目在开发阶段我几乎是离不开Vue Devtools的——热词里也有一句“vue devtools插件下载”。安装之后可以直观地看组件的props和data状态排查数据为什么不显示时特别好用。Vue Devtools主要有两个核心面板组件树和Vuex/Pinia状态数据流出了问题八成能在状态面板里找到线索。另外接口联调最怕跨域。我在ThinkPHP后端加了跨域中间件设置Allow-Origin为前端域名。开发环境也可以直接用Vite的proxy配置把/api开头的请求代理到后端服务器这样就不用处理跨域了。这个方案比后端开跨域更干净生产环境也不容易留隐患。4.4 推荐算法效果不佳的排查方案做完推荐功能后我通过对真实用户行为的复盘发现两个问题第一冷启动问题。新用户没有任何行为数据推荐结果几乎是随机的。我的方案是默认给新用户推荐全站热门积累5条行为后再进入协同过滤。新菜谱也有冷启动问题没有任何用户评分可以结合发布时间做一个加权让新菜谱有机会在推荐流里露脸。第二热门物品抑制效应。协同过滤非常容易推荐出一堆大众菜谱个性化不足。改进办法是热门物品降权如果一个菜谱被超过一定比例的用户收藏它在推荐结果里就不再作为主推只作为补充。这样长尾菜谱才有机会被看到。5. 部署上线与后续迭代方向5.1 部署环境与流程实战部署我推荐直接用宝塔面板操作直观对新手特别友好。流程大概是上传代码到服务器配置Nginx站点指向项目的public目录。设置伪静态thinkphp规则保证URL能正确路由。新建MySQL数据库导入建表SQL。修改.env环境配置填好数据库连接信息。前端执行 npm run build把dist目录下的静态文件上传到服务器Nginx指向它。配置一个计划任务定时触发爬虫脚本采集新数据。这里有一个小细节值得说一下ThinkPHP的runtime目录需要写入权限很多部署后白屏的问题都是因为runtime目录没给权限导致的。宝塔里直接把这目录权限改成755或者777本机调试无所谓服务器上要谨慎。5.2 后续功能扩展建议做到这一步一个完整的“美食推荐爬虫可视化”系统已经跑起来了。后续还可以从几个方向拓展爬虫升级从单一豆果扩展到多个源站数据量大了推荐效果会更好但要注意目标网站robots协议和版权合规数据只能用于学习和研究不能商用。推荐升级在协同过滤基础上加入基于内容的推荐比如用户经常看川菜就多推荐川菜甚至可以结合Word2Vec把菜谱的标签向量化做语义相似推荐。可视化增强大屏加入实时数据推送WebSocket做成运营监控大屏实时刷新确实比定时刷新有视觉冲击力热词里也提到了“python可视化实时刷新”原理是相通的。搜索优化引入Elasticsearch做菜谱全文搜索分词和相关性排序比MySQL的LIKE要好得多。从我做了这么多项目的经验来看这类系统真正难的地方从来不在某个单独环节而在把采集、清洗、存储、算法、展示整条链路打通。每一个环节都会有一些意想不到的小坑但也都是一次次试错后才记住的。这篇文章里写的每一条几乎都是我当时踩过坑之后总结出来的照着走能省下不少时间。最后给你一个建议做这个项目时尽量把重点放在数据和推荐逻辑上界面反而不需要太花哨。数据质量上来了推荐效果和可视化图表自然就有说服力答辩或者展示时能讲的东西也多得多。