1. 项目概述:当Django遇上旅游大数据
放假想出门散心,打开旅游App翻了半小时,推荐的全是去过的地方,要不就是热门景区人挤人,最后关了手机在家躺了一天。这种体验不少人都有过,也正因如此,“怎么把合适的景点推荐给合适的人”成了旅游平台的核心问题。
我这次做的项目,就是一套基于Django的旅游景区推荐系统,完整链路包括:爬虫采集旅游数据 → 清洗入库 → 协同过滤算法推荐 → ECharts可视化大屏展示。整个过程跑下来,既是一个完整的Web应用,也是一个能写进简历的大数据项目。
先说结论:这套系统用Python实现,核心框架是Django,数据来源是公开旅游网站的景点信息、评论和评分,推荐算法用的是基于用户的协同过滤(User-Based Collaborative Filtering),最终通过可视化大屏展示热门景点分布、用户偏好画像、评分趋势等内容。系统不只输出“推荐列表”,而是把“数据采集—数据存储—算法推荐—数据展示”全流程串了起来。
适合谁参考?如果你正在做毕业设计、准备大数据方向的项目面试,或者想把爬虫、Django、推荐系统这几个知识点整合成一个完整作品,这篇文章会给你一套可以直接落地的思路和代码。我会把架构设计、核心代码、踩过的坑都写出来,尽量让照着做的人少走弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统整体架构与数据流设计
2.1 技术栈选型:为什么是Django而不是Flask或SpringBoot
做个旅游推荐系统,后端框架的选择其实有多种。我在项目里用了Django,核心原因是它自带Admin后台、ORM、模板引擎和完整的用户认证体系,对“推荐系统+后台管理”这种组合非常友好。
Django的ORM让我不用手写SQL就能完成复杂查询,比如“找出评分大于4.5且评论数超过100的景点”,直接链式调用即可。Admin后台更是省了不少事——爬虫抓回来的数据如果有个别脏数据,直接在后台改,不用写一堆管理页面。
当然,Flask更轻量,SpringBoot更适合企业级微服务,但单论“一个人快速搭起带后台管理的推荐系统”,Django的综合效率是最高的。项目里我用的是Django 4.x版本,Python 3.10环境,数据库选了MySQL——虽然SQLite开箱即用,但考虑到数据量过万条后SQLite的性能瓶颈,从一开始就上了MySQL。
2.2 数据流:从爬虫到可视化的完整链路
整个系统的数据流分为4个阶段:
- 爬虫采集:用Requests请求目标网站,BeautifulSoup解析HTML,提取景点名称、评分、评论数、经度纬度、图片链接等信息。
- 数据清洗入库:通过Pandas做去重、缺失值处理,再经Django ORM写入MySQL。
- 推荐计算:离线计算用户相似度矩阵和推荐列表,结果存入Redis缓存,在线请求时直接读取。
- 可视化展示:从数据库聚合查询统计结果,通过接口返回给前端ECharts渲染大屏。
这套流程最核心的思考是:推荐结果不是实时计算的,而是离线算好、在线读取。原因是协同过滤算法的时间复杂度较高,如果每次请求都实时计算用户相似度,数据库压力很大,响应时间也会飙到几秒。离线预计算加缓存,是工业界的标准做法。
2.3 项目目录结构与核心模块划分
code复制travel_recommend/
├── manage.py # Django入口
├── travel/ # 主应用
│ ├── models.py # 数据模型定义
│ ├── views.py # 视图函数
│ ├── recommend.py # 推荐算法核心
│ ├── spider.py # 爬虫脚本(独立运行)
│ └── urls.py # 路由配置
├── static/ # 静态资源
│ ├── echarts/ # ECharts库
│ ├── css/ # 样式文件
│ └── js/ # 前端脚本
├── templates/
│ ├── index.html # 大屏展示页面
│ ├── recommend.html # 推荐结果页
│ └── admin_custom.html # 自定义后台页
└── db_config.py # 数据库配置
模块划分遵循“松耦合”原则:爬虫独立于Django运行,抓完数据后仅通过ORM写库;推荐算法模块不依赖视图层,只负责输入用户ID、输出推荐列表;视图层只做参数解析和模板渲染。这样任何一层改动都不会影响其他层。
3. 爬虫模块:从0到1抓取旅游数据
3.1 爬虫设计思路与目标站点分析
旅游数据从哪来?我选择了两个方向:
- 景点基础信息:名称、所在城市、景区等级(5A/4A)、门票参考价、开放时间
- 用户评论数据:评论者ID、评分、评论文本、评论时间
用户评论是推荐算法最核心的输入,因为协同过滤的本质就是“基于用户对物品的历史行为”来计算相似度。没有评分数据,巧妇难为无米之炊。
目标站点的结构我做了简单分析:景点列表页是分页的,每个景点详情页包含评分、评论数等信息;评论通过独立接口加载,接口返回JSON格式数据,不需要解析HTML,反而简单一些。为降低对目标站点的压力,我设定了请求间隔:每次请求后随机sleep 0.5到1.5秒,同时限速到每分钟不超过30次请求。
3.2 Requests+BeautifulSoup实战代码
先上核心代码,爬取景点基础信息的部分:
python复制import requests
import time
import random
from bs4 import BeautifulSoup
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3',
}
def fetch_spot_list(page):
url = f'https://example-travel-site.com/scenic/list?page={page}'
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
spots = []
for item in soup.select('.scenic-item'):
name = item.select_one('.name').text.strip()
score = float(item.select_one('.score').text.strip())
comment_num = int(item.select_one('.comment-num').text.strip())
level = item.select_one('.level').text.strip()
city = item.select_one('.city').text.strip()
spots.append({
'name': name,
'score': score,
'comment_num': comment_num,
'level': level,
'city': city,
})
return spots
def run_spider(max_pages=50):
all_spots = []
for page in range(1, max_pages + 1):
try:
page_spots = fetch_spot_list(page)
if not page_spots:
break
all_spots.extend(page_spots)
print(f'第{page}页完成,共{len(page_spots)}条数据')
except Exception as e:
print(f'第{page}页出错: {e}')
time.sleep(random.uniform(0.5, 1.5))
return all_spots
这里有几个细节值得注意:timeout=10必须设置,否则某个请求卡住了整个爬虫就挂了;选择器要根据目标站点的实际HTML结构调整;异常处理要覆盖网络超时、解析失败、页面结构变更等情况,不能因为一页失败就终止整个任务。
3.3 反爬应对:从Headers伪装到请求频率控制
爬虫写出来容易,但能不能稳定跑完50页才是关键。我在实操中遇到过的反爬策略有:User-Agent检测、请求频率检测、IP临时封禁、数据动态加载。
针对这些情况的应对方案:
- User-Agent伪装:上述代码已经设置了常用的UA,必要时可以准备一个UA池,随机切换。
- 请求间隔:这个最重要,我踩过坑——最初为了追求速度,间隔设为0.1秒,爬到第20页左右IP就被封了。后来改成随机0.5到1.5秒,稳定跑完了全部页面。
- 动态数据加载:如果发现页面里的数据是AJAX加载的,直接用Requests拿不到,需要用Selenium或直接分析接口。我优先推荐分析接口——找到JSON接口后直接请求,比Selenium轻量得多。
- IP封禁:如果目标站点封禁严格,需要代理池。但个人项目不建议一上来就上代理,因为代理的质量参差不齐,反而增加调试成本。
注意:爬虫必须遵守目标网站的Robots协议,控制合理抓取频率,数据仅用于学习和研究,不要用于商业用途。
3.4 数据清洗:Pandas处理脏数据的标准流程
爬回来的数据不是直接入库的,我用Pandas做了三步清洗:
python复制import pandas as pd
def clean_data(spots):
df = pd.DataFrame(spots)
# 1. 去重:同名景点保留评分最高的那条
df = df.drop_duplicates(subset='name', keep='first')
# 2. 缺失值处理:评分缺失的删除,城市缺失的填充为"未知"
df = df.dropna(subset=['score'])
df['city'] = df['city'].fillna('未知')
# 3. 异常值处理:评分范围必须是0-5,超出范围的替换为均值
df.loc[df['score'] < 0, 'score'] = df['score'].mean()
df.loc[df['score'] > 5, 'score'] = df['score'].mean()
# 4. 类型转换
df['score'] = df['score'].astype(float)
df['comment_num'] = df['comment_num'].astype(int)
return df
踩坑提示:去重时一定注意keep参数,默认是保留第一个,但如果你的数据源里后面的记录通常比前面的信息更完整,可以考虑keep='last'。另外,缺失值处理要根据数据实际情况决定是删除还是填充——如果某列缺失比例超过30%,直接填充反而引入偏差,不如删除该字段。
4. 数据库设计与ORM建模:让数据住进MySQL
4.1 数据表结构设计
推荐系统的数据库设计,核心有三张表:用户表、景点表、评分表。用户表存用户基本信息,景点表存景点属性,评分表记录用户对景点的打分行为。
python复制from django.db import models
class User(models.Model):
username = models.CharField(max_length=50, unique=True)
age = models.IntegerField(null=True, blank=True)
city = models.CharField(max_length=50, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'user'
def __str__(self):
return self.username
class Spot(models.Model):
name = models.CharField(max_length=100)
city = models.CharField(max_length=50)
level = models.CharField(max_length=20, blank=True) # 5A/4A等
score = models.FloatField(default=0)
comment_num = models.IntegerField(default=0)
description = models.TextField(blank=True)
longitude = models.FloatField(null=True, blank=True)
latitude = models.FloatField(null=True, blank=True)
image_url = models.URLField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'spot'
def __str__(self):
return self.name
class Rating(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
spot = models.ForeignKey(Spot, on_delete=models.CASCADE)
score = models.IntegerField() # 1-5分
comment = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'rating'
unique_together = ('user', 'spot') # 一个用户对一个景点只能有一条评分
设计时我特别注意了两点:
表名不加Django默认的前缀。Django默认会在表名前加上app名,比如travel_user,如果你后续要直接用SQL分析数据,这个前缀很麻烦。通过Meta类的db_table指定表名,可以去掉前缀。
Rating表加了unique_together约束。这保证了同一用户对同一景点只能有一条评分记录,避免数据重复导致推荐结果异常。
4.2 ORM写库与查询优化技巧
数据通过ORM写库时,刚开始我用的是逐条save(),结果爬了5000条数据跑了十几分钟,太慢了。后来改成bulk_create,速度提升了近100倍:
python复制def batch_save_spots(spots):
spot_objs = [Spot(**s) for s in spots]
Spot.objects.bulk_create(spot_objs, batch_size=500)
注意batch_size参数——数据库驱动对单次INSERT的条数有限制,MySQL超过1000条可能会报错,分批写入更稳妥。
查询优化方面,最关键的是给外键和常用查询字段加索引:
python复制class Rating(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True)
spot = models.ForeignKey(Spot, on_delete=models.CASCADE, db_index=True)
加索引后,推荐算法里根据用户ID查询评分记录的速度会明显提升。数据量到几万条时,没索引的查询可能要几百毫秒,加了索引能降到几十毫秒甚至几毫秒。
另一个优化技巧是使用select_related和prefetch_related避免N+1查询。比如要展示某用户评论过的所有景点信息,如果不用select_related,每次循环取景点信息都会发一条SQL;加上之后只发一条带JOIN的SQL,性能差距明显。
4.3 缓存设计:Redis在推荐系统中的作用
推荐列表的计算结果放在Redis里,key的格式为recommend:{user_id},value为JSON格式的景点ID列表,过期时间设为24小时。这样用户当天内多次请求推荐,直接从缓存读取,不触发重算。
还有一个比较巧妙的用途:热门景点的实时排行也放Redis。因为大屏展示页面每5秒刷新一次,如果每次都去MySQL做GROUP BY聚合,压力太大。改为定时任务每分钟更新一次排行到Redis,前端从Redis读取,效果流畅得多。
python复制import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def get_recommend_from_cache(user_id):
cache_key = f'recommend:{user_id}'
data = redis_client.get(cache_key)
if data:
return json.loads(data)
return None
def set_recommend_cache(user_id, spot_ids, expire=86400):
cache_key = f'recommend:{user_id}'
redis_client.setex(cache_key, expire, json.dumps(spot_ids))
5. 推荐系统核心:协同过滤算法从原理到实现
5.1 推荐算法选型:UserCF还是ItemCF
推荐算法有很多种,基于内容的、基于协同过滤的、基于矩阵分解的。我最终选了协同过滤里的UserCF(基于用户的协同过滤),原因是:
- 用户对景点的评分数据天然存在,不需要额外构建物品内容特征
- 旅游场景下,用户兴趣往往是“人以群分”的——和我相似的人喜欢的景点,大概率也符合我的口味
- 实现门槛相对低,不需要训练复杂的深度学习模型,对毕设或面试项目来说性价比很高
UserCF和ItemCF的选择逻辑是:用户数量远大于物品数量时,用ItemCF计算更快;反之用UserCF。旅游场景下,景点数量一般是几千到几万,用户数量可能更多,严格来说ItemCF更合适。但我做了个简化——系统只采集了数百个种子用户的行为数据,这个量级下UserCF完全够用。
5.2 UserCF核心公式与计算过程
UserCF算法的核心分三步,我分别拆解:
第一步:构建用户-景点评分矩阵
行是用户,列是景点,值为评分。实际操作中不需要真的构建一个二维数组,用一个字典就够了:
python复制# 格式: {user_id: {spot_id: score}}
user_ratings = {
1: {101: 5, 102: 4, 103: 3},
2: {101: 4, 102: 5, 104: 2},
...
}
第二步:计算用户相似度
常用的相似度计算方式有余弦相似度和皮尔逊相关系数。在实际场景中,用户打分会偏高或偏低(有人习惯打4分以上,有人习惯打3分左右),皮尔逊相关系数通过减去用户平均分来消除这种主观偏差,所以我选了皮尔逊相关系数。
公式是:sim(u, v) = Σ[(r_ui - r̄_u)(r_vi - r̄_v)] / sqrt(Σ(r_ui - r̄_u)² * Σ(r_vi - r̄_v)²)
简化处理:只计算两个用户共同评分过的物品。
python复制import math
def pearson_sim(user1_ratings, user2_ratings):
common_items = set(user1_ratings.keys()) & set(user2_ratings.keys())
# 没有共同评分物品时,相似度为0
if not common_items:
return 0.0
n = len(common_items)
# 计算均值
sum1 = sum(user1_ratings[item] for item in common_items)
sum2 = sum(user2_ratings[item] for item in common_items)
avg1 = sum1 / n
avg2 = sum2 / n
# 计算分子
numerator = sum((user1_ratings[item] - avg1) * (user2_ratings[item] - avg2)
for item in common_items)
# 计算分母
denom1 = math.sqrt(sum((user1_ratings[item] - avg1) ** 2
for item in common_items))
denom2 = math.sqrt(sum((user2_ratings[item] - avg2) ** 2
for item in common_items))
if denom1 == 0 or denom2 == 0:
return 0.0
return numerator / (denom1 * denom2)
第三步:生成推荐列表
找出与目标用户最相似的K个用户(K通常取10-20),收集这些用户评分过但目标用户未评分的景点,按加权评分累加排序,取top N。
python复制def generate_recommendations(user_id, user_ratings, k=10, n=10):
target_ratings = user_ratings[user_id]
target_set = set(target_ratings.keys())
# 计算目标用户与其他所有用户的相似度
similarity_scores = []
for other_id, other_ratings in user_ratings.items():
if other_id == user_id:
continue
sim = pearson_sim(target_ratings, other_ratings)
if sim > 0:
similarity_scores.append((other_id, sim))
# 取相似度最高的K个用户
similarity_scores.sort(key=lambda x: x[1], reverse=True)
top_k_users = similarity_scores[:k]
# 加权累加:推荐分值 = 相似度 * 评分
recommend_scores = {}
for other_id, sim in top_k_users:
other_ratings = user_ratings[other_id]
for spot_id, score in other_ratings.items():
if spot_id not in target_set: # 排除已评分的
recommend_scores[spot_id] = recommend_scores.get(spot_id, 0) + sim * score
# 按推荐分值排序
sorted_scores = sorted(recommend_scores.items(), key=lambda x: x[1], reverse=True)
top_n = [spot_id for spot_id, _ in sorted_scores[:n]]
return top_n
5.3 冷启动问题的应对策略
推荐系统绕不开冷启动问题:新用户没有评分记录,怎么推荐?
我用了混合推荐策略:
python复制def hybrid_recommend(user_id, user_ratings):
# 用户不存在或没有评分记录,走冷启动策略
if user_id not in user_ratings or not user_ratings[user_id]:
return recommend_hot_spots() # 返回热门景点
# 有足够评分,正常走协同过滤
return generate_recommendations(user_id, user_ratings)
冷启动的替代方案是“热门推荐”——把评分人数最多、平均分最高的景点推给新用户。这个策略简单有效,尤其对旅游场景来说,热门景点虽然可能人挤人,但至少是经过大量用户验证的,踩坑概率低。
另外一个细节:评分数太少(比如只有1条)的用户的推荐结果往往不准确,我在实现时加了阈值判断:评分记录少于3条的用户也走热门推荐。
5.4 Redis缓存推荐结果的工程实践
离线计算好推荐列表后,整体流程是:
- 每天凌晨2点,定时任务触发推荐计算
- 对所有活跃用户计算推荐列表,写入Redis
- 用户在Web端访问推荐页面时,从Redis读取
这套设计的好处非常明显:读多写少场景下,Redis的QPS可以轻松过万,Web接口响应时间能压制在50ms以内。而且定时任务可以放慢计算节奏,避开业务高峰期,对数据库压力也小。
6. 可视化大屏:把数据变成故事
6.1 大屏布局设计与ECharts选型
可视化大屏是整个项目最直观的“门面”,也是面试展示时的亮点。大屏分辨率我按1920x1080设计,整体布局分为三栏:
- 中间主区域:景区评分TOP10柱状图、评论数TOP10柱状图
- 左侧区域:景点类型分布饼图、城市景点数地图
- 右侧区域:用户评分分布直方图、实时推荐动态列表
图表库选择了ECharts,原因是:社区资源丰富、开箱即用的交互组件多、性能足以应对几千个数据点的渲染。当然很多项目用Highcharts或AntV,但ECharts的文档和示例最完善,上手最快,出了问题搜一下基本都有现成方案。
6.2 Django视图返回JSON数据接口
大屏页面通过AJAX向Django后端请求数据,后端聚合数据并返回JSON。我在views.py里写了几个接口:
python复制from django.http import JsonResponse
from django.db.models import Count, Avg
from .models import Spot, Rating
from django.core.cache import cache
def api_hot_spots(request):
# 从缓存读取,避免每次请求都查库
data = cache.get('hot_spots')
if data:
return JsonResponse({'code': 0, 'data': data})
# 按评论数排序取TOP10
spots = (Spot.objects.all()
.values('name', 'score', 'comment_num')
.order_by('-comment_num')[:10])
data = list(spots)
cache.set('hot_spots', data, 60)
return JsonResponse({'code': 0, 'data': data})
def api_score_distribution(request):
# 评分分布统计
rating_dist = (Rating.objects.values('score')
.annotate(count=Count('id'))
.order_by('score'))
return JsonResponse({'code': 0, 'data': list(rating_dist)})
def api_city_stats(request):
# 各城市景点数量
city_stats = (Spot.objects.values('city')
.annotate(total=Count('id'))
.order_by('-total'))
return JsonResponse({'code': 0, 'data': list(city_stats)})
接口里加了缓存。大屏页面5秒轮询一次,如果不加缓存,3个接口每秒产生近1次请求,虽然量不大,但每次都做聚合查询也是个浪费。缓存60秒后,数据库压力几乎为零。
6.3 前端ECharts渲染:核心代码与避坑指南
前端页面用原生JavaScript加ECharts渲染,没有引入Vue或React,避免过度设计。核心逻辑是:
javascript复制// 热门景点柱状图
async function loadHotSpots() {
const resp = await fetch('/api/hot-spots/');
const result = await resp.json();
if (result.code !== 0) return;
const data = result.data;
const chart = echarts.init(document.getElementById('chart-hot-spots'));
chart.setOption({
title: { text: '热门景点评论数TOP10' },
tooltip: {},
xAxis: { type: 'category', data: data.map(item => item.name) },
yAxis: { type: 'value' },
series: [{
type: 'bar',
data: data.map(item => item.comment_num),
itemStyle: { color: '#37a2da' }
}]
});
}
// 地图用ECharts的map类型,需要注册地图数据
async function loadCityMap() {
const resp = await fetch('/api/city-stats/');
const result = await resp.json();
if (result.code !== 0) return;
// 这里数据是城市名+数量,需要转换成ECharts地图要求的格式
const mapData = result.data.map(item => ({
name: item.city,
value: item.total
}));
const chart = echarts.init(document.getElementById('chart-city'));
chart.setOption({
tooltip: {},
visualMap: { min: 0, max: 20 },
series: [{
type: 'map',
map: 'china',
data: mapData
}]
});
}
踩坑提示:ECharts 5.x版本开始默认不再内置中国地图数据,必须单独引入china.js。这个文件在npm包里或CDN上能找得到,但一定要确认版本与ECharts主库匹配,否则会报“地图不存在”的错误。我当时就卡在这里半天,后来在官网示例页找到了正确加载方式。
6.4 大屏轮询刷新与页面性能优化
大屏需要实时感,我用的是setInterval轮询,每5秒刷新一次数据。但有个坑:如果每次刷新都调用chart.setOption,图表会出现闪烁,用户体验很差。
正确做法是只更新数据,不重建图表:
javascript复制function startAutoRefresh() {
setInterval(async () => {
const resp = await fetch('/api/hot-spots/');
const result = await resp.json();
if (result.code !== 0) return;
// 使用setOption合并配置,而不是重新init
chart_hot_spots.setOption({
xAxis: { data: result.data.map(item => item.name) },
series: [{ data: result.data.map(item => item.comment_num) }]
});
}, 5000);
}
另外,页面上有多个图表同时渲染时,建议用echarts.init的第二个参数指定渲染模式,默认的canvas足够,不需要特别设置。如果图表特别多导致首屏加载慢,可以考虑用ECharts的按需引入方案,只引入用到的图表类型。
7. 推荐接口实现:Web端打通前后端
7.1 视图逻辑与URL路由配置
推荐页面的流程是:用户登录后,前端请求/recommend/?user_id=1,后端先查缓存,缓存命中直接返回推荐数据;未命中则走协同过滤算法计算,把结果写入Redis后返回。
python复制from django.shortcuts import render
from django.http import JsonResponse
from .recommend import get_recommendations
from .models import Spot
def recommend_view(request):
user_id = request.GET.get('user_id')
if not user_id:
return JsonResponse({'code': 1, 'msg': '缺少user_id参数'})
spot_ids = get_recommendations(user_id)
spots = Spot.objects.filter(id__in=spot_ids)
# 按推荐顺序排序
spot_dict = {spot.id: spot for spot in spots}
ordered_spots = [spot_dict[sid] for sid in spot_ids if sid in spot_dict]
data = [{
'id': spot.id,
'name': spot.name,
'city': spot.city,
'score': spot.score,
'comment_num': spot.comment_num,
'image_url': spot.image_url,
} for spot in ordered_spots]
return JsonResponse({'code': 0, 'data': data})
URL配置在urls.py中:
python复制from django.urls import path
from . import views
urlpatterns = [
path('', views.index_view, name='index'),
path('recommend/', views.recommend_view, name='recommend'),
path('api/hot-spots/', views.api_hot_spots, name='api_hot_spots'),
path('api/score-distribution/', views.api_score_distribution, name='api_score_distribution'),
path('api/city-stats/', views.api_city_stats, name='api_city_stats'),
path('api/recommend-list/', views.recommend_list, name='api_recommend_list'),
]
接口风格走的是简单的JSON接口,没有加REST framework。毕设或中小型项目用DRF有点重,原生的JsonResponse+Django View足够,减少依赖也是降低项目复杂度的一种方式。
7.2 推荐页面模板与展示效果
推荐页面展示推荐景点的封面色块、名称、评分和推荐理由。推荐理由我用了一个比较简单的解释方式:“因为你喜欢XXX、XXX(用逗号连接用户评分最高的3个景点),所以推荐你试试XXX”。
这种解释机制对用户信任度提升很关键。黑盒推荐永远让人心存疑虑——为什么给我推这个?给出依据后,用户接受推荐的概率会高很多。
模板的核心结构:
html复制<div class="recommend-grid">
{% for spot in spots %}
<div class="card">
<div class="card-img" style="background-image: url('{{ spot.image_url }}')"></div>
<div class="card-body">
<h3>{{ spot.name }}</h3>
<div class="meta">
<span>{{ spot.city }}</span>
<span>评分: {{ spot.score }}</span>
<span>{{ spot.comment_num }}条评论</span>
</div>
<p class="reason">{{ spot.reason }}</p>
</div>
</div>
{% endfor %}
</div>
7.3 Django用户认证的集成方案
系统的登录注册基于Django自带认证模块,没有重造轮子。创建用户时用create_user而不是create,后者不会对密码做哈希,安全隐患很大:
python复制from django.contrib.auth.models import User
from django.contrib.auth import authenticate, login
def register_view(request):
if request.method == 'POST':
username = request.POST['username']
password = request.POST['password']
if User.objects.filter(username=username).exists():
return JsonResponse({'code': 1, 'msg': '用户名已存在'})
user = User.objects.create_user(username=username, password=password)
login(request, user)
return JsonResponse({'code': 0, 'msg': '注册成功'})
def login_view(request):
if request.method == 'POST':
username = request.POST['username']
password = request.POST['password']
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return JsonResponse({'code': 0, 'msg': '登录成功'})
return JsonResponse({'code': 1, 'msg': '用户名或密码错误'})
8. 部署上线与企业级考量
8.1 云服务器部署流程
项目开发完成后需要部署到云服务器上对外提供服务。部署方案是经典的Nginx+Gunicorn+Django三层架构,流程如下:
- 云服务器安装Python 3.10、MySQL、Redis、Nginx
- 使用virtualenv创建虚拟环境,pip安装依赖
- 把项目代码上传至服务器,执行
python manage.py migrate初始化数据库 - 启动Gunicorn服务:
gunicorn travel_recommend.wsgi:application -b 127.0.0.1:8000 -w 4 - 配置Nginx反向代理,把80端口转发到8000端口
- 配置静态文件收集:
python manage.py collectstatic - 设置supervisor守护进程,保证Gunicorn崩溃后自动重启
这里有个坑:Django的静态文件和媒体文件在DEBUG=False时不会自动提供,必须在Nginx配置中单独处理:
nginx复制location /static/ {
alias /path/to/project/static/;
}
location /media/ {
alias /path/to/project/media/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
8.2 性能优化:从数据库到接口的层层加速
这个项目做完了之后,我又做了第二轮性能优化,主要动作有:
数据库层面:热门列表、评分分布等聚合查询,加上了合理的数据库索引。比如Rating表的user_id和spot_id字段分别加索引,Spot表的comment_num字段也加了索引——排序和WHERE过滤用得上。
缓存层面:除了用户推荐列表和热点数据放Redis,把页面片段也做了缓存。Django自带的cache_page装饰器可以缓存整个视图的输出,对非个性化的大屏接口效果很好:
python复制from django.views.decorators.cache import cache_page
@cache_page(60) # 缓存60秒
def api_hot_spots(request):
# 逻辑不变
接口层面:返回给前端的JSON数据做了精简,只包含前端需要的字段,不用values()查询而是用only()或values()限制字段范围,减少数据传输量。
这几层优化做下来,大屏页面从最初的每次请求200ms左右降到了20ms以内,效果显著。
8.3 安全加固:SQL注入、CSRF和XSS防护
Django的ORM已经做了SQL注入防护,因为参数是通过预编译语句传递的,这一点基本不用额外操心。但有两个地方需要手动注意:
CSRF中间件默认开启,所有POST请求必须携带CSRF Token。前端使用fetch时需要从cookie中读取Token并放入请求头:
javascript复制function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
fetch('/api/recommend-list/', {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
XSS方面,Django模板引擎默认会对变量内容做HTML转义,所以后台数据传到模板渲染时是安全的。但如果用了mark_safe或safe过滤器,一定要确保内容来源可信,尤其是用户提交的评论数据,绝对不要直接标记为安全。
9. 常见问题排查与避坑实录
9.1 爬虫数据为空:选择器失效的排查方法
爬虫最大的坑就是目标网站改版,之前能跑通的CSS选择器突然失灵。排查步骤:
- 用浏览器开发者工具重新检查目标元素的class和id是否变化
- 打印出
resp.text的前1000个字符,确认返回的HTML是否包含目标内容 - 如果返回的是空页面,大概率被反爬拦截了——检查状态码,可能是301重定向或403拒绝访问
- 如果是动态加载的内容,用搜索功能在源码中搜索关键词,找不到说明数据由JavaScript渲染,需要换方案
我的经验是:不要只依赖CSS选择器,适当用正则表达式做兜底。尤其是提取JSON数据时,用re.search(r'var data = ({.*?});', html)之类的正则往往比复杂的选择器更稳定。
9.2 Django ORM报错“Unknown column”的解决思路
这个错误通常发生在数据库迁移后:新增了字段但忘记执行makemigrations和migrate。解决办法:
bash复制python manage.py makemigrations
python manage.py migrate
如果还是不行,检查一下models.py字段名是否和数据库表真实列名一致。有时你修改了模型字段名,但迁移文件没有正确生成,这时需要手动检查迁移文件,确认改动被Django正确识别。
9.3 推荐列表全为空?从数据角度排查
推荐结果为空,最常见的两种原因:
- 用户评分数据太少,找不到相似用户。最简单的解决方法是增加种子用户——手动生成一批模拟用户评分数据,或者从爬虫拿到的评论数据中按用户维度拆分。
- 相似度阈值太高。我把
sim > 0的过滤改成了sim > 0.1——如果所有用户的评分习惯都偏中高分段(3-5分),Pearson相关系数普遍偏高,此时把阈值调低一点可以获得更多候选用户。
另外有个很有意思的现象:如果两个用户只有一条共同评分,计算出来的Pearson相似度是0(因为两者均值相同,分子为0),实际效果还行,说明用皮尔逊能自动避免“共同打分少但打分偏差大”的虚假相似。
9.4 部署后静态文件403/404的解决方法
部署上线后最常遇到的问题就是静态文件打不开。排查顺序:
DEBUG=False后,runserver不再处理静态文件,必须由Nginx提供- 确认已执行
python manage.py collectstatic,文件被正确收集到STATIC_ROOT - 检查Nginx配置中
alias路径是否和实际路径一致 - 静态文件权限问题:权限不足时Nginx会返回403,执行
chmod -R 755调整
9.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 爬虫请求超时 | 目标站点响应慢或网络波动 | 设置timeout参数,增加重试机制 |
| 评分数据写入失败 | 违反unique_together约束 | 插入前检查是否已有记录,用update_or_create |
| 推荐接口响应慢 | 没有使用缓存 | 添加Redis缓存,离线预计算 |
| 大屏地图显示空白 | 缺少地图数据文件 | 引入china.js地图注册文件 |
| ECharts图表不更新 | 重复init导致实例冲突 | 页面初始化时init一次,后续用setOption |
| Django后台样式丢失 | STATIC_ROOT配置错误 | 检查静态文件收集和路径配置 |
| MySQL连接数过多 | 没有使用连接池 | 限制数据库连接池大小,或者用redis分担查询 |
10. 项目后续扩展方向
如果这个项目要继续深化,我觉得有这几个方向值得尝试:
一是推荐算法升级。协同过滤只是推荐系统的入门级方案,可以考虑引入矩阵分解(SVD)或深度学习模型(如DeepFM、Wide & Deep),在相同数据集上对比推荐效果,这会是很棒的面试素材。
二是引入实时推荐能力。目前是离线计算推荐结果,无法捕捉用户当下的行为。可以接入消息队列(Kafka或RabbitMQ),用户点击、搜索行为实时上报,基于用户实时行为更新推荐列表。
三是做用户画像系统。爬取的数据除了评分,还有评论内容。可以对评论做情感分析和主题聚类,提取用户偏好标签(如“喜欢自然风光”“偏爱历史文化”),构建用户画像,再做基于标签的召回策略。
四是数据层面扩展。目前数据来源是公开网站的静态页面,可以接入更多数据源,比如携程、马蜂窝的景点攻略数据、天气接口等,让推荐结果融入实时天气、节假日等场景因素。
这个项目上线跑了一段时间后,我最大的感受是:推荐系统并不神秘,核心就是把用户历史行为数据化为“相似度”的计算,难点不在算法本身,而在于数据的质量和工程实现的细节。爬虫要稳定、数据要干净、接口要快、展示要好看,每一步都有坑,每一步也都值得打磨。希望这篇文能帮你把整套链路跑通,少踩一些我踩过的雷。
