基于Python的豆瓣电影TOP250爬虫数据分析实战解析
2026/9/15 4:28:04 网站建设 项目流程

简介:基于Python的豆瓣电影TOP250爬虫数据分析设计源码,面向希望系统掌握爬虫与数据可视化流程的Python学习者。项目完整呈现从页面抓取、数据清洗到可视化展示的实现路径,内置核心爬虫脚本、基于Flask的Web应用以及ECharts图表展示模块,并附带豆瓣电影Top250数据表格和说明文档,适合课程设计、毕业设计或实战练手。压缩包共86个文件,涵盖Python源代码、JavaScript脚本、CSS样式、HTML页面、图表配置文件及字体图片等静态资源,整体约11.17MB,目录划分清晰,便于按功能模块阅读。已有1000人下载学习,从中可获取完整项目源码、页面模板、数据文件以及爬虫与Web前端结合的整合思路,对理解请求解析、数据存储和图表联动有直接参考价值。

1. 基于Python的豆瓣电影TOP250爬虫数据分析设计源码到底在解决什么问题

「基于Python的豆瓣电影TOP250爬虫数据分析设计源码」拆开看,只有三件事:爬虫抓取、数据分析、源码组织。爬虫负责把榜单上的片名、评分、评价人数、年份抓下来;数据分析负责把这张二维表清洗统计成结论;源码设计决定这套代码能不能复用、能不能交给下一个人维护。三个环节串起来,就是一条从请求页面到输出图表的完整数据流水线。

这个项目流传广,是因为数据量小、结构规整、抓取难度适中,恰好能练一遍 requests、BeautifulSoup、pandas、matplotlib 的组合。适合刚学完 Python 语法、想碰真实数据的入门者,也适合需要快速迁移一套爬虫模板的从业者——把解析部分换成任意 HTML 列表页,代码框架可以直接带走。

2. 爬虫抓取与字段解析:用 requests 拿到豆瓣 TOP250 的 250 条影片数据

2.1 start 和 filter 参数是什么

豆瓣 TOP250 的入口地址是 movie.douban.com/top250,翻页靠查询参数 start 控制。第一页等效于 start=0,第二页是 start=25,最后一页 start=225。每页固定 25 部影片,所以完整抓取只需要循环 10 个页码。URL 末尾的 filter 参数即使为空也要保留,它是站点的筛选入口,写请求时保留完整地址能避免被服务端判定为异常请求。

页面结构上,每个榜单条目放在ol.grid_view下的li.item节点里,节点内部是div.info,包含标题、导演信息、评分和引言。写选择器时不要从整个 document 一次性取所有字段,而是先定位li.item,再在 item 节点内继续select_one,避免跨条目错位。

2.2 抓取函数、翻页循环与请求头配置

requests 阶段最要紧的不是找公开 API,而是让服务端认为请求来自普通浏览器。不带 User-Agent 请求豆瓣,常见返回是 418 或 403;补上 UA 后状态码回到 200。下面是抓取函数的基础实现:

import time import requests HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_page(start: int) -> str: url = f"https://movie.douban.com/top250?start={start}&filter=" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 非200状态直接抛异常,方便上层重试 return resp.text

timeout=10 是连接和读取的总体超时时间,防止请求卡死;把 UA 写成多行拼接是为了可读性。raise_for_status()会把 403、418、500 等状态码转成异常,比到处写if resp.status_code != 200干净。

翻页循环把抓到的 HTML 先暂存,稍后统一解析。每抓一页time.sleep(3),是 TOP250 这种低频变动页面很合适的节奏,既不影响本地处理速度,也不会给服务器造成集中压力:

def crawl_all_pages(): html_pages = [] for page_start in range(0, 250, 25): html_pages.append(fetch_page(page_start)) time.sleep(3) return html_pages

如果目标站点本身响应慢,比如平均响应超过 5 秒,sleep 时间建议直接翻倍,避免异常重试把自己拖成高频请求。

2.3 用 BeautifulSoup 抽取 title、评分、人数等字段

