Python爬虫实战:豆瓣Top250全流程数据采集与可视化分析
很多人学Python爬虫的第一个实战项目就是爬取豆瓣电影Top250这个项目我前前后后带过不少新人跑通也亲眼看着它从一个练手demo变成很多人简历里拿得出手的项目经历。今天把这套东西完整摊开讲一遍从页面分析、请求构造、数据解析到清洗落库再到最后的数据分析和可视化一条线走完所有代码都能直接跑。这个项目能练到什么requests发HTTP请求、BeautifulSoup解析HTML、pandas做清洗聚合、SQLite和CSV双轨存储、matplotlib和pyecharts出图一套组合拳全给串起来了。适合刚学完Python基础语法、想找一个真实场景练手的人也适合已经写过零散脚本、想系统走一遍“采集→清洗→分析→可视化”全流程的初级开发者。不要觉得Top250这个题目太老恰恰因为它页面规整、数据量适中、字段丰富才是把整条链路跑明白的最佳训练场。1. 项目拆解Top250这口井到底挖什么很多人一看到“爬虫项目”就兴奋脑子里立刻冒出IP代理池、逆向分析、验证码识别这些词结果动手第一步就卡住了。豆瓣Top250能成为经典练手项目核心原因是它的难度梯度设计得非常合理页面是静态HTML没有复杂的接口鉴权和加密参数翻页逻辑肉眼可见反爬力度也适中不会让你轻易被封但也逼着你学会设置请求头、控制请求频率。这些恰恰是日常工作中真正用得上的基础能力。我建议的技术组合是requests负责发请求BeautifulSoup负责解析pandas负责清洗SQLite加CSV负责双轨存储matplotlib加pyecharts负责可视化。整套全在Python生态内不需要额外装数据库服务不依赖浏览器自动化框架跑起来不挑机器。等你把这套流程跑通以后换任何目标站点无非是换页面解析规则和字段提取逻辑骨架完全可以复用。1.1 为什么偏偏是Top250选这个目标有几个很实际的理由。第一数据字段丰富。每部电影包含排名、中文名、原名、导演、主演、年份、地区、类型、评分、评价人数、经典台词这些字段对后续数据分析来说几乎是为教学量身定做的。第二分页规律清晰。每页25条一共10页URL里只有start参数以25为步进递增从0到225不需要处理动态加载和复杂参数签名。第三数据量恰到好处。250条不多不少清洗时能遇到缺失值、格式混杂、多值字段等问题分析时又能得出有统计意义的结论比抓几百条小打小闹或者爬几万条算不动都更合适。还有一点容易被忽略豆瓣Top250的页面结构已经稳定了很多年。这意味着你查到的资料、踩过的坑、网上的解决方案都还有效学习成本被大幅降低。比起去爬一个改版频繁的站点这个项目能让你的注意力完全集中在爬虫和数据处理的本身。1.2 流程设计先想清楚再动手动手写代码之前我习惯先把整条流水线在脑子里过一遍拆成下面几步页面分析用浏览器开发者工具定位数据所在的HTML节点结构。构造请求带上合理的请求头模拟正常访问处理翻页URL。解析提取从每个电影条目中提取需要的字段做必要的字符串清洗。落盘存储把结果同时写入CSV和SQLite双保险。数据清洗用pandas读取处理缺失值、格式归一化、拆分多值字段。分析论证从评分、年代、地区、类型、导演等维度寻找规律。可视化呈现用图表把结论直观地表达出来。每一步都有明确的产出物。这样做的好处是当你中途卡住的时候你能很快定位是采集的问题还是清洗的问题而不是把代码从头到尾翻一遍还没头绪。接下来我们从最核心的采集环节说起。2. 数据采集跟页面解析死磕到底采集是整个项目的地基地基没打好后面分析再漂亮也是空中楼阁。这一部分我拆成页面分析、请求头构造、翻页逻辑、异常处理四个小节来讲。2.1 先用手刨开页面结构再写代码任何爬虫项目拿到手第一步永远不是写代码而是打开浏览器按F12老老实实看页面结构。豆瓣Top250的列表框架非常规整所有250部电影都躺在ol标签里每个li就是一部电影的完整卡片。拿其中一个li来说里面信息分布如下排名在div.pic里的em标签中比如1、2。电影主名在div.hd下的span.title[propertyv:itemreviewed]里。原名在第二个span.title通常以斜杠开头。导演、主演、年份、地区、类型全部挤在div.bd里第一行p中以空格和换行分隔。评分在div.star里的span.rating_num。评价人数在div.star里的最后一个span格式像1551495人评价。经典台词在p.quote的span中注意它是可选的不是每部电影都有。我用浏览器开发者工具逐层点开确认了这些节点之后才动手写解析代码。经验是先手动在一个页面里定位到所有字段的完整XPath或CSS选择器路径再写通用解析逻辑能省掉大量调试时间。2.2 请求头是底线别裸奔豆瓣对爬虫的防护并不算严厉但如果你的请求头里没有User-Agent十有八九会被拒绝访问返回一个显示“检测到有异常请求”的页面。这是因为服务器在默认情况下会拦截那些看起来不像正常浏览器的请求。所以第一件事就是给请求穿上伪装。我自己常用的请求头配置是下面这样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: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive }这里的User-Agent我取的是新版Chrome的标准UA实测用下来很稳。Accept和Accept-Language是加分项能进一步让请求看起来像真实浏览器。有人会问只写User-Agent行不行行但遇到严格点的站点就容易漏馅养成带全请求头的好习惯后面爬别的站能少踩很多坑。还有一个小细节我用了一个timeout10参数给请求设置超时防止某个请求卡住导致整个程序挂在那里不动。超时时间设太短容易被网络抖动误伤设太长又影响效率10秒是实测下来还算合理的中位数。2.3 翻页逻辑与解析完整代码翻页是这个项目里最不需要动脑的部分但很多人第一次写还是容易搞错。Top250的URL规律是第一页是https://movie.douban.com/top250?start0第二页是start25以此类推第十页是start225。所以翻页的循环就一句话for start in range(0, 250, 25)。下面是完整的采集代码每一步我都加了注释import time import csv import random 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } def fetch_page(start): 抓取单页Top250返回解析后的电影列表 url fhttps://movie.douban.com/top250?start{start} resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) movies [] for item in soup.select(ol.grid_view li): # 排名 rank item.select_one(div.pic em).get_text(stripTrue) # 电影名第一个title是中文名第二个是原名 titles item.select(div.hd span.title) name_cn titles[0].get_text(stripTrue) if titles else name_other titles[1].get_text(stripTrue).replace( / , ) if len(titles) 1 else # 导演、主演、年份、地区、类型 info_line item.select_one(div.bd p).get_text( , stripTrue).split( ) # 评分与评价人数 rating item.select_one(span.rating_num).get_text(stripTrue) rating_count item.select(div.star span)[-1].get_text(stripTrue) # 经典台词不一定有 quote_tag item.select_one(p.quote span) quote quote_tag.get_text(stripTrue) if quote_tag else movies.append({ rank: int(rank), name_cn: name_cn, name_other: name_other, info_line: info_line, rating: float(rating), rating_count: rating_count, quote: quote, }) return movies def main(): all_movies [] for start in range(0, 250, 25): print(f正在抓取 start{start} ...) try: page_movies fetch_page(start) all_movies.extend(page_movies) except Exception as e: print(f抓取失败: {start}, 错误: {e}) # 随机延时避免请求过快 time.sleep(random.uniform(1, 2)) print(f共抓取 {len(all_movies)} 条数据) # 临时落盘CSV方便检查 with open(top250_raw.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[rank, name_cn, name_other, info_line, rating, rating_count, quote]) writer.writeheader() writer.writerows(all_movies) if __name__ __main__: main()这里我把info_line一整行先原样存下来没有立刻拆分。原因是拆分逻辑放在pandas里做更顺手批量处理比逐条用字符串切割要方便得多。后面清洗环节再集中处理字段拆分的活。2.4 限速、重试做一个绅士爬虫爬虫写出来跑通不是终点能稳定地跑完才是。豆瓣虽然对爬虫容忍度还可以但你要是每秒发十几个请求IP照样会被封。我的做法是每抓一页之后time.sleep(random.uniform(1, 2))让每次请求之间至少有1到2秒的随机间隔。这个速度下250条数据不到20秒就能抓完完全够用又不会给服务器带来压力。再就是异常重试。网络波动和目标站点临时抽风都是常有的事。我只加了简单的try-except把异常打出来建议你在实际项目里升级成带重试的版本def fetch_with_retry(start, retries3): for attempt in range(retries): try: return fetch_page(start) except Exception as e: print(f第 {attempt 1} 次尝试失败: {e}) time.sleep(3) raise RuntimeError(f连续 {retries} 次抓取失败, start{start})这个模式在真实生产环境里很常用爬虫跑得越久越能体会“自动重试”比“人工守着盯”靠谱得多。3. 数据清洗与入库把脏数据收拾成规整表格采集完的原始数据直接拿来分析是能出图的但字段都挤在一坨字符串里根本没法聚合。这章就是把info_line拆开、把格式统一、把数据写进数据库的完整过程。3.1 拆字段一行文字藏着五六个属性在采集阶段我们把导演、演员、年份、地区、类型全部塞进了info_line。实际打印出来长这样导演: 弗兰克·德拉邦特 Frank Darabont / 主演: 蒂姆·罗宾斯 / 1994 / 美国 / 犯罪 剧情注意年份前面没有中文标签它是被空格分隔的第四段。地区也是一样只有一个裸的字符串。更麻烦的是类型可能不止一个用空格连接了多个词。清洗这段信息我先把这行按字段位置切割再用pandas做多值展开。下面是我实际用的清洗代码import pandas as pd df pd.read_csv(top250_raw.csv, encodingutf-8-sig) # 拆分info_line def split_info(line): # line 形如: 导演: xxx / 主演: xxx / 1994 / 美国 / 犯罪 剧情 parts line.split( / ) if len(parts) 4: director parts[0].replace(导演:, ).strip() actors parts[1].replace(主演:, ).strip() year int(parts[2].strip()) country parts[3].strip() genres parts[4].strip() if len(parts) 4 else else: director actors country genres year None return director, actors, year, country, genres df[[director, actors, year, country, genres]] df[info_line].apply( lambda x: pd.Series(split_info(x)) )这里要注意info_line在采集时我用get_text( , stripTrue)把换行符替换成了空格所以到了pandas里格式比较规整。如果你直接在浏览器里复制文本换行会造成切分错位这也是为什么我一直强调先用统一的方式把原始数据“定格式”再落盘的原因。3.2 去重、缺失值和格式归一化250条数据理论上不会重复但清洗环节依然要养成去重的习惯毕竟很多真实场景的数据源会不定期给你塞重复记录。我用电影名作为主键df df.drop_duplicates(subset[name_cn], keepfirst)缺失值方面经典台词是重灾区很多电影没有quote字段采集时补了空字符串这里统一填充成“无”df[quote] df[quote].fillna(无) df df.fillna()评分在清洗时已经是float类型但评价人数还是像1551495人评价这样的字符串需要抽出数字部分import re def extract_count(text): match re.search(r\d, text) return int(match.group()) if match else 0 df[rating_count] df[rating_count].apply(extract_count)到这一步DataFrame里已经是干干净净的规整结构化数据了每一列都是一个独立维度终于可以开始分析了。3.3 双轨存储CSV垫底、SQLite主力清洗之前存了一份CSV那是临时检查用的。清洗之后我建议把最终结果同时存两份。一份CSV方便你用Excel打开直观查看一份SQLite方便后续用SQL做复杂的多条件查询也方便以后扩展项目时接其他数据表。SQLite建表和入库代码不长import sqlite3 conn sqlite3.connect(top250.db) df.to_sql(movies, conn, if_existsreplace, indexFalse) conn.close()对pandas的to_sql一行就能搞定建表和写入。真正需要注意的反而是要不要带索引列、数据库文件放哪里、后续查询要不要建索引这些细节。250条数据量不大索引先不加也行但如果以后扩展成爬全部豆瓣电影就得在name_cn和year上建索引了。4. 数据分析从250行数据里挖出金矿数据清洗完之后就到了整个项目最有意思的部分——看看这些电影背后到底藏了什么规律。我选了几个最典型的分析维度每一个都能从数据得到明确的结论。4.1 评分分布高分区间到底有多拥挤先看评分的分布情况用pandas就能直接输出统计量print(df[rating].describe())实测你会发现Top250的评分最小值也有8.2最高分9.7中位数在8.6左右分布高度集中在9分附近。换句话说能进这个榜单的几乎没有“平庸之作”评分区间被压缩得非常狭窄。这也侧面说明评分的区分度有限9.0和9.3之间的差距远没有普通榜单那样有直观感受上的巨大落差。如果把评分做成直方图分箱最直观的结论就是8.5到9.0这个区间差不多占掉半壁江山。这能让人直观感受什么叫“幸存者偏差”——你看到的都是经过筛选的顶尖影片而高分区间内部的内卷反而没那么明显。4.2 年代与地区谁在霸榜把年份和评分一起看能发现一些有趣的时代脉络。我先按年代分组统计df[decade] (df[year] // 10) * 10 decade_stats df.groupby(decade)[rating].agg([count, mean]).sort_index()从数量上看1990年代和2000年代是上榜电影最密集的两个十年。这不难理解这两个年代是电影工业的黄金期也在互联网评分体系逐渐成型的时间窗口内。往上追溯1970年代之前的黑白片和早期彩色片数量明显偏少一是年代久远打分人数基数不够二是大众评分天然偏向“近当代”作品。地区方面要注意一个细节虽然我单独拆出了country字段但部分电影是合拍片country里会有多个国家名用空格或斜杠连着。分析前需要先按空格拆分再展开成多行否则统计出来的占比会失真。我做地区占比时用的是Counter展开每个国家独立计数结果里美国电影占比接近一半日本和英国、法国紧随其后。这个结果符合大多数人对经典电影版图的认知算是数据对直觉的一次验证。4.3 类型、导演、演员的隐形规律类型是另一个有意思的维度。同样因为合拍片和跨类型片一列genres里可能包含“犯罪 剧情”这种多个类型。我把genres按空格拆分后依次计数最终发现剧情、爱情、犯罪、悬疑这几个类型的上榜次数遥遥领先。这背后的逻辑很容易理解进得了Top250的电影大多靠的是深入人心的叙事和完整的情感表达重工业特效大片反而因为评分受众的分化更难挤进前列。导演维度的统计也很直白。循环统计全部250部电影的导演出现频次排名靠前的那几位几乎都是公认的大师级人物。我还统计过男女配比、演员合作次数之类更细颗粒度的指标数据量虽然小但拿来练手完全足够。5. 可视化让结论自己说话数据算出来了光在终端里打印数字太干瘪用图表呈现会直观得多。这一步我用matplotlib和pyecharts各做了一版简单、出图快。5.1 用matplotlib画评分分布和年代趋势评分直方图是最容易出效果的第一张图import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False df[rating].hist(bins15, edgecolorblack) plt.title(豆瓣电影Top250评分分布) plt.xlabel(评分) plt.ylabel(数量) plt.show()这里有个新手高频踩坑点matplotlib默认字体不含中文不设置rcParams的话标题会显示成方块。我代码里直接指定了SimHei字体如果你的环境里没有这个字体可以改成别的中文字体名或者用英文标题绕过去。年代趋势图用折线图呈现看看每个年代的上榜数量起伏decade_counts df[decade].value_counts().sort_index() decade_counts.plot(kindline, markero) plt.title(不同年代上榜电影数量变化) plt.xlabel(年代) plt.ylabel(数量) plt.show()这张图能看到从1950年代一路走高、在1990年代见顶、随后缓慢回落的整体趋势比单纯打印一组数字直观很多。5.2 用pyecharts做类型占比和词云如果有交互式展示的需求pyecharts是更趁手的工具。类型占比的饼图用pyecharts的Pie组件几行就能实现词云则更适合把genres拆出来的词频可视化。词云这个图不承担精确统计的功能但胜在视觉冲击力。做词云之前先准备好词频字典from collections import Counter genre_list df[genres].dropna().str.split().sum() genre_counter Counter(genre_list)拿到词频字典之后换任何词云库都能直接接上。pyecharts的WordCloud需要数据是[(词, 频次), ...]的格式直接把counter转成列表传入就行。如果只是想快速出一张静态词云图wordcloud这个库也是不错的选择。6. 常见问题与避坑记录每个环节都有新手高频踩坑的地方我把这些年被问得最多的几个问题整理成一张速查表方便你排错。6.1 常见问题速查表问题表现根本原因解决办法返回内容不是电影列表而是“检测到有异常请求”未设置User-Agent或请求频率过快配置完整请求头每次请求间隔1秒以上打开CSV后中文乱码CSV编码用了utf-8Excel默认用其他编码打开写入时使用encodingutf-8-sig不要用utf-8抓取过程中途失败程序直接退出没有异常捕获或没有重试机制用try-except包住请求失败后重试3次排名全部是0解析器没有正确匹配到div.pic em标签检查页面结构是否被反爬替换重新用F12确认选择器电影名抓出来是空的页面结构中有动态加载内容或者CSS类名发生了变化打开浏览器开发者工具重新定位真实节点某些电影没有经典台词导致字段不对齐p.quote标签在某些条目里不存在解析时用条件判断缺省时填“无”评分小数位被截断解析后直接转int先转float保存再入库分析时年份列报错当年份是None时做了运算清洗阶段统一处理缺失值保证year无空值6.2 爬虫的边界与个人经验最后必须多说一句。很多人做了爬虫项目容易产生一种错觉觉得“能爬到就是王道”其实不然。这个项目抓的是公开的榜单页面数据本身是半公开信息个人学习使用通常没有问题。但练习过程中要养成三个习惯第一控制频率别去压垮对方服务器第二只抓自己分析需要的数据不碰用户隐私和需要登录后才能访问的内容第三如果项目要商用或发布先确认目标站点的条款和数据的版权边界。把爬虫的技术边界和数据使用的合规边界分开看这个项目才能做得踏实。磨刀不误砍柴工态度和手法都对了写代码只是一层薄薄的执行力问题。我在实际带人做这个项目时有几个体会特别深。第一次跑通采集代码的时候多数人会兴奋地盯着终端刷出来的250行数据觉得大功告成。但真正能拉开差距的是后面清洗那一步——很多人在拆info_line那一刻才发现自己写的解析器不够健壮。同一个字段有的条目多了一个空格有的缺失了某个标签这些细节正是真实数据常见的状态。能把这些边角处理干净比会写一百个爬虫函数更值钱。后续想扩展还可以往这几个方向走抓取每部电影的短评和影评做情感分析把Top250作为种子数据去爬更多关联电影做一个简单的推荐系统或者部署成定时任务每天自动更新榜单变化。这个项目最不缺的就是继续生长的空间先把这一条链路练扎实后面的事水到渠成。