news 2026/10/16 23:00:06

从requests到BeautifulSoup:豆瓣Top250电影爬虫实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从requests到BeautifulSoup:豆瓣Top250电影爬虫实战

爬取豆瓣评分前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)

这段能提取到所有正标题,但无法区分中文名和外文名,而且遇到&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;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 Redemption9.728765431994美国犯罪 剧情
2霸王别姬Farewell My Concubine9.62050000+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
请求时报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(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、数据清洗和文件存储这几个核心技能已经有了真实的工程感知。再往后,换成其他静态网站,基本就是修改选择器和字段映射的事情了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 22:59:58

专有云DTS API调用实战:从凭证准备到任务编排

简介&#xff1a;阿里云专有云企业版 V3.16.0 数据传输服务DTS开发指南是一份面向企业开发者和系统集成工程师的官方产品文档&#xff0c;专注于DTS API接口的完整调用方法与集成流程。资源压缩包共1个PDF文件&#xff0c;大小3.57MB&#xff0c;便于检索和离线翻阅&#xff0c…

作者头像 李华
网站建设 2026/10/16 22:59:48

ASP.NET MVC 5项目源码包:含权限、数据库、JWT改造的完整站点

简介&#xff1a;本资源是面向高校计算机专业学生及.NET初学者的ASP.NET网站开发实战教程配套源码包&#xff0c;聚焦图书管理系统这一典型Web项目&#xff0c;系统覆盖页面生命周期、MVC架构、C#核心编程、ADO.NET与Entity Framework数据库操作、Web API构建及IIS部署等关键技…

作者头像 李华
网站建设 2026/10/16 22:56:14

MATLAB数字信号处理实战:从源码包到滤波器设计

简介&#xff1a;万永革《数字信号处理的MATLAB实现》高清扫描版&#xff0c;是一本以大量实例和可运行程序贯穿始终的数字信号处理实践教材&#xff0c;主要面向数理基础相对薄弱、重视动手能力的读者&#xff0c;也适合本科生、研究生与工程技术人员作为案头参考。压缩包内共…

作者头像 李华
网站建设 2026/10/16 22:55:43

MATLAB爬虫实战:从webread到JSON接口的轻量级数据采集指南

1. 为什么我推荐用MATLAB做爬虫1.1 别小看MATLAB在爬虫领域的定位先回答一个最常被问到的问题&#xff1a;MATLAB也能写爬虫&#xff1f;能&#xff0c;而且比很多人想象的要顺手。我在某个数据分析项目里需要批量采集公开气象站点数据&#xff0c;一开始也想直接上Python&…

作者头像 李华
网站建设 2026/10/16 22:54:11

VoLTE掉话根因分析与实战优化指南

简介&#xff1a;本资源是一份面向通信网络优化工程师与VoLTE运维技术人员的实战型掉话分析技术文档&#xff0c;聚焦4G VoLTE网络中高频掉话问题的根因定位与版本级解决方案。文档系统梳理了异频/异系统重定向掉话、TM3/TM8模式转换异常、X2接口未启用导致跨站重建失败、RLC优…

作者头像 李华
网站建设 2026/10/16 22:52:04

AI生成界面总是千篇一律?用Skill包约束设计决策

1. 为什么你的AI总在生成“同一个模子刻出来的”界面1.1 一个让所有AI编程用户头疼的普遍现象如果你最近半年用过任何主流AI编程工具来生成前端界面&#xff0c;大概率遇到过这种情况&#xff1a;你让它做一个后台管理系统&#xff0c;它给你一个侧边栏加顶部导航加卡片式数据面…

作者头像 李华