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(..., cookies=cookie_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太大推荐结果会变得太大众化。我对比过几个取值,K=25时推荐结果的点击率和多样性平衡得最好。
第三步是评分预测,加权公式如下:
用户u对景点i的预测评分 = sum(用户u与邻居v的相似度 * 邻居v对景点i的评分) / sum(|用户u与邻居v的相似度|)
注意预测计算时只累加那些对景点i有评分的邻居,没有评分的邻居直接跳过。最后对推荐候选集的预测评分降序排列,去掉用户已经去过的景点,取Top10输出。
4.3 多样性与实时性的平衡策略
只按预测评分排序的推荐结果有一个问题——品类过于集中。如果一个用户去过故宫和颐和园,系统就会持续推各种历史遗迹,虽然个性化是有了,但缺少惊喜感和延展性。
我在排序环节加了一步多样性重排:从候选集中每批取一个景点时,如果它与已选景点的类别或关键词重合度超过50%,就跳过,选择下一个评分稍低的。实际操作中用了一个简单的惩罚系数:
最终排序分 = 预测评分 * (1 - 0.2 * 与已选景点的平均相似度)
这样既保住了主方向,又不会让推荐结果看起来像复制粘贴。
实时性方面,我的处理策略是“离线计算为主,实时读取为辅”。用户-景点评分矩阵每晚凌晨两点跑一次离线任务,更新用户画像和相似度矩阵;但用户的实时行为,比如刚收藏的景点、刚搜索的关键词,会立刻写入Redis缓存,推荐接口优先读取缓存中的实时信号,加上离线模型算出的基础推荐列表,两者按7:3的比例融合。这个方案在数据量不算大的场景下完全是够用的,响应时间基本在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,指定metric='cosine',直接产出每个用户的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. 踩过那么多坑之后的实操心得
整套系统从爬虫到可视化完整跑通,我最想分享的心得是:这类项目的成败,七成在数据,三成在算法。很多人花了大量时间调推荐算法的参数,结果发现数据脏得根本没法用,调参调出来的“优化”都是在过拟合脏数据上的噪音。
第二点心得是关于项目管理的节奏。一定要先把爬虫和数据入库的链路打通,哪怕初始只爬一千条数据,也要让整条链路质量闭环。我看到太多人第一天就把推荐算法调得飞起,结果两周后发现自己用的数据集还是网上找的,完全没有自己的数据特征。
第三点是文档意识。爬虫的字段映射、清洗规则、特征计算公式,这些一定要随手记录下来。这个项目我前期偷懒没记录,后来回头补充特征工程时只能对着代码猜当时的意图,浪费了不少时间。写清楚文档,回头看和给别人交接都会轻松很多。
最后说一个小技巧:不要在推荐算法上追求花哨,先跑通最基础的协同过滤和热度排序,保证推荐结果可解释、可复盘,然后再考虑加深度学习模型。深度学习模型的效果提升在旅游推荐这种特征相对稀疏的场景里,很多时候没有你想象的那么大,但调试成本却是成倍增加的。基础方案稳定运行一段时间后,再对照数据缺口决定要不要升级,这条路才是稳妥的。