爬取豆瓣评分前50的电影信息这个需求我太熟了。前阵子帮一位朋友做可视化课程作业他要的就是这个——把豆瓣评分最高的前50部电影整理成表格再画个评分分布图。我一听这事看着简单真做起来全是坑。豆瓣的反爬不是最凶的但足够让你多花两三个小时。直接requests.get上去大概率收到一条 418 或者直接被重定向到登录页。但别慌只要懂一点基本套路普通个人学习用的爬取完全能搞定。这篇文章我就用一次完整的实操过程把从分析页面结构、构造请求、解析数据到清洗入库的全部步骤拆开讲每一步为什么这么做、踩过什么坑都掏出来说说。先说清楚这里用的方案是“静态页面 正则/解析器提取”的路子。豆瓣电影的排行榜列表页是服务端渲染的列表数据直接嵌在 HTML 里不需要模拟点击、不需要跑无头浏览器一个requests加一个BeautifulSoup就够了。整件事的核心不是“爬”而是“怎么解析出正确的电影名、评分和链接”。适合谁来参考刚学完 Python 基础、想拿真实网站练手的朋友或者需要一份电影榜单数据做分析但又不想手动复制的人。看完这篇你可以自己动手抓下来还能避开我在过程中踩进去的那些坑。1. 需求拆解与目标确认做爬虫最忌讳上来就写代码。先花五分钟想清楚我要拿什么数据、从哪个页面拿、以什么形式保存。这一步想清楚了后面至少少改三版代码。1.1 明确数据字段“电影评分前50”——单看这个需求至少需要这几个字段电影名称中文名为主有的电影有别名需要额外处理导演和主演信息方便后续分析比如哪位导演上榜最多评分这是排序和筛选的核心依据评分人数用来判断这个评分是否有足够的样本支撑8.5 分但只有几百人评跟 8.5 分有几十万人评说服力完全不同电影链接后续想爬详情页留个入口上映年份和类型这两个字段在列表页能看到一并抓下来省事别小看字段设计这一步。我以前爬过一次数据只抓了片名和评分结果老板临时想看看“上榜电影里哪年的老片最多”我又回去重新爬了一遍。一次写全后续分析能省掉大量重复劳动。1.2 数据源页面确认豆瓣评分最高的电影列表通常对应的是豆瓣电影 Top250这个页面的前 50 条。不过豆瓣默认按“评分 评价人数”综合排序页面上的顺序大体上就是评分从高到低同分时参考评价人数。所以“评分前50”和“Top250页面前50”基本吻合。我这个实操用的是https://movie.douban.com/top250?start0filterstart参数控制起始偏移量0是第 1~25 名25是第 26~50 名。所以要拿前50只需要拼接两个 URL第一页start0 第二页start25每页固定 25 条。这里有个细节不是所有榜单页都固定每页 25 条豆瓣 Top250 刚好是 250 25×10你拿全部 250 条的话翻 10 页即可只拿前 50 的话两页就够。1.3 反爬情况评估动手之前我对豆瓣的反爬做了低调侦察。当前实际感受是豆瓣对单纯列表页的防护不算极端但它有一个非常典型的特征——User-Agent 校验和 cookie 校验。不带浏览器 UA直接使用 Python 默认 UA大概率返回 418Im a teapot。带普通 UA 但未带 cookie偶尔也能通但频率高了会触发验证。如果访问频率特别高会进入“请打开豆瓣首页验证”的跳转这个页面里是验证码解析不到任何列表数据。所以我给这次爬取定的策略是频率限制到“每请求一次间隔 2~3 秒”总共只有 2 个页面50 条数据加起来请求次数极少可以安全完成。另外严格遵守 robots.txt 精神——个人学习、低频访问、不用于商业用途这是底线。2. 页面结构分析与解析策略拿到 HTML 之后怎么提取电影信息这一步是整篇的核心。你不需要看完整页面的 3000 行 HTML只需要找到每个电影条目对应的局部结构就行。2.1 列表页的 HTML 特征豆瓣 Top250 页面里每一部电影都放在一个li标签下li内部有固定的层级li div classitem div classpic a href电影链接 img width100 alt电影名 src图片地址 /div div classinfo div classhd a href电影链接 class span classtitle肖申克的救赎/span span classtitle / The Shawshank Redemption/span /a span classother /span /div div classbd p class 导演: 弗兰克·德拉邦特 Frank Darabont / 主演: 蒂姆·罗宾斯 Tim Robbins / ...br 1994 / 美国 / 犯罪 剧情/p div classstar span classrating5-t/span span classrating_num9.7/span span (2876543人评价)/span /div p classquote希望让人自由。/p /div /div /div /li这几个关键节点的 class 作用要记牢rating_num评分数字float类型.star span:last-child评价人数带有“人评价”文字.hd .title第一个是中文名第二个是原始电影名.bd p第一行是导演和主演第二行是年份/国家/类型.quote一句话描述很多时候是经典台词这个字段是否留用可以看需求2.2 使用 BeautifulSoup 还是正则两种都可以但我强烈推荐 BeautifulSoup不要用正则。原因很实在正则匹配 HTML 非常脆弱电影名里如果出现特殊字符或者某个条目少了一个 span你的正则可能直接匹配失败而 DOM 解析则天然具备结构容忍性。当然如果不想引入额外库只用正则也能提取。比如re.findall(rspan classtitle(.*?)/span, html)这段能提取到所有正标题但无法区分中文名和外文名而且遇到nbsp;这类实体时会一起抓到后面还要再清洗。既然要写爬虫请规范地选择解析器。2.3 构造爬虫代码的基本骨架方案定下来之后我先写了个骨架版本单页抓取用来验证解析逻辑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/122.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch_page(url): resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text html fetch_page(https://movie.douban.com/top250?start0filter) soup BeautifulSoup(html, html.parser) items soup.select(.item) print(len(items)) # 预期输出 25用来验证是否被反爬拦截如果这里len(items)输出不是 25那基本就是被重定向到了验证页检查一下响应内容的标题是不是“豆瓣电影”。这个验证技巧非常有用任何爬虫第一步都建议先输出条数自检而不是直接写完整逻辑跑完再报错。3. 数据提取与字段清洗框架跑通后开始逐个字段提取。电影名、评分、导演主演、类型年份每个字段都有各自的“坑”下面一个一个过。3.1 提取电影名和链接def parse_item(item): # 主标题中文名 title_elem item.select_one(.hd .title) title title_elem.get_text().strip() if title_elem else # 外文名可选 foreign_title_elem item.select_one(.hd .title:nth-of-type(2)) foreign_title foreign_title_elem.get_text().strip() if foreign_title_elem else # 链接 link_elem item.select_one(.hd a) link link_elem.get(href) if link_elem else return title, foreign_title, link这里有个细节select_one(.hd a)只会取第一个匹配到的a因为整个条目里只有一个链接图片那个 a 在.pic里所以这样写安全。但要注意标题里可能含有空格get_text()之后一定要strip()否则存进 CSV 会多出莫名其妙的空格排序列都会对不齐。3.2 提取评分与评分人数rating_elem item.select_one(.rating_num) rating rating_elem.get_text().strip() if rating_elem else people_elem item.select_one(.star span:last-child) people_text people_elem.get_text().strip() if people_elem else # 形如 (2876543人评价)需要提取数字 import re people_count re.search(r(\d), people_text.replace(,, )).group(1)评分人数这里有个大坑当数字超过一万时豆瓣显示为“2876543”不带逗号分隔但个别浏览器页面会显示“2,876,543”。所以稳妥做法是先把字符串里的逗号全部去掉再用正则提取数字。另外.star里其实有多个span最后一个才是人评价。有些版本的豆瓣会在最后一个 span 后面多一个空 span所以你要确保select_one(.star span:last-child)拿到的文本里含“人评价”字样否则就要换选择器。我在这上面的防御性写法def extract_people_count(star_tag): text .join(star_tag.stripped_strings) nums re.findall(r\d, text.replace(,, )) return int(nums[0]) if nums else 0用stripped_strings把该节点下所有文字拼起来再统一取数字这样无论 span 结构怎么变都能拿到正确的人数。3.3 拆分导演、主演、年份、类型列表页的这行文字是我觉得比较难拆的导演: 弗兰克·德拉邦特 Frank Darabont / 主演: 蒂姆·罗宾斯 Tim Robbins / 摩根·弗里曼 Morgan Freeman / ... 1994 / 美国 / 犯罪 剧情注意第一行和第二行都在同一个p里它们之间用br分隔。用 BeautifulSoup 提取时可以拿到整个p的纯文本里面包含换行符。info_p item.select_one(.bd p) full_text info_p.get_text( , stripTrue) # 用空格替代换行 parts full_text.split( ) # 或者按换行符 split取决于文本 # 更稳的写法直接按 br 拆 p item.select_one(.bd p) lines [s.strip() for s in p.stripped_strings] credits lines[0] if len(lines) 0 else meta lines[1] if len(lines) 1 else p.stripped_strings会自动按标签拆分文本节点同时把空白清理掉br正好被当作分隔。这样credits就是导演主演那行meta就是年份类型那行。再拆分creditsdirector_part credits.split(导演:)[1].split(主演:)[0].split(/)[0].strip() stars_part credits.split(主演:)[1].split( / )[0].strip() # 此处需要注意坦白说导演和主演的拆分是最容易写错的。因为不同电影的主演数量不一致有的人名里有“/”符号比如“约翰·列侬 / 小野洋子”导致split(/)会拆错。我的建议是如果只是做电影评分榜你完全可以只保留完整的一行“导演: xxx / 主演: xxx”不必强行拆成单独字段。做了这个取舍之后代码的鲁棒性一下子高很多。年份和类型的拆分反而简单meta_parts meta.split( / ) year meta_parts[0].strip() # 1994 country meta_parts[1].strip() # 美国 genres meta_parts[2].strip() # 犯罪 剧情有些电影的年份是“1994(中国大陆)”因为上映地区不同这个字段会有括号类型字段则可能包含多个词中间用空格隔开。这些都不是大问题存储时保留原样即可。3.4 存储为 CSV / JSON抓完 50 条之后保存。我选择 CSV 格式方便直接用 Excel 或 pandas 打开编码用utf-8-sig。import csv def save_to_csv(movies, filenametop50_movies.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[排名, 电影名, 外文名, 评分, 评分人数, 导演主演, 年份, 国家, 类型, 链接]) writer.writeheader() writer.writerows(movies) print(f已保存 {len(movies)} 条数据到 {filename})为什么用utf-8-sig而不是utf-8因为 Excel 打开 UTF-8 编码的 CSV 时中文会乱码成䏿–‡加了 BOM 之后 Excel 才能正确识别。这个小细节救过我好几次。4. 完整爬虫代码示例到这里核心逻辑已经全部就绪。下面给出一份可以直接运行的完整代码包含翻页、解析、保存和基础反爬措施。为了方便阅读我做了少量注释。import csv import time 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/122.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif, image/webp,*/*;q0.8, } def fetch_page(url): resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text def extract_people_count(star_tag): text .join(star_tag.stripped_strings) nums re.findall(r\d, text.replace(,, )) return int(nums[0]) if nums else 0 def parse_item(item): title_elem item.select_one(.hd .title) title title_elem.get_text().strip() if title_elem else foreign_title_elem item.select_one(.hd .title:nth-of-type(2)) foreign_title foreign_title_elem.get_text().strip() if foreign_title_elem else link_elem item.select_one(.hd a) link link_elem.get(href) if link_elem else rating_elem item.select_one(.rating_num) rating rating_elem.get_text().strip() if rating_elem else star_tag item.select_one(.star) people_count extract_people_count(star_tag) p item.select_one(.bd p) lines [s.strip() for s in p.stripped_strings] if p else [] credits lines[0] if len(lines) 0 else meta lines[1] if len(lines) 1 else meta_parts meta.split( / ) if meta else [] year meta_parts[0] if len(meta_parts) 0 else country meta_parts[1] if len(meta_parts) 1 else genres meta_parts[2] if len(meta_parts) 2 else return { 电影名: title, 外文名: foreign_title, 评分: rating, 评分人数: people_count, 导演主演: credits, 年份: year, 国家: country, 类型: genres, 链接: link, } def main(): all_movies [] rank 1 for start in range(0, 50, 25): # 0, 25 url fhttps://movie.douban.com/top250?start{start}filter print(f正在抓取: {url}) html fetch_page(url) soup BeautifulSoup(html, html.parser) items soup.select(.item) if not items: print(警告: 未解析到内容请检查页面是否被反爬) break for item in items: movie parse_item(item) movie[排名] rank rank 1 all_movies.append(movie) time.sleep(2.5) # 控制请求频率对服务器礼貌一点 if all_movies: save_to_csv(all_movies) # 简单打印预览 for m in all_movies[:3]: print(m) def save_to_csv(movies): with open(top50_movies.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter( f, fieldnames[排名, 电影名, 外文名, 评分, 评分人数, 导演主演, 年份, 国家, 类型, 链接], ) writer.writeheader() writer.writerows(movies) print(f已保存 {len(movies)} 条数据到 top50_movies.csv) if __name__ __main__: main()运行后CSV 里的数据大概是这样的形态排名电影名外文名评分评分人数年份国家类型1肖申克的救赎The Shawshank Redemption9.728765431994美国犯罪 剧情2霸王别姬Farewell My Concubine9.620500001993中国大陆 / 中国香港剧情 爱情说明第二行仅为示意数字具体人数以实际抓取为准。这里我还做了个细节处理把rank放在循环里递增而不是依赖列表顺序防止某页被过滤掉后排名依然按原始自然顺序走。5. 实操中的常见问题与解决速查做完整个爬取之后我把实战里会遇到的最典型问题整理成了一张速查表并附上排查思路。你如果遇到类似状况直接按表格走就能节省大量时间。现象可能原因解决办法items为空len(items)是 0被重定向到了验证页面打印soup.title看是不是“豆瓣电影”如果是验证页降低频率、重启网络环境或等待一会儿再试只爬到 25 条没有第二页start参数拼接错误确认 URL 中start25不是start50两页偏移分别是 0 和 25评分人数解析为0.star span:last-child选择器抓到的不是人数节点改用extract_people_count(star_tag)汇总所有文本再提取数字电影名里出现“/ 肖申克的救赎”遇到没有中文名的外片需要按.title个数判断如果只有外文名则正常存为外文名写入 CSV 后中文乱码编码用了utf-8改用utf-8-sig请求时报418UA 被识别为脚本换完整浏览器 UA并加上Accept-Language爬到一半被暂时封禁请求频率太高目前只爬 2 页问题不大如果要多页务必睡 3~5 秒还可随机化间隔5.1 一个隐藏的反爬陷阱请求头里的 Referer有些情况下豆瓣会检查Referer也就是你从哪个页面跳过来的。直接访问列表页时默认是没有Referer的这在多数情况下没问题。但如果你模仿浏览器访问可以加上Referer: https://movie.douban.com/加上之后更像是用户从豆瓣首页点进来的能减少被误判的概率。我实测过只加 UA 和Accept-Language已经足够搞定 50 条数据加Referer是保险操作适合以后扩展到 250 条时用。5.2 反爬的最终原则不要想着绕过验证码。豆瓣的验证码系统一旦触发就得等它以小时计的冷却我们这只是拿 50 条数据完全没必要去跟验证码硬刚。总之低频率、少请求、间隔长、守规矩是个人学习用途最稳妥的姿势。6. 拿到数据之后还能做什么50 条电影信息只是开始。既然字段都抓下来了顺手做点分析会让这次爬虫更有价值。我这边拿到数据后简单做了三件事你可以参考6.1 分析评分分布用 pandas 读取 CSV然后看评分的分布import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(top50_movies.csv) df[评分] df[评分].astype(float) df[评分].hist(bins10) plt.title(Top50电影评分分布) plt.show()通常你会看到这些高分电影大量集中在 8.7~9.3 这个区间超过 9.5 的凤毛麟角。6.2 统计上榜次数最多的国家这个字段是为了分析“哪国电影在高分榜上出现最多”df[国家].value_counts().head(10)注意有的字段是“美国 / 英国”这种复合国家直接统计会出现包含关系需要先拆分。这又印证了当时清洗字段的重要性——如果不是一开始把国家单独拆出来后面还得重新爬。6.3 按评价人数排序在同样的评分下评价人数越多说明这个评分越有稳固性。比如 9.2 分的甲电影有 80 万人评价9.1 分的乙电影有 5 万人评价从样本量角度看前者更值得推荐。所以如果你要做一个“更可信的高分榜单”可以额外按“评分 × 评价人数”做一个加权指标够写一篇下回分解了。7. 个人经验与升级方向最后说几点我反复踩过之后的体会。第一爬虫的代码永远不会第一版就完美。我一开始也犯过“直接写全量抓取”的错误结果单个条目解析出错导致整个程序崩溃。所以建议你分步调试先解析 1 条 → 打印出来 → 确认无误 → 再循环所有。第二把sleep写进代码里不要觉得丢人。很多人为了让爬虫看起来“快”把间隔设成 0.1 秒甚至没有间隔这在正经项目里完全不可取。你是在访问别人的服务器保持合理抓取速度既是对对方的尊重也能最大程度避免自己 IP 被封。2 页数据总共间隔不到 3 秒毫无感觉。第三这个项目的扩展方向很实用。你可以把start的range(0, 250, 25)改成range(0, 250, 25)直接抓全 250 条代码不需要任何改动因为解析逻辑完全一致。你甚至可以再往下做抓取每部电影的详情页获取更多字段片长、编剧、奖项等对导演、演员进行统计看看哪些人高产且高质把爬取任务做成定时脚本每周自动更新一次榜单对我个人而言爬豆瓣电影榜单永远是练手的好题材页面结构稳定、数据结构清晰、反爬有挑战但不极端。你在动手的时候也别忘了数据最终是拿来用的保存格式、字段设计、编码选择每一步都是为后面的分析铺路。如果你顺着这篇文章的思路操作一遍能成功跑通并保存 50 条数据那说明你对requests、BeautifulSoup、数据清洗和文件存储这几个核心技能已经有了真实的工程感知。再往后换成其他静态网站基本就是修改选择器和字段映射的事情了。