做这个“django基于大数据的旅游景区推荐系统”之前,我其实犹豫了一段时间。市面上的毕设系统五花八门,但真正能把“爬虫采集—数据清洗—推荐算法—可视化展示”串成一条完整技术链路的并不多。这个项目名称里“juj13”看起来像是机房编号或者作者标识,但核心内容非常明确:用Django做后端,用Python爬虫抓取景区数据,再通过数据可视化把推荐结果呈现给用户。整体下来,它不只是一个普通的Web系统,而是把大数据处理流程中最常用的几个环节都过了一遍,非常适合拿来练手,也适合作为大数据方向毕业设计的主项目。
这篇文章我会从技术选型、爬虫实现、数据存储、推荐算法、Django后端、可视化大屏到问题排查,完整拆解这个项目的设计与实现。无论你是准备做类似题目,还是想找一个能落地的Django实战项目,都可以直接照着我的思路去复现。
1. 项目需求拆解与整体设计思路
1.1 这个系统到底要解决什么问题
旅游景区的信息在互联网上非常分散。用户想去一个城市玩,往往要打开好几个网站对比景点介绍、评分、门票价格、游玩时长、用户评价,再结合自己的偏好做决策。这个过程很费时间,而且信息过载严重——有的景点评分虚高,有的冷门宝藏不容易被发现。
所以这个项目的核心定位很简单:把分散在多个来源的景区数据统一采集下来,清洗成结构化数据,基于用户偏好做个性化推荐,最后用一个可视化大屏把景点热度、评分分布、城市排行等信息呈现出来。 系统面向三类人:普通游客找景点、管理员维护数据、数据分析人员看全局趋势。
从功能上拆,系统至少需要做到以下几点:
- 爬虫从公开旅游平台采集景区基础信息、评分、评论、标签、地理位置等数据。
- 数据经过清洗、去重、归一化后入库。
- 根据用户的浏览历史和收藏行为,用推荐算法生成个性化景区列表。
- Django提供后端接口和后台管理界面。
- 前端展示推荐结果,并提供可视化大屏展示整体数据分布。
听起来功能不少,但每一项都不算深,刚好符合一个完整项目应有的体量。
1.2 技术选型:为什么是Django + 爬虫 + ECharts
这套组合是这个项目的灵魂。我先说说每块为什么这么选。
Django做后端框架。这一项基本没有什么争议。Django自带的ORM、Admin后台、模板引擎、表单处理和用户认证体系,能让你在很短时间内把Web系统的骨架搭起来。对于景区推荐这种业务逻辑并不复杂的系统,Django的“全家桶”模式比Flask那种轻量框架更适合——不需要自己挑插件,默认配置就够用。另外,Django的ORM让你写推荐算法时能直接用User.objects.filter(...)这类查询操作,不用手写SQL。
爬虫选型。常见的选择有requests+BeautifulSoup、Scrapy、Selenium。我最终用的是requests + BeautifulSoup作为主力,配合Selenium处理少量动态加载的页面。为什么不用Scrapy?因为本地采集数据量级在几千条,Scrapy的异步高并发优势发挥不出来,反而增加学习成本。requests更直观,中间出问题也更容易排查。
可视化大屏用ECharts。ECharts是百度开源的可视化库,中文文档齐全,图表类型丰富,地图、柱状图、饼图、词云都是一行配置的事。如果不想在前端写太多JavaScript,也可以直接用pyecharts在Python端生成图表,再嵌入Django模板。我实际项目里用的方案是:前端用ECharts + Ajax请求后端接口动态加载数据,这样图表能随数据变化实时更新,效果比静态图表好得多。
1.3 系统总体架构与数据流
系统整体采用典型的B/S架构,逻辑上分成四层:
- 数据采集层:Python爬虫定时抓取景区数据,执行清洗后写入MySQL。
- 业务层:Django应用,包含用户管理、景区管理、推荐算法模块。
- 接口层:Django视图函数返回JSON数据,供前端调用。
- 展示层:用户端的推荐页面和管理端的可视化大屏。
数据流是这样走的:爬虫采集原始数据 → 清洗去重归一化 → 存入MySQL景区表 → Django后台读取数据 → 推荐算法基于用户行为计算推荐列表 → 前端展示推荐结果 → 可视化大屏从数据库聚合统计数据并渲染图表。
整个过程里,数据是单向流动的,后端不直接依赖爬虫运行,爬虫产出的数据都落库。这个设计在实际运行中有一个明显好处:就算爬虫挂了,网站本身不受影响,数据库中已有数据照样能提供推荐服务。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据采集层:爬虫的完整实现
2.1 数据源分析与爬虫方案确定
爬虫的第一步不是写代码,而是分析数据源。我做这个项目时调研了几个主流旅游平台,最终选取了以公开页面数据为主的网站,重点是那些无需登录即可访问景点列表页和详情页的站点。具体数据字段包括:景区名称、所在城市、景区等级(5A/4A等)、门票价格、评分、热度、简介、标签、用户评论数。
有一点必须提醒:爬虫合规性要摆在第一位。 建议优先选择提供开放接口或明确允许抓取的数据源,控制请求频率,不要对目标站点造成压力。毕设项目主要是为了展示技术流程,不需要也不应该去搞大规模高并发抓取。
确定了数据源之后,我用浏览器开发者工具仔细分析了页面结构。景点列表页的数据通常在HTML标签里可以直接拿到,详情页的评分和评论数可能需要滚动加载。针对这两种情况,我用了两种策略:
- 列表页:requests请求 + BeautifulSoup解析,速度快。
- 详情页动态加载部分:Selenium模拟浏览器滚动,等待数据渲染完成后再抓取。
这样组合下来,既能保证抓取效率,又能覆盖动态渲染的页面。
2.2 爬虫代码实现与常见坑
这里我贴一段核心的爬虫代码,展示requests + BeautifulSoup的基本流程。
python复制import requests
import time
import random
from bs4 import BeautifulSoup
import pandas as pd
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept-Language": "zh-CN,zh;q=0.9",
}
def fetch_spot_list(city_url, max_pages=5):
"""
抓取某城市景点列表页,返回景点名称和详情页链接
"""
spot_list = []
for page in range(1, max_pages + 1):
url = f"{city_url}/p{page}"
try:
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
resp.encoding = "utf-8"
soup = BeautifulSoup(resp.text, "html.parser")
# 以某一类景点卡片结构为例,具体选择器需按目标站点调整
for item in soup.select(".spot-card"):
name = item.select_one(".name").text.strip()
link = item.select_one("a").get("href")
spot_list.append({"name": name, "url": link})
time.sleep(random.uniform(1, 3)) # 随机延时,避免请求过密
except Exception as e:
print(f"[error] page {page}: {e}")
continue
return spot_list
if __name__ == "__main__":
data = fetch_spot_list("https://example.com/beijing")
df = pd.DataFrame(data)
df.to_csv("spots.csv", index=False, encoding="utf-8-sig")
print(f"抓取完成,共 {len(df)} 条数据")
这段代码本身就是一次完整的抓取循环。有几个细节值得说一下:
请求头必须伪装。 很多网站会拦截没有UA或UA明显是脚本的请求。我的做法是从真实浏览器里复制一份完整的UA,再配置Accept-Language为中文,减少被识别异常的概率。
异常处理不能省。 网络请求超时、页面结构变更、编码识别错误,这些都是爬虫的常态。我在每个请求外面都包了try-except,出现异常时打印日志后继续跑,而不是整个程序崩掉。项目后期我加了一个重试装饰器,连续失败3次的URL会被记录到日志文件,方便人工排查。
随机延时是必须的。 刚开始写爬虫时我踩过坑:不加延时,几百个请求几秒内全打过去,IP直接被网站封了。后来改成random.uniform(1, 3)秒的随机延时,才算稳定下来。切记,毕设项目不需要追求抓取速度,稳定比速度重要。
2.3 数据清洗:去重、补全与归一化
抓下来的原始数据基本不能直接用。常见的脏数据包括:重复的景区条目、价格字段含“暂无”或“免费”、评分字段带“分”字或“暂无评分”、城市名不统一(如“北京市”和“北京”并存)。我写了一个清洗脚本,用pandas处理这些问题。
python复制import pandas as pd
import re
def clean_spot_data(df):
# 去重:按景区名称+城市去重,保留第一条
df = df.drop_duplicates(subset=["name", "city"], keep="first")
# 清洗评分:去掉非数字字符,转为float,无法转换的置为NaN
def parse_score(val):
if val in ["暂无", "", None]:
return None
match = re.search(r"(\d+\.?\d*)", str(val))
return float(match.group(1)) if match else None
df["score"] = df["score"].apply(parse_score)
# 清洗价格:将“免费”置为0,数字字符串转为float
def parse_price(val):
if val in ["暂无", "免费", "", None]:
return 0
match = re.search(r"\d+", str(val))
return float(match.group(1)) if match else 0
df["price"] = df["price"].apply(parse_price)
# 城市名称统一:去掉“市”“省”等后缀
df["city"] = df["city"].str.replace("市", "", regex=False).str.replace("省", "", regex=False)
# 填充缺失评分:用该城市平均分填充
df["score"] = df.groupby("city")["score"].transform(
lambda x: x.fillna(x.mean())
)
return df
清洗逻辑里最需要注意的是评分缺失值的处理。这里用同一城市景区的平均分来填充,对于推荐系统来说是合理的——用户搜索“北京景点”时,期望看到的是北京范围内整体水平较好的景点,用城市均值填充不会对排序造成明显偏差。如果直接用全局均值填充,反而会让某些景点少的城市吃亏。
2.4 增量抓取与定时更新
景区数据不是一成不变的,评分和热度会随用户反馈实时变化。如果每次全量抓取,既浪费资源又容易触发反爬。我这里采用了简单的增量策略:以景区名称+城市作为唯一标识,数据库中已存在的记录只更新评分、评论数、热度字段,新增记录则完整插入。
实现方式是在爬虫脚本末尾加一个同步逻辑:
python复制def sync_to_db(df):
from django.db import transaction
from spots.models import Spot
for _, row in df.iterrows():
with transaction.atomic():
spot, created = Spot.objects.update_or_create(
name=row["name"],
city=row["city"],
defaults={
"score": row["score"],
"price": row["price"],
"comment_count": row["comment_count"],
"tags": row["tags"],
},
)
if created:
print(f"[new] {spot.name}")
update_or_create是Django ORM里非常实用的方法,一句代码就完成了“有则更新、无则新增”的逻辑。为了避免单条数据异常导致整个事务回滚,每条记录都包裹在独立的transaction.atomic()里。这个脚本我放到Django的management command里,配合定时任务每天凌晨执行一次。
3. 大数据存储与处理层
3.1 数据库表结构设计
项目用MySQL作为主数据库。数据量级在万条以内,MySQL完全够用,不需要上HDFS或者Hive那套重型方案。但表结构设计还是要认真对待,因为推荐算法的查询性能直接取决于表的索引和字段设计。
核心表设计如下:
景区表(Spot)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| name | varchar(100) | 景区名称 |
| city | varchar(50) | 所在城市 |
| level | varchar(20) | 景区等级(5A/4A等) |
| score | decimal(3,2) | 评分 |
| price | decimal(10,2) | 门票价格 |
| comment_count | int | 评论数 |
| longitude | decimal(10,6) | 经度 |
| latitude | decimal(10,6) | 纬度 |
| tags | varchar(255) | 标签,逗号分隔 |
| description | text | 简介 |
| cover_url | varchar(255) | 封面图URL |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
用户表(User):Django自带的auth.User即可,如果想跟推荐功能解耦,可以建立UserProfile扩展表,存偏好标签。
收藏表(Favorite)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| user_id | int | 用户ID |
| spot_id | int | 景区ID |
| created_at | datetime | 收藏时间 |
浏览记录表(ViewLog)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| user_id | int | 用户ID |
| spot_id | int | 景区ID |
| view_count | int | 浏览次数 |
| updated_at | datetime | 最后浏览时间 |
推荐结果表(Recommendation):保存算法计算出的推荐结果,避免每次请求都重新跑算法。定时任务每天凌晨重新计算一次,白天用户访问时直接查表返回。
设计索引时,(city, level)、(name, city)等组合索引是必须建的,因为推荐列表最常用的筛选条件是“某城市的景点按评分排序”。
3.2 数据预处理与特征工程
清洗后的数据进入数据库,但推荐算法还需要特征。这里说的特征不是机器学习里那种高维向量,而是更偏业务理解的属性组合。
我给每个景点构建了四类特征:
- 基础属性:城市、等级、票价区间。
- 质量属性:评分、评论数。
- 内容属性:标签(“自然风光”“历史古迹”“亲子游”“主题乐园”等)。
- 热度属性:近期浏览和收藏的累计次数。
标签特征非常重要。推荐算法里做相似度计算时,两个景点如果标签重合度高,它们就被认为是内容相似的。我给每个景点分配了1到4个标签,全部来自爬虫抓取的页面标签和简介文本的关键词抽取。
3.3 为什么这种规模不需要“真”大数据框架
很多同学看到“大数据”三个字,第一反应是上Hadoop、Spark、Hive。这里我需要泼一盆冷水:如果数据量只有几千条,硬套大数据框架完全没必要。 Hadoop生态的启动和维护成本极高,在你写文档的时候会消耗大量时间,而系统实际跑出的效果跟MySQL+缓存没有本质区别。
那这个项目还能叫“大数据”吗?能。大数据的核心价值是“从海量数据中发现有价值的信息”,这里的数据量级虽然不大,但整体处理流程是标准的大数据链路:数据采集(爬虫)→数据清洗→数据存储→数据分析→数据可视化。技术上,你可以在项目文档里用“PB级数据扩展思路”来展望后续演进——比如接入Kafka做实时数据流、用Spark做离线计算、用Redis做缓存。这些扩展点在架构上预留好,比如把爬虫数据先进入消息队列再落库,把推荐计算做成独立的任务模块,这样才能让项目具备“大数据”的说服力。
4. 推荐系统核心算法实现
4.1 冷启动方案:基于热度和评分的基础推荐
新用户进入系统还没有任何行为数据,推荐算法无法基于个性化偏好计算。这时候需要一个冷启动方案兜底。
我的做法是做一个“热门推荐榜”,计算规则综合评分和热度:
code复制hot_score = 0.6 * normalized_score + 0.4 * normalized_hot
normalized_score:景点的评分经过Min-Max归一化到0-1区间。normalized_hot:景点的评论数归一化到0-1区间。
为什么加热度权重?只看评分的话,一个评分4.9但只有3条评论的冷门景点会排在评分4.5且有3000条评论的热门景区前面,这不合理。评论数代表了数据的可信度,评论数越多,评分越有参考价值。
python复制def get_hot_spots(city=None, top_n=10):
from django.db.models import F, Q
from spots.models import Spot
from django.db.models.functions import Coalesce
from django.db.models import DecimalField
spots = Spot.objects.all()
if city:
spots = spots.filter(city=city)
# 归一化计算在SQL层面完成,避免全量加载到Python内存
spots = spots.annotate(
norm_score=F("score") / 5.0,
norm_hot=Coalesce(F("comment_count") / 1000.0, 0.0)
).annotate(
hot_score=0.6 * F("norm_score") + 0.4 * F("norm_hot")
).order_by("-hot_score")[:top_n]
return list(spots)
这里我特别注意了性能问题。如果先list(Spot.objects.all())把几千条数据全加载到Python里再计算排序,还算能接受,但一旦数据量到几十万条就会内存溢出。用Django的annotate在SQL层面做算术运算,是所有ORM框架推荐的做法,既高效又简洁。
4.2 基于内容的推荐:标签与城市匹配
一个核心场景是:用户收藏了几个景点,系统要根据这些收藏行为推荐更多相似景点。这里用“基于内容的推荐”最合适,不需要其他用户的数据,只分析用户收藏景点本身的属性。
算法思路:
- 提取用户所有收藏景点的标签集合,统计每个标签出现的次数。
- 对未收藏的景点计算标签相似度。
- 剔除用户已收藏的景点,按相似度倒序返回TopN。
python复制def recommend_by_content(user, top_n=10):
from collections import Counter
from spots.models import Spot, Favorite
# 用户收藏的景点
favs = Favorite.objects.filter(user=user).select_related("spot")
if not favs.exists():
return get_hot_spots()
fav_spots = [f.spot for f in favs]
tag_counter = Counter()
for spot in fav_spots:
for tag in spot.tags.split(","):
tag_counter[tag] += 1
total_tags = sum(tag_counter.values())
# 排除已收藏景点ID
fav_ids = [s.id for s in fav_spots]
candidates = Spot.objects.exclude(id__in=fav_ids)
scored = []
for spot in candidates:
if not spot.tags:
continue
spot_tags = spot.tags.split(",")
overlap = sum(tag_counter[tag] for tag in spot_tags if tag in tag_counter)
similarity = overlap / total_tags
# 结合评分做加权,避免低分但标签相似的景点排前面
scored.append((similarity * 0.7 + spot.score / 5.0 * 0.3, spot))
scored.sort(key=lambda x: x[0], reverse=True)
return [spot for _, spot in scored[:top_n]]
这里做了一个加权处理:相似度占70%,评分占30%。为什么要结合评分?因为标签相似度只能反映“是否同类”,不能反映“质量高低”。单独用标签相似度的话,一个冷门又低分的同类景点可能会排在高分景点前面,用户看到的推荐列表质量不高。
4.3 协同过滤:基于用户的推荐
基于内容的推荐有一个天然缺陷:它永远只能推荐用户已收藏类型的类似景点,缺乏“跨类型惊喜”。比如用户只收藏了自然风光,系统就不会推荐优秀的博物馆。这时候协同过滤就能派上用场。
这里我实现了简化版的UserCF(基于用户的协同过滤)。核心逻辑是:找到与当前用户兴趣相似的其他用户,把那些用户收藏过但当前用户没收藏过的景点推荐给当前用户。
计算用户相似度最常用的方法是余弦相似度。但在实际项目里,用户量不大时,可以直接用“共同收藏的景点数”作为相似度指标,效果直观且计算简单。
python复制def recommend_by_user_cf(user, top_n=10):
from spots.models import Favorite
from django.db.models import Count
# 找到与当前用户有共同收藏景点的其他用户
my_favs = Favorite.objects.filter(user=user)
my_spots = set(my_favs.values_list("spot_id", flat=True))
if not my_spots:
return get_hot_spots()
# 其他用户收藏了哪些景点
other_favs = Favorite.objects.exclude(user=user).filter(spot_id__in=my_spots) \
.values("user_id").annotate(common_count=Count("spot_id")).order_by("-common_count")
# 取共同收藏数最多的前5个用户
similar_users = [item["user_id"] for item in other_favs[:5]]
# 从相似用户收藏中排除已收藏的,按收藏次数排序
rec_spots = Favorite.objects.filter(user_id__in=similar_users) \
.exclude(spot_id__in=my_spots) \
.values("spot_id").annotate(rec_count=Count("id")).order_by("-rec_count")[:top_n]
spot_ids = [item["spot_id"] for item in rec_spots]
return list(Spot.objects.filter(id__in=spot_ids))
这段代码的精髓在于全程只用SQL聚合,没有把大数据一次性load到内存。common_count就是用户之间的“相似度分数”,rec_count是相似用户群里最受欢迎的景区。如果你有足够多的用户行为数据,可以在这个基础上进一步加权评分和标签相似度,算法精度会更高,但对毕设项目来说,这个版本已经能看出完整的推荐思路了。
4.4 推荐结果的融合策略
实际部署时,我没有只用一种推荐算法,而是做了结果融合:
- 新用户(无收藏行为):热门推荐。
- 有收藏行为的用户:基于内容的推荐结果占60%,协同过滤结果占40%。
- 候选结果不足时:用热门推荐补足。
融合的好处是兼顾了稳定性和多样性。实测下来,纯内容推荐容易让推荐结果太“窄”,纯协同过滤在用户数据少时又不够稳定,融合之后用户反馈明显好转。你在项目文档里可以把这个策略写成“混合推荐策略”,这是一个加分项。
5. Django后端与API设计
5.1 项目结构与配置
推荐算法再花哨,最终还是要通过Django暴露给前端。项目结构我按功能拆成了多个app,每个app职责清晰:
code复制project/
├── manage.py
├── config/
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── spots/ # 景区模块
│ ├── models.py
│ ├── views.py
│ ├── urls.py
│ └── recom.py # 推荐算法
├── users/ # 用户模块
├── analytics/ # 数据统计模块
└── templates/
└── index.html # 可视化大屏
settings配置里,有几个容易踩坑的地方。数据库配置要确认MySQL的字符集是utf8mb4,否则景区简介里偶尔出现的特殊字符(比如emoji)会报编码错误。静态文件路径一定要配好,否则前端页面的CSS和JS加载不出来。另外,Django 4.0以后USE_TZ默认是True,如果你的MySQL存储的是本地时间,查询时要小心时区偏差。
5.2 核心模型代码
Django模型的设计直接决定后续查询的方便程度。我贴出景区模型和收藏模型的示例:
python复制from django.db import models
from django.contrib.auth.models import User
class Spot(models.Model):
name = models.CharField("景区名称", max_length=100)
city = models.CharField("所在城市", max_length=50, db_index=True)
level = models.CharField("景区等级", max_length=20, blank=True)
score = models.DecimalField("评分", max_digits=3, decimal_places=2, default=0)
price = models.DecimalField("门票价格", max_digits=10, decimal_places=2, default=0)
comment_count = models.IntegerField("评论数", default=0)
longitude = models.DecimalField("经度", max_digits=10, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField("纬度", max_digits=10, decimal_places=6, null=True, blank=True)
tags = models.CharField("标签", max_length=255, blank=True)
description = models.TextField("简介", blank=True)
cover_url = models.URLField("封面图URL", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "spot"
verbose_name = "景区"
def __str__(self):
return self.name
class Favorite(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="用户")
spot = models.ForeignKey(Spot, on_delete=models.CASCADE, verbose_name="景区")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "favorite"
unique_together = ("user", "spot")
unique_together这一行很重要,它防止同一用户重复收藏同一景点。但要注意,数据库层的唯一约束和代码层的逻辑判断是两回事——如果你在视图里先Favorite.objects.filter(user=user, spot=spot).exists()再创建,高并发下会出现重复记录。更稳妥的做法是直接.get_or_create(user=user, spot=spot)。
5.3 接口设计与实现
系统核心接口就几个,我用Django视图返回JSON格式数据,前端通过Ajax调用。
推荐接口
python复制import json
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from .recom import recommend_for_user
@login_required
def recommend_api(request):
user = request.user
top_n = int(request.GET.get("top_n", 10))
spots = recommend_for_user(user, top_n)
data = [{
"id": s.id,
"name": s.name,
"city": s.city,
"score": float(s.score),
"price": float(s.price),
"tags": s.tags,
"cover_url": s.cover_url,
"description": s.description[:50] + "..." if len(s.description) > 50 else s.description,
} for s in spots]
return JsonResponse({"code": 0, "data": data})
这里有几个让人印象深刻的细节。float(s.score)是必须的,因为Django的DecimalField默认返回字符串,JSON序列化时会出现引号,前端拿到的就不是数字。description[:50]是做摘要截取,列表页不需要完整简介,减少传输数据量。登录装饰器@login_required用起来简洁,但注意它默认会重定向到登录页,如果前端是Ajax请求,返回的会是302,你得在settings里配置LOGIN_URL或者返回JSON的401状态码。
可视化数据接口
可视化大屏需要几类数据:各城市景区数量、热门景点TOP10、评分分布、标签占比。这些数据全部通过聚合查询得到,不需要额外维护统计表。
python复制def analytics_api(request):
from django.db.models import Count, Avg
from spots.models import Spot
# 城市景区数量
city_data = Spot.objects.values("city").annotate(
spot_count=Count("id"),
avg_score=Avg("score")
).order_by("-spot_count")[:15]
# 热门景点TOP10
hot_spots = Spot.objects.order_by("-comment_count")[:10].values(
"name", "score", "comment_count"
)
# 评分分布
rating_dist = {
"4.5以上": Spot.objects.filter(score__gte=4.5).count(),
"4.0-4.5": Spot.objects.filter(score__gte=4.0, score__lt=4.5).count(),
"3.5-4.0": Spot.objects.filter(score__gte=3.5, score__lt=4.0).count(),
"3.5以下": Spot.objects.filter(score__lt=3.5).count(),
}
return JsonResponse({
"city_data": list(city_data),
"hot_spots": list(hot_spots),
"rating_dist": rating_dist,
})
这种聚合查询在Django ORM里效率很高,因为values("city").annotate(...)会生成GROUP BY city的SQL。需要注意的是,Avg("score")在存在NULL值时会忽略空行,如果某个场景需要把NULL当作0参与计算,要先用Coalesce处理。
5.4 Django Admin配置
Django Admin是后端管理的利器,强烈建议把核心模型注册进去。在admin.py里做简单配置后,管理员就能直接在后台增删改景区数据、查看用户收藏记录,不需要写任何前端页面。
python复制from django.contrib import admin
from .models import Spot, Favorite
@admin.register(Spot)
class SpotAdmin(admin.ModelAdmin):
list_display = ("name", "city", "level", "score", "price", "comment_count")
list_filter = ("city", "level")
search_fields = ("name", "description")
ordering = ("-comment_count",)
list_per_page = 20
search_fields会生成一个搜索框,支持按景区名称和简介模糊搜索。list_filter在右侧生成筛选栏,可以快速筛选某城市或某等级的景区。这些看似不起眼的功能,在做数据校对时会节省大量时间。
6. 可视化大屏实现
6.1 可视化方案选择:ECharts还是pyecharts
可视化大屏是很多毕设老师一眼就会关注到的亮点。实现方案主要有两种:
方案一:前端ECharts + Ajax获取数据。 页面用HTML + CSS + JavaScript搭建,图表用ECharts初始化,数据从后端接口异步加载。优点:图表交互流畅、可定制性强、热加载时不需要刷新页面。缺点:需要一些前端基础。
方案二:pyecharts直接在Python端生成HTML。 在Django视图里用pyecharts生成完整的HTML片段,嵌入模板返回。优点:纯Python开发,不需要写JavaScript。缺点:每次请求都要重新渲染图表,大数据量下性能一般,交互性弱。
我给学生的建议是:如果没有前端基础,先选方案二快速出成果;如果你能静下心来折腾几天前端,方案一的视觉效果和稳定性要高出不少。
6.2 大屏布局与图表实现
我实际采用的是方案一,整个大屏页面按“总览—分布—排行”的逻辑划分为三个区域:
- 顶部:标题和全局统计卡片(景区总数、城市数、平均评分、总评论数)。
- 中部左侧:中国地图,按城市标注景区数量热力图。
- 中部右侧:热门景区TOP10柱状图。
- 底部左侧:评分分布饼图。
- 底部右侧:标签分布词云。
ECharts中,地图需要引入GeoJSON数据。国内地图的GeoJSON可以直接通过echarts.registerMap注册,也可以从开源项目里下载中国地图的JSON文件放到本地静态目录。这里有个很常见的坑:ECharts 5.x默认不再内置地图数据,必须手动注册,否则地图区域显示空白。
核心JavaScript代码如下:
javascript复制function loadCharts() {
fetch("/api/analytics/")
.then(res => res.json())
.then(data => {
initCityMap(data.city_data);
initHotBar(data.hot_spots);
initRatingPie(data.rating_dist);
})
.catch(err => console.error("加载数据失败:", err));
}
function initHotBar(hotSpots) {
const chart = echarts.init(document.getElementById("hotBar"));
const option = {
title: { text: "热门景区TOP10" },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: hotSpots.map(s => s.name) },
yAxis: { type: "value" },
series: [{
name: "评论数",
type: "bar",
barWidth: "50%",
data: hotSpots.map(s => s.comment_count),
itemStyle: { color: "#3b82f6" }
}]
};
chart.setOption(option);
}
loadCharts();
fetch发请求后用.then链式处理返回结果,这是现在前端最主流的写法。注意initCharts必须在DOM元素渲染完成后调用,否则document.getElementById会返回null。我的习惯是把loadCharts()放在window.onload事件里,确保所有DOM加载完成。
6.3 大屏适配与美化细节
大屏项目还有一个常见的坑:屏幕分辨率适配。在电脑上调试好的页面,投到教室的大屏上可能布局全乱。ECharts图表本身会自适应容器宽高,但大屏的容器尺寸往往需要用百分比或vw/vh单位来定义,而不是固定的px像素。
另外一个容易被忽略的细节是配色。可视化大屏的受众是站在远处看的人,浅色系文字在小屏幕上可能没问题,在投影仪上就会看不清。我推荐用深色背景搭配高对比度的亮色文字,比如深蓝背景+白色标题+浅蓝图表主体,这是很多数据大屏项目的经典配色方案。
7. 常见问题与排查技巧实录
这个项目从头到尾走下来,遇到的问题不少。我整理了一份速查表,基本覆盖了最常见的坑,分为两大类。第一类是爬虫和数据相关问题,第二类是Django和前端联调问题。
| 问题现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 爬虫请求被拒绝,返回403 | 反爬机制拦截了非浏览器UA | 修改User-Agent,加入Referer信息,降低请求频率 |
| 页面HTML能获取但解析不到数据 | 数据由JavaScript异步加载 | 用Selenium或抓取包含数据的接口URL |
| 写入MySQL时中文乱码 | 数据库字符集不是utf8mb4 | 修改MySQL库和表的字符集为utf8mb4 |
| Django Admin样式丢失 | 静态文件未正确收集或配置 | 执行python manage.py collectstatic并检查STATICFILES_DIRS配置 |
| ECharts地图显示空白 | ECharts 5.x未注册地图GeoJSON | 用echarts.registerMap("china", geoJson)注册地图 |
| 前端Ajax请求返回302 | 接口需要登录而用户未登录 | 配置LOGIN_URL或对Ajax接口返回JSON 401 |
| 推荐结果全是热门景点 | 用户没有行为数据走冷启动 | 属于正常逻辑,但可设计“猜你喜欢”页面给冷启动用户尝试 |
| 数据量大时推荐接口响应慢 | 未加索引或全表扫描 | 给常用筛选字段加索引,热点计算预生成推荐结果缓存 |
其中有两个问题我想展开说说,因为它们非常容易遇到且网上资料不集中。
第一个:Selenium动态页面超时。 Selenium启动的浏览器如果网络慢或者目标页面资源过多,find_element容易抛超时异常。我的解决办法是把隐式等待webdriver.ChromeOptions().add_argument("--headless")配合WebDriverWait的显式等待一起用,并且加了一个页面加载超时的option设置。抓取时用--headless无头模式,窗口可见但是不弹出来,效率高也不会干扰其他工作。
第二个:Django分页与推荐排序冲突。 推荐算法返回的通常是一个打分排序后的列表。如果直接用Django的Paginator对QuerySet分页,排序逻辑会变得混乱。我的做法是先把推荐结果转成纯Python列表,再用手动切片分页,避免SQL层面的LIMIT/OFFSET打乱推荐权重。
python复制rec_spots = recommend_for_user(user, top_n=50)
page_num = int(request.GET.get("page", 1))
page_size = 10
start = (page_num - 1) * page_size
end = start + page_size
page_data = rec_spots[start:end]
这个方案虽然带点“土办法”的味道,但逻辑非常清晰,而且对推荐系统这种“结果集不大但排序很重要”的场景很实用。如果你的推荐候选集大到上百万,再考虑引入Redis缓存或搜索引擎来优化。
回到项目本身,如果时间充裕,这个系统还能继续扩展。比如接入Redis缓存推荐结果、用Docker容器化部署、在前端加一个用户反馈按钮收集真实偏好数据。每扩展一步,项目深度就会多一个档次。但从工程交付的角度,先把这七块基本功做扎实,已经足以成为一个完整、高质量的大数据旅游推荐系统。无论你是做毕设还是找工作时的个人项目,这套东西拿得出手。
