说实话,我一开始是有点抗拒用Selenium去爬豆瓣Top250的。毕竟这个页面是服务端渲染的老牌页面,直接requests加BeautifulSoup三分钟就能拿下来,何必动用一台真实浏览器。但后来带过几个新人,发现大家普遍卡在登录弹窗、翻页失效、等待时间踩不准这些地方,我才意识到:豆瓣Top250其实是把Selenium核心知识点练熟的好靶场。它足够简单——分页规律、元素结构几年都不怎么大变;又足够真实——不定时弹登录框、偶尔加载慢半拍、访问频繁了还会被限流。在这样一个“你知道正确答案”的页面里,正好可以验证自己对Selenium每一步的理解到底对不对。
这篇文章就是我完整走一遍的实战复盘,包含可以直接运行的代码,以及我在里面踩过的坑、想通的逻辑。适合刚学完Python基础、想写第一个完整爬虫的朋友,也适合已经会用requests但想补上“浏览器自动化”这块拼图的人。
1. 为什么拿Selenium啃豆瓣Top250,而不是直接上requests
1.1 这个项目覆盖了Selenium的核心用法
很多人问:爬个静态页面,有必要上Selenium吗?真没必要,但作为练习项目,它的价值恰好在于“对照实验”。
你清楚知道页面上有哪些元素、翻页参数是怎么变的,所以代码一旦报错,你能很快判断是定位写错了、等待没给足,还是网站改了结构。这种“已知答案调试未知问题”的过程,就是学习自动化测试和爬虫最舒服的路径。
整个项目会覆盖Selenium的几块核心能力:浏览器驱动配置、元素定位、显式等待、翻页处理、异常捕捉、数据抽取。这几块能力,几乎所有的浏览器自动化项目都用得上。不管是以后去爬需要登录的站点、处理无限滚动页面,还是用Playwright做自动化测试,思维模型完全一致。
1.2 requests方案和Selenium方案的区别
先放一张对比表,方便你理解为什么有人喜欢requests、有人离不开Selenium。
| 维度 | requests + BeautifulSoup | Selenium |
|---|---|---|
| 请求方式 | 直接模拟HTTP请求 | 驱动真实浏览器加载页面 |
| 速度 | 快,250条几秒内完成 | 慢,页面渲染、资源加载都有耗时 |
| 反爬对抗 | 需要手动维护Headers、Cookie | 像真实用户操作,但也会被识别无头特征 |
| 动态内容 | 拿不到JS渲染后的数据 | 能拿到完整渲染后的DOM |
| 交互能力 | 无法模拟点击、输入、滑动 | 可以模拟几乎一切用户操作 |
对豆瓣Top250来说,requests确实够用。但注意,这个页面的列表数据是包含在首屏HTML里的,所以requests能轻松拿到。如果网站哪天把列表改成异步加载,或者点击“展开更多”才会出现数据,requests方案就立刻失效,Selenium的改动成本就要小很多。
1.3 为什么最后还是推荐Selenium走一遍
新手写爬虫,最常见的问题是“不知道怎么判断页面加载完了没有”。requests只要拿到HTML就算完事,但真实浏览器里,页面是边加载边渲染的,元素出现有先后顺序。Selenium的核心价值,就是逼着你学会“等待”——等条件成立再继续操作,而不是蒙头sleep。
另外,Selenium会暴露一个新手经常忽略的事实:网站不是一个稳定的接口,它是一个会弹窗、会变慢、会抽风的应用。你在Selenium里学会try/except、学会失败重试,这套代码防御能力放到任何爬虫项目里都是通用的。
2. 环境准备:浏览器驱动版本匹配,这一关就能卡掉一半新人
2.1 最少要装哪些东西
Python环境就不多说了,建议3.8以上。核心依赖只有一个:
pip install seleniumChrome浏览器建议用最新稳定版。这个项目本质上只是调用浏览器去访问页面,所以Chrome版本越新,和驱动的兼容性就越容易处理。
2.2 浏览器驱动的版本陷阱
这是新手栽跟头最多的地方。Selenium要控制浏览器,需要ChromeDriver,而ChromeDriver必须和浏览器版本匹配。版本差多了,启动时会报类似session not created、unknown error: cannot find Chrome binary这样的错误。
现在的Selenium从4.6版本开始内置了Selenium Manager,可以自动去下载匹配的驱动,省掉了很多麻烦。但我个人的习惯仍然是手动下载驱动,放到一个固定目录,然后用Service来指定路径。原因很简单:Selenium Manager在部分公司内网环境下会下载失败,手动管理更可控。
from selenium.webdriver.chrome.service import Service service = Service(r"D:\chromedriver\chromedriver.exe") driver = webdriver.Chrome(service=service, options=options)注意,老代码里常见的executable_path=r"..."写法在Selenium 4.0之后已经标记为过期,新项目就别用了。
2.3 headless模式到底开不开
无头模式就是浏览器在后台运行、不弹窗。好处是节省资源,适合部署在服务器上;坏处是部分反爬系统会识别无头浏览器的特征,导致页面表现异样。
我的建议是:第一次调试时不要开headless,把浏览器窗口摆在眼前,亲眼看它一页一页翻过去,这样你能立刻发现弹窗有没有出现、元素有没有加载。等代码稳定之后,再改成headless模式跑批量任务。
如果你确实需要headless,新版Chrome建议用这个参数:
options.add_argument("--headless=new")同时可以加上这些配置,降低被识别的概率:
options.add_argument("--disable-gpu") options.add_argument("--window-size=1920,1080") options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False)--window-size这个参数很多人忽略,不带的话,headless模式默认窗口尺寸是800x600,部分元素会被判定为不可见,导致读取失败。先固定成1920x1080,能少很多麻烦。
3. 抓取代码逐段拆解:显式等待、稳健定位、start参数翻页
3.1 等待策略:不要用time.sleep
这是Selenium最容易被用错的地方。新手喜欢在driver.get(url)后面直接time.sleep(2),等两秒再抓。这个写法的问题在于:网络好时白白等2秒,网络差时2秒压根不够,页面没加载完就去定位元素,照样报错。
正确做法是显式等待,让代码等到“条件成立”才继续执行:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait = WebDriverWait(driver, 10) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "ol.grid_view")))上面这段的意思是:最多等10秒,直到页面里出现ol.grid_view这个列表容器。如果10秒内没出现,就抛出Timeout异常。这就是“显式等待”的含义——等的是条件,不是时间。
3.2 定位策略:宁可条件写稳一点
豆瓣Top250每部电影的信息都在ol.grid_view > li里,这是一个很清晰的重复区块。建议用它来圈定范围,再在每部电影的内部去定位具体字段,避免把整个页面搅在一起处理。
核心字段的定位方式:
- 排名:
item.find_element(By.CSS_SELECTOR, "em").text - 片名:
item.find_elements(By.CSS_SELECTOR, "span.title"),第一个是中文名,第二个是外文名/别名,有的电影只有一个,所以要用find_elements再取列表 - 导演/主演/年份/国家/类型:
item.find_element(By.CSS_SELECTOR, "p").text,再按字符拆分 - 评分:
item.find_element(By.CSS_SELECTOR, "span.rating_num").text - 评价人数:
item.find_elements(By.CSS_SELECTOR, "div.star span"),最末一个span里包含“人评价” - 一句话简介:
item.find_elements(By.CSS_SELECTOR, "span.inq"),注意有的电影没有,必须判空
3.3 翻页逻辑:直接用start参数,而不是点击“后页”
豆瓣Top250的URL非常有规律:
https://movie.douban.com/top250?start=0&filter= https://movie.douban.com/top250?start=25&filter= https://movie.douban.com/top250?start=50&filter=每页25条,10页刚好250条。循环10次就行:
for page in range(10): url = f"https://movie.douban.com/top250?start={page * 25}&filter=" driver.get(url) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "ol.grid_view"))) items = driver.find_elements(By.CSS_SELECTOR, "ol.grid_view > li") # 解析 items为什么不用点击页面上的“后页>”?我实测下来,点击的方式依赖“后页”按钮可见、未被遮挡、可点击,任何一个条件不满足都会失效。而直接改URL再driver.get,等价于手动在地址栏输入地址再回车,是最稳的翻页方式。
我个人后期做数据回填时,还喜欢把这种情况做成函数,传一个start值就抓一页,逻辑更清爽。
3.4 完整解析代码
下面这段是解析单页所有电影的代码:
def parse_page(driver, page): url = f"https://movie.douban.com/top250?start={page * 25}&filter=" driver.get(url) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "ol.grid_view"))) movies = [] items = driver.find_elements(By.CSS_SELECTOR, "ol.grid_view > li") for item in items: # 排名,有时前面有空格,顺手strip rank = item.find_element(By.CSS_SELECTOR, "em").text.strip() # 片名:第一个span.title是中文名,第二个是外文名/别名 title_tags = item.find_elements(By.CSS_SELECTOR, "span.title") title_cn = title_tags[0].text.strip() if title_tags else "" title_other = title_tags[1].text.strip() if len(title_tags) > 1 else "" # 导演等信息 info_text = item.find_element(By.CSS_SELECTOR, "p").text.strip() # 评分 rating = item.find_element(By.CSS_SELECTOR, "span.rating_num").text.strip() # 评价人数 star_spans = item.find_elements(By.CSS_SELECTOR, "div.star span") rating_people = star_spans[-1].text.strip() if star_spans else "" # 一句话简介,可能没有 inq_tags = item.find_elements(By.CSS_SELECTOR, "span.inq") inq = inq_tags[0].text.strip() if inq_tags else "" movies.append({ "rank": rank, "title_cn": title_cn, "title_other": title_other, "info_text": info_text, "rating": rating, "rating_people": rating_people, "inq": inq }) return moviesinfo_text的原始格式一般是这样的:
导演: 弗兰克·德拉邦特 Frank Darabont / 主演: 蒂姆·罗宾斯 Tim Robbins / ... / 1994 / 美国 / 犯罪 剧情年份、国家、类型都在后半段,用/分割后取对应位置即可。但要注意,有的老电影没有“主演”信息,直接是导演: XXX / 上映年份 / 国家 / 类型,所以不要硬性按固定下标取,建议先按/切分,再逐个字段去判断里面有没有“年份数字”“国家关键词”。我写这个项目时,是用正则去提取年份:
import re year_match = re.search(r"(19\d{2}|20\d{2})", info_text) year = year_match.group(0) if year_match else ""这样比固定下标稳妥得多。
4. 实测中的稳定性问题:登录弹窗、元素遮挡、超时重试
4.1 登录弹窗怎么处理
豆瓣有个让人头大的习惯:连续访问几页之后,会弹一个“登录豆瓣”的模态框,有时候还会把页面遮住,导致后续操作定位不到元素。
弹窗这玩意儿并不是每次都出现,所以不能用程序流程去硬等。我的做法是每页开始先“尝试关一次弹窗”,关不掉也无所谓,不中断主流程:
def close_modal_if_present(driver): try: close_btn = driver.find_element(By.CSS_SELECTOR, ".modal-dialog .btn-close") close_btn.click() except Exception: pass有些弹窗没有关闭按钮,或者按钮被其他浮层挡住,这时候可以模拟按Esc键把它关掉:
from selenium.webdriver.common.keys import Keys driver.find_element(By.TAG_NAME, "body").send_keys(Keys.ESCAPE)如果页面因为弹窗变成了登录页,最简单的识别方式是看当前URL有没有包含login。有的话,直接重新driver.get回到原地址:
if "login" in driver.current_url: driver.get("https://movie.douban.com/top250?start=0&filter=")4.2 元素不在视口内:滚动与可见性问题
豆瓣Top250这个页面本身不需要滚动就能加载全部25条,但如果你以后去爬其他网站,会遇到“元素在页面底部,直接读取/点击失败”的情况。热词里有人提到“Selenium 网页左右滑动”“左右滚动可见”,本质都是同一类问题——元素不在视口内,浏览器认为它不可见、不可交互。
通用解法是先滚动到目标元素所在位置:
target = driver.find_element(By.CSS_SELECTOR, "某个选择器") driver.execute_script("arguments[0].scrollIntoView(true);", target)如果页面的数据是懒加载的,滚动到页面底部还会触发新的数据加载,这也是无限滚动类页面的标准处理方式。熟练掌握scrollIntoView,就能应对绝大部分“元素看不见”的报错。
4.3 网络波动与超时重试
真实爬虫里,网络波动导致driver.get半天加载不出来,是家常便饭。WebDriverWait会等10秒,但10秒内页面只加载了一半,依然拿不到数据。
我给这个项目加了一个简单的重试函数:
def fetch_with_retry(driver, url, max_retries=3): for attempt in range(max_retries): try: driver.set_page_load_timeout(15) driver.get(url) wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "ol.grid_view"))) return True except Exception: if attempt == max_retries - 1: return False time.sleep(2) return False运行结果里如果哪一页返回False,记录下来,最后再单独补抓即可。不要为了省事重试太多次,连续失败说明大概率触发了限流,这时候停下来比硬刚更明智。
4.4 频率控制,别把自己爬成攻击者
Selenium开着真实浏览器,看起来比requests更接近真实用户,但豆网的限流策略不是摆设。我在实测中发现,如果每页间隔0.5秒以内连续抓,大约第4页开始就极易触发登录弹窗,严重时还会出现“请求过多”的提示。
解决方案很朴素,随机延时:
import random import time time.sleep(random.uniform(1, 3))每页之间随机休息1到3秒,全程也就多花30秒,但稳定性提升非常明显。这种“让行为更像人”的思路,比任何技术对抗都重要。
另外说一句边界问题:微信公众号付费内容、朋友圈这类私有数据,本来就不该去爬,也不属于公开页面的范畴。爬虫要做的是在公开信息、合理频率、尊重版权的前提下做自动化采集,这一点守住底线,然后谈技术才有意义。
5. 数据落地与项目升级:CSV存储、可视化与Playwright迁移思路
5.1 存成CSV,Excel打开不乱码
数据抓到手之后,第一件事是落盘。我习惯用CSV格式,因为它足够通用,Excel、pandas都能直接读。这里有个小坑:直接用默认的utf-8编码写CSV,Excel打开会乱码;要用utf-8-sig。
import csv def save_to_csv(movies, filename="douban_top250.csv"): # utf-8-sig 是重点,Excel才不会乱码 with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["排名", "片名", "外文名", "年份", "国家/地区", "类型", "评分", "评价人数", "一句话简介"]) for m in movies: writer.writerow([ m["rank"], m["title_cn"], m["title_other"], m["year"], m["country"], m["genre"], m["rating"], m["rating_people"], m["inq"] ])如果你有pandas,直接这样更省事:
import pandas as pd df = pd.DataFrame(movies) df.to_csv("douban_top250.csv", index=False, encoding="utf-8-sig")5.2 简单看一眼数据分布
数据落盘之后,顺手做个可视化,这个项目才更有意思。比如用matplotlib画一下评分分布,能直观看到豆瓣Top250的评分基本都集中在8.5到9.5分这个区间:
import matplotlib.pyplot as plt df["rating"] = df["rating"].astype(float) df["rating"].hist(bins=20) plt.title("豆瓣Top250评分分布") plt.xlabel("评分") plt.ylabel("数量") plt.show()这种“爬下来之后还能干什么”的延伸,会让整个项目从单纯的爬虫练习变成一次完整的数据分析流程。
5.3 从Selenium到Playwright:新项目还选谁
最近这两年,Playwright越来越流行,热词里也能看到“playwright爬取抖音评论区”的需求。它和Selenium最大的区别是内置了auto-wait,很多时候你不需要自己写WebDriverWait,元素还没出现时它会自动等待;另外Playwright的API设计更现代,支持隔离的浏览器上下文,在做多账号场景时天然有优势。
但我不建议新手一上来就学Playwright。原因很简单:Selenium的教程和踩坑文档数量是Playwright没法比的,而且Selenium的等待、定位、异常恢复这套心法,恰恰能在Playwright里面原样复用。你先用Selenium把“等待是什么”“为什么元素会找不到”这些问题搞明白,再切换到Playwright,会发现只是把find_element换成了page.locator,思维完全不用变。
小建议:跑完这个项目之后,再上手Playwright去爬一次上面的榜单,感受一下两者在代码写法上的差异。有了对比,你才能真正理解为什么有些人执着于Selenium,为什么有些人跳去玩Playwright。
最后再分享一点个人经验。用Selenium写爬虫,真正决定脚本能不能长期跑的,不是定位写得多花哨,而是等待、重试、异常恢复这三件事。我见过太多人把代码写完,跑一次成功就收进收藏夹,过两周再跑,发现豆网改了一行class,脚本直接崩。爬虫天生就是脆弱的工程,保持“页面变了马上能改”的心态,比追求一次性写完美重要得多。项目跑完后,建议你再改几个地方试试:把start参数翻页改成点击翻页,感受一下差异;把等待时间改成随机延时,观察什么时候会触发登录弹窗;把存储格式改成Excel,体验一下不同编码的坑。这几项改动,每一项都会让Selenium的理解更扎实。