1. 从需求到架构这套旅游景点数据分析与推荐系统要解决的核心问题1.1 为什么旅游景点需要一套分析和推荐系统先说个我自己的观察。这几年大家的出行方式变了很多从以前报团跟着走变成自己查攻略做计划但真到订行程的时候很多人还是会卡在同一个环节景点太多了不知道去哪。打开旅游平台刷到的全是热门榜、销量榜排来排去就那么几个知名景点。可实际上每个城市周边都有大量有特色但曝光不多的景点它们的网络评价、游客满意度、邻近年份的客流数据并不差只是缺少一个合理的分发渠道。另一方面景区管理部门和旅游从业者对数据的利用率太低了。很多景区连最基础的游客来源地分析游客在本地停留时长分布景点热度周期性变化都没有系统化的工具全靠经验拍脑袋。我认识的几个做民宿的朋友旺季备货、淡季促销基本看天吃饭数据就摆在OTA后台里根本没被结构化挖掘过。所以做这套系统的初衷就一句话把分散在各平台上的旅游景点数据采集下来清洗成结构化数据再通过分析维度呈现给用户同时基于用户行为做个性化推荐。技术上选型很明确——ThinkPHP 负责后端接口和业务逻辑Vue 做前端交互和可视化展示爬虫负责数据采集ECharts 承担图表呈现推荐算法放在后端独立模块里跑。这套组合不追求大而全而是保证一个学生团队或小开发组能在可控周期内完整落地。1.2 整体架构三条链路串起全套系统系统的物理拓扑不复杂但逻辑上有三条相对独立的链路数据采集链路Python 写爬虫脚本定时抓取多个旅游信息源的景点基础信息、评分、评论数量、门票价格区间、地理位置清洗后存入 MySQL。业务服务链路ThinkPHP 提供 RESTful API包含用户注册登录、景点列表查询、景点详情、用户评分/收藏行为记录、推荐接口、统计分析接口。前端展示链路Vue 单页应用包含首页数据大屏、景点列表页、景点详情页、个人中心我的收藏、我的评分、基于 ECharts 的分析报表页。这三条链路之间通过 MySQL 和 Redis 衔接。MySQL 存业务数据Redis 缓存热点数据和推荐结果避免重复计算。爬虫采集的数据不会直接进业务表而是先进原始数据表经过校验去重后再同步到正式的景点表这样能防止脏数据污染在线业务。选择 ThinkPHP 而不是 Spring Boot 或 Go核心考虑是开发效率。这个项目场景下后端业务复杂度没有高到需要微服务架构ThinkPHP 的 ORM、验证器、中间件能快速把接口搭起来。而且 ThinkPHP 对 PHP 版本要求比较宽容虚拟主机也能跑部署成本低。Vue 那边用 Vue 3 Vite Element Plus ECharts组件生态成熟尤其是表格、表单、图表这些后台管理常用组件开箱即用不用自己造轮子。项目结构示意图 ├── crawler/ # Python爬虫脚本 │ ├── scenic_spider.py # 景点基础信息爬虫 │ ├── review_spider.py # 评论数据抓取 │ └── cleaner.py # 清洗去重模块 ├── server/ # ThinkPHP后端 │ ├── app/controller/ # API控制器 │ ├── app/model/ # 数据模型 │ └── app/service/ # 推荐算法、统计逻辑 └── web/ # Vue前端 ├── src/views/ # 页面组件 └── src/components/ # 图表、地图组件2. 爬虫任务的字段规划与合规边界不止是requests加正则2.1 目标数据定义先想清楚要什么再写爬虫很多做爬虫的人上来就写代码结果爬到一半发现字段不够用或者采集的数据根本没法做分析返工成本很高。我在项目初期就把数据字段定义在前数据类别字段列表用途景点基础信息名称、所在城市、区县、地址、经纬度、简介、类别自然/人文/主题乐园、门票参考价列表展示、地图打点、分类过滤景区热度指标评分、评论总数、月访问人次、热度指数、排名变化推荐算法的初始分、可视化分析游客评价信息评分分布、好评关键词、差评关键词、典型评论摘要用户画像、推荐解释对目标站点我做了三个层次的分工景区官网、旅游OTA平台、本地文旅局公开数据。景区官网数据权威性高但覆盖不全OTA平台点评数据丰富但字段结构复杂文旅局公开数据是最规范的往往有游客量统计和评级信息是可视化分析最可靠的来源。三者合并去重后数据质量才有保障。2.2 请求策略与清洗逻辑爬虫中最容易被低估的部分爬虫代码本身不复杂我用 Python 的 requests 库加 BeautifulSoup 加 lxml 就能搞定。真正花时间的反而是请求频率控制和数据清洗。请求策略上我配置了随机 User-Agent 池、请求间隔 1到3 秒随机、重试机制连续失败三次则跳过该景点并记录日志同时对单个站点设置了每日采集上限。这样做不是为了炫技而是出于对目标站点的基本尊重——谁也不希望自己的服务器被脚本频繁请求拖垮换位思考就能理解。实测下来把阈值控制在单日2000个页面以内基本不会触发站点的访问限制采集成功率维持在95%以上。数据清洗是我重点分享的部分。爬下来的数据脏得超出想象有的景点名称带HTML实体字符有的门票价格从纯数字到成人票80元/学生票40元各种格式都有有的经纬度缺失还有同一景点在不同平台名称不完全一致比如西湖风景名胜区和杭州西湖风景区其实是同一个。我的处理方案是写了一个基于规则加简单相似度匹配的清洗模块# 景点名称归一化示例去除括号备注、统一行政区前缀 import re def normalize_name(name): # 去掉括号内的说明信息如西湖(5A景区) name re.sub(r[(].*?[)], , name).strip() # 统一常见行政区后缀 name re.sub(r[市区县]$, , name) return name地址、价格、经纬度三个字段分别写了校验函数不符合规则的数据进不了正式表。一次采集任务跑完原始表可能有1.2万条数据清洗后能进正式表的可能只有8000多条这很正常宁可数据少而精也不要把脏数据倒进分析报表里不然后面可视化展示出来的图形会误导人。3. 推荐系统从零实现为什么我选协同过滤而不是深度学习3.1 算法选型的真实理由这个项目做推荐功能的时候团队里有人提议用深度学习模型比如 Wide Deep 或者双塔结构。我否了这个方案原因很实际冷启动阶段没有足够的用户行为数据。系统刚上线时用户连100个都没到行为日志更少深度学习模型根本训不起来。线上推理需要 GPU 或高CPU配置ThinkPHP 部署的环境多半是普通云服务器跑推理服务成本高。深度学习模型的解释性差出了问题不好排查。协同过滤至少能说清楚给你推荐这个景点是因为和某某用户偏好相似。所以我最终选择了基于物品的协同过滤Item-based CF 热度惩罚因子的组合。基础逻辑是如果用户对景点A和景点B的评分行为高度相似那么给看过景点A的用户推荐景点B就是合理的。这种算法在数据量1000到10000这个量级表现稳定实现成本可接受而且结果可解释。3.2 ThinkPHP后端实现协同过滤的完整思路实现分三步构建用户-景点评分矩阵、计算景点相似度矩阵、生成推荐列表。评分矩阵的数据来源有两个一是用户显式提交的评分1到5分二是根据用户行为隐式转化——浏览景点详情算1分收藏算2分搜索后点击算3分。显式和隐式加权合并成最终评分。相似的度量我用的是修正余弦相似度Adjusted Cosine。先说为什么要用修正版本普通余弦相似度不考虑用户的评分偏好差异——有人习惯给3分有人习惯给4分导致评分尺度不一致。修正余弦相似度会在计算时先减去用户对景点的平均评分消除这种尺度偏差。计算每个景点的相似度矩阵时最笨的办法是双重循环时间复杂度O(n^2)n是景点数量。我实际跑的规模是8000个景点全量计算一次大概需要十几分钟所以用了两个优化一是只计算有共同评分用户的景点对通过SQL先关联出被同一用户评分过的景点组合数量通常会下降到全量的10%到20%二是把结果矩阵存入Redis设定7天过期时间这样不需要每次请求都重新计算。推荐列表的生成逻辑放在 ThinkPHP 的 Service 层大致流程是接收当前用户的景点评分列表取评分最高的前10个景点作为种子查相似度矩阵找到每个种子的Top20相似景点加权汇总后减去用户已去的景点再考虑热度惩罚因子首期热门景点被推荐的概率降权30%最终输出25条候选结果分页返回。// 伪代码ThinkPHP 中生成推荐列表的核心流程 public function recommend($userId, $limit 25) { // 1. 获取用户的行为种子 $seedSpots (new UserBehavior())-getTopRatedSpots($userId, 10); // 2. 查Redis中的相似度矩阵 $candidates []; foreach ($seedSpots as $spot) { $similarSpots $this-redis-zRevRange(similarity:{$spot[id]}, 0, 19); foreach ($similarSpots as $similarSpotId) { $candidates[$similarSpotId] $spot[score] * $this-similarityCache($spot[id], $similarSpotId); } } // 3. 去除用户已浏览/已去过的景点应用热度惩罚 // 4. 排序后返回 }这套实现跑下来的实时推荐响应基本在200毫秒以内因为矩阵计算提前做好了线上只做读取和排序。3.3 这个推荐模块的坑与解决最值得说的是新景点冷启动问题。新录入的景点没有用户评分在协同过滤中是孤立节点永远不会被推荐。我的解决办法是给新景点设置一个为期30天的探索红利期期间用基于内容的推荐垫底——根据景点类别、城市、价格区间做规则匹配推荐给近期浏览过同类目的用户等积累到足够评分后自动切换成协同过滤。这是一个工程惯例不是什么高深算法但确实管用。另一个坑是相似度矩阵在Redis里的序列化。我开始用 PHP 数组序列化存储每次取出来还要反序列化内存开销大后来改用Redis的ZSet结构景点ID作为member相似度作为score取TopK直接用 ZREVRANGE速度提升明显。4. Vue可视化看板数据怎么展示才不算白采4.1 看板布局逻辑与图表选型数据采了、算法跑了最后一步是让用户看得懂。这套系统的可视化是整个项目中用户感知最强的部分也是我认为这类型项目区别于普通CRUD应用的门面。我的可视化页面拆成了四个视图放置在不同的Tab页总览大屏顶部一排核心指标卡片景点总数、覆盖城市数、平均评分、月均游客量中间是地图和热点散点图下方是评分Top榜和热度趋势折线图。城市分析区域看板选中城市后展示该城市的景点类型分布饼图、热度排名柱状图、门票价格区间分布。用户画像当前登录用户的偏好雷达图自然/人文/休闲/刺激/亲子/文化六维以及个性化推荐结果列表。口碑分析热门景点的正负向关键词词云用词云反映评论提及频率。图表选型相对固定ECharts 的地图、折线图、柱状图、饼图、雷达图、词云用 echarts-wordcloud 扩展。地图用的是 ECharts 中国地图加散点层经纬度数据直接来自景区的坐标字段。需要注意的是ECharts 5 的 maps 目录不再内置中国地图GeoJSON需要单独下载 china.json 并在项目中引入注册这个细节卡了我半天写出来提醒一下// Vue组件中注册中国地图 import * as echarts from echarts; import chinaJson from /assets/map/china.json; echarts.registerMap(china, chinaJson);4.2 页面交互的细节处理可视化不只是画几张图交互设计决定这套系统好不好用。我做交互时定了一个原则所有图表都是可点击的点进去要有下一步动作。例如总览大屏的地图上散落着景点点点击某个散点后右侧联动面板显示该景点的关键指标摘要同时下方柱状图切换成该城市所有景点的排名列表。这个联动用 Vuex 管理全局状态每个图表组件监听同一个 action收到数据变更后刷新自身。实现不复杂但体验比静态图表好很多。页面上还做了一个比较实用的功能——对比分析模式。用户在景点列表里勾选最多4个景点点击对比弹出一个包含评分、热度、类型占比、价格区间四条平行坐标的对比面板。这个功能数据分析师类的用户反馈很好用有点类似Excel中条件格式的交互版。对于数据的实时性前端不直接请求爬虫库而是统一走后端统计接口。为了减轻数据库压力统计接口加了 Redis 缓存缓存粒度是按天。因为景点数据变化频率本来就不高没必要分钟级刷新。// 核心指标卡片的异步加载 async function loadStats() { const { data } await api.get(/stats/overview); statCards.forEach((card, index) { card.value data[card.key]; animateNumber(card.el, card.value); // 数字滚动动画 }); }大屏适配也是一个常见的坑。我在开发机1366x768的窗口调好的布局投到会议室1920x1080的屏幕上直接错位。后来统一用flex加百分比布局图表容器设置 min-height再配合 window.resize 事件触发 echarts.resize()才基本解决。真正要求高的场景还可以引入 scale 缩放方案但对这个项目来说响应式布局已经够用了。4.3 可视化如何辅助决策两则实际分析案例讲两个跑通之后拿真实数据做的分析直观感受一下这套系统分析的价值。第一个是城市景点热度周期分析。对华东某城市的景点数据按月份做热度折线图后发现4月和10月有两个明显峰值本地景区的热度峰值比外地游客聚集的头部景点早两周出现。对这个城市做落地页推荐时系统就在3月底提前增加当地人文景点的曝光权重效果比单纯按评分推荐要好。第二个是用户群体偏好差异。把用户按年龄段分组后画偏好雷达图发现25岁以下的用户群体对刺激型乐园类景点的偏好显著高于其他年龄段而35岁以上的用户对自然风光型和文化古迹型的偏好更均衡。这个发现直接影响了推荐算法的行为权重——年龄特征被纳入种子景点的选择逻辑中冷启动阶段的新用户推荐准确率有了明显提升。可视化的意义不是画画图好看而是让人能从数据中看到原本需要大量时间整理才能发现的规律这一点在景区管理和旅游产品规划场景里价值极大。5. 实际运行部署ThinkPHP Vue前后端联调的常见坑5.1 跨域、会话保持与接口鉴权前后端分离的项目第一个绕不开的坑就是跨域。本地开发时Vue跑在 5173 端口PHP 接口跑在 8000 端口浏览器直接请求就会被同源策略拦截。我在 ThinkPHP 中间件里写了跨域处理允许指定域名访问同时设置Headers// 跨域中间件核心代码 public function handle($request, \Closure $next) { $response $next($request); $origin $request-header(Origin, ); $allowOrigin [http://localhost:5173, https://yourdomain.com]; if (in_array($origin, $allowOrigin)) { $response-header(Access-Control-Allow-Origin, $origin); $response-header(Access-Control-Allow-Credentials, true); $response-header(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); $response-header(Access-Control-Allow-Headers, Content-Type, Authorization); } return $response; }会话保持是最容易开发的时候没毛病、部署后出问题的环节。我用的方案是 JWT登录成功后后端返回 token前端把 token 存到 localStorage每次请求在 Axios 拦截器里加上 Authorization 头。这样保证了前后端完全分离时的无状态鉴权不需要考虑 PHP session 和前端域名之间的 Cookie 作用域问题。还有个细节是注意在 OPTIONS 预检请求直接返回200否则前端登录接口在部分浏览器里会一直报跨域错误。5.2 部署到云服务器从0到能访问的完整流程部署环境是 CentOS 7.9 Nginx 1.20 PHP 7.4 MySQL 5.7 Redis 5.0。前后端分离后有两个Nginx站点配置前端静态文件放在/var/www/web后端 PHP 项目放在/var/www/server两个 location 块规则不同。ThinkPHP 这边需要注意 Nginx 配置中要使pathinfo模式生效否则访问/api/v1/spots会变成404。配置如下location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }如果你用的不是 Nginx 而是 Apache还需要在.htaccess里开启mod_rewrite并把 AllowOverride 设为 All。线上的性能优化我做了三件事开启 OPCache、数据库慢查询日志、Redis缓存统计结果。OPCache 能显著降低 PHP 每次请求重新编译文件的开销测试场景下接口RT从平均180ms降到了90ms左右效果立竿见影。5.3 前期规划建议先跑通1.0版本再谈扩展如果你想自己在本地复现这个项目我建议按这个节奏走先只建旅游景点基础表和用户表跑通 ThinkPHP 的登录注册接口和 Vue 的列表展示。再写爬虫脚本抓一个城市的数据清洗导入验证展示链路。推荐算法可以放最后做先做一个按评分排序按城市筛选的版本算法后续替换不影响前端。可视化从图表的静态数据假数据起步接口联调通过后再接真实数据。6. 运行效果和复盘后的优化方向系统目前跑了两轮完整的数据更新周期。爬虫稳定产出数据后不论是从推荐接口的响应速度还是从可视化页面的交互流畅度来看都达到了当初设定的预期。有一个比较直观的对比是之前在一个商用的旅游数据分析工具里手动导数据做报表每次要等爬虫截图加Excel处理差不多要一个下午现在这套系统点开就是实时看板变化是质的提升。如果说有什么最想复盘的东西那就是不要把爬虫当核心把数据质量当核心。爬虫只是获取原始素材的手段数据的准确性、时序完整性、字段一致性才是整个系统的命脉。后续如果要迭代我会优先做两个方向一是更细粒度的用户行为埋点。现在采集的用户行为只有浏览、收藏、评分三种太粗了后续希望加上页面上滑深度、停留时长、目的地切换路径等信号让协同过滤的种子更加精准。二是推荐解释模块。现在的推荐是一串景点列表用户不知道为什么被推荐。下一步可以在每个推荐卡片下方显示一句推荐理由比如因为你对X这类景点感兴趣所以推荐Y这要求后端维护一份简短的推荐轨迹记录但带来的用户信任感提升很值得。这两个方向做完这套系统就不只是爬虫加可视化演示而是真正能作为旅游产品流量分发的实用工具了。从选型到落地这套 ThinkPHP Vue 爬虫 可视化的组合虽然技术难度都不算深但把数据采集、算法、展示串成一条完整链路这件事比单独做任何一个环节都要有价值得多。