TOP250 是静态 HTML,用 requests 拿回来的文本直接交给 BeautifulSoup 解析,不需要引入 Selenium。字段锚点如下:

字段CSS 选择器说明
中文名span.title每个条目最多两个span.title,第一个是中文名
外文名span.other可能为空字符串
年份/国家div.bd p整行文本,按换行符和/拆分
评分span.rating_num字符串,后续转 float
评价人数div.star span:last-child文本含“人评价”,需要去掉单位
一句话引言span.inq可缺失,必须做 None 判断

评价人数取div.star span:last-child而不是写死 class,是因为豆瓣单个条目的 star 区域有五个 span,前面四个是星星图标和评分,最后一个是人数描述,按位置取比按文本猜测更稳。

from bs4 import BeautifulSoup def parse_page(html: str) -> list[dict]: soup = BeautifulSoup(html, "html.parser") records = [] for item in soup.select("ol.grid_view li.item"): title_el = item.select_one("span.title") title = title_el.text if title_el else "" other_el = item.select_one("span.other") rating_el = item.select_one("span.rating_num") people_el = item.select("div.star span")[-1] quote_el = item.select_one("span.inq") quote = quote_el.text if quote_el else "" info_line = item.select_one("div.bd p").text.strip() meta_parts = info_line.split("\n")[-1].split(" / ") year = meta_parts[0].strip() records.append({ "title": title, "other_title": other_el.text if other_el else "", "rating": rating_el.text.strip(), "people": people_el.text.strip(), "quote": quote, "year": year, }) return records

这段代码有意让 year、rating 保持字符串,类型转换放到清洗环节。原因很简单:解析层只做字段抽取,不做数据修正,两种职责混在一起,后面改需求时很容易改坏。

2.4 解析中常见的字段缺失处理

quote_el可能为 None,直接取.text会抛 AttributeError。这里用quote_el.text if quote_el else ""兜底,拿到的是空字符串,不会污染后续统计。other同理。如果某一天选择器失效,item.select_one("span.title")返回 None,要做到的是记录一条警告,而不是让整个程序中断:

if title_el is None: print(f"警告:解析到无标题条目,当前页 HTML 片段: {item.prettify()[:100]}")

这一步在实际维护中很关键。页面改版时,报错信息会直接告诉你哪个结构变样了,而不是给你一个莫名其妙的空 DataFrame。

3. 数据清洗与存储:把抓取结果整理成可直接分析的 DataFrame

3.1 为什么抓取结果不能直接参与运算

爬虫拿回来的是一个list[dict],交给 pandas 一行就能变成 DataFrame。但直接分析会遇到三个问题:类型不对、字段有缺失、重复记录混入。rating 是"9.7"这种字符串,people 是"1871862人评价",不转换就没法算 mean、corr 这类统计量。

构造 DataFrame 的常见做法是先把所有页的 records 收集到一个列表,最后统一pd.DataFrame(records)。不要在翻页循环里反复执行df.append,那样每次都会复制整个表,250 行时感觉不出来,换到两千行的数据就能明显看到卡顿。

3.2 类型转换、去重与缺失值处理

import pandas as pd df = pd.DataFrame(records) print(df.dtypes) df["rating"] = pd.to_numeric(df["rating"], errors="coerce") df["people"] = ( df["people"] .str.replace("人评价", "") .str.replace(",", "") .str.strip() ) df["people"] = pd.to_numeric(df["people"], errors="coerce") df["year"] = pd.to_numeric(df["year"], errors="coerce")

errors="coerce"表示无法解析的字符串置为 NaN,而不是抛异常终止程序。people 的处理顺序不能反:先去“人评价”,再去逗号,顺序反了会把"1,871,862人评价"残留成中间值。.str通道是整列批量操作,性能比 for 循环高一个数量级。

缺失值处理要看业务含义。quote 和 other_title 缺失可以把空字符串保留,或者填成"无";但 title、rating、year 这三列是分析主字段,缺失行直接丢弃:

