这段时间一直在处理一个数据采集任务,最初用的是老一套 requests + 解析,结果对方某个页面突然升级成了动态渲染方案——打开浏览器时数据正常,查看源码干干净净,甚至连 XHR 接口都加了动态 cookie 校验。遇到这种情况基本逃不掉两条路:要么在 Python 爬虫里接 Selenium,要么上 Playwright。但问题也来了,这俩自动化工具只要参数没调对,运行几分钟就会被识别并封禁,而这个"识别 -> 封禁"的过程往往发生在你看不到的地方。
这篇文章不打算讲安装教程,重点聊聊我在动态渲染页面反爬对抗里实际用过的方案、Selenium/Playwright 的防检测参数怎么配置,以及为什么很多看似正常的代码会被风控系统一眼看穿。适合已经写过一些爬虫、正被动态页面和风控劝退的 Python 开发者,也适合刚接触浏览器自动化但不想只停留在 demo 阶段的朋友。
1. 拿到页面源码不等于拿到数据:先认清动态渲染的真实机制
1.1 requests 读回来的 HTML,常常只是一副空壳
大部分人对动态渲染的理解停留在"页面上有 JS",这个认知太模糊了。真正动态渲染的页面,requests 获取到的 HTML 往往是一个空壳:里面只有 <div id="app"> 之类的挂载点,真正的表格、列表、图表内容都是浏览器执行 JavaScript 之后,由框架动态插入到 DOM 里的。
举个例子,我最初用 requests 抓一个数据平台时,返回的 HTML 里能看到 20 多个 JS 文件路径和一个空的 root 节点。这种结构意味着数据不在初始 HTML 里,而是由前端 JS 通过异步请求拿到 JSON,再渲染成页面。这种情况下如果只靠 requests 拿 HTML 再解析,无论正则还是 XPath 都无从下手。
但是,这里有个容易被忽略的判断:不要看到 JS 渲染就慌着上 Selenium。先用浏览器的开发者工具切到 Network 面板刷新页面,观察到底触发了哪些请求。很多时候数据其实在接口里已经返回了,只是前端用 JS 把它们渲染到页面上。如果 XHR 或 Fetch 请求能找到完整的 JSON 数据,那最优解仍然是 requests/python-httpx 直接调接口,顺便带上接口需要的 headers 和 cookies,完全没必要启动一个浏览器。
1.2 哪几类场景,才是非浏览器自动化不可
真正逼着你上 Selenium/Playwright 的,通常是几类特殊的"反爬存根机制",我在项目里整理过三类高频场景。
第一类是动态 Cookie 校验。页面加载时会先在本地执行一段自定义 JS(比如某些金融资讯站用混淆后的 chameleon 脚本),计算出一个带有签名特征的 Cookie,后续的数据接口请求只有带上这个动态 Cookie 才能返回正常数据。这类逻辑如果靠手写 Python 去还原 JS 加密过程,成本极高,因为对方可能每周都换算法。用浏览器自动化的思路就简单了——让真实浏览器去执行 JS,再把生成好的 Cookie 拿回来用。
第二类是动态 iframe 嵌套。页面主体看似简单,但数据被塞进多个同源或跨域的子框架里。普通 requests 拿到的 HTML 只能看到 iframe 标签,iframe 的 src 往往又是前端生成或延后加载的。此时用 scrapy 配合 playwright 处理动态 iframe,比纯 requests 拼接几个请求要省心得多。
第三类是事件触发加载。比如点击"查询"按钮、拖动滑块、滚动到页面底部才加载更多内容,又比如鼠标悬浮才显示的价格。这些交互行为背后依赖一整套前端状态管理,纯 HTTP 层模拟会把代码写得非常脆。浏览器自动化是自然解。
1.3 选型之前,先回答三个问题
面对一个动态渲染页面,我会在写代码前先回答三个问题,省下大量踩坑时间:
- 目标数据是只在浏览器渲染后出现在 DOM 中,还是 Network 面板中的某个请求已经返回了完整数据?如果是后者,优先直接调接口。
- 目标接口的请求头或 Cookie 是否包含动态生成值?可以尝试在无痕窗口手动复制一段 Cookie 放到 requests 里测试,若十几分钟后就失效,大概率有动态校验。
- 目标网站本身是否已经布了强风控?判断标准包括:普通浏览器访问偶尔弹出验证码、无头浏览器访问直接被重定向到验证页、访问频率稍微提高就返回 202/302 之类异常状态码。如果是,后面的防检测章节必须仔细看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么 Selenium 一开就被封:站点到底在检测什么
2.1 自动化工具的三层暴露面
浏览器自动化之所以会被识别,是因为无论 Selenium 还是 Playwright,本质上都是"人类用真浏览器 + 驱动的外部控制"。只要没有做额外处理,这种控制活动会通过三个层面暴露给网页:协议层、特征层、行为层。
协议层指浏览器开发者工具协议(Chrome DevTools Protocol)与 WebDriver 协议。网页里的 JS 可以通过 navigator.webdriver 属性来判断当前浏览器是否由自动化驱动控制,有些站点还会通过 window.cdc_ 开头变量检测 WebDriver 注入痕迹,这类代码在浏览器环境执行,普通用户访问的值和自动化访问的值存在差异。
特征层指浏览器环境中各种容易被比对的对象,比如 navigator.plugins、navigator.languages、window.chrome、WebGL 渲染器信息、Canvas 指纹、字体列表、屏幕尺寸等。这些值在正常 Chrome 与自动化默认配置下会有明显区别,风控系统把一次访问的几百个特征映射成向量,与真实用户聚类结果对比后就能给访问者打上风险分。
行为层指鼠标移动轨迹、点击间隔、滚动速度、表单填写节奏。自动化测试常见的"瞬间点击""匀速移动"本身就是最好识别的标志。像 Selenium 官方示例那样 driver.find_element().click() 之后立即抓数据,真实用户不可能零延迟完成,即便特征全伪装好了,行为分也会暴露你。
2.2 站点检测自动化浏览器的几个常见特征点
下面这张表是我在调试时最常检查的特征。注意,不需要保证每个特征都和真人一模一样,关键是别出现明显可定向识别的少数几个点。
| 特征 | 检测位置 | 识别原理 | 常见伪装方法 |
|---|---|---|---|
| navigator.webdriver | JS 属性 | 自动化驱动会将其设为 true | CDP 注入 JS 重新定义该属性 |
| window.chrome | JS 对象 | 去掉 --disable-blink-features 后普通 Chrome 存在该对象 |
保留或注入模拟对象 |
| navigator.plugins / languages | JS 属性 | headless 模式部分插件信息丢失 | 注入伪造数组 |
| WebDriver 遗留标记 | JS 全局变量 | 如 cdc_ 开头变量 |
修改驱动源码或使用 patch 版 |
| 浏览器指纹 | 服务端算法 | 对 UA、Canvas、WebGL、字体等综合计算 | 使用同一套真实性较高的浏览器环境 |
| 访问频率/行为轨迹 | 服务端日志 | 高频同 IP、定时间隔请求 | 代理池 + 随机行为模式 |
| TLS/HTTP2 指纹 | 服务端握手 | 非真实浏览器的请求库握手特征不同 | 使用浏览器网络栈或专用客户端 |
2.3 Selenium 与 Playwright 的防检测差异
Selenium 最大的痛点是它的通信协议在设计之初没有考虑反爬场景。通过 WebDriver 协议启动的 Chrome 会带 --enable-automation 开关,页面里很容易检测出控制标记,所以我自己在做高强度采集时一般会配合 undetected-chromedriver 这类补丁库使用。
Playwright 默认走 CDP 协议,通信方式比 WebDriver 隐蔽一些,而且它不依赖本机安装的 ChromeDriver,浏览器二进制可以直接通过 playwright install chromium 拉取。但它并不是天然免疫检测,如果只是简单地 launch() 一个 headless 浏览器,同样会被众多页面识别。Playwright 真正的优势在于提供了 add_init_script 机制,可以在页面加载任何脚本之前注入伪装代码,这一步对后面修改 navigator 等属性非常关键。
在集成度上,Playwright 在 Python 生态里和 pytest、scrapy 都有相对成熟的组合用法,比如 scrapy-playwright 插件能让普通 Scrapy 爬虫用同一个异步事件循环驱动浏览器,处理含 iframe 的页面也更有结构性。
2.4 一个容易让人误判的点:伪装不等于绝对安全
做久了就会意识到,风控系统给的从来不是"0/1"结论,而是一个风险分。就算把上文提到的所有特征都处理了,如果同一 IP 每分钟拉 20 次数据,服务端行为模型一样会判定异常。规避检测不是"绕过一个开关",而是尽量降低每次访问与真人访问的偏差。
所以我对"防检测方案"的定位是:它能让你在针对动态渲染页面的正常采集频率下,不被显式特征触发验证流程;它不是用来无限制暴力抓取的。
3. 防检测方案落地:把"人味"注入自动化浏览器
3.1 先看 Selenium 防检测的完整封装
下面这段代码是一套我在 Python 爬虫里用了很久的 Selenium 启动方案。使用 Options 时,我加了一组针对常见检测点的配置:
python复制import undetected_chromedriver as uc
from selenium.webdriver.chrome.options import Options
def create_selenium_driver(headless=False):
options = Options()
# 去掉"Chrome 正在受到自动软件控制"提示条,同时避免把自动化开关暴露给页面
options.add_argument("--disable-blink-features=AutomationControlled")
# 无头模式现在反而是高风险项,很多站点对 headless 核对了 WebGL/字体特征,必要时要设为 False
if headless:
options.add_argument("--headless=new")
# 下面这两个参数在 Linux 容器环境下尤其重要,否则容易白屏或崩溃
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
# 去掉自动化扩展
options.add_argument("--disable-extensions")
# 让浏览器有比较常规的窗口尺寸
options.add_argument("--window-size=1440,900")
# 设定常见浏览器语言,避免被识别成默认英文状态
options.add_argument("--lang=zh-CN")
driver = uc.Chrome(options=options, version_main=124)
return driver
使用 undetected_chromedriver 包装的好处是它会在驱动层绕过 navigator.webdriver,并对 cdc_ 标记做处理,比裸 Selenium 稳妥得多。但它不是万能,页面加载完以后,我还习惯再执行一段 JS 巩固环境:
python复制def after_load_patch(driver):
script = """
// 重新定义 navigator.webdriver 为 undefined,让 after 检测也拿不到正确值
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// 补全 plugins 信息
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
]
});
// 补全 languages,避免出现空数组或过短列表
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en-US', 'en']
});
// 伪造 window.chrome,很多自动化浏览器没有这个对象
if (!window.chrome) {
window.chrome = { runtime: {}, loadTimes: function(){}, csi: function(){} };
}
"""
driver.execute_script(script)
这里有一个原理值得展开:为什么 Object.defineProperty 能骗过检测?因为页面的 JS 在运行时读取 navigator.webdriver 时,调用的是属性 getter。我们通过 Object.defineProperty 重新定义该属性的 getter,就能把返回结果改写为 undefined。只要注入的 JS 在目标检测 JS 之前执行,站点就读取不到真实的 true。
3.2 Playwright 的防检测思路与代码
Playwright 的注入点比 Selenium 更靠前,它有一个 add_init_script 方法,注册的脚本会在每个新页面创建后、任何页面脚本执行前运行,相当于把上面的补丁直接埋进了浏览器。同时配合启动参数,可以达到不错的掩饰效果。
python复制from playwright.sync_api import sync_playwright
def hide_windows(ua=None):
hid_js = """
// 重写 navigator.webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// 增加 chrome 对象
window.chrome = window.chrome || { runtime: {}, loadTimes: function(){}, csi: function(){} };
// 修改 permissions:让 Notification.permission 等看起来正常
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
if (originalQuery) {
window.navigator.permissions.query = (parameters) => {
if (parameters && parameters.name === 'notifications') {
return Promise.resolve({ state: Notification.permission });
}
return originalQuery(parameters);
};
}
// 覆盖 languages
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' }
]
});
"""
launch_options = {
"headless": False,
"args": [
"--disable-blink-features=AutomationControlled",
"--disable-dev-shm-usage",
"--no-sandbox",
"--lang=zh-CN",
"--window-size=1440,900"
]
}
if ua:
launch_options["user_agent"] = ua
p = sync_playwright().start()
browser = p.chromium.launch(**launch_options)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
locale="zh-CN",
timezone_id="Asia/Shanghai"
)
context.add_init_script(hid_js)
return p, browser, context
这段代码的价值点有两个。一是 timezone_id 和 locale 必须和站点区域匹配,否则 WebGL 渲染、日期格式、时区偏移都会被拿去交叉比对。二是在 context 层注入而非 page 层注入,这样后续打开的所有标签页都会继承同样的补丁。
3.3 隐藏指纹时大多数人会漏掉的方向
绝大部分教程停留在隐藏 navigator.webdriver,但实际被风控识别的关键往往不在单一的 obvious 标记,而在一个环境向量的整体一致性。
举一个我踩过的例子:有一次我在 Selenium 里设置了 UA 为最新版 Chrome for Windows,但 navigator.platform 还是 MacIntel,导致服务端检测到"平台与 UA 不符",没过几分钟就被强制跳转验证页。后来我在注入 JS 里统一把所有环境信息改为对应平台,问题就消失了。这类隐蔽的不一致点包括:
- UA 是 Windows 但
navigator.platform/navigator.oscpu为 Mac - 浏览器语言列表不含目标地区语言但
Accept-Language请求头却是中文 - Canvas 指纹在高版本显卡环境下渲染结果与 headless 模式不一致,导致 WebGL 的
UNMASKED_RENDERER_WEBGL出现SwiftShader/Google SwiftShader字样 - 字体列表不完整,缺少真实 Windows 系统常见的中文字体
每一条我都在实际项目里碰到过。真要做高强度对抗,最省事的方案其实是直接用一台装有真实 Chrome 的常驻 Windows 服务器,用 Playwright 以 headless=False 方式接真实显示器或虚拟显示器,并修改注册表把远程桌面/虚拟环境特征隐藏起来。这个方案成本较高,但稳定性比纯参数堆砌强太多。
4. 动态 Cookie 与动态 iframe:两个贯穿式反爬任务的实战案例
4.1 从热搜里的 chameleon 反爬 JS 说起
之前碰到一个财经股票论坛的数据采集需求,页面本身不复杂,但用 requests 请求接口时始终返回参数错误。后来在浏览器 Network 面板里对比了几组请求,发现每次访问页面后,接口请求头都会自动携带一个 Cookie 字段,而这个 Cookie 是页面里一段名为 chameleon 的混淆 JS 在本地执行后生成的。
这种反爬设计很有代表性:服务端并不会在首次响应里直接给你可用 Cookie,而是把"如何生成 Token"的算法用 JS 下发到浏览器,浏览器算完再去请求数据接口。如果直接用 Python 复刻 JS 逻辑,每一轮算法升级都要重新逆向,维护成本很高。
我采用的方案是用 Playwright 打开一个页面,触发页面加载 JS 生成 Cookie,再持续监听目标 Cookie 是否出现;拿到 Cookie 后立刻交给 requests 循环池去请求接口。这样既拿到了真实浏览器计算的签名值,又能让大量 HTTP 请求快速跑在普通 requests 上,减轻浏览器资源占用。
关键字轮询逻辑可以这样写:
python复制from playwright.sync_api import sync_playwright
def get_dynamic_cookie(url, cookie_name, timeout=30):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
page.goto(url, wait_until="domcontentloaded")
cookie_value = None
for _ in range(timeout * 10):
cookies = context.cookies()
for c in cookies:
if c["name"] == cookie_name:
cookie_value = c["value"]
break
if cookie_value:
break
page.wait_for_timeout(100)
browser.close()
return cookie_value
调用的时候注意:不能只 goto 一次就立刻读 Cookie,有些生成脚本依赖 setTimeout 或异步接口返回结果,必须轮询等待。如果超过 30 秒仍然没有出现,就去页面的 Source 里排查生成逻辑是不是依赖了某个特定点击行为。
4.2 iframe 页面怎么配合 Scrapy Playwright 处理
另一种高频场景是内嵌多层的 iframe。比如某些行情页面,最外层只是工具栏,真正的数据表在一个 iframe 里,iframe 的 src 又是后来才动态设置的。如果直接用普通的 page content 去解析,肯定什么都拿不到。
Scrapy 场景下我一般会写一个 Downloader Middleware,通过 scrapy-playwright 把请求交给浏览器执行,并在页面加载完成后切换到指定 iframe 再提取数据。大概的思路是:
python复制async def parse(self, response):
page = response.meta["playwright_page"]
# 等待 iframe 出现,并按 name 或 src 判断是不是目标 frame
await page.wait_for_selector("iframe[data-role='data-panel']", timeout=15000)
frame = page.frames # 也可以遍历所有 frames 判断 url 关键字
target_frame = None
for f in frame:
if "detail" in f.url:
target_frame = f
break
if target_frame is None:
raise ValueError("target frame not found")
# 切换进去后等表格渲染完成再抓
await target_frame.wait_for_selector("table.data-table", timeout=10000)
rows = await target_frame.query_selector_all("table.data-table tbody tr")
for row in rows[:20]:
cells = await row.query_selector_all("td")
print(await cells[0].inner_text())
这段代码踩过一个坑:selenium 时代常用的 driver.switch_to.frame() 在 Playwright 里不是首选。Playwright 会把所有同源/跨源 iframe 都暴露在 page.frames 中,只要判断 frame 的 url 特征就能锁定目标。不要笨拙地在主文档里通过 XPath 查找定位之后再做切换,那样反而容易因为 iframe 尚未加载而报错。
4.3 动态 Cookie 与风控平台的联合作战
现代站点往往不只有单点检测。以电商电商的场景为例,第一次访问会返回一个低风险 Cookie,同时页面加载一段安全 SDK,根据你的鼠标轨迹、滚动、点击判断真人概率,再把计算结果发给服务端;服务端回来一个高置信度 Cookie 后,数据接口才允许访问。
这种场景单靠伪造几个属性无法解决,必须在 Playwright 里模拟真人行为。我的建议是把每个下载请求都封装成一个"带随机等待 + 随机滚动"的浏览器操作,而不是一个接一个瞬间访问页面。虽然速度降下来了,但账号和 IP 的存活周期会显著变长。
5. 集成到数据管道时的真实障碍与排查清单
5.1 Cookie 怎么从浏览器自动化交回给 requests
防检测做到位之后,下一个问题是架构层面的:总不能每次请求数据都启动一遍浏览器,那样效率太低。我在项目中比较常用的一套分工是:
- 浏览器自动化(Playwright)负责做"签名机":定时打开目标页面,等待动态 Cookie 生成后回传。
- 普通请求层(requests/httpx)负责批量拉取:拿到 Cookie 后,用连接池并发请求数据接口,配合代理轮换,将数据传给 Scrapy 管道。
- 定时任务每 10~20 分钟刷新一次 Cookie,避免过期。
Playwright 侧只需要暴露一个接口给 Scrapy 进程(比如通过本地 Redis 或一个简单的 Flask 服务)。千万不要在 Scrapy Spider 内部为每个请求都单独启动 browser,创建浏览器上下文的开销很大,并发几十个请求会导致内存直接打满。
5.2 排查"Playwright target closed"等目标关闭类错误
运行 Playwright 时,我非常高频地遇到 playwright: target closed,其本质是你要操作的 page/context/browser 已经被关闭了。但触发原因各不相同,我总结出三种最常见的情况:
- 页面收到了服务端 302 跳转或 JS 跳转,导致原页面对象被关闭,而你的代码仍然对着旧 page 调用方法。
- 站点检测到风险后主动执行
window.close()或跳转到 about:blank,新页面对象和你持有的引用不一致。 - 并发任务共用同一个 context 里的 page,一个任务执行了
context.close(),另一个还在里面找元素。
排查建议:操作任何页面元素前先判断 page.is_closed();遇到跳转后如果需要继续采集,使用 context.wait_for_event('page', ...) 捕获新页面;每个浏览器 context 不要同时跑多个任务,尽量一个 context 一个任务,资源不足则减少并行数而不是复用 context。
5.3 从失败到稳定上线,我总结的排查顺序
如果自动化脚本上线后经常被限制,我一般按以下顺序排查,而不是一上来就怀疑是代理问题:
- 先用正常浏览器手动访问目标页面,确认是否存在验证码、登录墙、人机校验。
- 查看脚本运行时所用的 UA、浏览器版本、平台信息是否真实合理。UA 与平台不一致往往是最先暴露的。
- 检查页面加载完成的判断条件。一定要等目标元素出现后再操作,不要用固定 sleep,否则行为节奏与真人明显不同。
- 检查请求中是否携带了多余的自动化特征,比如
--enable-automation开关、不正常的 Sec-Fetch 头。 - 再确认访问频率。动态渲染页面通常比普通接口更敏感,建议加上 3~8 秒的随机延迟。
- 最后检查出口 IP 的复用率和历史风险分。住宅代理的质量往往比机房 IP 好很多,但价格也更贵,看项目预算平衡。
5.4 关于合规的一点个人提醒
浏览器自动化能做很多事,但做技术验证的时候,我习惯于遵守几个原则:只在授权范围内采集数据,优先爬取公开页面而不是登录后才能看的受限内容,对数据接口的并发做合理限速。爬虫技术本身没有善恶,真正决定风险的是用它去做什么、拿到数据后怎么处理。这里不展开说教,但至少不要用自己的主账号去跑高风险采集,测试时用一个独立的环境,也是对数据安全负责。
最后再分享一个实操小习惯
写到这里,我特别想把一个教训放到结尾:防检测代码写得再完整,都不如先和页面"手动聊聊天"。我每次接手一个动态渲染页面,都会先手动打开浏览器,用 Playwright 的 playwright codegen 录制一段真人操作,然后观察生成的脚本里有哪些步骤是 requests 模拟不到的,比如滑块校验、请求间延迟、鼠标悬浮。这些观察结果能直接指导防检测参数怎么设计。
另外,我建议给浏览器自动化进程单独准备一个干净的 profile 目录,不要复用日常登录账号的默认 profile,也不要装任何多余扩展,因为扩展本身会引入新的可检测对象。开发阶段用 headed 模式调试,上线前再切 headless;如果是 Linux 服务器且必须 headless,考虑加上 xvfb 之类虚拟显示,稳定性会好很多。
动态渲染页面反爬这件事没有一劳永逸的方案,对方前端一改版,你可能就要跟着调参数。但理解了检测原理、掌握了启动参数和注入技术、知道怎么从失败信息里快速定位问题,这套方法论能陪着你在后续几乎所有的动态反爬场景里不被劝退。希望这篇实战笔记能帮你少走几步弯路,也欢迎看到这里的你在评论区聊聊自己遇到过的 "target closed" 或者神秘风控案例。
