Python爬虫实战:豆瓣Top250数据采集、清洗与可视化
2026/9/17 2:39:03 网站建设 项目流程

简介:这是一份基于Python的豆瓣电影Top250数据分析与可视化完整项目,面向计算机相关专业正在完成大作业的学生,以及需要项目实战练习的Python与数据分析初学者。项目源码经本地编译调试通过,可稳定运行,难度适中,适合作为课程设计或毕业设计的参考。压缩包内共2000个文件,以Python脚本为主,并包含少量C/C++扩展文件、样式脚本、HTML页面与PDF说明文档等,整体约80.74MB,目录结构清晰,便于按模块查阅。目前已吸引940人学习下载。资源不仅提供完整可运行的爬虫代码,还包含数据清洗、统计分析、可视化展示等环节的实现,文档说明对关键步骤和设计思路做了梳理,可帮助读者快速理解项目全貌,高效复用代码并迁移到其他数据采集与分析场景。

1. 为什么从 Top250 切入爬虫与数据分析

豆瓣电影 Top250 是爬虫练手里少见的「低反爬、高信息密度」样本:它总共只有 10 个列表页,每页固定 25 条,单页里就能拿到评分、评价人数、导演、主演和经典台词这些跨度很大的字段。相比爬全站书目或者评论区,Top250 的请求规模小,触发封禁的概率低,但数据维度足够撑起一次完整的数据分析流程。这套源码做的就是抓取、清洗、入库、可视化一条链路,适合正在做课程设计、或者想从单点爬虫走向完整数据项目的学习者。它的核心价值不在于爬虫本身多复杂,而在于把「抓下来的数据如何变成图表结论」这条路走通了,这也是大作业评分能拿到 98 分的直接原因——老师看到的不只是 requests 调用,而是有清洗逻辑和分析维度的完整闭环。

2. requests + lxml 的列表页解析与反爬自适应

2.1 列表页结构与单页 25 条的数据密度

Top250 的列表页结构非常规整:ol.grid_view下面挂着 25 个li,每个li里包含em(排名)、span.title(片名)、span.rating_num(评分)、span.pl(评价人数)以及p标签里的导演、主演、年份、地区、类型。值得注意的一点是,页面里片名其实有两个span.title,第一个是中文名,第二个是原名或别名,解析时要用find_all('span', class_='title')[0]只取第一个。下一页的 URL 参数是?start=25,翻页时只需要把start按 25 的倍数递增,10 页全部抓完就是完整的 250 条。

这些信息密度足够支撑后续的多维分析:评分和评价人数是数值型数据,可以直接做分布统计;年份、地区、类型是离散型数据,可以聚合后对比;台词字段虽然不适合量化,但能用来做词云展示,增加可视化层面的可读性。如果只爬片名和评分,后面的分析维度就会很单薄,所以这套源码在字段抽取上做得比较全。

2.2 请求头、随机延时与异常降级

豆瓣对爬虫的检测不算严格,但完全裸请求(无 UA、无延时)还是容易被临时限制。源码里用了典型的低频爬取策略:自定义User-Agent模拟浏览器,time.sleep(random.uniform(1, 3))做随机延时。这两个手段属于最基础的反爬自适应,虽然简单,但对 Top250 这种规模完全够用。UA 池和代理在这里是多余的,反而会拖慢请求速度并引入不稳定因素。

异常降级方面,源码对每次请求做了response.raise_for_status()检查,配合try...except捕获requests.exceptions.RequestException。爬虫最容易挂的场景不是被 ban,而是网络波动导致的连接重置,所以这里对超时时间也做了显式设置:

import requests import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9" } def safe_get(url, retries=2): for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=(5, 10)) resp.raise_for_status() # 显式指定编码,避免中文乱码 resp.encoding = resp.apparent_encoding return resp except requests.exceptions.RequestException as e: print(f"[retry {attempt+1}] {url} -> {e}") time.sleep(2) return None

timeout=(5, 10)表示连接超时 5 秒、读取超时 10 秒,分开设置比单一超时值更适合不稳定网络。resp.encoding = resp.apparent_encoding这行很多人会漏掉,requests 的默认编码猜测有时会 miss 掉 UTF-8,导致后续解析时中文乱码。重试次数设 2 次就够,因为失败往往是瞬时性的;反复重试同一个失败请求反而会浪费时间。

2.3 字段抽取与存储结构设计

解析层用的是lxmletree.HTML()做 XPath 定位。相比 BeautifulSoup,lxml 在批量属性抽取上更快,而且 XPath 的索引定位在这种规整列表页里更精准。核心抽取逻辑如下:

from lxml import etree def parse_page(html_text): tree = etree.HTML(html_text) items = tree.xpath('//ol[@class="grid_view"]/li') page_data = [] for li in items: rank = li.xpath('.//em/text()')[0].strip() title = li.xpath('.//span[@class="title"][1]/text()')[0].strip() rating = li.xpath('.//span[@class="rating_num"]/text()')[0].strip() comment_count = li.xpath('.//div[@class="star"]/span[4]/text()')[0].strip() # 导演 / 主演 / 年份 / 地区 / 类型都在这一行 p 标签里 info_lines = li.xpath('.//div[@class="bd"]/p[1]//text()') info = "".join(info_lines).replace("\xa0", " ").strip() quote = li.xpath('.//p[@class="quote"]/span/text()') page_data.append({ "rank": int(rank), "title": title, "rating": float(rating), "comments": int(comment_count.replace("人评价", "")), "info": info, "quote": quote[0].strip() if quote else "" }) return page_data

字段说明:

字段来源处理要点
rankem标签文本int,后续排序用
title第一个span.title[0],避开别名干扰
ratingspan.rating_numfloat
commentsdiv.star下第 4 个span原始文本带「人评价」,需先 replace 再转int
infop[1]的全部文本节点XPath 的//text()会拆出多个节点,必须 join 后再清洗
quotep.quote下的span可选字段,缺失时补空字符串

info字段的解析是最容易写错的地方:p[1]里的文本被换行和<br/>分隔成多个 text node,如果直接text()只会拿到第一个片段。这里用//text()拿到全部节点再用空字符串 join,配合\xa0替换,才能得到完整的一行信息。解析完成后把每页数据 append 到全局列表,全部抓完后一次性写入文件。

3. pandas 清洗、去重与 CSV/SQLite 双通道落库

3.1 原始数据的长尾问题:脏数据长什么样

爬下来的数据直接做分析会有几个隐患:info字段里年份、地区、类型是拼接在一行里的,需要二次拆解;有些条目没有台词,quote是空字符串;更隐蔽的问题是某些电影名里带了多余空格或者全角符号,字符串比较时会出现「看起来一样但分组不合并」的情况。这套源码在数据分析前加了一层 pandas 清洗,把半结构化的info拆成独立的数据列,这一步是大作业评分里区分「会爬」和「会做数据」的关键。

清洗逻辑拆成三个步骤:正则提取年份和地区,类型字段按/分隔后展开成独立行,最后去掉完全重复的条目。豆瓣 Top250 里一般不会出现跨页重复,但去重是数据管线里的标准动作,源码里保留了这个步骤也是因为评审老师会关注这类细节。

3.2 清洗流程与类型字段拆分

import pandas as pd import re df = pd.DataFrame(raw_data) # 从 info 中提取年份、地区、类型 def extract_info(info_str): year_match = re.search(r"(\d{4})", info_str) year = year_match.group(1) if year_match else None # 年份后面通常跟地区,形如 "1994 / 中国大陆 / 剧情" parts = [p.strip() for p in info_str.split("/")] region = parts[-2] if len(parts) >= 2 else None genres_raw = parts[-1] if len(parts) >= 1 else None return year, region, genres_raw df[["year", "region", "genres_raw"]] = df["info"].apply( lambda x: pd.Series(extract_info(x)) ) # 类型字段拆成多行,便于按类型分组统计 df_genres = df.assign(genre=df["genres_raw"].str.split("/")).explode("genre") df_genres["genre"] = df_genres["genre"].str.strip() # 去重 df = df.drop_duplicates(subset=["rank", "title"]).reset_index(drop=True)

extract_info里对parts的索引依赖一个约定:info的格式基本是「导演 / 主演 / 年份 / 地区 / 类型」,按/切分后,倒数第一个是类型,倒数第二个是地区。这个约定在豆瓣数据里是稳定的,但如果后续扩展爬更多页面,建议改成正则的 group 命名来定位。explode("genre")是 pandas 处理一对多映射的核心方法,会把「一部电影多个类型」拆成多行同一 rank 的记录,这样后面做类型分组时就不用手动拆字符串再循环计数。注意拆完之后genre列可能存在首尾空格,必须再str.strip()一次,否则「剧情」和「剧情 」会被当成两个不同的类。

3.3 入库与幂等控制