df = df.drop_duplicates(subset=["title"], keep="first") df = df.dropna(subset=["title", "rating", "year"])

重复记录有两种来源:一是运行两次爬虫没清空之前的数据,二是某部影片在两个分类入口里同时出现。按 title 去重并保留第一次出现,是多数场景下最合理的策略。

3.3 CSV 和 SQLite 两种落盘方式

数据量只有 250 行,CSV 完全够用;如果要支持条件查询、持续追加,SQLite 更合适。两种都保留也只是几行代码的事,源码里建议同时提供两个入口。

存储方式优点缺点典型用途
CSV随处可读,Excel/notebook 直接打开不支持事务和条件写交付给他人、配合可视化工具
SQLite支持 SQL、事务、增量写入需要额外建表定时采集、历史数据累计
df.to_csv("douban_top250.csv", index=False, encoding="utf-8-sig")

encoding 用utf-8-sig而不是utf-8,是因为 Windows 上的 Excel 默认按带 BOM 的方式识别文件,少这个参数打开 CSV 就会中文乱码。

写 SQLite 同样不用手拼 SQL:

import sqlite3 with sqlite3.connect("douban.db") as conn: df.to_sql("top250", conn, if_exists="replace", index=False)

sqlite3.connect的对象用作上下文管理器时,with 块结束后自动提交;if_exists="replace"在重复运行时整体替换旧表,避免每次采集都叠一层数据。如果你做的是增量采集,要把这个参数改成"append",并提前在建表语句里给 title 加唯一索引。

3.4 数据完整性自检

清洗完不检查就进入分析,往往白忙一场。最少要校验三件事:行数是不是 250、标题有没有重复、评分是否都在合法区间:

print("行数:", len(df)) print("重复标题:", df["title"].duplicated().sum()) print("缺失统计:\n", df.isna().sum()) df = pd.read_csv("douban_top250.csv") assert len(df) == 250, f"期望 250 行,实际 {len(df)} 行" assert df["title"].nunique() == 250, "标题存在重复" assert df["rating"].between(8.0, 10.0).all(), "评分列出现范围外数值"

这三个断言放在分析任务之前。一旦某天页面改版导致字段错位,程序会立刻停在入口处,而不是带着脏数据生成一堆没有解释力的图表。

4. 数据分析与可视化:用 matplotlib 看评分的分布与年份趋势

4.1 评分分布直方图和中文字体设置

评分列清洗成 float 后,第一件事是看整体形状。TOP250 本身按评分排序,结果必然集中在 8.0 到 9.8 之间,绝大多数落在 8.5 到 9.2 区域。直方图最能体现这种左偏分布:

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 中文字体,缺了会显示方框 plt.rcParams["axes.unicode_minus"] = False # 防止负号显示成乱码 fig, ax = plt.subplots(figsize=(8, 5)) ax.hist(df["rating"], bins=20, edgecolor="black", alpha=0.85) ax.set_xlabel("评分") ax.set_ylabel("影片数量") ax.set_title("豆瓣电影 TOP250 评分分布") plt.savefig("rating_dist.png", dpi=150, bbox_inches="tight")

bins=20 把评分区间切成了 20 份,每份宽度约 0.1 分,短视频非专题分析够用。dpi=150 保证导出图在文档和投屏里不糊;bbox_inches="tight" 把多余留白裁掉。如果本机没有 SimHei,可以写成["Noto Sans CJK SC", "Microsoft YaHei", "SimHei"]做回退。

axes.unicode_minus看起来和本图无关,但建议放进模板,因为后续只要画出负相关散点图,中文字体配置不全立刻会爆出坐标轴方块。

常用的保存参数可以固定成这套基准:

matplotlib 参数作用本项目建议值
figsize画布宽高,英寸(8, 5)
bins直方图分箱数20
dpi输出图像分辨率150
bbox_inches导出时是否裁掉空白"tight"

4.2 按年份和年代聚合,观察高分电影的时间分布

