先说说我自己的经历吧。早几年做数据采集最烦的就是打开一个页面F12一看接口和数据全在但requests返回的HTML里啥都没有稀稀拉拉的几个script标签数据全靠JavaScript运行之后才填进去。那时候还是老老实实地找XHR接口、逆向加密参数直到后来把Selenium搬进了爬虫工具箱才算是给这类场景找到了一个能一锤定音的解法。这篇东西不打算写成Selenium的官方文档翻译。我按自己实际踩坑的顺序来先说清楚JavaScript渲染页面到底难在哪然后讲我为什么最终选了Selenium而不是别的方案接着是环境搭建、定位与等待、反爬规避、效率优化最后用一个无限滚动列表页的完整案例收尾。内容偏向实战代码以Python为主适合已经写过一阵子requests爬虫、现在被JS渲染页面卡住的朋友。1. 从F12看不到数据讲起JavaScript渲染页面到底难在哪1.1 三种渲染方式决定了爬虫的抓取难度我们平时用浏览器打开一个网页能看见的内容和目录里给出的HTML源代码往往不是一回事。原因在于前端项目的渲染方式不同。我把常见的渲染方式分成三类服务端渲染SSR服务器直接返回填好数据的HTML浏览器拿到就能显示。这种页面最友好requests拿到什么就是什么直接解析就行。静态站点生成SSG构建时生成静态HTML内容和SSR类似对爬虫也算友好。客户端渲染CSR服务器只返回一个空的HTML外壳加一大堆JS文件真正的数据靠浏览器执行JS后通过Ajax拉取、再动态填充进DOM。现在用Vue、React、Angular这类框架做的网站大部分默认走的是CSR。你requests请求首页拿到的是这样的结构div idapp/div script src/static/js/chunk-xxx.js/script就一个空壳数据呢还在接口里躺着。浏览器里能看到完全是JS的功劳。形象一点说requests拿到的是一套毛坯房JavaScript就是那个装修队数据是家具你没等装修进场就拍照当然拍不到家具。1.2 怎么快速判断一个页面是不是JS渲染判断方法很简单打开浏览器按CtrlU查看网页源代码然后在源代码页面里搜索你页面上看到的某个文本关键词。如果源代码里压根搜不到但浏览器页面上显示得清清楚楚那基本可以断定是JS渲染。还有一个更直观的判断方式按F12打开开发者工具切到Network面板刷新页面盯着XHR/Fetch类型的请求看。如果数据是Ajax拉取的这里会出现一个返回JSON的接口。这个接口往往才是数据的源头。看到XHR接口的第一反应不该是启动Selenium而是先分析这个接口能不能直接用requests调。很多时候接口请求里带着时间戳、签名、加密参数甚至需要前面的请求先种下cookie才能访问逆向成本就上来了。这时候Selenium的价值就体现出来了——它不跟你玩加密逻辑直接把整个浏览器跑起来等页面渲染完拿最终结果就行。1.3 适合上Selenium的场景清单我在选型时会过一遍下面这个清单满足两三条以上就直接上Selenium数据走XHR接口但请求参数有签名、加密、时间戳校验逆向耗时太长数据在页面里经过JS二次计算或处理后展示直接调接口拿到的不是最终形态要模拟用户行为比如登录、点击、展开折叠、滚动加载、拖拽页面有复杂的校验逻辑单纯带着cookie去请求会触发风控需要同时兼容登录态、多标签页操作、文件下载等场景。反过来如果接口就在那里参数也不复杂那我建议你还是老老实实用requests配多线程效率和稳定性都远高于浏览器自动化。工具这东西关键在匹配场景。2. 为什么我最后选了Selenium而不是Playwright和纯接口2.1 三套主流方案的横向对比市面上处理JS渲染的爬虫方案不少除了Selenium还有Playwright、PyppeteerPuppeteer的Python移植版。下面是我在实际项目里的体感对比方案上手难度稳定性反爬痕迹维护活跃度适用场景requests 逆向接口高高无取决于目标接口可逆向的大规模采集Pyppeteer中中中较低老项目维护、轻量使用Selenium低高中可调低高复杂交互、跨端兼容Playwright中高低高新一代自动化带监听和录制说实话Playwright在很多方面已经超过Selenium比如自动等待机制、Context隔离、更细的浏览器协议控制。但Selenium有它不可替代的优势W3C WebDriver标准、生态沉淀时间长、社区里能搜到的踩坑案例最多。真遇到问题Selenium的解决方案一定比Playwright的好找。而且对于爬虫场景来说我们需要的功能其实不到Selenium能力的十分之一够用和稳才是第一位的。2.2 Selenium 4带来的变化值得重新审视如果你对Selenium的印象还停留在两三年前那建议重新看看。Selenium 4有几个对爬虫特别有用的改变不需要再写executable_path了WebDriver自动管理工具可以直接接管提供了相对定位器Relative Locator可以根据某个元素上方、下方、左边、右边来定位元素处理复杂页面结构时很实用原生支持打开新标签页、新窗口的API不用再靠driver.execute_script(window.open())这种hack新窗口句柄的管理逻辑更简单跨iframe切换也更顺手。当然Selenium 4最香的其实是Chrome DevTools Protocol的支持。配合它你甚至可以监听网络请求、拦截响应、修改请求头这些之前只有Playwright才能玩的操作现在Selenium也能做了。不过这些属于进阶玩法后面有机会单独开一篇。2.3 我的选型建议如果你是新项目且团队里没人用过任何浏览器自动化框架我依然推荐先学Selenium。原因很简单出问题时搜python selenium xxx报错得到的答案量级是其他框架没法比的。爬虫的本质是和目标站点的前端斗智斗勇排错效率就是生产力。Playwright我会推荐给两种人一种是连Selenium都嫌不够灵活、要做请求拦截和网络mock的另一种是纯粹的新手讨厌配置环境想让框架把该管的都管了的。3. 环境搭建这一步能不能别卡在driver版本上3.1 安装与版本匹配的最小实践Selenium环境的坑十个人有八个人踩在chromedriver版本不匹配上。chrome浏览器版本和chromedriver版本必须是对应的差一个大版本就会报SessionNotCreatedExceptionsession not created: This version of ChromeDriver only supports Chrome version xx手动去chrome://version查版本号再去下载对应driver然后再配置PATH这套流程太原始了。现在我的标准做法是直接用webdriver-manager这个库它会根据本机浏览器版本自动下载匹配的driverpip install selenium webdriver-manager启动脚本长这样from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager options webdriver.ChromeOptions() driver webdriver.Chrome( serviceService(ChromeDriverManager().install()), optionsoptions ) driver.get(https://example.com) print(driver.title) driver.quit()webdriver-manager会把driver缓存到本地以后每次启动都先去检查版本匹不匹配不匹配才重新下载。这一个小改动能省掉你未来大量的时间。3.2 Options配置一上来就配好的几项基础参数第一次写Selenium的人容易忽略Options直接用默认配置。但默认配置在爬虫场景下既慢又显眼。我常用的基础配置是这样options webdriver.ChromeOptions() options.add_argument(--window-size1920,1080) options.add_argument(--disable-gpu) # 非必要但兼容某些老机器 options.add_argument(--no-sandbox) # Linux环境下要加 options.add_argument(--disable-dev-shm-usage) # Docker容器内必须 options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False)几个参数分别解释一下--disable-blink-featuresAutomationControlled关掉浏览器自动化控制的特征标记这个后面详细说--window-size1920,1080给视口一个固定大小避免某些页面因为视口太窄触发移动端布局导致元素位置错乱user-agent显式指定一个完整的PC端UA避免被识别成默认的HeadlessChrome。如果还想提升加载速度可以禁用图片和CSS的加载prefs { profile.managed_default_content_settings.images: 2 } options.add_experimental_option(prefs, prefs)图片对大多数文字类数据没影响但加载时间能缩短40%以上。注意如果你的目标页面本身依赖图片懒加载来触发后续数据请求那禁用图片这招就别用了会直接把后边的数据请求给禁掉。踩过一次这个坑提醒一下。3.3 第一个跑通的脚本长什么样配置完之后跑一个最简单的验证脚本确认环境OK再往下走from selenium import webdriver driver webdriver.Chrome() driver.get(https://www.baidu.com) print(driver.title) driver.save_screenshot(debug.png) driver.quit()成功的话会打印出标题并在当前目录生成一张截图。截图的用处很大——后面排查所有元素找不到的问题第一件事就是截图看现场。4. 元素定位和等待策略决定爬虫稳定性的两块压舱石环境跑通只算万里长征第一步。真正决定爬虫能不能24小时稳定跑的是元素定位和等待策略这两块。很多朋友写得出来脚本但一换网络、一改页面加载速度就崩问题基本出在这儿。4.1 元素定位优先用那些不会变的属性Selenium提供了八种元素定位方式但日常频繁使用的其实就三种ID、CSS选择器、XPath。优先级我是这么排的有id且稳定优先用ID。页面上唯一又快又准没有ID看有没有># 按文本内容定位注意别把整段文本写死用contains做模糊匹配 driver.find_element(By.XPATH, //button[contains(text(), 加载更多)]) # 从某个已知元素往上找祖父级容器再往下找兄弟节点 driver.find_element(By.XPATH, //div[contains(class, list-item)]//a)还有一个常被忽略的技巧find_elements带s在没找到元素时返回空列表而find_element不带s直接抛NoSuchElementException。判断页面是否有某个元素时永远用find_elements:if driver.find_elements(By.CLASS_NAME, toast-error): print(页面出现错误提示)比用try-except包裹find_element干净得多。4.2 等待策略time.sleep是条不归路新手最爱写time.sleep(5)觉得等个固定时间万事大吉。实际上一旦网络波动5秒不够就崩网络好时又白白多等4秒。效率低稳定性也差。在Selenium里正确的方式是等一个条件成立。这背后分两种隐式等待driver.implicitly_wait(10)它只对find_element生效元素出现了就立即返回等不到就等满10秒再抛异常。它是全局配置设置一次所有元素查找都受影响。但它处理不了元素已经在DOM里但还没渲染完的情况——比如一个空div已经插入了但里面的文字还没加载出来find_element能找到这个div但你派人去取数据时还是空的。显式等待WebDriverWait配合expected_conditions等的是某个条件成立能精确控制到你想要的粒度。这是爬虫脚本的主力来看正确姿势from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10) # 等待按钮可点击 btn wait.until(EC.element_to_be_clickable((By.XPATH, //button[contains(text(), 加载更多)]))) # 等待某个文本出现 wait.until(EC.text_to_be_present_in_element( (By.CLASS_NAME, list-count), 共100条 )) # 等待某个元素消失比如loading图标 wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, loading-spinner)))我常用的几个EC列一下EC名称作用presence_of_element_located元素出现在DOM里不一定可见visibility_of_element_located元素可见有尺寸且不为hiddenelement_to_be_clickable元素可见且可点击text_to_be_present_in_element元素里出现指定文本invisibility_of_element_located元素不可见或不存在staleness_of元素从DOM中移除常用于翻页后等待旧数据消失4.3 自定义一个稳定的等待点击工具长时间跑爬虫之后我把等待和点击封装成了一个工具函数避免每次重复写又臭又长的等待链def wait_click(driver, locator, timeout10): 等待元素可点击并完成点击找不到就返回False try: element WebDriverWait(driver, timeout).until( EC.element_to_be_clickable(locator) ) driver.execute_script(arguments[0].scrollIntoView();, element) element.click() return True except Exception: return FalsescrollIntoView可以解决大量元素在页面下方被遮挡导致点击无效的问题。这个函数加上后面的显式等待基本覆盖了90%的交互场景。5. 无头模式、反爬痕迹和运行效率三个绕不开的现实问题5.1 headless与反检测无头模式Headless是指浏览器不弹窗口、在后台运行。好处是不占桌面资源适合服务器上跑坏处是容易被网站识别。很多站点虽然没有明说但对HeadlessChrome是有倾向性检测的。Selenium操作过的Chrome一个最典型的特征就是navigator.webdriver属性为true。正常浏览器的这个值是undefined。针对这一点除了前面Options里已经写过的--disable-blink-featuresAutomationControlled如果还不够干净可以再注入一段初始化脚本driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); Object.defineProperty(navigator, languages, { get: () [zh-CN, zh, en-US] }); Object.defineProperty(navigator, plugins, { get: () [1, 2, 3, 4, 5] }); })这段代码要在driver.get()之前执行作用是每次加载新文档时先隐藏自动化特征。注意没有任何一种方案能100%伪装成真实用户如果目标站点已经上了高级行为检测比如鼠标轨迹、键盘节奏分析那靠Selenium硬顶不是长久之计。这时候要么降低运行频次要么用更细粒度的协议层工具。5.2 效率优化的几个抓手Selenium慢是它最大的缺点。一个真实浏览器启动就要两三秒每次get还要加载样式、脚本、图片慢的页面能等你十秒以上。怎么把这个慢控制住是Selenium爬虫能不能实际投入生产的核心。第一是复用浏览器进程。千万千万别在循环里每次WebDriver()新建driver再quit()这是最慢的写法。启动一次浏览器让它反复get十来个页面比反复开关浏览器快好几倍。很多业务场景需要保留登录态的时候这种复用就更有必要了。第二是带上登录态继续跑。很多页面需要登录才能看到完整数据。与其每跑一次都扫码登录一次不如第一次手动登录后把cookie存下来下次爬虫启动时直接注入import pickle # 登录成功后保存 pickle.dump(driver.get_cookies(), open(cookies.pkl, wb)) # 下次启动时注入 cookies pickle.load(open(cookies.pkl, rb)) driver.get(https://example.com/login) for cookie in cookies: driver.add_cookie(cookie) driver.refresh()注意先get到目标域名的页面再add_cookie否则cookie加不进去。这招能让那些需要登录的采集任务效率提升非常多。第三是善用Profile目录。如果页面登录态是存在localStorage或者IndexedDB里的那光靠cookie可能不够。这种情况下直接用--user-data-dir指定一个固定的Chrome用户目录把整个浏览器状态包括登录态持久化到本地磁盘options.add_argument(--user-data-dir/path/to/chrome-profile)第一次手动打开这个目录下的浏览器登录后续Selenium启动就自带登录态了。我有个长期跑的采集任务靠这个方式已经连续跑了一个多月没重新登录过。5.3 混合架构Selenium只负责给requests“打辅助”这个思路值得单独说说。很多时候我们不需要Selenium去解析数据而是用它在前面冲开一道口子拿到关键的token、cookie剩下的大批量请求交给requests完成。举个例子用Selenium打开目标站点完成登录和滑块校验从driver里拿到登录后的cookie从浏览器Network请求里挖出带签名的接口和参数规则后续所有高并发采集都交给requests去跑Selenium直接退出。这个混合架构把Selenium的慢和requests的快结合得很好。代价是你需要先搞清楚接口的调用规则但对于长期项目来说第一天的逆向成本换来的是后面每天数倍的采集效率提升非常划算。6. 动手抓一个无限滚动列表完整案例拆解理论说得再多不如直接跑一个完整的例子。我选一个很典型的场景——无限滚动列表页这也是JS渲染页面里最常见的交互之一。6.1 目标与策略假设我们要抓一个文章列表页页面大概长这样初始加载20条数据滚动到底部后自动加载下一页数据直到没有更多。列表里的内容是JS渲染出来的直接requests拿不到。我们计划打开页面记录当前有多少个列表项滚动到底部等待新列表项出现重复第二步直到列表项数量不再变化认为已经到底一次性解析所有列表项提取标题和链接退出。6.2 完整代码实现import time from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager def build_driver(): options webdriver.ChromeOptions() options.add_argument(--window-size1920,1080) options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) return webdriver.Chrome( serviceService(ChromeDriverManager().install()), optionsoptions ) def scroll_and_collect(driver, list_selector, max_scrolls50): items set() last_count 0 for _ in range(max_scrolls): # 滚动到底部 driver.execute_script(window.scrollTo(0, document.body.scrollHeight)) time.sleep(1.5) # 等滚动动画和数据加载 # 统计当前列表数量 elements driver.find_elements(By.CSS_SELECTOR, list_selector) current_count len(elements) if current_count last_count: # 数量不再变化说明到底了 break last_count current_count # 解析所有列表项 for ele in elements: try: link ele.find_element(By.CSS_SELECTOR, a).get_attribute(href) title ele.find_element(By.CSS_SELECTOR, h3, .title).text.strip() if link and title: items.add((title, link)) except Exception: continue return list(items) if __name__ __main__: driver build_driver() try: driver.get(https://example.com/articles) WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, .article-list .list-item)) ) results scroll_and_collect(driver, .article-list .list-item) for title, link in results[:10]: print(f{title}: {link}) print(f总共采集到 {len(results)} 条) finally: driver.quit()6.3 这段代码里的几个细节决策为什么用set存数据因为滚动加载过程中某些列表项可能因为页面复用被重新渲染set天然去重避免重复入库。为什么用time.sleep(1.5)而不是显式等待严格来说滚动加载没有一个固定的加载完成标志列表数量在滚动后立刻变化等待新元素出现反而不容易判断。这里用固定间隔配合数量判断逻辑上更清晰。为什么用window.scrollTo而不是driver.execute_script(window.scrollBy(0, 1000))因为scrollTo直接跳到底部一次就能触发到底部加载的逻辑省去多次滚动。无限滚动的页面通常只需要触发一次到底事件就会持续加载如果页面实现的是每次滚动加载一点那要改成在一段时间内循环小幅滚动。最后driver.quit()是必须的。只调close()的话浏览器进程可能还挂在后台占着内存不释放。跑长期任务时每漏一次quit就是漏一块内存。7. 踩坑记录高频问题的定位链路与修复方案最后这部分把我这些年用Selenium踩过的坑做个汇总。每个坑都给定位思路比直接给答案更有用。7.1 element not interactable元素找到了但是点不进去这个报错的本质是元素存在于DOM中但它所在的位置被其他元素遮挡或者元素本身不可见、不可操作。最常见的是页面底部弹出了悬浮广告、Cookie提示条把目标按钮挡住了。定位链路先截图看现场 → 确认元素是否被遮挡 → 用滚动JS点击绕过去。# 方案一滚动到元素正中间再点 driver.execute_script(arguments[0].scrollIntoView({block: center}), element) element.click() # 方案二直接用JS点击绕过点击被拦截的判断 driver.execute_script(arguments[0].click(), element)方案二对大部分被遮挡的场景有效但如果点击事件是JS里用监听器绑定的JS点击和真实点击的触发效果可能有细微差异。优先用方案一。7.2 iframe嵌套明明页面上有代码里就是找不到遇到iframe里的元素直接find_element是永远找不到的。必须先把driver切进iframe上下文才能看见里面的东西。这是新手最容易崩溃的一个坑因为报错信息只是元素找不到完全不会提示你问题出在iframe上。定位链路打开开发者工具Elements面板里看目标元素外层有没有iframe标签 → 有的话切进去再操作。# 切成iframe driver.switch_to.frame(iframe_id) # 按id切 driver.switch_to.frame(0) # 按索引切 driver.switch_to.frame(driver.find_element(By.CSS_SELECTOR, iframe[src*xxx])) # 按元素切 # 在iframe里操作完切回主文档 driver.switch_to.default_content()处理完iframe一定要记得切回主文档不然后续的定位全部会失效。7.3 新标签页打开driver却还留在旧页面点击一个在新标签页打开的链接后页面明明跳转了但driver的当前页面还是原来的定位新页面的元素全部失败。定位链路检查driver.window_handles如果句柄数量大于1说明有新页面被打开。切换到最后一个句柄即可# 先记住旧句柄 old_window driver.current_window_handle # 点击打开新标签页 driver.find_element(By.LINK_TEXT, 打开新页面).click() # 等待新句柄出现切换过去 WebDriverWait(driver, 10).until( lambda d: len(d.window_handles) 1 ) new_window [w for w in driver.window_handles if w ! old_window][0] driver.switch_to.window(new_window)用完新标签页可以driver.close()关掉它再switch_to.window(old_window)切回来。7.4 StaleElementReferenceException元素引用失效了这个报错的高频场景是你定位了一个元素之后页面任何一部分DOM被重新渲染比如点击了某个按钮、滚动加载后之前保存的元素引用就失效了。再去操作它报错element is not attached to the page document。定位链路看代码里是不是把find_element的结果存成了变量然后多次使用。正确做法是每次操作前重新查找元素不要复用旧引用。# 错误示范 btn driver.find_element(By.ID, submit) btn.click() btn.click() # 如果第一次click触发了DOM重渲染第二次click就崩 # 正确做法 for _ in range(2): btn driver.find_element(By.ID, submit) btn.click()如果必须复用同一个元素做多次操作给它加个简单的重试机制def safe_click(driver, by, value, retries3): for i in range(retries): try: element driver.find_element(by, value) element.click() return except Exception: time.sleep(1) raise Exception(重试多次仍然失败)7.5 无头模式下一切正常换成headless就各种抽风典型表现是元素找不到、点击无效、窗口尺寸不对。原因基本出在无头浏览器没有真实视口很多依赖布局计算的JS在headless下行为异常。解决方式就是显式指定窗口大小以及给浏览器设置完整UA。options.add_argument(--window-size1920,1080) options.add_argument(--force-device-scale-factor1)还有一个比较隐蔽的问题headless模式下Chrome的某些GPU特性会失效导致canvas绘制出来的图形异常如果目标页面用canvas渲染数据那一定要留意输出结果的准确性。7.6 等待超时先别改代码先截图看现场我见过太多人一行报错就怀疑自己定位写错了改来改去浪费时间。正确的排查顺序是固定的driver.save_screenshot(debug.png)看看当前页面到底长什么样print(driver.current_url)确认URL有没有被重定向print(driver.page_source[:500])看页面内容是不是自己想要的确认这些都没问题再怀疑是不是定位表达式的问题。这一套流程走完80%的问题原因就清楚了。很多时候超时是因为页面弹了滑块验证、跳转到了登录页、或者被风控拦了这些用眼睛一看截图就知道了根本不用分析定位逻辑。在写爬虫这件事上没有银弹。Selenium不是最快的方案但它是处理JS渲染页面时思路最直接、最不容易出幺蛾子的兜底工具。我自己现在维护的采集服务里Selenium的定位就是攻坚小分队复杂交互、反爬校验、动态渲染交给它去啃啃下来拿到登录态和接口规律之后大批量跑数据的活儿还是交给requests。这套搭配我用了两三年稳定性和效率都算满意。如果你正被一个JS渲染的页面卡住希望这篇文章能让你少走点弯路。