Selenium动态渲染页面采集实战:从元素定位到稳定运行

发布时间:2026/9/20 12:01:31
Selenium动态渲染页面采集实战:从元素定位到稳定运行 1. 从页面能打开但代码抓不到说起动态渲染页面的抓取困局很多人第一次接触数据采集都是从requests加BeautifulSoup这套组合拳开始的。写几行代码发个请求解析 HTML数据就乖乖躺在列表里了。这套方法对付静态页面确实够用但一旦遇到那种右键查看源代码里空空如也、数据全靠 JavaScript 动态渲染出来的页面就会立刻卡壳——你请求回来的 HTML 里根本没有目标数据浏览器里看到的和代码里拿到的完全是两回事。这类页面的典型特征就是内容通过前端框架异步加载或者干脆把数据藏在接口请求里页面初始 HTML 只是一个空壳。你如果直接去解析这个空壳自然什么都拿不到。这时候就需要换一个思路——不再去请求 HTML 然后解析而是让一个真实的浏览器把页面完整渲染出来等 JavaScript 执行完毕、数据挂载到 DOM 上之后再去读取渲染后的结果。Selenium 就是干这件事的。这篇文章要聊的就是怎么用 Selenium 去处理这类动态渲染页面的采集任务。我会用一个公开的、结构相对规整的政务类信息查询页面作为演示场景把从环境搭建、元素定位、翻页处理到稳定性优化的完整链路讲清楚。需要先说明的是本文的重点是技术方案的实现与工程化思路演示对象选择的是公开可访问、允许正常浏览的信息页面采集行为仅用于个人学习和技术研究实际使用中请务必遵守目标站点的服务条款和相关法律法规控制请求频率不要对目标服务器造成压力。适合谁看如果你已经会写基础的 Python 脚本用过requests但对动态页面束手无策或者你听说过 Selenium 但一直没搞明白它到底怎么用、为什么比直接请求慢、怎么让它稳定跑起来那这篇内容应该能帮到你。我会尽量把每个选择背后的为什么讲透而不是只丢一段能跑的代码给你。2. 为什么这类页面必须上浏览器渲染机制与方案选型2.1 动态渲染页面到底动态在哪要理解为什么requests抓不到数据得先搞清楚浏览器加载一个页面时到底发生了什么。当你用requests.get()请求一个 URL 时你拿到的只是服务器返回的第一手 HTML 文档也就是所谓的初始文档。而真实浏览器拿到这份文档后还会继续做一系列事情解析 HTML 构建 DOM 树、加载 CSS、执行 JavaScript、根据 JS 的逻辑再去发起额外的网络请求拿数据、最后把数据动态插入到 DOM 里。关键就在最后这一步。很多现代页面的数据并不是写在初始 HTML 里的而是页面加载后由 JavaScript 通过接口请求拿到再渲染到页面上。你用requests拿到的初始文档里对应的位置可能只是一个空的div或者一个加载中的占位符。这就是动态渲染的本质——数据是运行时才产生的不是文档自带的。所以解决方案有两条路一条是找到那个真正返回数据的接口直接请求接口拿 JSON另一条就是用一个真实浏览器把整个渲染过程跑一遍等数据出现后再读取。前者效率高但需要分析网络请求、处理各种签名参数门槛不低后者更接近模拟真人操作通用性强代价是慢、吃资源。Selenium 走的是第二条路。2.2 Selenium 和直接请求接口的取舍这里必须诚实地讲清楚能用接口就别用 Selenium。如果你能通过浏览器开发者工具的网络面板找到返回目标数据的接口并且这个接口没有复杂的加密签名、token 校验那直接请求接口是最优解——速度快、资源占用低、稳定。Selenium 启动一个浏览器实例光是初始化就要好几秒每个页面渲染又要等跑几百条数据下来时间成本很高。但现实情况是很多页面的接口带了各种动态生成的参数或者干脆把数据做了加密逆向成本极高。还有些场景比如需要登录、需要点击、需要滚动加载、需要处理验证码的页面用浏览器模拟操作反而更直接。这时候 Selenium 的价值就体现出来了它不关心数据是怎么来的它只关心页面上最终显示了什么你像真人一样操作它就像真人一样把结果给你。我的经验判断标准是这样的如果目标页面的数据获取逻辑简单、接口清晰优先用requests如果页面交互复杂、接口难啃、或者需要模拟真实用户行为再上 Selenium。不要一上来就无脑用浏览器那是杀鸡用牛刀而且牛刀还容易卡。2.3 环境准备里最容易被忽略的细节环境搭建这块网上教程一抓一大把但有几个坑几乎每个新手都会踩我提前说清楚。第一是浏览器驱动版本必须和浏览器版本匹配。Selenium 本身只是个遥控器真正干活的是浏览器驱动比如 ChromeDriver。驱动版本和浏览器大版本对不上就会报各种莫名其妙的错。现在比较省心的做法是用webdriver-manager这个库它能自动检测你本地的浏览器版本并下载匹配的驱动省去手动下载的麻烦。第二是虚拟环境一定要建。Selenium 相关的依赖不少直接装在全局环境里时间长了版本冲突会让你怀疑人生。用venv或者conda建一个独立环境把依赖锁在里面这是基本素养。第三是无头模式的取舍。无头模式headless不显示浏览器界面跑起来快、省资源适合部署到服务器。但调试阶段强烈建议不要开无头因为你能亲眼看到浏览器在干什么元素定位失败时一眼就能看出是页面没加载完还是选择器写错了。等脚本稳定了再切无头。# 建虚拟环境 python -m venv spider_env # 激活Windows spider_env\Scripts\activate # 激活macOS/Linux source spider_env/bin/activate # 安装依赖 pip install selenium webdriver-manager装完之后写个最小验证脚本确认浏览器能被正常拉起from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice) driver.get(https://www.example.com) print(driver.title) driver.quit()能打印出标题说明环境通了。这一步别嫌麻烦环境问题占了新手报错的一大半先把地基打牢。3. 元素定位的实战逻辑从找得到到找得稳3.1 定位方式的选择顺序Selenium 提供了七八种元素定位方式find_element_by_id、find_element_by_xpath、find_element_by_css_selector等等。新手最容易犯的错是随手抓一个就用结果页面稍微一改就全崩。定位方式是有优先级讲究的。我的选择顺序是id name css selector xpath。id 通常是唯一的、稳定的优先用name 在表单场景下好用css selector 表达能力强、性能好是主力xpath 功能最强但最脆弱尤其是那种从根节点一层层写下来的绝对路径页面结构一变就废。举个反面例子很多人喜欢用浏览器右键复制 XPath生成的那种路径长这样/html/body/div[3]/div[2]/div/div[1]/table/tbody/tr[5]/td[2]这种路径看着精确实际上极其脆弱页面多一个 div 就全错位。正确的做法是找元素的稳定特征比如它自己的 class、它附近的文本内容、它相对某个稳定父节点的位置。from selenium.webdriver.common.by import By # 不推荐绝对路径 xpath driver.find_element(By.XPATH, /html/body/div[3]/div[2]/table/tbody/tr[5]/td[2]) # 推荐基于稳定属性的 css selector driver.find_element(By.CSS_SELECTOR, table.result-table tr:nth-child(5) td.name) # 推荐基于文本内容的相对 xpath driver.find_element(By.XPATH, //td[contains(text(),批准文号)]/following-sibling::td)3.2 等待机制显式等待才是正解元素定位失败十有八九不是选择器写错了而是元素还没加载出来你就去找了。页面是异步渲染的你get()之后立刻find_elementDOM 里可能还没有那个元素自然报NoSuchElementException。新手常用的补救办法是time.sleep(3)硬等三秒。这招能用但很蠢——等短了不够等长了浪费时间而且网络波动时三秒可能还是不够。正确做法是用显式等待Explicit Wait让 Selenium 每隔一小段时间去检查条件是否满足满足就立刻继续超时才报错。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) element wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, table.result-table)) )presence_of_element_located只保证元素出现在 DOM 里不保证可见。如果元素是懒加载或者被遮挡要用visibility_of_element_located。如果是可点击的按钮用element_to_be_clickable。选对条件很重要不然会出现元素明明在却点不了的诡异情况。提示显式等待的超时时间不要设太短网络慢的时候 10 秒是合理值。但也不要设成 60 秒那样一旦真出错你要等一分钟才知道。3.3 处理 iframe 和动态加载的坑有一类定位失败特别隐蔽元素明明在页面上看得见代码就是找不到。这种情况大概率是元素在iframe里。iframe 是一个独立的文档上下文主文档的定位器进不去必须先切换进去。# 切换到 iframe iframe wait.until(EC.presence_of_element_located((By.ID, contentFrame))) driver.switch_to.frame(iframe) # 在 iframe 内操作 data driver.find_element(By.CSS_SELECTOR, .data-row).text # 操作完切回主文档 driver.switch_to.default_content()另一个坑是动态加载。有些页面滚动到底部才会加载更多内容或者点击加载更多按钮才追加数据。这时候需要模拟滚动或点击# 滚动到底部触发加载 driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) # 等新内容出现 wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, .new-item)))我踩过最深的坑是一个页面它的数据表格在 iframe 里而 iframe 本身又是异步插入的。也就是说你得先等 iframe 出现切进去再等表格出现。两层等待嵌套少一层就报错。这种时候把等待逻辑拆开写清楚比堆一个超长选择器靠谱得多。4. 翻页、数据提取与结构化存储的完整链路4.1 翻页策略点击式还是 URL 式数据往往不止一页翻页是绕不开的。翻页有两种典型模式一种是点击下一页按钮一种是 URL 里带页码参数。优先选 URL 式因为可控性强、可复现、断了能续。如果 URL 是xxx?page1这种结构直接构造 URL 循环请求就行base_url https://example.com/list?page{} for page in range(1, 11): driver.get(base_url.format(page)) # 等待并提取数据如果只能点击按钮就要注意按钮的状态。到了最后一页下一页按钮通常会变成禁用状态disabled属性或者 class 变化这时候要能识别出来并停止循环否则会陷入死循环或者报错。while True: # 提取当前页数据 extract_current_page(driver) try: next_btn wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, a.next-page:not(.disabled))) ) next_btn.click() # 等新一页数据加载 wait.until(EC.staleness_of(old_table)) except Exception: print(已到最后一页) break这里用了一个技巧staleness_of等待旧元素失效。点击下一页后旧表格会被替换等它变陈旧就说明新内容来了。这比硬等几秒精准得多。4.2 数据提取把渲染后的 DOM 变成结构化数据数据提取的核心思路是先定位容器再遍历行最后逐列取值。不要一行一行去写死选择器那样页面一改就全废。def extract_current_page(driver): rows driver.find_elements(By.CSS_SELECTOR, table.result-table tbody tr) page_data [] for row in rows: cells row.find_elements(By.TAG_NAME, td) if len(cells) 4: continue item { name: cells[0].text.strip(), code: cells[1].text.strip(), date: cells[2].text.strip(), status: cells[3].text.strip(), } page_data.append(item) return page_data几个细节值得注意。第一find_elements复数返回的是列表找不到时返回空列表而不是报错适合遍历场景find_element单数找不到会抛异常适合确定存在的元素。第二.text拿到的是渲染后的可见文本比get_attribute(textContent)更符合直觉但要注意隐藏元素拿不到 text。第三一定要.strip()页面上经常有看不见的空白字符不清掉存进数据库会很难看。4.3 存储CSV、JSON 还是数据库小规模数据几千条以内存 CSV 或 JSON 就够了简单直接。CSV 适合表格型数据用 Excel 能直接打开JSON 适合嵌套结构方便后续程序处理。import csv with open(result.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[name, code, date, status]) writer.writeheader() writer.writerows(all_data)注意encodingutf-8-sig这个-sig是为了让 Excel 正确识别中文不加的话用 Excel 打开会乱码。这是无数人踩过的坑。数据量大或者需要去重、查询就上 SQLite。它是个文件型数据库不用装服务Python 内置支持非常适合中小型采集项目。import sqlite3 conn sqlite3.connect(data.db) conn.execute( CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, code TEXT UNIQUE, date TEXT, status TEXT ) ) conn.executemany( INSERT OR IGNORE INTO records (name, code, date, status) VALUES (?, ?, ?, ?), [(d[name], d[code], d[date], d[status]) for d in all_data] ) conn.commit()code字段设成UNIQUE配合INSERT OR IGNORE天然实现去重。重复采集时不会产生脏数据这个设计在增量采集场景下特别有用。5. 让脚本稳定跑完反爬应对与工程化优化5.1 识别被拦的信号脚本跑着跑着突然拿不到数据了先别急着改代码要判断是不是被目标站点拦了。常见的信号有几种页面返回的内容变成了验证页、请求被重定向到别的地址、返回状态码异常、或者干脆连接被拒绝。判断方法很直接把driver.page_source打印出来看看或者截图保存下来。如果看到的是访问过于频繁请稍后再试之类的提示那就是频率太高被限了。这时候要做的不是硬刚而是降速。import random import time # 每次请求之间随机停顿模拟真人节奏 time.sleep(random.uniform(2, 5))固定间隔容易被识别出规律随机间隔更自然。这个道理很简单真人不会每隔精确的 3.000 秒点一次。5.2 请求头与浏览器指纹的基本处理Selenium 启动的浏览器默认会带一些自动化特征有些站点能检测出来。基础的应对是设置合理的窗口大小、User-Agent以及关闭一些明显的自动化标志。options webdriver.ChromeOptions() options.add_argument(--window-size1920,1080) options.add_argument(--disable-blink-featuresAutomationControlled) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) driver webdriver.Chrome(serviceservice, optionsoptions) driver.execute_cdp_cmd( Page.addScriptToEvaluateOnNewDocument, {source: Object.defineProperty(navigator, webdriver, {get: () undefined})} )这些手段能绕过一些基础的检测但我要说句实在话没有万能的绕过方案。目标站点的检测手段在进化今天能用的方法明天可能就失效。与其把精力全花在对抗上不如把请求频率控制好、把行为做得像真人这才是长久之计。注意任何采集行为都必须在目标站点允许的范围内进行。遵守 robots 协议、控制频率、不采集敏感和个人隐私数据这是底线。技术是中性的怎么用取决于人。5.3 异常处理与断点续传一个跑几百页的脚本中途出点意外太正常了网络抖动、元素没加载出来、浏览器崩溃。如果没做异常处理一崩就得从头再来前面的活全白干。核心思路是把每一页的结果及时落盘并记录进度。这样即使中断下次也能从断点继续。import os import json PROGRESS_FILE progress.json def load_progress(): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, r) as f: return json.load(f) return {last_page: 0} def save_progress(page): with open(PROGRESS_FILE, w) as f: json.dump({last_page: page}, f) # 主循环 progress load_progress() for page in range(progress[last_page] 1, total_pages 1): try: driver.get(base_url.format(page)) data extract_current_page(driver) save_to_db(data) save_progress(page) except Exception as e: print(f第 {page} 页出错: {e}) # 出错时保存现场方便排查 driver.save_screenshot(ferror_page_{page}.png) continue出错时截图是个好习惯事后一看截图就知道当时页面是什么状态比对着日志猜强多了。5.4 资源清理与性能优化Selenium 最容易被忽视的问题是资源泄漏。每次webdriver.Chrome()都会启动一个浏览器进程如果脚本异常退出没调quit()这些进程会一直挂在后台跑几次下来内存就被吃光了。正确做法是用try...finally保证浏览器一定被关闭driver webdriver.Chrome(serviceservice, optionsoptions) try: # 主逻辑 pass finally: driver.quit()性能方面如果不需要看界面切无头模式能省不少资源。另外图片和 CSS 对数据提取没用可以禁用掉加速加载options.add_argument(--headlessnew) prefs {profile.managed_default_content_settings.images: 2} options.add_experimental_option(prefs, prefs)禁用图片加载后页面渲染速度能明显提升尤其是图片多的页面。但要注意有些页面的布局依赖图片尺寸禁用后可能导致元素位置变化定位失败。这个要视具体页面测试后再决定。6. 几个真实踩过的坑和对应的解法6.1 元素时有时无的间歇性失败最让人头疼的不是稳定报错而是十次里成功八次、失败两次。这种间歇性失败通常有三个原因等待时间不够、页面有随机弹窗、或者网络波动导致加载慢。排查思路是先加长等待时间看是否改善如果改善了说明是加载速度问题如果还不行检查是不是有弹窗遮挡。很多站点会随机弹出公告、广告或者 Cookie 同意框这些弹窗会挡住你要点的元素。# 尝试关闭可能存在的弹窗 try: close_btn WebDriverWait(driver, 3).until( EC.element_to_be_clickable((By.CSS_SELECTOR, .popup-close, .cookie-accept)) ) close_btn.click() except Exception: pass # 没有弹窗就跳过用短超时的try...except处理弹窗有就关没有就跳过不影响主流程。6.2 文本提取拿到空字符串element.text返回空字符串但元素明明在页面上有内容。这种情况通常是元素不可见display:none或者被遮挡Selenium 的.text只返回可见文本。解决办法是用get_attribute(textContent)或get_attribute(innerText)它们能拿到不可见元素的文本。# .text 拿不到时试试这个 value element.get_attribute(textContent).strip()另一个可能是元素还没渲染完.text拿到的是空。这时候加个显式等待等文本非空再取。6.3 翻页后数据没更新点击下一页后代码立刻去提取数据结果拿到的还是上一页的内容。原因是新数据还没加载完DOM 里还是旧的。前面提到的staleness_of就是解决这个的——等旧元素失效说明新内容替换完成了。如果页面是局部刷新、旧元素不失效那就换个思路记录当前页第一条数据的某个特征值翻页后轮询等待这个特征值变化。old_first driver.find_element(By.CSS_SELECTOR, tr:first-child td).text next_btn.click() wait.until(lambda d: d.find_element(By.CSS_SELECTOR, tr:first-child td).text ! old_first)这种等特征值变化的思路很通用凡是异步更新内容的场景都能用。6.4 内存占用越来越高长时间运行的脚本内存持续上涨最后卡死。除了前面说的浏览器进程泄漏还有一个常见原因是没有及时清理不再使用的引用。比如把每一页的数据都堆在一个大列表里几百页下来内存自然吃不消。解法是分批处理、及时落盘。每采集 N 页就把数据写一次数据库然后清空内存里的临时列表。不要等到全部采完再统一写入。buffer [] for page in range(1, total_pages 1): buffer.extend(extract_current_page(driver)) if len(buffer) 500: save_to_db(buffer) buffer.clear() # 收尾 if buffer: save_to_db(buffer)这个攒一批写一批的模式在处理大数据量时是标配。7. 关于这套方案的一些个人体会用 Selenium 做采集本质上是在用资源换通用性。它慢、吃内存但胜在能处理各种复杂的动态页面不用去逆向接口。我的建议是把它当成工具箱里的一把重锤而不是万能钥匙。能用轻量方案解决的就别上它。另外技术之外的东西往往更重要。采集之前先想清楚这个数据我有没有权利采目标站点允许吗频率会不会影响人家正常服务这些问题想明白了再动手比写多少行代码都重要。我见过太多人技术很溜但因为没有边界意识最后给自己惹了麻烦。最后分享一个实用的小习惯给脚本加日志。不要只用print用logging模块把关键节点、耗时、异常都记下来。脚本跑一晚上第二天看日志就知道哪一页慢、哪一页出错、总共采了多少条。这个习惯在调试和优化阶段能帮你省下大量时间。import logging logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(spider.log, encodingutf-8), logging.StreamHandler() ] ) logging.info(开始采集第 %d 页, page)日志这东西平时觉得多余真出问题的时候就是救命稻草。养成习惯受益无穷。