清洗完的数据需要落地存储,源码同时提供了 CSV 和 SQLite 两条通道。CSV 适合快速检查和用 Excel 打开,SQLite 适合后续做条件查询。这里有个设计细节值得借鉴:写入 SQLite 前先删掉同名表再重建,而不是逐条INSERT OR REPLACE,这样保证重复运行脚本不会产生脏数据累积。

import sqlite3 CSV_PATH = "output/douban_top250.csv" DB_PATH = "output/douban_top250.db" # CSV 落盘 df.to_csv(CSV_PATH, index=False, encoding="utf-8-sig") # SQLite 落库 conn = sqlite3.connect(DB_PATH) try: conn.execute("DROP TABLE IF EXISTS movie_top250") conn.execute(""" CREATE TABLE movie_top250 ( rank INTEGER PRIMARY KEY, title TEXT, rating REAL, comments INTEGER, year TEXT, region TEXT, genre TEXT, quote TEXT ) """) finally: conn.commit() conn.close()

CSV 编码用utf-8-sig而不是默认的utf-8,是因为 Windows 上 Excel 打开无 BOM 的 UTF-8 文件会乱码,加 BOM 后双击就能直接看。DROP TABLE IF EXISTS这种重建策略对一次性爬取任务最省事,但如果你的脚本会增量爬取并保留历史数据,就应该改成CREATE TABLE IF NOT EXISTS配合INSERT INTO SELECT WHERE NOT EXISTS。两种策略的取舍点是数据是「快照」还是「累积」,这套源码的场景是快照,所以重建没有问题。

4. 评分分布、年份聚合与可视化参数调优

4.1 评分直方图的 bin 选择与坐标轴截断

评分是 8.0 到 9.8 之间的浮点数,分布区间窄、峰值明显,画直方图时最容易犯的错是不设 bin 边界——pandas 默认会把 8.0 到 9.8 切出十几个箱子,结果就是每个柱子都矮矮地分布,看不出重点。这里手动指定bins=np.arange(8.0, 10.0, 0.2),让每个箱子代表 0.2 分,直方图会更接近真实的高分集中度。

import matplotlib.pyplot as plt import numpy as np fig, ax = plt.subplots(figsize=(10, 5)) # bins 从 8.0 到 9.8,步长 0.2,共 9 个箱子 ax.hist(df["rating"], bins=np.arange(8.0, 9.9, 0.2), color="#2c7fb8", edgecolor="white") ax.set_xlabel("评分") ax.set_ylabel("电影数量") ax.set_title("Top250 电影评分分布") # 截断 x 轴到有效数据区间,避免左侧大片空白 ax.set_xlim(7.8, 10.0) ax.tick_params(axis="x", rotation=0) plt.tight_layout() plt.savefig("output/rating_dist.png", dpi=300) plt.show()

np.arange(8.0, 9.9, 0.2)的终点 9.9 是开区间,所以最后一个箱子覆盖到 9.8 为止,正好覆盖数据的最大值。dpi=300是为了论文或报告里打印时不模糊,屏幕预览时 150 足够。要注意 x 轴起点设成 7.8 而不是 0,因为评分没有低于 8 的数据,默认从 0 开始会浪费三分之二的画幅。

4.2 类型聚合与年份趋势的图表选型

类型数据经explode展开后,用value_counts()就能拿到各类型的电影数量。但这里有一个坑:一部电影属于多个类型,所有类型的计数加起来会超过 250,所以画饼图时必须明确说明这是「类型出现次数」而非「电影数量」。对比柱状图更适合这种场景,它不会让读者误以为比例之和必须等于 100%。

年份趋势分析适合用折线图,把 year 列聚合后看每个年代的产出数量。Top250 的年份跨度从 1930 年代到 2020 年代,直接按年份画折线会太碎,建议先按年代分箱再聚合:

# 按年代聚合 df["decade"] = (df["year"].astype(float) // 10 * 10).astype(int) trend = df.groupby("decade").size() fig, ax = plt.subplots(figsize=(10, 5)) # marker 和 linewidth 是折线图的关键参数 ax.plot(trend.index, trend.values, marker="o", markersize=6, linewidth=2, color="#e34a33") ax.set_xticks(trend.index) ax.set_xlabel("年代") ax.set_ylabel("上榜电影数量") ax.grid(axis="y", linestyle="--", alpha=0.6) plt.tight_layout() plt.savefig("output/year_trend.png", dpi=300)

decade列的计算逻辑是「先整除 10 再乘 10」,把 1994 转成 1990,把 2003 转成 2000,这比字符串截取更不容易出错。折线图里marker="o"能标出每个数据点,避免相邻年代值接近时看不出波动;alpha=0.6让网格线退到背景层,不抢主数据的视觉权重。

