
简介基于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 控制。第一页等效于 start0第二页是 start25最后一页 start225。每页固定 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;q0.9, } def fetch_page(start: int) - str: url fhttps://movie.douban.com/top250?start{start}filter resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() # 非200状态直接抛异常方便上层重试 return resp.texttimeout10 是连接和读取的总体超时时间防止请求卡死把 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. 数据清洗与存储把抓取结果整理成可直接分析的 DataFrame3.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], errorscoerce) df[people] ( df[people] .str.replace(人评价, ) .str.replace(,, ) .str.strip() ) df[people] pd.to_numeric(df[people], errorscoerce) df[year] pd.to_numeric(df[year], errorscoerce)errorscoerce表示无法解析的字符串置为 NaN而不是抛异常终止程序。people 的处理顺序不能反先去“人评价”再去逗号顺序反了会把1,871,862人评价残留成中间值。.str通道是整列批量操作性能比 for 循环高一个数量级。缺失值处理要看业务含义。quote 和 other_title 缺失可以把空字符串保留或者填成无但 title、rating、year 这三列是分析主字段缺失行直接丢弃df df.drop_duplicates(subset[title], keepfirst) 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, indexFalse, encodingutf-8-sig)encoding 用utf-8-sig而不是utf-8是因为 Windows 上的 Excel 默认按带 BOM 的方式识别文件少这个参数打开 CSV 就会中文乱码。写 SQLite 同样不用手拼 SQLimport sqlite3 with sqlite3.connect(douban.db) as conn: df.to_sql(top250, conn, if_existsreplace, indexFalse)sqlite3.connect的对象用作上下文管理器时with 块结束后自动提交if_existsreplace在重复运行时整体替换旧表避免每次采集都叠一层数据。如果你做的是增量采集要把这个参数改成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], bins20, edgecolorblack, alpha0.85) ax.set_xlabel(评分) ax.set_ylabel(影片数量) ax.set_title(豆瓣电影 TOP250 评分分布) plt.savefig(rating_dist.png, dpi150, bbox_inchestight)bins20 把评分区间切成了 20 份每份宽度约 0.1 分短视频非专题分析够用。dpi150 保证导出图在文档和投屏里不糊bbox_inchestight 把多余留白裁掉。如果本机没有 SimHei可以写成[Noto Sans CJK SC, Microsoft YaHei, SimHei]做回退。axes.unicode_minus看起来和本图无关但建议放进模板因为后续只要画出负相关散点图中文字体配置不全立刻会爆出坐标轴方块。常用的保存参数可以固定成这套基准matplotlib 参数作用本项目建议值figsize画布宽高英寸(8, 5)bins直方图分箱数20dpi输出图像分辨率150bbox_inches导出时是否裁掉空白tight4.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, ascendingFalse) print(stats.head())groupby 加 agg 时元组第一个元素是参与聚合的列第二个是聚合函数关键字参数直接给了输出列名。这张表里 count 比 avg_rating 更值得关注——某年代上榜数量多说明那一时期整体质量稳定。再细化到单一年份能直接看到类似 1994 年的“大年”现象year_counts df.groupby(year).size().sort_values(ascendingFalse) 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_pagesparser.pyHTML 解析成字典parse_pagestorage.py保存和读取数据save_csv、save_sqlite、load_dataanalysis.py统计计算和画图rating_dist、year_stats、weighted_scoremain.py串联整条流水线main、validate5.2 可复用的指数退避重试单次请求失败时立刻重试往往还是失败让重试间隔逐渐拉长更稳妥import functools import time def retry(max_times3, base_interval2): 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_times3, base_interval2) 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就能通过断言判断抓取链路是否被破坏。本文还有配套的精品资源点击获取