项目标题: "基于python的影片数据爬取与数据分析_django+spider"
项目正文: 本项目以Python为核心语言,集成爬虫(spider)与Django框架,完成影片数据的自动采集、存储、清洗、展示与多维数据分析,完整覆盖从数据获取到数据落地的全链路流程。
关键词: python,数据爬取,数据分析,django,spider
摘要描述: 使用Python与Django搭建影片数据爬取与分析系统,实现数据的采集、入库、平台展示与可视化分析。
做电影数据分析这件事,我琢磨了很久。市面上有不少现成的榜单、影评网站,但数据都散落在不同平台,想按自己的维度做统计——比如某类影片的票房分布、评分趋势、不同年份的题材变化——根本找不到一个现成的数据集。既然找不到,那就自己动手。用 Python 写爬虫去采集,用 Django 做数据落地和展示,最后用 pandas 配合可视化库做分析。这套流程跑通之后,你手里就有了一个可以随时扩展的影片数据底座,后续想加数据源、加分析维度,都只是在这个框架上做增量。
这篇文章就把我实现“影片数据爬取与数据分析(Django + Spider)”这套系统的完整过程写出来,包括架构怎么拆、爬虫怎么写、反爬怎么破、数据怎么进 Django、分析怎么做,以及我在实际开发中踩过的那些坑。项目代码整体基于 Python 3.10,后端用 Django 4.x,爬虫用 requests + BeautifulSoup,数据清洗用 pandas,可视化用 ECharts 做前端图表输出。
1. 项目整体设计与思路拆解
1.1 为什么选择 Django + Spider 这套组合
很多做数据分析的人习惯用 Jupyter Notebook 一把梭,从爬数到出图全在一个 notebook 里完成。这种方式做探索性分析(EDA)没问题,但一旦涉及数据持续更新、多人使用、定时任务调度,就撑不住了。我选 Django 的核心原因有两个:第一,Django 自带 ORM 和 Admin 后台,数据模型定义好之后,可以直接在后台管理数据,不用额外写管理界面;第二,Django 的 MTV 架构天然适合把分析结果变成可视化页面,爬虫入库的数据通过 View 查询出来,模板渲染成图表,一条链路是通的。
爬虫部分我用的是 requests + BeautifulSoup,没有上 Scrapy。原因是这个项目的数据源相对固定,采集量级在几千到几万条,单机单线程跑完也就几十分钟,Scrapy 的异步并发优势在这里不明显,反而增加了框架学习成本。但如果你的数据源是几百个网站、每天上百万条更新,那 Scrapy 的中间件、管道、调度器会让你省心很多,这个需要根据实际场景权衡。
补一句我对“Django + Spider”这个组合的理解:Django 在这个项目里不只是“展示层”,它本身就是数据中台。爬虫负责把外部数据变成结构化记录,Django 的 Model 负责定义字段和约束,Migration 负责表结构的演进,QuerySet 负责分析查询——数据从进来到出去,全程都在一个技术栈内,不用在多个工具之间倒腾数据格式,这是我觉得这个组合最顺滑的地方。
1.2 数据模型设计:先想清楚要分析什么
在写任何爬虫代码之前,我建议先把数据模型定下来。我的习惯是反推:先列出最终想输出的分析图表,再倒推需要哪些字段。比如我想分析“不同类型影片的票房对比”,那至少需要影片名称、类型、票房;我想分析“年度评分趋势”,需要年份、评分。
我最终设计的表结构如下:
python复制# movies/models.py
from django.db import models
class Movie(models.Model):
title = models.CharField(max_length=255, verbose_name='影片名称')
year = models.IntegerField(verbose_name='上映年份', null=True, blank=True)
genres = models.CharField(max_length=255, verbose_name='类型', null=True, blank=True)
rating = models.FloatField(verbose_name='豆瓣评分', null=True, blank=True)
votes = models.IntegerField(verbose_name='评分人数', null=True, blank=True)
box_office = models.FloatField(verbose_name='票房(万元)', null=True, blank=True)
director = models.CharField(max_length=255, verbose_name='导演', null=True, blank=True)
actors = models.TextField(verbose_name='主演', null=True, blank=True)
source_url = models.URLField(verbose_name='来源URL', unique=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name='采集时间')
class Meta:
db_table = 'movie_info'
indexes = [
models.Index(fields=['year']),
models.Index(fields=['genres']),
models.Index(fields=['rating']),
]
def __str__(self):
return self.title
这里有几个设计细节值得说。source_url 加 unique=True 约束,是为了防止爬虫重复采集。created_at 是采集时间,方便后续做增量更新。genres 没有用多对多表,而是直接存逗号分隔的字符串,虽然不符合数据库第三范式,但对于数据分析场景,这样查询效率更高,省去了每次分析都要 JOIN 的麻烦——分析型项目,读性能优先,规范化可以适当让步。
索引我建了三个,分别是年份、类型、评分。数据分析的项目大部分查询都是按这些维度过滤和分组,没有索引的话,几万条数据虽然也能跑,但 Django Admin 里筛选会明显变慢,这个在后面做可视化的时候体感差别很大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据爬取:从目标站点到干净数据集
2.1 爬虫模块:广度优先遍历 + 字段解析
数据源我选了豆瓣电影 Top250 和正在热映两个榜单。豆瓣的页面结构相对规整,HTML 里能直接拿到大部分字段,不用走 API,这对初级爬虫项目非常友好。
核心爬虫代码如下:
python复制# spider/douban_spider.py
import requests
from bs4 import BeautifulSoup
from movies.models import Movie
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
def fetch_top250():
"""抓取豆瓣电影 Top250,每隔 25 条翻页"""
for start in range(0, 250, 25):
url = f'https://movie.douban.com/top250?start={start}&filter='
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
for item in soup.select('div.item'):
title = item.select_one('span.title').text
rating = float(item.select_one('span.rating_num').text)
votes_text = item.select_one('div.star span').get('title', '')
# 详细页信息在链接里
detail_url = item.select_one('a')['href']
yield {
'title': title,
'rating': rating,
'source_url': detail_url
}
这个过程有两个坑要提。第一个是翻页参数,Top250 的 URL 用 start 参数控制偏移,每次加 25。第二个是 span.title 的选择器,如果影片有副标题,会存在两个 span.title 元素,必须用 select_one 取第一个,否则会把“肖申克的救赎 / The Shawshank Redemption”这种中英文混在一起的值存进去。
采集热映榜单时需要注意时效性。热映数据每天都会变,我的做法是写一个 Django management command,配合 cron 每天定时跑一次:
python复制# movies/management/commands/update_movies.py
from django.core.management.base import BaseCommand
from spider.douban_spider import fetch_top250
class Command(BaseCommand):
help = '更新影片数据'
def handle(self, *args, **options):
count = 0
for item in fetch_top250():
_, created = Movie.objects.update_or_create(
source_url=item['source_url'],
defaults={
'title': item['title'],
'rating': item['rating'],
}
)
if created:
count += 1
self.stdout.write(self.style.SUCCESS(f'新增 {count} 条数据'))
用 update_or_create 的好处是幂等:重复执行不会产生重复记录,只有字段有变化时才会更新。这样定时任务挂上去之后,不用操心“这次会不会跑重了”的问题。
2.2 反爬应对与请求频率控制
豆瓣的反爬机制不算严厉,但也不至于裸奔就能抓。直接 requests 不带 headers,大概率返回 418。我的经验是三个维度配合:
第一,User-Agent 必须伪装成真实浏览器。第二,请求间隔不能太短,我在翻页循环里加了 time.sleep(random.uniform(1, 3)),随机睡眠,避免规律性太强被限制。第三,单次任务采集量控制在几百条以内,跑完就停,不要一次性抓几万条。
如果遇到了 418 或验证码,最直接的应对是降速,把间隔拉到 3 到 5 秒,再不行就换数据源。有人喜欢用代理池轮流换 IP,但对这种轻量级项目,完全没必要,反而引入不稳定因素。
还有一个小细节:建议在爬虫里把响应状态码打出来,做成日志。我见过很多新手,爬虫跑完一条数据都没抓到,还以为是页面结构变了,打印状态码一看,全是 418。这种基础排查手段,能帮你省掉一半的调试时间。
2.3 数据清洗与字段归一化
原始页面里拿到的字段是脏的。比如票房字段可能是“2.36亿”这种格式;类型字段可能是“剧情 / 爱情 / 历史”这种带斜杠的字符串。所以在入库之前,必须做一次清洗。
python复制# spider/cleaning.py
import re
import pandas as pd
def parse_box_office(value):
"""将'2.36亿'转换为万元数值"""
if not value:
return None
m = re.search(r'([\d.]+)(亿|万)?', str(value))
if not m:
return None
num = float(m.group(1))
unit = m.group(2)
if unit == '亿':
return num * 10000
elif unit == '万':
return num
else:
return num
def clean_genres(value):
"""将'剧情 / 爱情'拆为列表"""
if not value:
return []
return [g.strip() for g in value.split('/')]
pandas 在这里的用途是把多个页面的数据合并成一个 DataFrame,统一处理缺失值和格式:
python复制import pandas as pd
# 把爬虫 yield 的 dict 列表转为 DataFrame
df = pd.DataFrame(data_list)
df['year'] = pd.to_numeric(df['year'], errors='coerce')
df['rating'] = pd.to_numeric(df['rating'], errors='coerce')
df['box_office_wan'] = df['box_office'].apply(parse_box_office)
df = df.dropna(subset=['title'])
errors='coerce' 这个参数值得记住。某些脏数据会包含非数字字符,直接用 pd.to_numeric 会抛异常,加上 coerce 之后,无法转换的值会被置为 NaN,然后把这一行过滤掉,整个过程不会中断。
3. Django 集成:数据入库与可视化展示
3.1 数据导入 Django:迁移、后台与批量写入
数据模型定义好之后,先执行迁移命令:
bash复制python manage.py makemigrations movies
python manage.py migrate
然后我们在 Django Admin 里注册模型:
python复制# movies/admin.py
from django.contrib import admin
from .models import Movie
@admin.register(Movie)
class MovieAdmin(admin.Model):
list_display = ('title', 'year', 'rating', 'box_office', 'genres')
list_filter = ('year', 'genres')
search_fields = ('title',)
list_per_page = 20
Admın 的好处是你可以快速查看数据质量,比如有没有字段明显不对、哪些记录缺票房。我通常在写完爬虫之后先在 Admin 里过一遍数据,再决定要不要调整清洗逻辑。
批量写入的时候要避免一条一条 save(),那样几万条数据要跑很久。Django 提供了 bulk_create,可以一次批量插入:
python复制movies = [
Movie(title=row['title'], year=row['year'], rating=row['rating'])
for _, row in df.iterrows()
]
Movie.objects.bulk_create(movies, batch_size=500, ignore_conflicts=True)
注意 ignore_conflicts=True 表示遇到唯一键冲突时跳过,不会整体回滚。这样重复采集的时候可以放心跑。
3.2 自定义接口输出 JSON:前后端分离的数据通道
Django 里写前端页面可以直接用模板渲染,但涉及图表时,我更推荐在 Django 里写 JSON 接口,前端用 ECharts 异步加载。方便后续单独扩展 API 给移动端用,也避免模板语法和 JavaScript 变量互相污染。
下面是一个供图表使用的统计接口:
python复制# movies/views.py
import json
from django.http import JsonResponse
from django.db.models import Count, Avg
from .models import Movie
def genre_stats(request):
"""按类型统计影片数量和平均评分,返回 ECharts 所需格式"""
rows = (
Movie.objects
.exclude(genres='')
.values('genres')
.annotate(total=Count('id'), avg_rating=Avg('rating'))
.order_by('-total')[:15]
)
data = {
'genres': [r['genres'] for r in rows],
'counts': [r['total'] for r in rows],
'avg_ratings': [round(r['avg_rating'] or 0, 2) for r in rows]
}
return JsonResponse(data)
这里有个查询效率的问题必须说。values('genres').annotate(total=Count('id')) 走的是 GROUP BY genres,由于我们在 genres 字段上建了索引,这条 SQL 在几万条数据下也能很快返回。如果没建索引,Django 不会报错,但查询慢到你怀疑人生。
3.3 前端图表展示:用 ECharts 让数据“说话”
图表展示部分,我的前端页面直接引用了 ECharts 的 CDN,没有引入重量级前端框架。页面逻辑很简单:页面加载时发起 fetch 请求,拿 JSON 数据,然后初始化图表。
html复制<!-- templates/movies/charts.html -->
<div id="genreChart" style="width: 100%; height: 400px;"></div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
fetch('/api/genre-stats/')
.then(resp => resp.json())
.then(data => {
const chart = echarts.init(document.getElementById('genreChart'));
chart.setOption({
tooltip: {},
legend: { data: ['影片数量', '平均评分'] },
xAxis: { type: 'category', data: data.genres },
yAxis: [{ type: 'value', name: '数量' }, { type: 'value', name: '评分' }],
series: [
{ name: '影片数量', type: 'bar', data: data.counts },
{ name: '平均评分', type: 'line', yAxisIndex: 1, data: data.avg_ratings }
]
});
});
</script>
这种双 Y 轴图是分析场景里最常用的:左边是柱状图数量,右边是折线图评分。一眼就能看出来哪种类型片多但评分低,哪种类型片少但口碑好。
4. 数据分析与可视化洞察
4.1 评分分布与年份趋势:用 pandas 做深挖
数据进了库、也能展示了,接下来才是正题——分析。我们直接在 Django 的 shell_plus 环境里跑 pandas,或者在视图里做聚合,目的都一样,就是从数据里读信息。
先看评分分布。用 pandas 加载全量数据:
python复制# analysis/eda.py
import pandas as pd
from movies.models import Movie
# 把 QuerySet 直接转 DataFrame
df = pd.DataFrame.from_records(Movie.objects.all().values('title', 'year', 'rating', 'genres', 'box_office'))
print(df.describe())
常见的统计输出一下全出来:平均分、中位数、最大最小值、分位数。这个过程中我发现一个有意思的现象:很多高口碑影片并没有进入票房榜前列,评分和票房的相关性并不强。
再按年份看趋势:
python复制yearly = df.groupby('year')['rating'].agg(['mean', 'count']).reset_index()
print(yearly.sort_values('year'))
这里有一段非常典型的 pandas 链式操作:groupby 按年份分组,agg 同时算均值和数量,reset_index 把年份从索引变回列。如果你写 df.groupby('year')['rating'].mean(),得到的是 Series,还得再转一次;直接 agg 一步到位。
4.2 类型与票房的关联分析:发现市场偏好
类型字段是逗号分隔的,要做类型维度的分析,需要先拆开:
python复制# 将 genres 拆分为多行,方便按单类型统计
df_expanded = df.assign(genre=df['genres'].str.split('/')).explode('genre')
df_expanded['genre'] = df_expanded['genre'].str.strip()
genre_group = (
df_expanded.dropna(subset=['box_office'])
.groupby('genre')['box_office']
.agg(['sum', 'mean', 'count'])
.sort_values('sum', ascending=False)
)
print(genre_group.head(10))
核心函数是 explode,它能把“剧情/爱情”这种一对多字段拆成多行,一行一个类型。这是处理多值字段的标准姿势。
这类分析做完之后,能得出一些非常直接的结论。比如在我的数据里,动作片和喜剧片的平均票房显著高于文艺片,但文艺片的评分均值更高。这种东西放在 Dashboard 上,比对着 Excel 看原始数据直观得多。
5. 常见问题与排查技巧实录
5.1 反爬被拦截、编码乱码与字段解析失败
我在开发过程中遇到最多的问题,整理成一个表格:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 请求返回 418 | 爬到一半突然全是 418 | 请求头不完整 / 频率过高 | 加完整 headers,降低请求频率,随机睡眠 |
| 中文乱码 | 页面显示正常,代码读出来乱码 | 响应编码未正确识别 | 使用 resp.encoding = resp.apparent_encoding |
| 字段为空 | 影片数据入库后导演/票房为空 | 爬虫解析规则未匹配到新结构 | 把解析逻辑抽成函数,单独用 BeautifulSoup 测试 |
| 重复数据 | 同一部影片在库里出现多条 | 未设置唯一约束 | 给 source_url 加 unique=True,用 update_or_create 入库 |
| 主键冲突 | bulk_create 报冲突 | 重复执行导入脚本 | ignore_conflicts=True |
| 运行变慢 | Admin 列表页卡顿 | 缺索引或一次性查全表 | 给筛选字段建索引,list_select_related |
第 2 个问题值得展开说。有次爬取某个页面,response.text 里全是乱码,排查过程非常痛苦。后来发现网页的响应头没有声明字符集,requests 默认按 ISO-8859-1 解码了。解决办法很简单:
python复制resp = requests.get(url, headers=HEADERS)
resp.encoding = resp.apparent_encoding
apparent_encoding 会从页面内容里智能推断编码,中文页面一般都能正确识别。这个方法不是百分百准,但胜在自动化程度高。如果发现还是乱码,再用 resp.content.decode('utf-8', errors='ignore') 手动指定。
5.2 数据清洗中的“脏数据”陷阱
清洗数据之后,我的建议是立刻输出一份质量报告,统计“有多少条数据被过滤了、哪些字段缺失率最高”。这个可以在爬虫入库时顺手做:
python复制def generate_quality_report(df):
total = len(df)
report = {}
for col in ['title', 'year', 'rating', 'director', 'box_office']:
missing = df[col].isna().sum()
report[col] = {
'缺失率': f'{missing / total:.1%}',
'空值数': missing
}
return report
这属于“经验补全”的建议,但确实管用。数据质量报告能让你在分析之前快速判断“这个字段能不能用”——缺失率超过 30% 的字段,在分析时基本只能丢弃。
5.3 更新策略与增量爬取
爬虫跑起来之后,还有一个策略问题:是全量更新还是增量更新?这个项目里我选了折中方案——每次从 Top250 热映榜开始抓,抓完和库里的记录比对,存在的更新字段,不存在的插入。这是因为影片库有“下映”的概念,一个电影下映后评分和票房可能会变化,但不会有新的分榜条目进来,全量更新会浪费大量请求。
增量更新的过程中,我建议在 Django 模型里加一个 updated_at 字段,每次更新自动刷新时间,这样能锁定最近更新的范围,排查问题时非常有帮助。
6. 项目部署与后续扩展(个人建议)
6.1 部署时容易忽略的 Django 配置
项目开发完成后,部署到服务器上跑,有几个 Django 配置容易踩坑。首先是 DEBUG = False 之后静态文件不加载,必须配 whitenoise 或上传到对象存储。其次是 ALLOWED_HOSTS,不配全域名会被 Django 拒绝访问。第三是数据库连接数,爬虫和 Web 服务用同一个 MySQL 实例时,注意连接池溢出。
部署我用的是 Gunicorn + Nginx,其中 Gunicorn 的 --workers 参数要根据 CPU 核心数调节。爬虫任务和 Web 请求会争抢数据库连接,我的建议是爬虫单独用一套数据库账号,方便出现问题的时候单独排查和限流。
6.2 后续扩展方向:定时任务、更多数据源与深度分析
这一步做完,基础版本已经能用了。后续想做得更完整,有三个方向:
第一,把定时爬虫挂到 crontab 或 Celery Beat 上,每天固定时间抓取热映榜单,保持数据新鲜度。第二,接入更多数据源——比如 IMDb 评分、票房大盘数据、不同国家地区的上映信息——这样的交叉分析才有价值。第三,分析维度可以更深入,比如按导演聚合看作品评分趋势、按演员统计票房号召力、用简单线性回归判断评分和票房的关系,这些都是用现有数据就能跑出来的分析。
如果想把页面做得更“产品化”,可以增加搜索框、筛选器、排行榜,让访问者在页面上直接交互查看图表。这套代码的基础已经能够支撑这些扩展,后续每个方向都是增量开发,不用推翻重来。
我在实际开发中的体会是:这种爬虫 + 框架 + 分析的项目,真正的瓶颈不是代码能力,而是“数据链路意识”——从页面解析到数据清洗到入库到分析图表,每一步都要能对上账。一方断了,后面全停。你只要把链路打通,哪怕只采集几千条数据,产出的可视化分析报告也已经能完整体现你的技术能力和业务思维了。最后再分享一个小技巧:爬虫代码里一定不要把解析逻辑写死在 for 循环里,单独抽成 parse_item() 函数,因为页面结构一定会变,到时候你只需要改一个函数,而不是在一堆代码里找 bug。