4.3 大屏化改造:从 matplotlib 到 pyecharts

如果作业或汇报要求交互式看板,这套源码里还附带了一个基于 pyecharts 的可视化大屏脚本。和 matplotlib 的静态图不同,pyecharts 输出 HTML 页面,支持鼠标悬浮显示数值、缩放图表、Tab 切换数据维度。对非技术背景的答辩评委来说,交互图表的演示效果往往比静态图加分明显。

from pyecharts.charts import Bar from pyecharts import options as opts genre_top = df_genres["genre"].value_counts().head(10) bar = ( Bar() .add_xaxis(genre_top.index.tolist()) .add_yaxis("电影数量", genre_top.values.tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="Top250 类型分布 Top10"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=15)), toolbox_opts=opts.ToolboxOpts(), ) ) bar.render("output/genre_bar.html")

rotate=15是类型名称防遮挡的关键参数,类型名超过 4 个汉字时横排会相互重叠。ToolboxOpts()自带导出图片、数据视图、缩放等交互按钮,演示时可以直接从页面里下载 PNG,省去另跑脚本的步骤。从 matplotlib 迁到 pyecharts 的核心成本在于 Series 名称、坐标轴、标题这些「声明式」配置项的写法差异,字段含义本身没有变化,所以前面的 pandas 清洗成果可以无缝复用。

5. 交付级细节:重试、断点续爬与文档组织

5.1 重试与缓存策略

对 10 个页面的爬取来说,断点续爬听起来是过度设计,但源码里确实有一个轻量级缓存思路:把每页解析后的原始 JSON 落成独立文件,下次运行如果检测到某页已经抓过,就直接从磁盘加载。这样做的好处是开发调试时不用反复请求豆瓣服务器,也降低了被封的风险。

import json import os CACHE_DIR = "cache" os.makedirs(CACHE_DIR, exist_ok=True) def fetch_page_with_cache(start): cache_file = os.path.join(CACHE_DIR, f"page_{start}.json") if os.path.exists(cache_file): with open(cache_file, "r", encoding="utf-8") as f: return json.load(f) resp = safe_get(f"https://movie.douban.com/top250?start={start}") if resp is None: return None html_text = resp.text page_data = parse_page(html_text) with open(cache_file, "w", encoding="utf-8") as f: json.dump(page_data, f, ensure_ascii=False, indent=2) return page_data

page_{start}.json的命名直接关联请求参数,自然具备幂等性:同一个start值永远映射到同一个缓存文件。ensure_ascii=False让 JSON 文件里直接显示中文而不是\uXXXX转义序列,方便用编辑器直接检查抓到的台词和片名。这套方案的边界在于它不做过期判断,如果一个新版本页面改了结构,旧缓存会持续返回过期数据;开发完正式跑批量之前,删掉 cache 目录重新生成即可。

5.2 数据完整性校验

爬完之后需要验证数据是否完整,校验两个维度就够了:

校验项方法期望值
总数len(df)250
排名连续性df["rank"].sort_values().tolist() == list(range(1, 251))True
必要字段缺失df[["rank", "title", "rating"]].isna().sum().sum()0

排名连续性检查特别有效:如果中间某一页解析失败但没抛异常,得到的可能是不足 250 条的数据,rank会出现跳号。比len(df) == 250更严格,因为即使总数对上了,也可能某个li被漏掉导致前后 rank 错位。建议把这段校验写进脚本末尾,不通过时直接抛AssertionError,而不是让坏数据流向下游。

5.3 文档组织与运行说明

一个能拿高分的数据类大作业,源码只是其中一半,另一半是文档和复现成本。这套源码附带的说明文档里写了环境版本和运行步骤,实际交付时建议再加一个requirements.txt锁定核心依赖版本,否则三个月后重新跑项目时,pandas 的 API 可能已经有破坏性变更。

pip install requests lxml pandas matplotlib pyecharts python spider.py # 爬取并落库 python analyze.py # 清洗并输出统计图表 python dashboard.py # 生成 HTML 交互看板

三条命令按顺序执行,每个脚本只做一个阶段的工作,中间产物通过 CSV 和 SQLite 传递数据。爬取脚本和可视化脚本分离的好处是:评审现场可以只跑dashboard.py重新生成图表,不用重新请求豆瓣;如果你只是想调图表样式,也不用担心重复触发对方的反爬机制。这个「数据采集、清洗分析、可视化」三段式任务拆分,本身就是大作业项目管理里最容易被忽略、却最能体现工程意识的地方。

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

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

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

立即咨询