把 year 按十位取整成 decade,是分析年份型数据最常用的分箱技巧:

df["decade"] = (df["year"] // 10) * 10 stats = df.groupby("decade").agg( count=("title", "count"), avg_rating=("rating", "mean"), avg_people=("people", "mean") ).reset_index() stats = stats.sort_values("avg_rating", ascending=False) print(stats.head())

groupby 加 agg 时,元组第一个元素是参与聚合的列,第二个是聚合函数,关键字参数直接给了输出列名。这张表里 count 比 avg_rating 更值得关注——某年代上榜数量多,说明那一时期整体质量稳定。

再细化到单一年份,能直接看到类似 1994 年的“大年”现象:

year_counts = df.groupby("year").size().sort_values(ascending=False) print(year_counts.head(10))

如果 year 里残留了"1994"这种带空格或不可见字符的值,前面的pd.to_numeric会把它转成 NaN,所以先跑df["year"].value_counts().head(20)做个目检最稳妥。

4.3 评价人数与评分的关系:加权分的意义

评分和评价人数不是独立指标。几千人打出 9.0 分和几十万人打出 8.9 分,前者的参考意义反而不大。业界常用类似 IMDB 的加权方式处理:给评价人数不足的影片一个先验均分做拖尾。

C = df["rating"].mean() m = df["people"].quantile(0.8) df["weighted_rating"] = ( df["people"] * df["rating"] + m * C ) / (df["people"] + m)

公式里 C 是全体均分,代表先验基准;m 是评价人数的 80% 分位数,只有人数足够多的影片才有资格几乎完全信任原始评分。当 people 远大于 m 时,加权结果贴近原始评分;people 不足时,结果被向 C 拉。

算完后对weighted_rating排序,再和原始排名对比,差异大的多半是评价人数少的小众高分片。严格的 TOP250 排序还包含豆瓣自己的时效权重,公开字段无法完全复现,但加权分足以做内部资料的参考排序。

5. 源码拆分、异常重试和结果自检的可复用写法

5.1 模块职责划分

爬虫项目最容易写成一坨从 import 一直顺排到底的脚本。TOP250 数据量小,这样写也能跑,但迁移到别的站点时,职责不清晰的代码改起来特别费劲。常见的工程化拆分是五个文件:

文件职责关键函数
spider.py请求和翻页fetch_page、crawl_all_pages
parser.pyHTML 解析成字典parse_page
storage.py保存和读取数据save_csv、save_sqlite、load_data
analysis.py统计计算和画图rating_dist、year_stats、weighted_score
main.py串联整条流水线main、validate

5.2 可复用的指数退避重试

单次请求失败时立刻重试往往还是失败,让重试间隔逐渐拉长更稳妥:

import functools import time def retry(max_times=3, base_interval=2): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_times): try: return func(*args, **kwargs) except Exception as exc: if attempt == max_times - 1: raise exc time.sleep(base_interval * (2 ** attempt)) return None return wrapper return decorator @retry(max_times=3, base_interval=2) def fetch_page(start: int) -> str: # 这里直接复用 2.2 节请求实现 ...

第二次重试等待 2 秒,第三次等 4 秒。functools.wraps保留原函数名和文档字符串,日志里排查时能看清到底是哪个函数在重试。

5.3 在 main 入口统一做结果自检

main.py 只干调度的事,不直接写解析细节:

def main(): html_pages = crawl_all_pages() records = [] for html in html_pages: records.extend(parse_page(html)) df = clean_and_save(records) generate_charts(df) validate(df) def validate(df: pd.DataFrame): assert len(df) == 250, f"期望250行,实际{len(df)}行" assert df["title"].nunique() == 250, "标题有重复,检查翻页逻辑" assert df["rating"].notna().all(), "存在缺失评分,检查解析选择器"

程序跑完最后打印一句保存成功: douban_top250.csv,作为手动确认的最直接反馈。之后任何一次代码改动,只要跑一遍python main.py就能通过断言判断抓取链路是否被破坏。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询