
简介这是一份基于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 参数是?start25翻页时只需要把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;q0.9 } def safe_get(url, retries2): for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeout(5, 10)) resp.raise_for_status() # 显式指定编码避免中文乱码 resp.encoding resp.apparent_encoding return resp except requests.exceptions.RequestException as e: print(f[retry {attempt1}] {url} - {e}) time.sleep(2) return Nonetimeout(5, 10)表示连接超时 5 秒、读取超时 10 秒分开设置比单一超时值更适合不稳定网络。resp.encoding resp.apparent_encoding这行很多人会漏掉requests 的默认编码猜测有时会 miss 掉 UTF-8导致后续解析时中文乱码。重试次数设 2 次就够因为失败往往是瞬时性的反复重试同一个失败请求反而会浪费时间。2.3 字段抽取与存储结构设计解析层用的是lxml的etree.HTML()做 XPath 定位。相比 BeautifulSouplxml 在批量属性抽取上更快而且 XPath 的索引定位在这种规整列表页里更精准。核心抽取逻辑如下from lxml import etree def parse_page(html_text): tree etree.HTML(html_text) items tree.xpath(//ol[classgrid_view]/li) page_data [] for li in items: rank li.xpath(.//em/text())[0].strip() title li.xpath(.//span[classtitle][1]/text())[0].strip() rating li.xpath(.//span[classrating_num]/text())[0].strip() comment_count li.xpath(.//div[classstar]/span[4]/text())[0].strip() # 导演 / 主演 / 年份 / 地区 / 类型都在这一行 p 标签里 info_lines li.xpath(.//div[classbd]/p[1]//text()) info .join(info_lines).replace(\xa0, ).strip() quote li.xpath(.//p[classquote]/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_num转floatcommentsdiv.star下第 4 个span原始文本带「人评价」需先 replace 再转intinfop[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(genredf[genres_raw].str.split(/)).explode(genre) df_genres[genre] df_genres[genre].str.strip() # 去重 df df.drop_duplicates(subset[rank, title]).reset_index(dropTrue)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, indexFalse, encodingutf-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 切出十几个箱子结果就是每个柱子都矮矮地分布看不出重点。这里手动指定binsnp.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], binsnp.arange(8.0, 9.9, 0.2), color#2c7fb8, edgecolorwhite) ax.set_xlabel(评分) ax.set_ylabel(电影数量) ax.set_title(Top250 电影评分分布) # 截断 x 轴到有效数据区间避免左侧大片空白 ax.set_xlim(7.8, 10.0) ax.tick_params(axisx, rotation0) plt.tight_layout() plt.savefig(output/rating_dist.png, dpi300) plt.show()np.arange(8.0, 9.9, 0.2)的终点 9.9 是开区间所以最后一个箱子覆盖到 9.8 为止正好覆盖数据的最大值。dpi300是为了论文或报告里打印时不模糊屏幕预览时 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, markero, markersize6, linewidth2, color#e34a33) ax.set_xticks(trend.index) ax.set_xlabel(年代) ax.set_ylabel(上榜电影数量) ax.grid(axisy, linestyle--, alpha0.6) plt.tight_layout() plt.savefig(output/year_trend.png, dpi300)decade列的计算逻辑是「先整除 10 再乘 10」把 1994 转成 1990把 2003 转成 2000这比字符串截取更不容易出错。折线图里markero能标出每个数据点避免相邻年代值接近时看不出波动alpha0.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_optsopts.TitleOpts(titleTop250 类型分布 Top10), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(rotate15)), toolbox_optsopts.ToolboxOpts(), ) ) bar.render(output/genre_bar.html)rotate15是类型名称防遮挡的关键参数类型名超过 4 个汉字时横排会相互重叠。ToolboxOpts()自带导出图片、数据视图、缩放等交互按钮演示时可以直接从页面里下载 PNG省去另跑脚本的步骤。从 matplotlib 迁到 pyecharts 的核心成本在于 Series 名称、坐标轴、标题这些「声明式」配置项的写法差异字段含义本身没有变化所以前面的 pandas 清洗成果可以无缝复用。5. 交付级细节重试、断点续爬与文档组织5.1 重试与缓存策略对 10 个页面的爬取来说断点续爬听起来是过度设计但源码里确实有一个轻量级缓存思路把每页解析后的原始 JSON 落成独立文件下次运行如果检测到某页已经抓过就直接从磁盘加载。这样做的好处是开发调试时不用反复请求豆瓣服务器也降低了被封的风险。import json import os CACHE_DIR cache os.makedirs(CACHE_DIR, exist_okTrue) def fetch_page_with_cache(start): cache_file os.path.join(CACHE_DIR, fpage_{start}.json) if os.path.exists(cache_file): with open(cache_file, r, encodingutf-8) as f: return json.load(f) resp safe_get(fhttps://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, encodingutf-8) as f: json.dump(page_data, f, ensure_asciiFalse, indent2) return page_datapage_{start}.json的命名直接关联请求参数自然具备幂等性同一个start值永远映射到同一个缓存文件。ensure_asciiFalse让 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重新生成图表不用重新请求豆瓣如果你只是想调图表样式也不用担心重复触发对方的反爬机制。这个「数据采集、清洗分析、可视化」三段式任务拆分本身就是大作业项目管理里最容易被忽略、却最能体现工程意识的地方。本文还有配套的精品资源点击获取