豆瓣Top250爬虫实战:requests+BeautifulSoup数据清洗与评分统计 如果让我给刚学完Python基础的朋友推荐一个爬虫练手项目我大概率会说去爬豆瓣电影Top250顺手做个评分统计。这个项目不涉及登录没有复杂的JS加密字段又足够丰富从发请求、解析HTML到用Pandas做统计是一条链路非常完整的实战。上个月我又从头到尾做了一遍趁着记忆热乎把分页规律、请求头伪装、BeautifulSoup解析、数据清洗和几个容易踩的坑整理出来。这篇文章适合想找一个完整实战项目巩固requests和pandas的同学也适合刚接触爬虫、想了解整个流程的开发者参考。1. 为什么把豆瓣Top250当成爬虫入门的最佳练手项目而不是选其他网站1.1 静态HTML结构稳定字段信息覆盖全豆瓣Top250是纯服务端渲染的页面不依赖JavaScript。你用requests发一个GET请求拿回来的就是完整的HTML字符串里面已经包含了榜单上所有电影的名称、链接、导演、主演、年份、评分、评价人数和一句话简介。整个过程不需要无头浏览器不需要等待异步加载对初学者来说思维负担小很多。更重要的是这些字段对后续的数据分析来说简直就是量身定制的小型关系表。评分是浮点数、评价人数是整数、年份是时间字段、国家地区是分类字段、导演主演是文本列表、quote是短文本。你爬完这些数据后不管做直方图、年份分布、地区统计还是简单的文本分析都有现成的素材可以折腾。有人可能会问为什么不直接选抖音评论区或者淘宝评论当练手项目那些地方要么是JS动态渲染要么接口做了签名校验要么需要登录态新手在第一周就撞上逆向工程很容易在环境配置和调试里消磨掉热情。豆瓣Top250这种“静态页面 基础反爬”的组合反而是最理想的过渡项目。1.2 反爬机制“恰到好处”——有挑战但不会劝退豆瓣并不是一个完全没有反爬的网站Top250页面的防护措施就两个核心点不设置User-Agent直接用python-requests的默认头大概率拿到403。短时间请求频率过高可能触发临时风控返回418或者302跳转。对刚入门的人来说这两个问题恰好是爬虫必须掌握的基本功。设置UA是基础中的基础控制请求频率又能让你明白爬虫不是无限发包的机器而是需要“讲礼貌”的自动化工具。对比一下其他平台抖音评论区可能要处理WebSocket和签名微信公众号文章需要登录态电商平台往往有滑块验证码。这些技术当然有人能搞定但对第一个项目来说门槛太高。豆瓣Top250只需要你“伪装成一个真实浏览器去请求”这种难度对新手刚刚好有成就感又不会产生不切实际的挫败感。1.3 数据规模刚好适合本地统计也方便验证Top250一共10页每页25条。250条数据放在内存里用Pandas直接处理就行不需要上数据库也不用分布式。而且这个榜单的基本情况大家都比较熟悉比如平均分大约在8.6分左右9分以上的作品是少数。这种“大众熟知的结论”是天然的验证工具如果你的统计结果和常识相差太远比如平均分只有7分或者爬回来只有180条那一定说明程序某个环节出了问题可以尽早排查而不是拿到一堆数据后盲信结果。对一个练手项目来说这种直观的可验证性比什么都重要。2. 请求层分页规律、UA伪装与请求节奏2.1 分页规律start参数从0递增到225豆瓣Top250的基础URL很简单核心参数是start。第一页start0第二页start25第十页start225每页恰好25条记录。也就是说start代表当前页第一条记录在整个榜单里的偏移量。用代码生成十个页面地址一行搞定urls [fhttps://movie.douban.com/top250?start{offset}filter for offset in range(0, 250, 25)]你可能会在地址栏里看到filter这个参数它实际对榜单内容没什么影响但浏览器访问时会自动带上。请求时带上也无妨不影响解析结果。值得注意的是Top250榜单的排序会随豆瓣编辑策略产生微小调整但分页规则多年未变这一点可以放心。2.2 UA伪装别用默认的python-requests第一次用requests去请求豆瓣最容易踩的坑就是不设置User-Agent。requests默认UA是类似“python-requests/2.31.0”这样的字符串豆瓣一眼就能识别出这是脚本直接给你403。模拟一个常见浏览器的UA之后一般就能正常访问HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Connection: keep-alive, }这里面Accept-Language经常被忽略但不设置的后果是可以预见的有些情况下豆瓣会返回英文版页面或者响应内容编码出现奇怪问题。把Accept-Language: zh-CN带上保证拿到的都是中文内容。cookie对Top250页面不是必需的因为榜单内容不需要登录所以整个项目完全不需要处理登录逻辑。2.3 请求节奏单线程加每页sleep既稳又讲礼貌爬Top250的正确姿势是串行请求每页之间sleep一到两秒。我见过有人为了“效率”写10个线程并发结果爬到第三页就开始收到418然后花更多时间处理风控典型的捡了芝麻丢西瓜。一个带重试的请求函数大约长这样import time import requests def fetch_one_page(start, retries3): url fhttps://movie.douban.com/top250?start{start}filter for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text else: print(fstatus_code{resp.status_code}, retry {attempt 1}) except requests.RequestException as e: print(frequest error: {e}, retry {attempt 1}) time.sleep(1.5) return None一句话概括请求层的经验能用单线程就别开线程池能sleep一秒就别只sleep零点一秒。Top250总共10页并发提速意义很小反而容易触发限制得不偿失。2.4 触发风控之后怎么判断豆瓣的风控表现得比较温和常见症状是返回418状态码或者302跳转到sec.douban.com。遇到这种情况不要反复硬试等几分钟再继续通常就能恢复正常。下面的踩坑章节会专门讲这个问题这里先给一个基本判断原则短时间内的418大概率是临时限流而不是永久性封禁停手比硬刚有效得多。3. 解析层用BeautifulSoup固定HTML结构把六类字段完整提取出来3.1 榜单页的HTML骨架用浏览器F12打开Top250页面可以看到每部电影都保存在ol classgrid_view列表里每个li对应一部电影。我定位时用的最稳定选择器是.grid_view .item这个类名在多年改版中一直保留不容易失效。单部电影的HTML结构大致如下li div classitem div classpic a hrefhttps://movie.douban.com/subject/1292052/ img width100 alt肖申克的救赎 src... /a /div div classinfo div classhd a href... span classtitle肖申克的救赎/span span classtitle / The Shawshank Redemption/span span classother / 月黑高飞(港) / 刺激1995(台)/span /a /div div classbd p class 导演: 弗兰克·德拉邦特nbsp;nbsp;nbsp;主演: 蒂姆·罗宾斯 / 摩根·弗里曼... br 1994 / 美国 / 犯罪 剧情 /p div classstar span classrating5-t/span span classrating_num9.7/span span1906840人评价/span /div p classquote span classinq希望让人自由。/span /p /div /div /div /li这个结构多年来相对稳定。真正偶尔变化的是导演主演那行的格式以及部分电影没有quote这两类情况下面都会讲到。3.2 中英文名和其他片名title选择器会命中多个.hd .title这个选择器会匹配多个span因为一部电影既有中文名又有英文名还有/开头的中文别名。我建议的处理方式很简单第一个span是中文名单独保存把所有title的文本用斜杠拼起来存一个完整标题字段方便后续搜索展示。title_spans item.select(.hd .title) all_titles /.join(span.text.strip() for span in title_spans) title title_spans[0].text.strip() if title_spans else None这样做的好处是中文名作为主键足够干净而完整标题字段又保留了英文名和别名不会丢信息。3.3 导演、年份、地区与类型的解析在p标签里做手术div classbd里的p标签没有class不能用属性选择器直接定位某个子字段所以最稳妥的方法是取整段文本再处理。p标签的文本大致是这样的导演: 弗兰克·德拉邦特主演: 蒂姆·罗宾斯 / 摩根·弗里曼1994 / 美国 / 犯罪 剧情年份、地区、类型都在换行后面的部分。我的做法是先用正则提取年份然后把年份后面的内容按/切分得到地区和类型import re p_text item.select_one(.bd p).get_text( , stripTrue) year_match re.search(r(\d{4}), p_text) year year_match.group(1) if year_match else None meta_part p_text.split(year)[-1].strip() meta_parts [part.strip() for part in meta_part.split(/) if part.strip()] # 例如: [美国, 犯罪 剧情]这里用get_text( , stripTrue)把换行符替换成空格再用正则提取四位数年份。要注意的是某些合拍片地区可能是“中国大陆 / 香港”统计地区时要么只取第一个元素要么把所有地区都拆开计数。我在统计华语片时选择的是“只要包含中国大陆、香港或台湾之一就算华语片”这个口径会更贴近常识。3.4 评分与评价人数注意逗号和纯数字清洗评分是span classrating_num9.7/span文本就是数字直接转float即可。评价人数位于star区域最后一个span文本形如“1906840人评价”。清洗时最怕的是混入中文和千分位逗号。我统一用一个函数处理import re def clean_int(text: str): cleaned re.sub(r[^\d], , text) return int(cleaned) if cleaned else 0正则只保留数字既去掉了“人评价”三个字也顺手处理了可能存在的逗号还能应对少数空值情况。这个函数虽然简单但在后续爬其他榜单时也能复用属于高频基础工具。3.5 quote缺失的情况p classquote不是每部电影都有。Top250里大约有不到十部电影没有一句话简介主要是一些纪录片和相对冷门的作品。解析时如果不判空直接.text就会触发AttributeError程序会在某几条记录上报错中断。quote_el item.select_one(.quote .inq) quote quote_el.text.strip() if quote_el else 很多新手在这个位置栽跟头真正原因不是代码复杂而是没有考虑到数据里天然存在缺失值。这也是爬虫和普通编程训练差异比较大的地方真实页面永远比你想象的更“脏”。3.6 完整解析函数示例把上面几个点组合起来一个带跨页偏移量的单页解析函数大约长这样from bs4 import BeautifulSoup def parse_page(html: str, page_offset: int) - list[dict]: soup BeautifulSoup(html, lxml) movies [] for idx, item in enumerate(soup.select(.grid_view .item), start1): try: title_spans item.select(.hd .title) title title_spans[0].text.strip() if title_spans else None all_titles /.join(span.text.strip() for span in title_spans) p_text item.select_one(.bd p).get_text( , stripTrue) year_match re.search(r(\d{4}), p_text) year int(year_match.group(1)) if year_match else None rating_num item.select_one(.rating_num).text.strip() rating_count_el item.select_one(.star span:last-child) rating_count clean_int(rating_count_el.text) quote_el item.select_one(.quote .inq) quote quote_el.text.strip() if quote_el else link item.select_one(.hd a)[href] movies.append({ rank: page_offset idx, title: title, all_titles: all_titles, year: year, rating_num: float(rating_num), rating_count: rating_count, quote: quote, url: link, }) except AttributeError as e: print(f解析失败: {e}, 当前索引: {idx}) continue return movies这里解释器选择的是lxml比默认html.parser快不少解析Top250这种规模的页面毫无压力。rank通过page_offset idx计算避免第2页开始排名又从1算起。4. 评分统计爬完数据之后把250条记录变成值得分享的结论4.1 评分分布9分以上有多少高分区间在哪拿到250条记录后第一步当然是转成DataFrame然后看评分分布。我这次跑下来的结果大致是这样的最高分9.7是《肖申克的救赎》。9.0分以上的作品大约有30部出头差不多占12%。整个榜单的中位数和平均分都在8.6附近。最低分在8.2到8.3之间没有低于8分的电影。用直方图看特别直观评分分布不是均匀的峰值集中在8.5附近高分尾巴逐渐收窄。这说明Top250的含金量确实高8分是门槛9分是绝对神作区间。这里有个小技巧分析评分时不要只看平均值要以中位数为主。因为个别的极值比如9.7和8.2会把平均值拉偏一点点而中位数更能反映榜单中间位置的典型水平。4.2 年份维度1994年这种“神仙打架”年份在数据里确实能看出来按年份做分组计数能看到明显的尖峰。我这次爬出来的结果显示1994年、1997年、1999年都是产出高分电影比较多的年份。尤其是1994年榜内那年产出的电影包括《肖申克的救赎》《阿甘正传》《这个杀手不太冷》《低俗小说》《霸王别姬》等这个密度放在整个影史上都是罕见的。再进一步按十年维度统计能发现一个比较有意思的现象新千年后进榜的片子数量更多但平均分不一定比80年代和90年代更高。这个结论背后其实是榜单的选片逻辑——老电影有更漫长的时间沉淀口碑新片必须特别惊艳才能挤进榜单所以年代结构天然偏向“老片沉淀”。4.3 华语片占比与地区维度地区解析稍麻烦因为合拍片会有一长串地区。我的清洗口径是只要记录里包含中国大陆、中国香港或中国台湾之一就算华语片。按这个口径Top250里华语片大约有50部左右占比20%上下。榜单里既有《霸王别姬》《活着》《大话西游》这样的内地经典也有大量香港老片和台湾电影。从地区结构看美国电影毫无疑问占据半壁江山欧洲电影和日本电影也占有相当份额。这大体反映了豆瓣用户的口味——偏文艺、偏全球视野同时在本土电影上也有比较稳定的选择。4.4 可视化直方图、分组柱状图一次到位如果不想在可视化上花太多时间pandas加matplotlib四五行代码就能出图import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, PingFang SC, Noto Sans CJK SC] plt.rcParams[axes.unicode_minus] False # 评分分布 plt.hist(df[rating_num], bins20) plt.title(豆瓣Top250评分分布) # 年份分布 year_group df.groupby(year).size() year_group.plot(kindbar, figsize(15, 5)) plt.title(豆瓣Top250年份分布)关于中文显示一定要提前检查环境字体。Linux服务器上如果没有任何中文字体图像里的中文会变成方块最简单的解决办法是安装Noto Sans CJK或者把图表的label改用英文。对于个人统计分析来说图能看懂就行文字不是核心。5. 工程细节JSON存储、异常处理与数据完整性校验5.1 为什么用JSON存储而不是CSV每条电影记录里导演主演是列表结构quote可能包含逗号、引号如果直接写CSV用Excel打开后很可能出现列错位和转义问题。JSON没有这些麻烦结构天然嵌套保存和恢复都方便import json with open(douban_top250.json, w, encodingutf-8) as f: json.dump(movies, f, ensure_asciiFalse, indent2)读取也一步到位with open(douban_top250.json, r, encodingutf-8) as f: movies json.load(f)如果后续需要用SQL分析JSON也很容易转换用pandas的json_normalize就能展开成表格。存储层用JSON保证了原始数据不丢失最后要什么结构再转换灵活性好得多。5.2 单页失败的兜底策略重试之后留记录十个页面里任何一页都可能因为网络抖动或临时风控失败。最省心的方案是请求函数带三次重试重试间隔逐步增加。如果三次都不行把失败的start记下来稍后统一补爬failed_pages [] for start in range(0, 250, 25): html fetch_one_page(start) if html: movies.extend(parse_page(html, start)) else: failed_pages.append(start) time.sleep(1.2) print(失败页:, failed_pages)这种做法不追求单次运行一定成功而是保证程序不会因为一页失败就整体退出。跑完一眼就能看出哪几页要补非常实用。5.3 数据完整性校验250条、无重复、字段基本完整爬虫代码写完、数据到手之后不要急着开统计先做完整性检查assert len(movies) 250, f实际拿到 {len(movies)} 条 assert len({m[title] for m in movies}) 250, 存在重复电影 missing [m for m in movies if not m[title] or not m[year]] if missing: print(f存在缺失核心字段的记录 {len(missing)} 条)在实践中第三条我倾向于只警告不断言因为个别字段缺失很可能来自页面本身并不代表爬虫坏了。真正影响分析质量的主键字段是标题、URL、评分和年份只要这四个完整其他字段可以容忍少量缺失。6. 踩坑实录403、选择器失效和数字解析的完整排查过程6.1 第一个坑请求403——不是IP被封是UA没设置第一次写的时候我用requests.get(url)直接跑结果返回403。当时第一反应是“IP是不是被封了”还差点想放弃。后来冷静下来打开浏览器F12看请求头再对比requests的默认UA很快就找到了真相python-requests的UA太明显目标服务器不想理你。排查步骤其实很固定在浏览器里打开Top250页面F12找到请求Headers看User-Agent。对比requests默认UA和浏览器UA的差异。把浏览器UA和相关请求头放进HEADERS字典重新请求。做完这一步403基本就消失了。这个经验在其他网站上同样通用很多反爬都是从UA做起的遇到403先检查请求头而不是先怀疑IP。6.2 第二个坑选择器失效——不要迷信教程用F12看当前页面网上那些教程大部分现在还能跑但如果你某天发现选择器失效了最该做的是去检查当前页面的实际结构而不是怀疑自己写错了什么。我的排查习惯是打印一小段HTML看真实结构soup BeautifulSoup(html, lxml) print(soup.select_one(.item).prettify())比较稳定的做法是使用相对保底的类名组合比如.grid_view .item而不是那种嵌套极深的位置选择器比如#content div div.article div div。位置选择器只要页面有一小块结构调整就全部失效类名选择器反而能扛住大部分微调。6.3 第三个坑评价人数的数字清洗——逗号、“人评价”、空字符串第一次解析评价人数时我直接把文本转int结果ValueError因为字符串里混着“人评价”三个汉字。后来有人还用带千分位逗号的数据比如“1,906,840人评价”如果只去掉汉字不处理逗号也会报错。用前面写的clean_int函数正则一步搞定。这个坑虽然小但它提醒我一个普遍规律网页上看起来是数字的文本真实转换前永远要先做清洗而且清洗要越保守越好只保留数字。这个函数后续爬其他平台时复用的概率非常高。6.4 风控判断是临时限流还是需要彻底停手如果爬到一半出现418或者302到sec.douban.com我会先停五分钟再试一次。如果页面能正常返回说明只是临时限流继续降低频率即可如果持续报错说明当天频率实在太高干脆放弃这个任务。这个经验对复杂项目非常关键爬虫最好设计成“可重入”的也就是随时可以停、随时可以续爬。Top250因为只有10页手动重跑成本很低但如果你将来爬几千页的站点没有断点续爬能力一天的心情都会被一个网络抖动毁掉。7. 进阶方向与合规提醒爬虫不只是一次性的脚本7.1 从Top250扩展到更多场景跑通Top250之后值得扩展的方向其实不少爬指定导演或演员的作品列表按年份统计口碑曲线。爬热门短评做简单的情感分析和词云。把榜单从电影扩展到豆瓣图书Top250、音乐Top250复用同一套解析框架。用Playwright等无头浏览器处理JS动态渲染的页面覆盖更复杂的站点。这里我不建议新手立刻上Scrapy。虽然Scrapy的工程化能力更强但先用requests加BeautifulSoup手工实现一遍理解请求、解析、存储这条链路再迁移到Scrapy会顺畅得多。爬虫的核心能力不是某个框架而是调试网络问题、处理页面结构和清洗脏数据的能力。7.2 合规提醒频率、数据量和用途爬虫并不是原罪但使用时有几个底线需要守住控制请求频率不要对目标站点造成压力。遵守robots.txt的约定有些路径并不适合批量抓取。涉及个人信息和版权内容的数据不要擅自商用或公开传播。豆瓣Top250本身是公开可见的榜单数据但批量保存后同样要注意使用边界。我做爬虫项目时习惯是“先看规则再写代码”。尊重规则不仅能保护自己也能让后续开发更安心尤其是当你并不确定这个爬虫会不会从个人学习变成线上服务的时候。7.3 我用过之后的感觉把一次性脚本做成可复跑的小工具这次项目跑完之后我把它整理成了一个简单的命令行工具传入起始页码、输出文件路径、是否拼接完整标题等参数就能直接运行。周日晚上定时跑一次结果落到本地JSON再配合一个CSV导出脚本随时可以用Excel翻看。对完整的数据分析流程来说爬取只是起点。真正让我觉得有价值的是那些评价背后藏着的电影史1994年的“神仙打架”、华语片在榜单里的位置、9分神作在不同年代的密度差异。如果你恰好也想做类似的分析别犹豫直接跑起来遇到问题动手排查一轮收获比看十篇教程都大。