ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

旅游数据分析与推荐系统:从爬虫到可视化大屏的完整实践

旅游数据分析与推荐系统:从爬虫到可视化大屏的完整实践 1. 项目整体设计思路与技术选型解析1.1 旅游数据分析和推荐系统的核心痛点做旅游数据分析与推荐系统最大的难点从来不是“要不要做推荐”这件事而是“数据从哪来、怎么保证干净、怎么让推荐结果真正贴合用户”。很多人在毕设或项目实战里摔跟头都摔在同一个地方用现成的公开数据集跑完算法发现结果好看但总感觉像玩具。因为那些数据集被清洗得干干净净根本看不到真实业务中的脏数据、缺失值、反爬和存储问题。我接手这个项目的第一个判断就是必须自己爬数据而且要爬完整的链路。旅游数据的核心源大致有这么几类OTA平台携程、去哪儿、飞猪这类、点评类平台大众点评、马蜂窝、搜索引擎下的攻略页以及各地景区的官网公告。不同渠道的数据结构差异非常大有半结构化的JSON接口、有嵌套在HTML里的静态文本、也有动态渲染的页面这就要求爬虫架构从一开始就要考虑“多源接入”而不是做一把只能适用于单站的锤子。另一个核心痛点是推荐系统的冷启动问题。一个刚上线的系统没有足够的用户行为数据协同过滤直接失效。所以我在设计推荐模块时没有孤注一掷采用某一种算法而是做了混合策略基于内容的推荐解决冷启动基于协同过滤的推荐解决个性化再用热度统计兜底。这样哪怕某个用户一条行为数据都没有页面也能基于“当下最热”这个策略给出不错的推荐结果。整个系统的数据流是这样设计的爬虫模块抓取数据经过清洗和标准化后落地到MySQL和MongoDB然后数据分析和特征工程从库里读取数据生成景点特征画像、用户偏好画像、热度榜单等中间结果这些结果一部分直接进入推荐引擎计算TopN一部分通过后端API输出给可视化大屏。这套链路的好处是每一层解耦哪个环节出问题可以直接定位不需要把整个系统拖下水。1.2 技术栈选型背后的理由技术选型上我花了比较多的时间纠结最后定下来的组合是Python Scrapy requests Pandas NumPy MySQL MongoDB Flask ECharts。先说爬虫端。Scrapy是Python生态里最成熟的爬虫框架自带异步并发、管道处理、中间件机制比用requests手写循环调度要省心得多。但Scrapy也有一个让人头大的地方——遇到动态渲染的页面比如景点详情页里用JavaScript异步加载的评论列表就比较被动。我试过用Selenium集成的方案不是不行但多开几个浏览器实例内存就直接吃紧。后来折中的做法是静态页面、接口数据优先用Scrapy抓只有实在拿不到数据的页面才用Selenium渲染渲染完立刻关闭浏览器进程避免内存泄漏。数据存储选了双库这个决定的动机很实际。MySQL存结构化程度高的数据比如景点基本信息名称、评分、门票、地址、经纬度、用户的显式行为记录收藏、评分、浏览MongoDB存半结构化和非结构化数据比如用户评论的原文、爬虫抓取的JSON原始报文、算法执行过程中的日志数据。双库的好处是查询和写入互不干扰我在后面做数据分析时只动MySQL里的数据表MongoDB里的大字段不会拖慢常用查询。数据分析这块没有上Spark这类重型计算引擎因为项目数据量级在万级到百万级之间Pandas的内存计算完全扛得住而且开发效率远高于写Spark作业。如果你的数据量级真的到了千万以上方案可以在后面平滑迁移把数据导出到Parquet或ORC格式用Spark SQL跑特征工程但前期的数据接入和分析逻辑基本可以复用不会白做。可视化选ECharts是最稳的选择没有之一。它支持大屏拼接、地图、关系图、词云这些高频图表类型配置项丰富完整的中文文档对Flask这类轻量级后端非常友好。我后面接了一个FastAPI的版本切换成本也极低。2. 旅游数据爬虫的工程化实现重点2.1 多源抓取的架构与去重策略爬虫模块是整个数据链路的最上游数据质量直接决定下游分析和推荐效果。很多初学爬虫的同学会犯一个典型的错误对着一个目标网站反复抓抓到几百条数据就觉得自己完成了任务但打开数据一看字段缺失严重来源单一连最基本的“城市-景点”对应关系都没做全。我的做法是按数据源分层设计。第一层是景点基础信息这部分以OTA平台的搜索接口和攻略站为主抓回来的是景点的名称、地址、评分、热度指数、门票价格、开放时间、游客评价数这类结构化字段第二层是用户评论从点评板块和旅游社区抓取评论正文、评分标签、出行时间、用户等级第三层是辅助信息比如天气数据、交通方式、当地美食关键词这些用于推荐时的场景化过滤。去重是爬虫工程里最容易忽略的问题。同一个景点在不同平台的名称可能不一样比如“故宫博物院”和“故宫”其实是同一个地方如果不去重后面做推荐时同一个景点会被当成两个item相似度计算和热度排序都会出问题。我实现了一个三层去重机制相同URL用哈希去重相同名称加城市组合去重同名但不同来源的描述做文本相似度去重。文本相似度用了简单的TF-IDF加余弦相似度阈值设为0.85超过就判定为同一实体保留信息最全的那条记录。2.2 并发控制与反爬规避的实操细节Scrapy默认的并发请求数是16但对旅游平台这类有一定反爬能力的站点一上来就16个并发往往会被封IP。我调参的过程比较折腾最后试出来一套相对稳的配置# settings.py 关键配置 CONCURRENT_REQUESTS 8 CONCURRENT_REQUESTS_PER_DOMAIN 4 DOWNLOAD_DELAY 1.5 RANDOMIZE_DOWNLOAD_DELAY True DOWNLOAD_TIMEOUT 30 COOKIES_ENABLED True RETRY_ENABLED True RETRY_TIMES 3 RETRY_HTTP_CODES [403, 429, 500, 502, 503]DOWNLOAD_DELAY设为1.5秒并且打开随机延迟让请求间隔在一定范围内波动比固定间隔更像真实用户行为。并发控制在8个这个数值不是拍脑袋定的我测试过从4到32的阶梯16以上时错误率明显上升8个既能保证约每秒5-6个请求的吞吐量又不会触发站点的频率风控。User-Agent和Cookie的处理也要上心。我维护了一个UA池把Chrome、Firefox、Edge几个主流浏览器的UA字符串放进去每个请求随机取一个。Cookie方面部分平台需要登录后才能查看完整评论我用手动登录后导出的Cookie文件加载到Scrapy请求里用scrapy.Request(..., cookiescookie_dict)传入。这里有个小坑Cookie字符串里如果带了一些特殊字符会被Scrapy解析失败建议先按分号和等号拆成字典再传入。IP代理我留了接口但没有重度使用因为大多数平台封IP的阈值其实比你想象的高合理限速加上轮换UA已经能覆盖大部分抓取场景。真遇到大面积封禁的情况再启动中间的代理轮换逻辑。网上有一些自建代理池的方案从中提取可用IP并做个简单校验就行不需要搞太复杂。2.3 断点续爬与增量抓取旅游平台的数据每天都在变化评论会新增评分会波动价格会调整所以爬虫一定要支持增量更新而不是每次全量重爬。我的做法是爬虫写入数据时带一个crawled_at时间戳同时在数据表里维护一个last_update字段。增量抓取时按更新时间倒序查询数据库把last_update距今超过一定阈值的景点URL捞出来重新发起请求。日志里记录每个景点的抓取状态失败的任务存入待重试队列下次启动时自动续跑。Scrapy本身支持通过JOBDIR参数实现暂停恢复scrapy crawl scenic_spider -s JOBDIR/tmp/spider_jobs/scenic中断后重新执行同样的命令Scrapy会从上次停止的请求队列恢复不会重复抓取已完成的数据。这个功能在实际爬长任务时非常实用不然一个跑了十几个小时的任务中途断一下就得从头再来心态直接崩掉。3. 数据清洗、特征工程与分析体系3.1 数据标准化与缺失值处理的实战方案爬回来到原始数据第一步永远是清洗。我把清洗分成四个阶段去重、去噪、补缺、标准化。去重在前面已经提过这里不展开。去噪主要处理两类问题无意义短文本和重复提交。有些用户评论只有“不错”两个字或者全是表情符号这种评论对情感分析和推荐特征都没有贡献直接过滤掉。我用评论的字符长度和有效词汇占比两个指标做判断少于5个字符且无有效主体词的评论直接丢弃。缺失值处理的策略不是一刀切填充而是分字段处理。景点名称、地址这类关键字段缺失就直接弃用该条数据评分和热度指数这类数值字段用同一城市、同类景点的均值填充评论内容缺失但评分存在时就保留评分但标记为“无文本评论”这样后面情感分析模块不会把它纳入计算。标准化的重点在于字段格式的统一。比如门票价格有的源返回“免费”有的是“0元”有的是数字我在清洗层统一转成浮点型免费转换为0.0经纬度字段有的源给的是字符串116.403,39.915有的拆成两个字段统一转换成WGS84坐标系下的float格式。这些看似琐碎的改动后来在做地图可视化时省了非常多时间。3.2 用户画像与景点画像的构建方法推荐系统做得好不好取决于画像建的细不细。我在项目里构建了两套画像体系景点画像和用户画像。景点画像由三部分组成静态属性、统计属性和文本特征。静态属性包括名称、城市、类型标签自然风光/人文古迹/主题乐园等、门票区间、建议游玩时长。统计属性包括总评论数、平均评分、评分分布1星到5星的比例、近30天热度指数。文本特征并不是把评论文本直接存入向量库而是对全部评论做一次分词和TF-IDF关键词提取得到景点的TOP 20关键词分布比如“震撼”“出片”“人多”“台阶多”这类词组合成一个轻量级的文本特征向量。用户画像这边显式行为包括收藏、评分和搜索关键词隐式行为从浏览记录里提取用户停留超过30秒的景点视为感兴趣不足10秒的视为不感兴趣。我把行为数据汇总成用户-景点评分矩阵值为1到5之间的得分由显式评分和隐式行为加权得到。公式是这样的用户u对景点i的最终得分 0.6 * 显式评分 0.3 * 停留时长得分 0.1 * 浏览频率得分这个权重比例是我反复调出来的。显式评分权重最大因为用户主动打出的分数信息量最高停留时长放在第二位这个数据最具普遍性因为大部分用户不会主动评分但会浏览浏览频率放最后因为低频浏览噪音比较大。3.3 从数据集中提取分析指标数据清洗完成后我建立了四类分析指标体系第一类是流量热度指标包含景点总访问热度、热度增幅、季节性和节假日波动趋势。这个指标用于发现哪些景点是“黑马”哪些是“常青树”。第二类是消费能力指标从用户评论中提取价格敏感度结合门票区间数据把景点划分为低价引流型、中端主力和高端精品三类为后面的个性化推荐提供价格偏好匹配维度。第三类是口碑指标基于评论文本做简单的情感倾向打分。我用的是一个轻量的情感词典方法加上否定词反转和程度副词加权不需要深度模型就能得到可用的好评率。好评率会直接进入推荐算法的一个特征字段。第四类是画像匹配指标把用户的兴趣标签美食、摄影、徒步、亲子、历史等和景点提取的关键词做匹配度计算得到一个0到1的匹配分数。这部分是内容推荐算法的主要依据。这些分析结果都物化为MySQL表比如scenic_profile字段包含scenic_id, type, avg_score, hot_value, comment_keywords, price_level, location后面推荐算法读取这一张表就能完成大部分计算非常高效。4. 推荐系统核心算法从相似度计算到TopN召回4.1 基于内容的推荐如何解决冷启动新系统上线最怕冷启动用户没有任何行为记录时协同过滤稀疏得没法算。我的策略是优先走基于内容的推荐分支。具体做法是这样的当系统识别到新用户没有评分记录时直接引导用户选择感兴趣的场景标签——自然风光、历史遗迹、亲子游乐、美食探店、户外徒步、网红打卡。每个标签对应一组景点特征向量系统按标签匹配度加综合热度排序输出Top20。这里有一个很关键的细节标签匹配度不是简单看景点是否包含该标签而是计算一个加权分数匹配分数 0.5 * 标签重合度 0.3 * 热度归一化值 0.2 * 好评率标签重合度表示景点关键词中命中用户所选标签的数量比例热度归一化值是把景点热度指数映射到0-1区间避免某些小众但优质的景点永远排不上好评率代表口碑兜底。这个计算逻辑虽然简单但效果非常稳定因为它从“用户想要什么”而不是“别人看了什么”出发冷启动阶段的点击率明显高于单纯按热度推荐。等用户积累了一定量的行为数据后系统就自动切换到协同过滤优先模式基于内容的推荐退居二线变成“相似景点推荐”这个附带模块。4.2 用户协同过滤的矩阵计算与评分预测当用户行为数据积累到一定程度协同过滤就派上用场了。我用的是基于用户的协同过滤UserCF核心思路找到与当前用户兴趣最相似的K个用户把这K个用户喜欢的、当前用户没看过的景点推荐出去。第一步是构建用户-景点评分矩阵行是用户ID列是景点ID值是前面算出的1到5的得分。矩阵很稀疏大部分位置是0所以我用scipy.sparse里的csr_matrix存储节省内存。第二步是计算用户相似度我用的是余弦相似度公式不复杂就是两个用户向量夹角的余弦值。Python实现只调一行现成的接口即可from sklearn.metrics.pairwise import cosine_similarity user_sim cosine_similarity(user_scenic_matrix)相似度计算完成后对每个目标用户取相似度最高的K个邻居用户K取20到30之间比较合适。K太小容易过拟合到个别用户上K太大推荐结果会变得太大众化。我对比过几个取值K25时推荐结果的点击率和多样性平衡得最好。第三步是评分预测加权公式如下用户u对景点i的预测评分 sum(用户u与邻居v的相似度 * 邻居v对景点i的评分) / sum(|用户u与邻居v的相似度|)注意预测计算时只累加那些对景点i有评分的邻居没有评分的邻居直接跳过。最后对推荐候选集的预测评分降序排列去掉用户已经去过的景点取Top10输出。4.3 多样性与实时性的平衡策略只按预测评分排序的推荐结果有一个问题——品类过于集中。如果一个用户去过故宫和颐和园系统就会持续推各种历史遗迹虽然个性化是有了但缺少惊喜感和延展性。我在排序环节加了一步多样性重排从候选集中每批取一个景点时如果它与已选景点的类别或关键词重合度超过50%就跳过选择下一个评分稍低的。实际操作中用了一个简单的惩罚系数最终排序分 预测评分 * (1 - 0.2 * 与已选景点的平均相似度)这样既保住了主方向又不会让推荐结果看起来像复制粘贴。实时性方面我的处理策略是“离线计算为主实时读取为辅”。用户-景点评分矩阵每晚凌晨两点跑一次离线任务更新用户画像和相似度矩阵但用户的实时行为比如刚收藏的景点、刚搜索的关键词会立刻写入Redis缓存推荐接口优先读取缓存中的实时信号加上离线模型算出的基础推荐列表两者按73的比例融合。这个方案在数据量不算大的场景下完全是够用的响应时间基本在200毫秒以内。5. 可视化大屏从指标设计到前端落地5.1 大屏核心指标与布局规划可视化绝不是把图表堆一块就完事真正有价值的大屏一定是以“能回答业务问题”为设计目标。我在规划大屏时先问自己三个问题谁在看这个屏想看什么看完之后能做什么决策针对旅游场景最核心的决策问题是应该重点推广哪些景点应该把营销资源投放到哪个区域哪些类型的景点在什么时间段最受欢迎围绕这三个问题我把大屏设计成六个模块左侧从上到下依次是“热门景点Top10”排行榜横向条形图和“景点类型分布”玫瑰饼图中间顶部是核心KPI卡片展示景点总数、评论总数、平均评分和月度热度增长率中间主体放全国景点热度地图右侧从上到下依次是“评论关键词词云”和“价格区间分布”堆叠柱状图。底部留一条滚动信息栏实时展示最新抓取的评论摘要。这个布局的逻辑是从概览到细节核心KPI给全局判断地图给区域决策排行榜给运营抓手词云给内容洞察价格分布给定价参考。每个模块之间还有联动我后面在ECharts里加了一个事件绑定点击地图上某个省份右侧的排行榜和词云会联动刷新成该省份的数据。5.2 后端API设计与ECharts数据对接可视化大屏的数据由Flask提供API支撑推荐和统计数据走的是同一套接口。我把接口设计成以下几种风格/api/overview返回核心KPI数据/api/hot_rankings返回热门景点Top10/api/geo_data返回省级维度的热度聚合/api/price_distribution返回价格区间分布/api/comment_cloud返回评论关键词及权重后端采用蓝图结构把路由、业务逻辑和数据访问分开。ECharts的配置项通过Ajax请求拿到JSON数据后动态填充option页面加载时先渲染默认数据再通过定时器每30秒轮询一次实现接近实时的数据刷新。一个重要经验是后端返回的数据格式必须和ECharts的data属性结构严格对齐比如玫瑰饼图需要{name: 自然风光, value: 38}这样的对象数组地图需要{name: 北京, value: 1200}这种结构。千万不要让前端在拿到数据后再做一层转换那样会引入很多不可控的异常也让前端的代码变得很难维护。5.3 大屏性能优化与自适配方案大屏项目最常见的翻车现场是数据量一大页面卡成幻灯片。我踩过这个坑主要原因有两个一是ECharts实例渲染大量数据点时没有开启sampling二是定时器重复创建图表实例导致内存泄漏。解决方案是给地图和柱状图的series加上sampling: lttb参数让ECharts自动在下采样时保留趋势特征定时器刷新时用myChart.setOption(newData, true)第二个参数传true表示合并替换而不是重建实例避免内存不断增长。大屏的屏幕适配也是必修课。我的做法是用vw和vh单位而不是px做布局图表容器宽度用calc(100vw * 0.3)这种写法因为ECharts初始化时会读取容器实际的px宽高如果容器尺寸变了记得调用chart.resize()方法。6. 常见问题与排查技巧实录6.1 反爬维度的问题数据抓不全我遇到过最典型的案例是爬虫跑着跑着突然连续返回403之前明明还能正常抓。排查思路是这样的先看是不是本机IP被临时封禁最简单的验证方法是直接用requests去请求目标页面如果返回200说明IP没事问题出在爬虫的请求头或频率控制上如果返回403说明IP已经被站方标记了需要停一段时间等待解封或者切换代理IP。另外很多站点的反爬是分层的对搜索接口这类高价值接口的封禁阈值远低于普通详情页。如果你发现详情页能正常抓但搜索页总是403那八成是接口的请求频率太高了。解决办法是把搜索接口的下载延迟单独调高到3到5秒别跟详情页的并发混在一起。6.2 数据质量维度的坑评分分布异常跑完第一轮数据清洗后我发现很多景区的评分集中在4.5分以上1到3分几乎没有。原因很简单大部分OTA平台的评分本身就有虚高现象加上我抓取的评论以好评为主差评用户更倾向于在微博这类社交平台发泄而不是回平台打分。知道这个规律后我在做口碑分析时把绝对评分改为相对排名评分也就是把同一城市内的景点评分做归一化高于均值一个标准差以上的定义成高分梯队低于均值一个标准差以下的定义成低分梯队。这样得到的口碑标签更符合真实分布推荐算法用起来也更可靠。6.3 推荐算法维度的坑相似度矩阵占用内存过大当用户量增长到几万时直接计算完整的用户相似度矩阵会出现内存告急的情况。cosine_similarity计算出的矩阵是N乘N的几万用户就是几亿个浮点数内存直接吃不消。解决方法是只保留每个用户的TopK相似用户不要计算和维护完整矩阵。实现上用neighbors库里的NearestNeighbors指定metriccosine直接产出每个用户的K个最近邻存储为稀疏结构。这样内存占用从O(N^2)降到O(N*K)计算效率也大幅提升。6.4 可视化维度的坑地图数据不显示ECharts地图组件最常见的问题就是地图数据不显示黑屏或者空白。这个问题多半是地图的GeoJSON没有正确注册。ECharts从5.0版本开始不再内置地图数据需要手动引入或请求GeoJSON文件import chinaGeoJson from ./china.json; echarts.registerMap(china, chinaGeoJson);此外地图数据里省份的name必须与数据源里省份名称完全一致。我的数据里写的是“北京”GeoJSON里也是“北京”但如果写成了“北京市”就匹配不上了。这个问题排查起来很隐蔽因为控制台不报错只是地图上缺了一块。7. 项目扩展方向这套系统的下一步往哪儿走项目做到这个程度主线功能已经形成了闭环爬虫抓数据、数据清洗入库、画像构建、推荐引擎计算、可视化大屏展示。但如果只停在这一步说实话稍微有点浪费。我梳理了几个扩展方向大家可以根据自己的情况选做。第一个方向是引入评论情感分析的升级版。目前用的是情感词典方法胜在速度快、可解释性强但碰到反讽、隐喻这类复杂表达就会失效。换用预训练语言模型做情感分类准确率会明显提升代价是推理性能和硬件要求都上来了部署时需要用GPU推理或者量化压缩。如果你对NLP感兴趣这个方向值得投入。第二个方向是把推荐系统从离线计算升级为实时计算架构。用Flask加Redis的方案在数据量小的时候没问题但用户行为一旦高并发写入数据库压力就会成为瓶颈。可以引入消息队列加流处理框架把用户行为日志异步写入Kafka再通过流式任务实时更新推荐候选集。这个改造工程量比较大但对理解整个大数据生态的运转方式非常有帮助。第三个方向是加一个行程规划模块。推荐系统目前是点状的推荐单个景点或单个酒店但用户的真实需求是一整条旅游线路“我去了成都应该怎么安排三天两夜的行程”这里可以把推荐结果和图路径搜索结合用图算法寻找符合时间约束、空间距离和用户兴趣偏好的景点序列这是推荐系统往应用层走的一个重要方向。8. 踩过那么多坑之后的实操心得整套系统从爬虫到可视化完整跑通我最想分享的心得是这类项目的成败七成在数据三成在算法。很多人花了大量时间调推荐算法的参数结果发现数据脏得根本没法用调参调出来的“优化”都是在过拟合脏数据上的噪音。第二点心得是关于项目管理的节奏。一定要先把爬虫和数据入库的链路打通哪怕初始只爬一千条数据也要让整条链路质量闭环。我看到太多人第一天就把推荐算法调得飞起结果两周后发现自己用的数据集还是网上找的完全没有自己的数据特征。第三点是文档意识。爬虫的字段映射、清洗规则、特征计算公式这些一定要随手记录下来。这个项目我前期偷懒没记录后来回头补充特征工程时只能对着代码猜当时的意图浪费了不少时间。写清楚文档回头看和给别人交接都会轻松很多。最后说一个小技巧不要在推荐算法上追求花哨先跑通最基础的协同过滤和热度排序保证推荐结果可解释、可复盘然后再考虑加深度学习模型。深度学习模型的效果提升在旅游推荐这种特征相对稀疏的场景里很多时候没有你想象的那么大但调试成本却是成倍增加的。基础方案稳定运行一段时间后再对照数据缺口决定要不要升级这条路才是稳妥的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进