爬取豆瓣评分前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?start=0&filter=start参数控制起始偏移量:0是第 1~25 名,25是第 26~50 名。所以要拿前50,只需要拼接两个 URL:
第一页:start=0 第二页:start=25每页固定 25 条。这里有个细节:不是所有榜单页都固定每页 25 条,豆瓣 Top250 刚好是 250 = 25×10,你拿全部 250 条的话,翻 10 页即可;只拿前 50 的话,两页就够。
1.3 反爬情况评估
动手之前我对豆瓣的反爬做了低调侦察。当前实际感受是:豆瓣对单纯列表页的防护不算极端,但它有一个非常典型的特征——User-Agent 校验和 cookie 校验。
- 不带浏览器 UA,直接使用 Python 默认 UA,大概率返回 418(I'm a teapot)。
- 带普通 UA 但未带 cookie,偶尔也能通,但频率高了会触发验证。
- 如果访问频率特别高,会进入“请打开豆瓣首页验证”的跳转,这个页面里是验证码,解析不到任何列表数据。
所以我给这次爬取定的策略是:
频率限制到“每请求一次,间隔 2~3 秒”,总共只有 2 个页面,50 条数据,加起来请求次数极少,可以安全完成。
另外,严格遵守 robots.txt 精神——个人学习、低频访问、不用于商业用途,这是底线。
2. 页面结构分析与解析策略
拿到 HTML 之后怎么提取电影信息?这一步是整篇的核心。你不需要看完整页面的 3000 行 HTML,只需要找到每个电影条目对应的局部结构就行。
2.1 列表页的 HTML 特征
豆瓣 Top250 页面里,每一部电影都放在一个li标签下,li内部有固定的层级:
<li> <div class="item"> <div class="pic"> <a href="电影链接"> <img width="100" alt="电影名" src="图片地址"> </div> <div class="info"> <div class="hd"> <a href="电影链接" class=""> <span class="title">肖申克的救赎</span> <span class="title"> / The Shawshank Redemption</span> </a> <span class="other"> </span> </div> <div class="bd"> <p class=""> 导演: 弗兰克·德拉邦特 Frank Darabont / 主演: 蒂姆·罗宾斯 Tim Robbins / ...<br> 1994 / 美国 / 犯罪 剧情</p> <div class="star"> <span class="rating5-t"></span> <span class="rating_num">9.7</span> <span> (2876543人评价)</span> </div> <p class="quote">希望让人自由。</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(r'<span class="title">(.*?)</span>', html)这段能提取到所有正标题,但无法区分中文名和外文名,而且遇到 这类实体时会一起抓到,后面还要再清洗。既然要写爬虫,请规范地选择解析器。
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;q=0.9", } def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text html = fetch_page("https://movie.douban.com/top250?start=0&filter=") 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(" ", strip=True) # 用空格替代换行 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就是年份类型那行。
再拆分credits:
director_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, filename="top50_movies.csv"): with open(filename, "w", newline="", encoding="utf-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;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif," "image/webp,*/*;q=0.8", } def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) 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 = f"https://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="", encoding="utf-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 Redemption | 9.7 | 2876543 | 1994 | 美国 | 犯罪 剧情 |
| 2 | 霸王别姬 | Farewell My Concubine | 9.6 | 2050000+ | 1993 | 中国大陆 / 中国香港 | 剧情 爱情 |
说明:第二行仅为示意数字,具体人数以实际抓取为准。
这里我还做了个细节处理:把rank放在循环里递增,而不是依赖列表顺序,防止某页被过滤掉后排名依然按原始自然顺序走。
5. 实操中的常见问题与解决速查
做完整个爬取之后,我把实战里会遇到的最典型问题整理成了一张速查表,并附上排查思路。你如果遇到类似状况,直接按表格走就能节省大量时间。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
items为空,len(items)是 0 | 被重定向到了验证页面 | 打印soup.title看是不是“豆瓣电影”,如果是验证页,降低频率、重启网络环境,或等待一会儿再试 |
| 只爬到 25 条,没有第二页 | start参数拼接错误 | 确认 URL 中start=25,不是start=50,两页偏移分别是 0 和 25 |
评分人数解析为0 | .star span:last-child选择器抓到的不是人数节点 | 改用extract_people_count(star_tag),汇总所有文本再提取数字 |
| 电影名里出现“/ 肖申克的救赎” | 遇到没有中文名的外片 | 需要按.title个数判断,如果只有外文名则正常存为外文名 |
| 写入 CSV 后中文乱码 | 编码用了utf-8 | 改用utf-8-sig |
请求时报418 | UA 被识别为脚本 | 换完整浏览器 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(bins=10) 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、数据清洗和文件存储这几个核心技能已经有了真实的工程感知。再往后,换成其他静态网站,基本就是修改选择器和字段映射的事情了。