
简介这是一套基于 Python 与 Django 搭建的电影数据分析与可视化系统完整代码适合具备一定 Python 基础、希望练习爬虫、Web 开发与可视化分析的数据分析初学者。项目覆盖用户登录、电影数据爬取、TOP10 电影推荐、模糊搜索以及基于年代、产地、类型的饼图/柱状图和评价词云等常见功能模块内置电影、用户、评分、评论等 CSV 数据集与 SQLite 数据库能作为课程设计、毕业设计或个人项目模板。压缩包共 42 个文件包含 11 个 HTML 页面、8 个 Python 源码、8 个 CSV 数据文件、SQLite 数据库、环境说明与 README 文档整体约 299.78MB目录按 Django 项目结构组织便于直接部署和二次开发。已有 1040 人学习下载。拿到资源后可对照项目代码了解数据从采集、清洗到可视化展示的完整链路也可复用其中的登录、搜索和图表模块快速迁移到其他数据分析场景。1. 从一份代码看电影数据分析与可视化系统拿到一份 Django 写的电影数据分析系统源码第一件事不是急着跑起来而是先搞清楚它的数据从哪来、怎么存、又通过哪些页面展示。这套系统的价值在于把「爬虫 → CSV → SQLite → 数据可视化」串成了一条完整链路而不是单纯给你一个静态后台。用户登录后可以看到 TOP10 推荐、按导演/演员模糊搜索并在图表页选择关键词切换饼图、柱状图与词云。适合想了解 Web 开发与数据分析如何结合的从业者也适合需要快速搭建内部演示系统的团队。项目核心不复杂但踩坑点不少下面按数据、视图、可视化、部署四个层面展开。2. 数据层拆解从 CSV 到 SQLite 的入库流程2.1 数据文件视角哪些字段值得建索引项目里可以看到movies.csv、users.csv、person.csv、ratings.csv、comments.csv等文件。直接看movies.csv的话最值得关注的字段有电影名称、导演、演员、年代、产地与类型。搜索功能依赖的是导演和演员字段评论词云则依赖 comments 数据。从数据库设计的角度看搜索频繁的字段必须建索引否则数据量到几万条时LIKE %张艺谋%这类查询会触发全表扫描。常见做法是在views.py或models.py中为这些字段配置db_indexTrue或者直接在 SQLite 里建立索引。CREATE INDEX IF NOT EXISTS idx_movies_director ON movies(director); CREATE INDEX IF NOT EXISTS idx_movies_title ON movies(title); CREATE INDEX IF NOT EXISTS idx_movies_actor ON movies(actor);这段 SQL 的作用有三点第一IF NOT EXISTS防止重复初始化报错第二索引命名一眼能看出对应表和字段排查时方便第三将索引建立在搜索条件常用的列上让 Django ORM 生成的查询快速命中索引而不是扫描整表。如果你后续把系统迁移到 PostgreSQL这个思路同样适用。在入库之前先通过一张表看清各个数据文件与业务表的关系方便后面写脚本时对齐字段。CSV 文件主要字段入库表名在系统里的作用movies.csvtitle、director、actor、year、country、typemovies电影列表、搜索、图表统计users.csvusername、password、prefusers登录验证、偏好推荐ratings.csvuser_id、movie_id、ratingratingsTOP10 评分排序comments.csvuser_id、movie_id、commentcomments评论词云分析person.csvname、role、movie_idperson演职人员关联查询这张表梳理清楚后你会发现整个系统的核心其实只有两张事实表ratings和comments其他都是维度表。做可视化时饼图和柱状图主要从movies聚合词云则从comments聚合两者数据源不同最好分开处理不要在同一个查询里 join否则数据量一大就会拖慢页面响应。2.2 create_data.py 的入库逻辑与重复处理源码包中的create_data.py是核心脚本它的职责通常是把 CSV 数据写入db.sqlite3。很多初学者最容易犯的错是每次运行脚本就重复插入一遍导致数据库出现大量重复行。我一般会在写入前做一次存在性判断以电影名和生产年份作为联合唯一键。import sqlite3 import csv conn sqlite3.connect(db.sqlite3) cur conn.cursor() with open(movies.csv, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: # 联合唯一键title year避免重复入库 cur.execute( SELECT id FROM movies WHERE title ? AND year ?, (row[title], row[year]), ) if cur.fetchone(): continue cur.execute( INSERT INTO movies (title, director, actor, year, country, type) VALUES (?, ?, ?, ?, ?, ?), ( row[title], row[director], row[actor], row[year], row[country], row[type], ), ) conn.commit() conn.close()这里的执行顺序很关键先查询再插入查询条件越精确越能防止并发写入时的重复问题。如果数据量特别大还可以给cur.execute加executemany批量写入减少事务提交次数。参数化查询用?占位符避免 SQL 注入这是写数据处理脚本的基本要求。运行完脚本后建议用sqlite3 db.sqlite3检查数据总量sqlite3 db.sqlite3 SELECT COUNT(*) FROM movies; sqlite3 db.sqlite3 SELECT COUNT(DISTINCT title || | || year) FROM movies;如果两个命令的结果不一致说明重复数据仍然存在。我曾经遇到的坑是 CSV 里同一部电影有不同年份版本单纯按标题判断会放过旧版本所以联合唯一键要包括年份。这个判断逻辑在后续报表统计时影响很大重复数据会导致饼图比例失真、词云权重错乱。2.3 数据一致性验证评论表与电影表的外键关系项目中存在ratings.csv与comments.csv这两张表一般通过用户 ID 与电影 ID 关联。写入时如果外键对不上后续可视化查询就会出现空值。建议在入库后执行以下检查-- 找出评论表中没有对应电影的记录 SELECT c.id, c.movie_id FROM comments c LEFT JOIN movies m ON c.movie_id m.id WHERE m.id IS NULL;如果查询结果不为空说明评论数据里存在脏数据。最简单的方式是在写入脚本里先过滤掉这些记录而不是在可视化阶段再处理。可视化阶段做数据清洗会非常被动因为所有图表的数据源都来自聚合查询脏数据会直接影响统计结果。3. Django 视图与模板登录、搜索与推荐背后的请求链路3.1 urlconf 与视图函数的路由映射项目目录中urls.py包含的路由决定了每个用户请求最终由哪个视图函数处理。典型的配置会包含登录、电影列表、搜索、图表展示这几条路径。下面是精简版的路由配置from django.urls import path from app import views urlpatterns [ path(, views.index, nameindex), path(login/, views.user_login, namelogin), path(movies/, views.movie_list, namemovie_list), path(search/, views.search_movie, namesearch_movie), path(charts/, views.chart_analysis, namechart_analysis), ]路由命名name很有讲究模板里用{% url search_movie %}反向生成 URL比硬编码/search/更安全。如果以后换路由前缀模板无需改动。views.py对应的函数应该各自只做一件事user_login处理登录表单search_movie负责模糊搜索chart_analysis聚合数据后传给前端。项目代码里有时会把所有逻辑堆在views.py导致耦合严重重构时可以根据功能拆分成views_auth.py、views_chart.py等模块。3.2 登录会话与用户偏好写入登录逻辑用的是 Django 自带 session。用户登录成功后把用户 ID 和筛选偏好写入 session后续推荐页面读取这些值来决定显示哪些 TOP10 电影。from django.contrib.auth.hashers import make_password, check_password from django.shortcuts import render, redirect from app.models import User def user_login(request): if request.method POST: username request.POST.get(username) password request.POST.get(password) try: user User.objects.get(usernameusername) except User.DoesNotExist: return render(request, error.html, {msg: 用户不存在}) if check_password(password, user.password): # session 中只存必要信息不存密码 request.session[user_id] user.id request.session[user_name] user.username request.session.set_expiry(7200) # 两小时过期 return redirect(movies) return render(request, error.html, {msg: 密码错误}) return render(request, login.html)check_password是 Django 自带的密码校验函数底层会根据数据库里的哈希算法自动选取验证方式。现实中我见过很多项目把明文密码直接存到users.csv仅供课程设计演示可以生产环境绝不能这么做。这里还用到了set_expiry(7200)让 session 在两小时后失效避免用户长期不操作导致信息泄露。用户偏好一般保存在users.csv或 SQLite 的 user 表中。推荐逻辑很简单根据用户注册时选择的偏好类型在电影表中按评分降序筛选 TOP10再传给templates下的start.html模板。关键点在于过滤条件必须和电影表的类型字段严格对应比如类型存的是「喜剧」而用户偏好存的是「喜剧片」匹配就会失败。写数据脚本时统一分类字典也是避免这种问题的手段。3.3 搜索功能的模糊匹配实现搜索界面覆盖的是电影名、导演、演员三个维度并且支持模糊匹配。Django ORM 里用icontains它对应 SQL 层的LIKE %keyword%并且不区分大小写。需要说明的是SQLite 默认的 LIKE 对中文也能正常工作但英文大小写要依赖icontains来保证匹配。from django.db.models import Q def search_movie(request): keyword request.GET.get(q, ).strip() movies [] if keyword: movies Movie.objects.filter( Q(title__icontainskeyword) | Q(director__icontainskeyword) | Q(actor__icontainskeyword) )[:50] return render(request, fc_search.html, {movies: movies, keyword: keyword})为什么用Q对象而不是三个filter叠加因为filter(title__icontainskeyword).filter(director__icontainskeyword)是 AND 关系要求同一个字段同时包含关键词完全不符合搜索期望。Q对象配合|实现 OR 关系三个条件任一命中即可返回。切片[:50]限制结果数量避免一次渲染几千行导致页面卡顿。搜索结果页fc_search.html通常用表格展示标题、导演、演员、地区、评分这几列足够用户判断是否需要点击详情。这里的搜索没有做分词以「张艺谋」搜索是成功的但如果输入「张艺」也能模糊匹配到这点对中文搜索来说已经是可接受的。3.4 模板继承与错误页处理模板目录包含了base.html、user_base.html、base_search.html继承关系一般是base.html作为全局骨架子模板扩展内容区。继承的好处在于登录状态和导航栏只写一遍。{% extends base.html %} {% block content %} div classmovie-card h2{{ movie.title }}/h2 p导演{{ movie.director }}/p p演员{{ movie.actor }}/p p评分{{ movie.rating }}/p /div {% endblock %}fc_movies.html和fc_show.html这类模板主要承担列表与详情展示。常见的错误是模板里直接写死action/search/而路由已经改了前缀导致提交 404。使用{% url %}标签则能避免。error.html是所有用户可见错误信息的统一出口后端视图发生异常时可以包一层try-except后 redirect 到带msg参数的error.html。4. 可视化层ECharts 配置与词云生成的踩坑记录4.1 三种图表类型的配置差异可视化界面的三件套是饼图、柱状图、词云。饼图适合展示分类占比例如电影类型分布柱状图适合对比年代或产地数量词云则是把评论关键词按照频次映射到字号大小。下表中对比了三种图表需要的数据结构图表类型核心数据格式典型接口常见坑饼图[{name: 剧情, value: 234}, ...]后端聚合 category 分组数据中混入空字符串柱状图categories: [2000,2001,...], data: [12,34,...]SQL 按年份 GROUP BY年度缺失时两端不对齐词云[{name: 好看, value: 89}, ...]中文分词后统计词频停用词未过滤高频词全为「的」后端视图chart_analysis的返回值建议是一个 JSON 序列化的字典前端拿到后直接 setOption。例如年份分布数据from django.db.models import Count from django.http import JsonResponse def year_distribution(): rows ( Movie.objects.values(year) .annotate(cntCount(id)) .order_by(year) ) categories [str(r[year]) for r in rows] values [r[cnt] for r in rows] return {categories: categories, values: values}这里值得注意的是年份要转成字符串。ECharts 的类目轴category会把数字型刻度当作连续坐标如果年份中间有缺档会显示成不存在的年份。转成字符串后 ECharts 会把它当成纯类目每个标签独立显示出来的柱状图更符合实际年份区间。4.2 词云图的中文分词与停用词处理评论数据在comments.csv中词云要先把所有评论文本合并再分词再统计词频。中文分词常见做法是使用 jieba 库配合项目自带的stopwords.txt过滤掉「的、了、是、我」这类无意义词。以下是标准流程import jieba from collections import Counter def build_wordcloud_data(comments): text .join(comments) words jieba.cut(text) stopwords set() with open(stopwords.txt, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) filtered [w for w in words if w.strip() and w not in stopwords and len(w) 1] counter Counter(filtered) return [{name: k, value: v} for k, v in counter.most_common(100)]为什么要len(w) 1单个汉字进入词云后视觉上常常是孤立字缺乏可读性。比如评论里的「好、牛、强」这类字虽然表达了情绪但在词云里容易断裂。most_common(100)限制词条数防止交通过量关键字导致渲染缓慢。这种词频统计思路与机器学习里的文本特征提取是一致的做分类器之前通常也是先做分词、去停用词、计数。词云图在前端有现成实现ECharts 的echarts-wordcloud插件或者更轻量的wordcloud2.js。如果使用echarts-wordcloud配置里需要注意shape参数不同值会生成圆形、菱形等不同外轮廓。真实项目中经常因为字体太小或gridSize过细导致词云重叠我会将gridSize设为 8、textStyle.fontSize的范围控制在 12 到 60 之间。4.3 前端异步加载与数据接口对接图表页可以一次性把全部数据渲染也可以由后端提供 JSON 接口、前端通过 fetch 按关键词动态加载。动态加载体验更好因为用户切换关键词不需要刷新整个页面而且可以和搜索功能联动。fetch(/api/chart_data?keyword${encodeURIComponent(currentKeyword)}) .then(res res.json()) .then(data { myChart.setOption({ xAxis: { type: category, data: data.year.categories }, yAxis: { type: value }, series: [ { type: bar, data: data.year.values, barWidth: 60% } ] }); });encodeURIComponent处理中文关键词避免 URL 里出现未编码字符导致 400。后端接口接收keyword后需要判断关键词属于「类型」「年代」还是「产地」这个逻辑可以在视图里先查一遍电影表。如果接口超时多半是因为聚合查询没加索引回到第 2 章索引部分排查。5. 部署与调试静态文件收集与日志定位技巧5.1 静态文件路径与 DEBUGFalse 的配置把系统放到服务器上时第一件事是关闭 Django 的 DEBUG 并设置ALLOWED_HOSTS否则页面会暴露详细报错信息。关闭 DEBUG 后静态文件经常全部丢失原因是settings.py里需要配置静态文件收集路径DEBUG False ALLOWED_HOSTS [localhost, your-server-ip] STATIC_URL /static/ STATIC_ROOT os.path.join(BASE_DIR, staticfiles) python manage.py collectstatic --noinputcollectstatic会把static目录下所有文件复制到staticfiles再由 Nginx 直接接管静态文件请求Django 只处理动态请求。这里常见错误是忘记在 Nginx 配置里添加 location 块location /static/ { alias /path/to/project/staticfiles/; }如果不配置CSS 和 JS 加载失败页面看起来就是「裸奔」状态图表控件完全无法渲染。5.2 日志输出与请求耗时定位排查慢请求时可以在views.py里打印耗时定位是 SQL 查询慢还是模板渲染慢import time def search_movie(request): start time.time() # ... 业务逻辑 ... elapsed time.time() - start print(f[search_movie] cost {elapsed:.3f}s, keyword{keyword})用日志而不是print更规范一些可以使用 Django 的logging写入本地文件。我通常把日志级别设置为 INFO并且把人库脚本、视图请求、爬虫任务分别写到不同文件后续排查时按时间线串联。5.3 爬虫数据更新的幂等设计既然系统涉及爬虫数据收集你可能会定期运行爬虫脚本更新 CSV。这时要保证每次脚本运行不产生重复数据除了前面提到的唯一键判断还可以用「先清空再导入」的策略对于小数据量的 CSV直接把对应表清空再批量导入简单可控。数据量阶层不同策略的选择也随之改变已有千万级数据再去清空就不合适了。# 小数据量下的一种稳妥做法 cur.execute(DELETE FROM movies;) cur.executemany( INSERT INTO movies (title, director, actor, year, country, type) VALUES (?, ?, ?, ?, ?, ?), movie_rows, )别忘了DELETE FROM movies后需要重置自增主键否则新数据从原来的max(id)1继续增长不会真正变成从 1 开始的序列。SQLite 里可以用DELETE FROM sqlite_sequence WHERE namemovies;处理。这个细节判断比较关键接口返回的 ID 如果越来越乱前端详情页跳转很容易错位。检查sqlite_sequence是否被重置以及日志中是否有重复写入警告这两步都通过后再跑一次图表接口验证统计值即可。本文还有配套的精品资源点击获取