爬虫抓豆瓣、做数据分析、再画可视化图表——这个组合几乎是每个Python学习者都会碰到的项目。可大多数人抓到数据、画了一两张图就卡住了,要么不知道怎么继续深入,要么做出来的东西根本看不出"分析"的价值。这篇文章我把自己的豆瓣电影数据抓取与可视化项目完整拆开讲,从需求分析、抓取策略、字段清洗到多图组合看板,用一套能直接复用的思路带你走完整个过程,适合正在学[数据抓取]、[数据分析]和[可视化]的同学参考,也适合想找一个能写进简历的完整Python项目的朋友对照实现。
1. 动手前先定需求:这个豆瓣分析系统到底要解决什么问题
1.1 为什么选豆瓣电影TOP250做数据源
很多教程选数据源时只看"能不能爬到",忽略了数据本身的分析价值。豆瓣电影TOP250这个榜单有个特别好的地方:它是经过了大量用户评分后筛选出的高分电影,样本量不大不小——250部,既能支撑起一些有统计意义的分析,又不会大到让新手无从下手。
更重要的一点是,这些数据天然自带多维度的"可分析属性":评分、评价人数、导演、主演、年份、国家地区、类型、经典台词。你仔细看一下,这几乎就是一个小型的结构化数据集,涵盖了数值型字段、分类型字段和时间型字段,恰好能把数据分析里最核心的几类操作全串起来。
还有一点就是页面结构足够稳定。豆瓣TOP250从诞生到现在,DOM结构虽然有小改动,但整体非常规整,非常适合用来讲解数据抓取。相比那些动态渲染、接口加密的网站,豆瓣公开列表页对新手友好太多,拿来当教学样本再合适不过。
提示:任何爬虫操作都要遵守目标网站的robots协议和相关法律法规,我将抓取频率控制在较低水平,数据仅用于学习分析,请勿对线上服务造成压力,更不要用于商业用途。
1.2 拆解分析需求:从"画图"变成"回答问题"
这是我认为整个项目里最重要的一步。大多数半途而废的项目,问题都出在"需求模糊"上——只想着"画几张好看的图",但图要回答什么问题,完全没想清楚。
我在这版项目里给自己定了5个明确的问题,后面的每张图都对应一个问题:
- 豆瓣高分电影的评分分布呈现什么特征?高分和低分之间差距有多大?
- 高分电影集中在哪个年代?这几十年来评分走势有怎样的变化?
- 哪些国家/地区产出的高分电影最多?
- 高分电影中,哪些类型出现频率最高?
- 评价人数和评分之间有没有关系?口碑是否等于热度?
有了这些问题,抓取哪些字段、洗哪些数据、画什么图就自然确定了。这比反过来先抓数据再看能画什么图,效率高得多。
1.3 技术选型为什么是 requests + pandas + pyecharts
选型这件事,我一直坚持一个原则:够用就好,别炫技。
抓取层,很多人一上来就上Scrapy。但对这个项目来说,250条数据分10页,requests加BeautifulSoup就能轻松搞定,完全不需要引入爬虫框架的调度、管道、中间件这些重量级概念。Scrapy是生产环境的利器,却不是教学项目的最优解,它会把初学者的注意力从"解析页面"转移到"框架配置"上。
清洗层选pandas,这个没有悬念。它天生就是干这活的,分组聚合、缺失值处理、类型转换,几行代码搞定。
可视化层我选了pyecharts而不是matplotlib。原因有三个:一是生成的是HTML页面,交互体验好,鼠标悬停能看数据;二是图表类型丰富,柱状图、饼图、折线图、词云都有现成接口;三是渲染为HTML文件后,可以直接用浏览器打开,方便分享,不需要像matplotlib那样去调字体、调布局。
2. 数据抓取的关键实现:从HTML到结构化数据
2.1 先看页面结构,再写抓取代码
我见过太多人拿到项目第一件事就是写爬虫代码,结果写一半发现字段提取不到,又回头改。正确做法是先打开页面,用浏览器开发者工具看清楚数据结构。
豆瓣TOP250的列表页URL规律非常直观:
https://movie.douban.com/top250?start=0&filter= https://movie.douban.com/top250?start=25&filter= https://movie.douban.com/top250?start=50&filter=每页25条,start参数跨度为25。页面里每部电影都在ol.grid_view下的li.item节点中,关键字段:
- 标题在
span.title节点,注意英文别名也是span.title,需要取第一个 - 评分在
span.rating_num - 评价人数在
div.star里的最后一个span - 导演、主演、年份、国家地区、类型都集中在一个没有class的
p标签里 - 一句话短评在
p.quote里,可能不存在
把结构弄清楚之后再写代码,基本就是一次通过。
2.2 分页抓取与字段提取的完整代码
这里直接给出我项目里的抓取核心代码,注释标得很清楚,有基础的同学可以直接跑:
import time import csv import re import requests from bs4 import BeautifulSoup 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" } def fetch_page(start): url = f"https://movie.douban.com/top250?start={start}&filter=" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return BeautifulSoup(resp.text, "html.parser") def parse_movie_items(soup): movies = [] items = soup.select("ol.grid_view li.item") for item in items: # 标题:取第一个 span.title title_tag = item.select_one("span.title") title = title_tag.text.strip() if title_tag else "" # 评分 rating_tag = item.select_one("span.rating_num") rating = rating_tag.text.strip() if rating_tag else "" try: rating = float(rating) except ValueError: rating = None # 评价人数:div.star 下最后一个 span,如 "1372303人评价" star_spans = item.select("div.star span") people_text = star_spans[-1].text if star_spans else "" people = re.sub(r"\D", "", people_text) # 去掉非数字字符 people = int(people) if people else 0 # 导演/主演/年份/地区/类型集中在 bd 下的第一个 p bd = item.select_one("div.bd") info_p = bd.find("p", class_=False) if bd else None info_text = info_p.get_text(" ", strip=True) if info_p else "" # 用正则拆出导演(简化处理,只取第一位) director_match = re.search(r"导演:\s*([^/]+)", info_text) director = director_match.group(1).strip() if director_match else "" # 短评 quote_tag = item.select_one("p.quote span") quote = quote_tag.text.strip() if quote_tag else "" movies.append({ "title": title, "rating": rating, "people": people, "director": director, "info": info_text, "quote": quote, }) return movies def main(): all_data = [] for start in range(0, 250, 25): soup = fetch_page(start) all_data.extend(parse_movie_items(soup)) print(f"已抓取 {start + 25} / 250 条") time.sleep(1.5) # 控制访问节奏,避免对服务器造成压力 with open("douban_top250.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=[ "title", "rating", "people", "director", "info", "quote" ]) writer.writeheader() writer.writerows(all_data) print(f"完成,共 {len(all_data)} 条") if __name__ == "__main__": main()这段代码有一个值得注意的设计:我没有在抓取阶段就强行把所有字段拆干净,而是把包含年份、地区、类型的原始字符串info先原样存下来,具体拆分放到清洗阶段做。这样做的原因是原始信息里各种分隔符并不完全统一,一次性拆字段容易漏数据,先保存再拆分更稳妥。
2.3 抓取节奏和请求头,怎么做到稳定不封
老爬虫最忌讳的就是一股脑地高频请求。豆瓣对异常访问的检测很敏感,我遇到过抓几页后服务暂时不可用的情况。几个我实测有效的经验:
- 必须设置完整的
User-Agent,不能挂默认的python-requests - 每页之间
time.sleep(1.5),这个节奏对250条数据整体耗时不到15秒,完全够用 - 不并发、不重试过猛,出错就先停下来检查
- 抓取的结果一次性落盘,不要反复访问页面
用比较克制的方式抓公开页面,基本不会遇到问题。但还是要提醒一句,如果只是做分析,抓一次存成本地文件就足够了,没有必要反复抓。
2.4 落盘:为什么用CSV而不是数据库
这一步我特意解释一下。很多人做数据分析项目时习惯把数据先放进MySQL或SQLite,但对这个项目的体量来说完全没有必要。
CSV的优势很直白:文本文件,任何编辑器能打开,pandas一个read_csv就能读进来,出了问题肉眼可见。SQLite虽然也很好,但对250条数据而言属于杀鸡用牛刀,而且初学者调试数据库连接消耗的时间远大于它带来的收益。
要注意的是写CSV时我用的是encoding="utf-8-sig",这个细节很关键。utf-8-sig会在文件开头加上BOM头,用Excel打开中文时不会乱码,而标准utf-8编码在Windows的Excel里经常显示乱码。这个坑后面还会在中文处理里遇到。
3. 数据清洗与特征工程:图表好不好看全靠这一步
3.1 把混合类型字段变成可分析的数值
爬下来的数据不是拿来就能分析的。rating字段虽然是浮点数,但偶尔会出现空值或异常字符串;people字段虽然我在抓取时做了提取,但还是建议在清洗阶段再验证一遍。
read_csv读进来后,第一件事就是看数据类型和缺失值:
import pandas as pd df = pd.read_csv("douban_top250.csv") print(df.info()) print(df.isna().sum())重点检查几个字段:
rating必须是float,如果有无法转换的值要检查来源people必须是int,它在CSV里可能被读成float(如果存在空值的话),需要决定是填充还是删除info里要能正常提取年份、地区、类型
清洗代码大概长这样:
df["people"] = pd.to_numeric(df["people"], errors="coerce").fillna(0).astype(int) df["rating"] = pd.to_numeric(df["rating"], errors="coerce")errors="coerce"的意思是遇到解析不了的值变成NaN,先保留位置,再统一处理。这样比直接抛异常要好,至少能知道脏数据分布在哪些区块。
3.2 年份缺失与"未上映"的处理策略
info字段是"导演 / 主演 / 年份 / 国家 / 类型"的大杂烩,典型的值长这样:
导演: 弗兰克·德拉邦特 主演: 蒂姆·罗宾斯 / 摩根·弗里曼 1994 / 美国 / 犯罪 剧情年份提取我用正则从info里抽四位数字:
df["year"] = df["info"].str.extract(r"(\d{4})").astype("float")这里有个特殊情况,某些条目年份可能缺失,或者不是纯四位数字。我在实际抓取中遇到的概率不高,但清洗时仍然要处理。策略是:年份缺失的样本不下滑,直接用"未知"标记,或者在按年份分析时过滤掉这些行。250条样本里缺一两条不影响整体趋势结论,强行填充反而会引入错误信息。
地区字段的拆分我放在后面的可视化部分讲,因为那里的处理方式跟具体图表直接相关。
3.3 生成星级、年份区间等新特征
原始字段足够分析,但为了图表更直观,我会额外生成几个衍生特征。
星级字段:按豆瓣评分惯例,9分以上算力荐,8到9分算优秀,7到8分算还行。我用cut函数做区间分箱:
bins = [0, 7, 8, 9, 10] labels = ["7分以下", "7-8分", "8-9分", "9分以上"] df["star_level"] = pd.cut(df["rating"], bins=bins, labels=labels, right=False)年份区间特征:把年份按年代分桶,能在折线图趋势之外再补一个各年代数量对比的视角:
df["era"] = (df["year"] // 10 * 10).astype("Int64").astype("string") + "年代" df.loc[df["year"].isna(), "era"] = "未知年代"这些衍生字段看起来简单,但它们的价值在画图时才会完全体现出来——分组聚合的粒度一下子就从"每一年"变成了"每个年代",图表更清爽、趋势更明显。
3.4 数据质量检查清单
清洗完成后,我会花30秒做一轮快速校验,确认数据没被洗坏:
len(df)等于250,没丢行rating的取值在0到10之间,没出现离谱数值year的最小值大于1900,最大值小于当前年份people没有负数,最大的评价人数在合理范围内
这一步虽然简单,但能帮你过滤掉后面分析阶段一大半的异常结果。很多跑出来"图很奇怪"的问题,回头一查都是这几个基础校验没过。
4. 可视化多图分析:一张图回答一个业务问题
4.1 评分分布直方图:理解"豆瓣评分"的脾气
分析的第一步永远是看分布。评分分布直方图能最快地回答"高分电影到底集中在什么区间"。
我用pyecharts画直方图时,处理方式和matplotlib不太一样。pyecharts的Bar本身不直接提供直方图分箱,需要先用pandas把数据分箱计数,再喂给柱状图:
import pandas as pd from pyecharts.charts import Bar from pyecharts import options as opts bins = [0, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0] labels = ["7以下", "7-7.5", "7.5-8", "8-8.5", "8.5-9", "9-9.5", "9.5以上"] df["rating_bin"] = pd.cut(df["rating"], bins=bins, labels=labels, right=False) rating_counts = df["rating_bin"].value_counts().sort_index() bar = Bar() bar.add_xaxis(rating_counts.index.tolist()) bar.add_yaxis("电影数量", rating_counts.tolist()) bar.set_global_opts( title_opts=opts.TitleOpts(title="豆瓣TOP250评分分布"), xaxis_opts=opts.AxisOpts(name="评分区间"), yaxis_opts=opts.AxisOpts(name="电影数量"), ) bar.render("output/rating_dist.html")跑出来的结果通常会显示,TOP250里8.5分以上的电影占了很大比例,这很正常——因为这个榜单本身就是高分片精选。这个图的真正价值是帮你建立"豆瓣评分尺度"的直觉:8分是优秀线,9分以上就是神片级别。
4.2 年份与评分的趋势散点/折线
第二个问题:高分电影集中在哪个年代,这几十年来评分走势怎样。
先把数据按年份分组聚合,算每年的平均评分和电影数量。由于单年电影数可能很少,平均值波动会比较大,建议再加一个滚动平均:
trend = df.groupby("year").agg( avg_rating=("rating", "mean"), movie_count=("rating", "count") ).reset_index() trend["avg_rating_smooth"] = trend["avg_rating"].rolling(5, min_periods=1).mean()这个平滑处理非常关键。如果不做,折线图会像心电图一样大起大落,压根看不出趋势;平滑之后才能看到90年代前后是高分片的高产期。画图时间用折线图,X轴是年份,Y轴是平均分,再加上电影数量的柱状图作为对比轴,一图讲两个维度。
需要说明的是,用python等编程工具做这类分析要基于自己合法获取的数据进行,大家在实践时也应以学习研究为目的、在合规前提下操作。
4.3 国家与地区的条形图
地区字段隐藏在info里的"年份 / 国家 / 类型"段落中,例如:
1994 / 美国 / 犯罪 剧情我的拆分思路是:先用正则把/分隔的三个部分取出来,取中间那段作为地区。但注意有些电影是合拍片,地区会是"美国 / 中国大陆",所以我在拆分后还会再split一次,把多个地区摊开统计:
def extract_country(info_text): parts = info_text.split("/") if len(parts) >= 2: return parts[-2].strip() return "未知" df["country_raw"] = df["info"].apply(extract_country) df_country = df["country_raw"].str.split(" / ").explode().str.strip() country_counts = df_country.value_counts().head(15)explode()是个很实用的pandas操作,能把"一行里多个值"拆成"多行单值",做多分类统计时就靠它。
画条形图时注意标题,比较惯例的做法是横向条形图,国家在Y轴,数量在X轴,观感更舒服。pyecharts里设置yaxis_opts做横向即可,不再赘述代码,逻辑和柱状图完全一样。
4.4 电影类型词云与组合看板
类型字段的拆分逻辑和地区同理,但类型可以画词云,视觉效果更直观。pyecharts的WordCloud接收词频列表:
from pyecharts.charts import WordCloud type_series = df["info"].apply( lambda x: x.split("/")[-1].strip() if "/" in x else "" ) type_freq = type_series.str.split().explode().str.strip().value_counts() wc = WordCloud() wc.add("", type_freq.items(), word_size_range=[20, 80], shape="circle") wc.set_global_opts(title_opts=opts.TitleOpts(title="豆瓣TOP250电影类型词云")) wc.render("output/type_wordcloud.html")到这里,系统已经能产出至少五张图:评分分布、年份趋势、国家Top榜、类型词云、评分与评价人数关系。为了阅读和交付方便,我用pyecharts的Page把多张图组合成一个HTML大看板,一次打开全部分析结果:
from pyecharts.charts import Page page = Page(layout=Page.SimplePageLayout) page.add(bar, trend_chart, country_bar, type_pie, wc) page.render("豆瓣电影可视化看板.html")有了这个组合看板,整个系统才真正称得上"可视化系统",而不是散落的几张图。交付给别人看时,一个HTML文件就够了。
5. 执行过程中的高频坑位与排查思路
5.1 抓下来的文件名出现乱码
我第一次用Windows跑这个项目时,输出的CSV用记事本打开好好的,用Excel打开中文全乱。问题出在编码格式上,我最初写文件用的是encoding="utf-8",而Windows Excel默认用GBK解码。
解决办法就是前面提到的encoding="utf-8-sig"。加了BOM之后,Excel能正确识别UTF-8编码,乱码直接消失。这个坑其实和爬虫无关,属于Windows环境的经典问题,但遇到一次就能记住。
5.2 评分人数出现"1,234,567"导致的类型报错
pandas读CSV时,如果people字段原样存了带千位分隔符的字符串,比如1,234,567,直接astype(int)会报错。我在抓取阶段已经用re.sub(r"\D", "", people_text)把非数字字符过滤了,所以我的CSV里没有这个问题。
但如果你拿的是别人抓好的数据,或者想复用其他人的源码,一定要在清洗阶段补这一手:
df["people"] = df["people"].astype(str).str.replace(",", "", regex=False) df["people"] = pd.to_numeric(df["people"], errors="coerce")这种"输入数据格式不可控"是数据清洗最常见的问题,处理思路永远只有一个:先看数据长什么样,再做转换,不要无条件信任字段类型。
5.3 一会儿能抓一会儿不能抓
我在测试抓取逻辑时,为了省时间把sleep时间从1.5秒改成了0.2秒,结果没跑几页就触发了访问限制,页面返回的不是TOP250列表而是验证页。
这个问题的排查链路很典型:
- 先看返回状态码,200不代表正常,要检查页面内容里有没有预期的节点
- 打印响应文本的前500个字符,和正常页面对比
- 确认是不是请求频率太高,把
sleep恢复到合理值 - 过几分钟再跑
我后来在代码里加了一个简单的内容校验,通过判断页面中是否存在"grid_view"来决定这次请求是否有效,无效就抛异常停止,避免在错误响应下继续抓取产生垃圾数据。
5.4 pyecharts图表显示空白
pyecharts渲染出来的HTML文件在浏览器打开时一片空白,这也是新手高频问题。原因通常是两点:
- 在Jupyter Notebook里用
render_notebook和在脚本里用render结果被搞混 - pyecharts生成的HTML依赖加载ECharts的JavaScript文件,如果页面文件被移动了位置,或者用浏览器直接打开时安全策略禁止加载本地JS,图表就会白屏
我最常用的方案是直接render()成HTML文件后用浏览器打开,而且不要在文件渲染后再次移动位置。如果你需要给别人发一个离线可看的报告,建议把pyecharts换成离线模式,具体做法是下载ECharts的JS文件放到项目本地,并在Page初始化时指定js_host参数,这里就不展开源码了,遇到的同学可以查一下pyecharts的离线配置说明。
5.5 HTML报告路径与静态资源问题
最后提一个交付层面的坑。如果你把生成的HTML文件发给别人,或者拷到别的电脑,对方打开时可能图表显示不出来,但同一个文件在自己电脑上是好的。这十有八九是JS资源依赖路径的问题——HTML内部引用的是绝对路径或CDN,在断网环境或资源没同步的情况下就会失效。
我这版项目的处理方式是:所有图表统一输出到output目录,组合看板放在项目根目录,资源路径统一使用相对路径。这样做虽然土,但足够可靠,换机器、发压缩包都不会出问题。
做这个豆瓣电影数据分析可视化项目,最有价值的地方不在于你抓了多漂亮的页面、画了多少张图,而在于你完整走了一遍"从问题出发,到数据落地,再到图表回答"的流程。我后来做其他行业的数据分析项目,发现思路完全是一脉相承的:先定义清楚问题,再决定要什么数据,最后才考虑用哪张图表达。这套流程跑通一次,后面遇到任何数据源都能迁移过去。源码里的结构和细节如果你照着从头敲一遍,收获会比我在这里贴出的任何代码片段都大。