一次真实案例引发的思考requests 正常返回页面却啥都没有去年我做新闻舆情监控目标站点的文章列表页用 requests 抓回来HTML 里干干净净只有一堆script src/assets/index.js?v20240312。可浏览器里打开标题、摘要、阅读量、评论数清清楚楚。这时候我才意识到爬虫的对手早已不是服务器而是前端打包出来的那一大坨 JavaScript。标题里说的“高级爬虫技巧处理 JavaScript 渲染”核心其实就一句话当你要的数据不在响应体里而在浏览器执行完脚本之后的 DOM 里Selenium 就成了最直接的那把钥匙。这篇文章不会从零讲 Selenium 是什么而是基于我真实踩坑的过程把“为何选择 Selenium”“环境版本怎么配对”“动态页面怎么等待”“反爬绕过怎么做”“抓完之后怎么用 SQLAlchemy 存库”这些链路完整串一遍。适合已经能用 requests BeautifulSoup 写简单爬虫、但一碰到 JS 渲染页就抓瞎的同学当然如果你正在做爬虫管理平台或分布式爬虫最后两节关于并发短板和架构取舍的内容也值得看一看。1. JS 渲染页面的爬坑起点为什么 requests 拿不到数据1.1 服务端渲染与客户端渲染的本质区别先把概念对齐。传统服务端渲染SSR的网站服务器把数据库里的文章标题、正文直接拼进 HTML 字符串返回给你。你用 requests 发一个 GET 请求收到的就是完整页面正则随便提取。但现在前端流行单页应用SPA服务端只返回一个空壳!DOCTYPE html html langzh-CN body div idroot/div script src/static/js/chunk-vendors.45f2e3.js/script script src/static/js/app.8a7b3c.js/script /body /html真正的内容是由app.8a7b3c.js里的代码发起 Ajax/Fetch 请求拿到 JSON 数据后用 DOM 操作渲染出来的。我们用 requests 直接抓拿到的只是那个“空壳”自然什么都提取不到。这个区别带来的第一个启示是抓 JS 渲染页之前先问自己一个问题——我需要的字段是不是藏在某个 XHR 接口里如果能找到返回 JSON 的接口直接用 requests 请求接口既快又稳定根本不需要上 Selenium。我通常会打开浏览器的开发者工具切换到 Network 面板刷新页面过滤 XHR逐一翻请求。很多时候列表页的核心数据都在/api/list?page1这样的接口里。1.2 什么情况下才必须上 Selenium只有当接口没法直接复用、或者数据被复杂的 JS 逻辑搅碎时Selenium 才登场。我总结出三个比较典型的场景接口加密严重请求参数里有动态 token、sign逆向成本高于浏览器自动化成本。数据分布在一连串交互操作之后比如需要点击“加载更多”、切换 Tab、鼠标悬停才能出数据。接口返回的数据并不是最终 DOM 数据后端还做了一层二次处理比如模板编译、Canvas 绘图、图表渲染。遇到这三种情况老老实实把浏览器给我叫出来干活。1.3 Selenium 与“浏览器自动化”的定位Selenium 不是一个爬虫框架它是一套 Web 自动化测试标准。它的本行是“模拟用户操作浏览器”而不是“高效抓数据”。这决定了它的两个特点一是真实、像人能执行完整 JS二是慢、消耗资源大。所以你在架构上要把它放在“最后一道闸门”位置能用 requests 接口搞定的绝不走浏览器必须走浏览器的再上 Selenium。我见过不少入门者一上来就 Selenium把静态页也扛去浏览器里跑结果一台 4 核 8G 的服务器只爬出了龟速这是典型的工具选型错误。2. 环境与 WebDriver 选型先解决版本地狱2.1 安装 Selenium 与浏览器驱动Selenium 目前主流的 Python 库版本是 4.x安装非常简单pip install selenium但真正让新手崩溃的是 WebDriver。Selenium 4 本身不自带浏览器驱动它需要通过一个驱动程序来控制真实的浏览器。Chrome 对应的是 ChromeDriverFirefox 对应的是 GeckoDriverEdge 对应的是 msedgedriver。版本匹配的规则说简单也简单驱动大版本必须和浏览器大版本一致。比如你本机 Chrome 是 126 版本就去找 126 版 Chromedriver。不一致时浏览器启动后会立刻回报session not created: This version of ChromeDriver only supports Chrome version xxx这是最常见的启动报错。2.2 使用 Selenium Manager 自动管理驱动Selenium 4.6 开始内置了 Selenium Manager它会根据你本机安装的 Chrome 版本自动下载匹配驱动省去手动查找的麻烦。日常开发中我基本不再手动指定 executable_pathfrom selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headlessnew) driver webdriver.Chrome(optionsoptions) driver.get(https://example.com) print(driver.title) driver.quit()如果公司网络管控严格Selenium Manager 下载不了驱动再去手动下载并放到 PATH 里也就是可执行文件目录。2.3 无头模式与有头模式的取舍无头模式headless非常常用它能在不弹出浏览器窗口的情况下执行全部 JS 逻辑options.add_argument(--headlessnew) # Chrome 109 推荐这种写法有头模式和--headlessnew绝大多数场景效果一样但有两个例外一是某些网站检测navigator.webdriver二是部分网站依赖 GPU 渲染无头模式可能出现元素缺失。遇到这种“无头模式下怎么都定位不到有头模式一开就好”的情况先别怀疑自己的代码可以先靠--headlessfalse肉眼确认一次页面到底是什么样子再决定要不要给无头模式加参数options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) options.add_argument(--window-size1920,1080)个人建议开发调试阶段用有头模式部署跑任务时再切无头。别一上来就无头连页面长什么样都没看过出了问题只能瞎猜。3. 核心 API 的正确调用方式加载、等待、元素定位3.1 别再用 sleep 等页面新手最容易犯的错就是在driver.get()之后来一句time.sleep(5)赌“5 秒应该够加载了吧”。这种写法在固定网络环境、固定页面规模时可能侥幸通过但一旦网络抖动或页面脚本变重5 秒没加载完定位元素就会抛NoSuchElementException反过来页面 2 秒就加载完了代码还在傻傻等 3 秒白白浪费时间。正确写法是显式等待。3.2 显式等待等待条件到位再做下一步显式等待的核心是WebDriverWait和expected_conditions。它每隔一段轮询间隔问一次浏览器“你要的条件满足了吗”满足就立即返回不满足就继续等直到超时。from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10) # 等待元素出现在 DOM 中哪怕不可见 wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, div.list-item))) # 等待元素可见且可交互 wait.until(EC.element_to_be_clickable((By.XPATH, //button[contains(text(),加载更多)])))这里有一点必须说清楚presence_of_element_located与visibility_of_element_located有本质差别。前者只代表元素存在于 DOM 树中但可能是 display:none后者要求元素可见。我调试分页列表时经常遇到前一种条件已满足但文本内容还没被 JS 填进去的情况然后读到的数据是空字段。稳妥做法是再追加一个“文本不为空”的条件wait.until(lambda d: d.find_element(By.CSS_SELECTOR, div.list-item).text.strip() ! )这个 lambda 看似粗糙实际非常管用它解决了很多动态渲染页面“元素已插入但数据未填充”的夹生饭问题。3.3 元素定位选择器优先级与易错点Selenium 8 种定位方式里我日常只推荐两种CSS 选择器和 XPath。两者各有优势定位方式优点缺点CSS 选择器速度快语法简洁主流前端框架的 class 命名可以直用无法直接按文本内容定位XPath可以按文本、按层级关系、按包含关系定位解析慢表达式复杂时易读性差实际写的时候我推荐一个组合拳# 先试 CSS尽量利用稳定的 class element driver.find_element(By.CSS_SELECTOR, .article-title a) # 动态 class 或需要按文本找时用 XPath button driver.find_element(By.XPATH, //span[text()展开全部]/..)注意很多现代网站用了动态 class比如styles__title___3f4fq每次发版都可能变。这时候建议优先用相对稳定的功能定位点比如 HTML 结构、父级元素关系、文本内容不要硬扛那些带 hash 的 class。还有个小技巧driver.find_element如果找不到立刻抛NoSuchElementException。如果你想“检查元素是否存在”最优雅的方式是把它写成一个函数def element_exists(driver, by, value): return len(driver.find_elements(by, value)) 0find_elements返回列表空列表不抛异常这在判断“是否还有下一页”“是否登录成功”的场景里非常顺手。4. 动态页面的数据收集技巧滚动、点击、iframe、新标签页4.1 触发懒加载的滚动大法现在的列表页十个里有八个做了懒加载。页面一开始只渲染首屏往下滚动到接近底部时JS 才会继续向后端请求下一页数据并追加 DOM。你用 Selenium 打开页面后如果不滚动能拿到的可能只有最前面十几个条目。我的做法是模拟滚轮事件from selenium.webdriver.common.action_chains import ActionChains import time def scroll_to_bottom(driver): # 获取当前页面高度 last_height driver.execute_script(return document.body.scrollHeight) while True: # 滚到底部 driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) time.sleep(1.5) # 再次获取高度 new_height driver.execute_script(return document.body.scrollHeight) if new_height last_height: break last_height new_height这里有个隐藏知识点如果页面没有新的内容加载scrollHeight不会再变化循环就会结束。但如果接口返回慢1.5 秒内没刷新高度可能不变造成误判。稳妥做法是给循环加一个最大循环次数或者把time.sleep换成显式等待某个 DOM 节点数量的变化。element_count len(driver.find_elements(By.CSS_SELECTOR, .list-item)) driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) wait.until(lambda d: len(d.find_elements(By.CSS_SELECTOR, .list-item)) element_count)这样就把“预估时间”换成了“实际条件”更可靠。4.2 处理点击“加载更多”的分页按钮懒加载之外另一种常见分页是“点击加载更多”。处理方式更加直接找到按钮点击等待列表项数量增加。while True: load_more_btn driver.find_element(By.XPATH, //div[classload-more]) if disabled in load_more_btn.get_attribute(class): break load_more_btn.click() wait.until(lambda d: len(d.find_elements(By.CSS_SELECTOR, .list-item)) current_count) current_count len(driver.find_elements(By.CSS_SELECTOR, .list-item))有个细节容易被忽略点击后按钮本身可能会被重新渲染原来持有的 driver 对象引用的旧元素会失效。所以你每次循环都应该重新查找按钮而不是复用循环外的那个引用。这正是 Selenium 中非常著名的StaleElementReferenceException问题。4.3 iframe 与弹窗中的数据怎么拿页面内容嵌在 iframe 里时直接driver.find_element是找不到内部元素的。必须先切换进去from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10) # 切换进去 iframe wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, iframe#main-frame))) driver.switch_to.frame(iframe) # 现在可以定位 iframe 内部元素了 data driver.find_element(By.CSS_SELECTOR, .inner-data).text # 用完切回主文档 driver.switch_to.default_content()弹窗Modal也是常客。大多数弹窗本质是 DOM 里的一个遮罩层直接作为普通元素处理即可。但如果用的是alert()原生弹窗就得用driver.switch_to.alert.accept()来关掉。说实话现在坚持用原生 alert 的网站比较少了更多是自定义弹层确实方便了爬虫。新标签页的情况我用得比较多比如搜索结果点开每个详情页。处理逻辑是original_window driver.current_window_handle link.click() wait.until(lambda d: len(d.window_handles) 1) new_window [w for w in driver.window_handles if w ! original_window][0] driver.switch_to.window(new_window) # 读取详情页... driver.close() driver.switch_to.window(original_window)这套流程不变式清晰先记住当前窗口句柄再等待新窗口出现切进去处理完关掉切回来。5. 反爬识别与隐身优化怎么让浏览器更像真人5.1 被网站识别为自动化工具的常见特征很多网站会在前端埋检测脚本判断你是不是真人。常见检测点我整理成一张表方便排查检测点说明规避思路navigator.webdriver自动化模式时该值为 trueCDP 执行参数覆盖window.chrome自动化驱动可能丢失 Chrome 对象注入初始化脚本浏览器指纹无头模式 Canvas、WebGL 有差异尽量用有头或真实指纹行为轨迹鼠标移动、键盘输入过于机械引入随机间隔、模拟滚动User-Agent无头默认 UA 与真实浏览器不同显式设置 UA并发频率同一 IP 短时间大量请求限制 QPS、加代理池5.2 隐藏 webdriver 属性一个 CDP 就能搞定最典型的检测是navigator.webdriver。Python Chrome 下一个常用做法是通过 Chrome DevTools ProtocolCDP在页面加载前把该属性隐藏options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) driver webdriver.Chrome(optionsoptions) driver.execute_cdp_cmd( Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); }, )这段脚本会在每个新文档创建之前执行覆盖掉navigator.webdriver。实测对大多数前端检测都有效。但说实话这种猫鼠游戏一直在升级强检测站点可能还会看你是否定义了window.chrome、navigator.plugins等。如果走得很深建议上 Stealth 相关工具库比如 Node 系的 puppeteer-extra-stealthPython 系可以自己封装一个 FixBrowser 类。5.3 Cookie 处理既要防封也要穿登录态反爬不只是“隐身”还有登录态的问题。数据量一旦变大每次都用账号密码重新登录既慢又容易触发风控。常见做法是第一次手动登录后把 cookie 序列化存到本地下次启动直接加载。import json # 登录后 with open(cookies.json, w, encodingutf-8) as f: json.dump(driver.get_cookies(), f, ensure_asciiFalse) # 下次启动 driver.get(https://example.com) for c in cookies: driver.add_cookie(c) driver.refresh()有一个细节必须注意add_cookie之前必须先把域名打开一次也就是先driver.get(url)否则 cookie 没地方可挂。另外cookie 里的过期时间往往较长但某些站点会把登录态和 IP 绑定换 IP 后 cookie 失效这种就只能重新登录。5.4 随机等待别把请求刷成固定节拍很多反爬系统会记录行为的时间间隔。如果你代码里每次循环都time.sleep(2)那就等于在脑门上写“我是脚本”。更合理的做法是加入随机抖动import random import time def human_delay(min_sec1.0, max_sec3.0): time.sleep(random.uniform(min_sec, max_sec))如果你要模拟真实用户滑动点击还可以用 ActionChains 生成一条略微弯曲的鼠标轨迹。不过对于大部分数据采集任务随机延时 随机滚动已经足够了。我自己写爬虫时还会刻意在页面里随机停留偶尔停下来“读一读”中间的内容虽然对数据采集本身没有帮助但能显著拉低风控的嫌疑分。6. 数据抽取与落库从杂乱的 DOM 到结构化表6.1 用 BeautifulSoup 解析 driver.page_sourceSelenium 拿到的是一个浏览器对象但你要的数据在driver.page_source这个 HTML 字符串里。推荐的搭配是Selenium 负责“让页面变成完整 HTML”BeautifulSoup 负责“解析这个 HTML”。二者分工明确比你用 Selenium 再去链式 find_element 快得多写起来也更简洁。from bs4 import BeautifulSoup soup BeautifulSoup(driver.page_source, html.parser) items soup.select(div.list-item) for item in items: title item.select_one(.title a) author item.select_one(.author) date item.select_one(.date) if title: data.append({ title: title.get_text(stripTrue), author: author.get_text(stripTrue) if author else , date: date.get_text(stripTrue) if date else , url: title.get(href), })为什么不用find_element直接抓因为页面数据量大时用 BeautifulSoup 解析整个 page_source 是毫秒级操作而 Selenium 每次 find_element 都要和浏览器进程通信开销至少是百毫秒级。能在 Python 内存里用字符串解决的问题就不要一次一次去麻烦浏览器。6.2 用 SQLAlchemy 建立一个干净的存储层很多教程只教到“打印数据”就结束了。但真实项目里爬下来的数据要增量抓取、要按时间过滤、要存到数据库。这时候直接INSERT INTO写原生 SQL 也能跑但表结构一多就乱。我习惯引入 SQLAlchemy 做一个 ORM 映射层。from sqlalchemy import create_engine, Column, String, Integer, DateTime, Text from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base declarative_base() class Article(Base): __tablename__ articles id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(500), nullableFalse) author Column(String(100), default) url Column(String(1000), uniqueTrue, nullableFalse) content Column(Text, default) created_at Column(DateTime, defaultdatetime.now) engine create_engine(mysqlpymysql://user:passwordlocalhost/spider_db?charsetutf8mb4) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session()入库时要防止重复。最简单有效的方法是在表结构上给 URL 加唯一索引然后捕捉 IntegrityErrorfrom sqlalchemy.exc import IntegrityError for item in data: article Article(titleitem[title], authoritem[author], urlitem[url]) session.add(article) try: session.commit() except IntegrityError: session.rollback() print(f重复: {item[url]})这样每次跑任务重复数据都会被自动跳过而不是重复入库。如果你不想依赖数据库层的唯一约束也可以先查一遍库existing set(row[0] for row in session.query(Article.url).all())但这在大数据量下效率堪忧还是唯一索引方案更干净。6.3 数据清洗与字段裁剪抓到的数据往往带一堆 HTML 标签和空白字符。入库前我习惯做个整体清洗import re def clean_text(raw): text re.sub(r[^], , raw) # 去标签 text re.sub(r\s, , text) # 合并空白 return text.strip()清洗看似简单但有一个坑某些页面的内容本身就被前端故意做了字符混淆比如把“价”写成“价”。这种需要在 cleaning 阶段做 HTML 实体解码可以用html.unescape()。7. 并发能力与架构取舍Selenium 的短板与补救7.1 Selenium 的并发短板在哪里Selenium 单实例就是慢。慢的原因不只是浏览器启动更在于每个操作都要经过一条远程通信链路。真要抓几十万条数据开几十个浏览器实例跑机器内存分分钟爆炸。我见过有人开 20 个无头 Chrome每个占 300MB 内存一台 16G 的服务器直接卡死。所以我的经验是不要拿 Selenium 当主力大规模采集工具。它适合小批量、高互动、复杂场景的采集。大规模数据采集的正确姿势是分层架构先用 Selenium 摸清楚 API 或数据规律然后回到 requests/httpx 走接口。就像打游戏先侦察兵探路再主力部队收割。7.2 一个可落地的分层爬虫架构我把自己的爬虫项目总结成三层层级工具职责调度层APScheduler / Celery Beat定时触发爬虫任务管理任务状态采集层requests / httpx / Selenium按页面复杂度选择采集方式存储层SQLAlchemy MySQL / PostgreSQL去重、结构化存储、供上层查询当接口能拿到 JSON 时绝不引入浏览器。当页面必须交互时Selenium 作为采集层的一部分单独跑采集结果统一写入消息队列Redis/RabbitMQ再由消费者写入数据库。这样即使 Selenium 实例崩了也不会影响整个数据链路。7.3 分布式爬虫里 Selenium 的定位如果你在做分布式爬虫Selenium 更适合作为“Pod 里的执行器”而不是整个系统的调度核心。每个 Worker 负责一个或几个 URL任务队列负责分配Worker 只干“打开页面、等渲染、提取数据、发结果”这件事。这里有一个比较实用的经验不要在每个 Worker 里重新启动浏览器那样太浪费时间。而是维护一个浏览器实例池比如multiprocessing.Pool(4)里放 4 个不同的 WebDriver每个 worker 只复用自己那一个 driver。进程退出时统一driver.quit()。from multiprocessing import Pool def crawl_one(url): driver create_driver() # 每个进程内只初始化一次但用全局变量也行 try: driver.get(url) # 等待、提取、返回 finally: # 这里不直接 quit因为还要复用 pass不过更符合工程实践的是在进程内初始化全局 driver 对象配合模块级单例模式。因为Pool里进程是常驻的全局 driver 可以反复使用。8. 高频坑位排查手册超时、空元素、会话失效8.1 页面加载超时driver.get()卡住是家常便饭。有的页面会发起大量统计请求部分请求长时间挂起导致pageLoadTimeout触发。处理方式是指定一个页面加载超时driver.set_page_load_timeout(20) # 20秒加载不了就raise TimeoutException超时之后还有救可以执行driver.execute_script(window.stop())强制停止页面剩余资源的加载再继续做元素等待。这在页面主内容已渲染、但一堆埋点脚本还在慢慢加载的场景里特别有用。8.2 元素不可交互定位到了元素但点击时报ElementClickInterceptedException通常是元素被浮层挡住。解决办法不一定是硬点可以先用 JS 直接点击driver.execute_script(arguments[0].click();, element)这招也能绕过按钮 disabled 的假象但要注意如果按钮实际上是不可点状态JS 点击也会没有效果。我的建议是先看一眼按钮的真正 class 或属性别上来就 JS 点击。8.3 StaleElementReferenceException陈旧元素引用这个问题几乎每个 Selenium 用户都会遇到。原因很简单你先是定位到一个元素引用然后页面发生了局部刷新比如翻页、点击后内容区重渲染原来那个 DOM 节点被浏览器回收了。你可以再重新定位一次但更好的办法是养成“用完即取不存引用”的习惯——在循环体内每次重新查找元素。8.4 日志与调试把浏览器当黑盒打开最后给一个实战建议写爬虫时永远保留一个调试入口。我通常在配置里加一个DEBUG开关开启后不无头、每一步截图、打印当前 URL 和 page_source 长度。if DEBUG: driver.save_screenshot(fdebug_{step}.png) with open(fdebug_{step}.html, w, encodingutf-8) as f: f.write(driver.page_source)截图 源码双管齐下排查问题时比单纯打日志高效太多。你只要对比“自己以为的页面”和“实际 DOM”就能快速定位是等待条件错了、选择器写错了还是页面压根没渲染出来。踩过坑之后我现在的 Selenium 使用习惯回到标题那句话处理 JavaScript 渲染Selenium 是绕不开的一站。但用到现在我最大的感受是——它更像一件有脾气的工具你用对它的脾气它就很顺手你用错场景它就会让你反复加班。现在我的固定习惯是能用接口就接口必须渲染就显式等待等待不成就看截图数据入库必去重运行节奏必有随机延时。这套方法论在我处理过的几十个 JS 渲染站点里基本都能落地。如果你也是做爬虫的希望这篇总结能帮你少走几步弯路如果你刚接触 Selenium建议先把文中的示例代码跑一遍再带着自己的场景去改远比啃完整本文档来得快。