动态渲染页面:Selenium 入门
用 requests 抓动态页面只能拿到一具空壳,这时才轮到 Selenium:它真的启动浏览器,等 JavaScript 把 DOM 渲染完,再读渲染后的结果。本章讲清什么时候值得付这个代价、启动选项怎么写,以及为什么浏览器应该是最后手段。
什么时候必须开浏览器
判断标准只有一条:目标数据是否只能由页面里的 JavaScript 产生,且找不到等价的接口。
| 场景 | 特征 | 结论 |
|---|---|---|
| 单页应用(SPA) | 首屏 HTML 只是挂载节点,列表由前端组件渲染 | 接口不可用时必须用 |
| 异步分片加载 | 数据由 XHR/fetch 分页拉取,DOM 里查不到 | 先找接口,找不到才用 |
| 需要交互 | 点「加载更多」、滚动到底、输入关键词 | 必须用 |
| 接口带签名保护 | 请求参数动态变化,没有官方开放接口 | 只按页面正常流程低频操作 |
签名保护这一类要格外克制:先找官方开放接口、RSS 或数据导出功能,都没有时才用浏览器按页面正常流程低频操作自己的账号,不做参数逆向。采集前先看 robots.txt 与站点服务条款,绕过登录、付费墙、验证码等访问控制永远不在范围内。
反例:能用接口就不要开浏览器
浏览器是最后手段,不是第一选择:先抓包看请求,能复现出等价的只读接口就写接口采集,纯静态 HTML 用 requests 加解析库即可。
| 维度 | HTTP 接口 | 浏览器自动化 |
|---|---|---|
| 单次耗时 | 几十到几百毫秒 | 1 秒以上,冷启动更久 |
| 内存占用 | 几 MB | 每个实例数百 MB 起 |
| 并发能力 | 单机几十到几百并发 | 单机十几个实例就已吃满 |
| 行为特征 | 与普通请求一致 | 指纹、轨迹、时序都更像机器人 |
只有数据确实由 JS 计算、且没有可复现接口时才切到浏览器;调一次浏览器够接口跑十次以上,这个差距决定了采集规模的上限。
安装与启动选项
pip install selenium 装的是包,驱动交给 Selenium Manager:4.6 起启动时会自动检测浏览器版本、下载匹配的驱动并缓存,一般不需要手动下载 chromedriver,也不用把驱动路径写进 PATH。
选项通过 webdriver.Chrome(options=options) 传入,容器里跑无头模式的必备三项是 --headless=new、--no-sandbox、--disable-dev-shm-usage,少了第三项页面一复杂就随机崩溃;需要固定视口时加 --window-size,需要声明身份时加 --user-agent。
等待:只用显式等待
time.sleep 是猜时间,WebDriverWait 是等条件;猜时间要么白等,要么不够。
| 条件 | 等到什么 | 适用场景 |
|---|---|---|
presence_of_element_located | 元素进入 DOM,不要求可见 | 读取属性或 .text 之前的兜底 |
visibility_of_element_located | 元素在 DOM 且宽高大于 0 | 读取可见文本、截图 |
element_to_be_clickable | 元素可见且可交互 | 点击、输入之前 |
text_to_be_present_in_element | 元素文本包含指定片段 | 等异步填充的标题或数字 |
invisibility_of_element_located | 元素消失或不可见 | 等加载遮罩、骨架屏消失 |
wait = WebDriverWait(driver, 15, poll_frequency=0.5) # 最长等 15 秒,每 0.5 秒轮询一次
wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "#list .item")))
不要和隐式等待混用:driver.implicitly_wait(10) 对每一次元素查找都生效,叠加显式等待后最坏情况是「隐式等 10 秒没找到,显式再等 15 秒」,定位失败时也难判断是页面没渲染还是选择器写错。统一用显式等待,让超时异常带着选择器信息抛出来。
定位与滚动
定位优先级是 By.ID 高于 By.CSS_SELECTOR,By.CSS_SELECTOR 高于 By.XPATH:ID 最稳定,CSS 选择器可读性好、基本够用,XPath 只在需要按文本匹配或向上溯源时用,它对结构调整极其敏感。
find_element 找不到会抛异常,find_elements 返回列表、找不到时是空列表,判断弹窗或列表是否存在直接判长度即可。要让页面继续加载得自己发起动作:driver.execute_script("window.scrollTo(0, document.body.scrollHeight)") 触发懒加载,element.click() 点「加载更多」,之后仍要用显式等待确认新内容真的出现。
完整可运行示例
import time
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
TARGET_URL = "https://example.com/list" # 只采集条款允许抓取的目标
PAGE_INTERVAL, MAX_PAGES = 3.0, 3 # 翻页间隔与页数上限,给目标站点留余量
def collect(driver, wait: WebDriverWait) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
driver.get(TARGET_URL)
for page in range(1, MAX_PAGES + 1):
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "#list .item")))
driver.execute_script("window.scrollTo(0, document.body.scrollHeight)") # 触发懒加载
soup = BeautifulSoup(driver.page_source, "html.parser") # 渲染后的 DOM 交给解析库
rows += [{"page": page, "title": n.select_one(".title").get_text(strip=True)}
for n in soup.select("#list .item")]
if not driver.find_elements(By.CSS_SELECTOR, "#next:not([disabled])"):
break # 没有下一页就结束
driver.find_element(By.CSS_SELECTOR, "#next").click()
time.sleep(PAGE_INTERVAL) # 主动降速,不要把目标站点打满
return rows
def main() -> None:
options = webdriver.ChromeOptions()
options.add_argument("--headless=new") # 无头模式
options.add_argument("--no-sandbox") # 容器内常需要
options.add_argument("--disable-dev-shm-usage") # 避免 /dev/shm 过小导致崩溃
driver = webdriver.Chrome(options=options)
try:
rows = collect(driver, WebDriverWait(driver, 15, poll_frequency=0.5))
print(f"共采集 {len(rows)} 条", rows[:3])
except TimeoutException as exc:
print(f"等待超时,先检查选择器再怀疑网络:{exc}")
finally:
driver.quit() # 必须退出,否则浏览器与驱动进程会一直残留
if __name__ == "__main__":
main()
driver.page_source 返回当前 DOM 的 HTML 字符串,数据此时已经在里面,交给 BeautifulSoup 解析比逐个 find_element 取值更快也更好写;无头模式省的是界面开销,不是内存,所以务必用 try/finally 保证 driver.quit() 被执行。
常见坑与调试
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 元素可见却定位不到 | 页面在 iframe 或 shadow DOM 内 | driver.switch_to.frame(...),必要时展开 shadow root |
| 超时但截图内容齐全 | 选择器命中了不可见的占位元素 | 换用 visibility_of_element_located |
| 跑一段时间机器变卡 | 忘了 driver.quit(),进程与临时目录堆积 | 用 try/finally 保证退出 |
| 无头模式下行为与手动不一致 | 视口过小、UA 被识别为无头 | 显式设置 --window-size 与 UA |
调试点只有一个原则:让失败现场可见。先去掉 --headless=new 用有界面模式手跑一遍,再加 driver.save_screenshot("debug.png") 与 print(driver.current_url),确认页面到底停在哪一步。
小结:先确认没有可复现的接口,再考虑用 Selenium 打开浏览器;用显式等待替代猜时间,用 page_source 加解析库处理渲染结果,用 try/finally 保证浏览器退出,频率与合规底线始终先于采集效率。