☰
Selenium反爬虫检测绕过实战:从浏览器指纹到行为模拟的六大关键配置
2026/10/8 21:27:51 网站建设 项目流程

你是不是也遇到过这种情况:Selenium写了一个爬虫,本地调试时一切正常,换到云服务器上跑了不到十分钟,就开始返回验证码、强制登录、甚至直接把IP封掉。更离谱的是,有些目标网站在Chrome手工访问时好好的,只要由WebDriver驱动,第一次打开页面就被识别。这其实就是“反爬虫检测”在起作用——而且它不是看你的爬虫多会写,而是看你的浏览器行为像不像一个“真人”。

我做了快十年的数据采集,最初也踩过无数个坑。后来我总结出一个朴素的结论:想让Selenium规避检测,本质上不是“对抗”,而是“伪装”。目标网站的JS脚本会从很多维度去判断当前浏览器是不是被自动化工具控制,我们只要把这几个维度都对齐,基本上就能稳定跑。这里面有六个动作是我每次搭建环境必做的,也是我认为性价比最高的组合。这篇文章就把完整思路和可复现的代码全部分享出来,给还在被检测折磨的朋友一个参考。

1. 先搞清楚:为什么Selenium一抓就死,这跟反爬还不完全是同一回事

很多人把“被封IP”和“被识别为Selenium”混在一起,其实这是两套不同的检测逻辑。被封IP往往是因为请求频率太快、单IP并发太高,属于服务端的流量治理;而被识别为Selenium,则是因为浏览器运行时暴露了太多自动化特征。我们要讨论的“规避检测”,主要解决的是第二类问题——让网站觉得当前这个浏览器就是一个正常用户在访问,而不是WebDriver在操作。

1.1 navigator.webdriver 这个标记,是第一个暴露点

用过Selenium的人应该都有印象:只要通过ChromeDriver启动浏览器,在控制台执行window.navigator.webdriver,返回的几乎都是true。这是一个由ChromeDriver在初始化时主动写入的全局标记,目的就是告诉页面“当前浏览器受自动化工具控制”。绝大多数网站的反爬脚本第一件事就是检查它,如果检测到就直接进入风控。

我知道有人会直接执行driver.execute_script("window.navigator.webdriver = undefined")来解决,但这在实战中基本是无效的。因为页面的检测脚本通常在你的代码运行之前就已经执行完了,而且很多新版的ChromeDriver会重新注入该属性,页面刷新一次就会恢复原状。正确做法是在页面加载之前就屏蔽掉这个特征的注入,而不是等页面加载完之后再去“擦除”。

1.2 行为指纹:你的动作太“稳定”了

除了JS属性,检测方还会收集鼠标移动轨迹、点击间隔、滚动速度、输入节奏等行为数据。真人操作永远是无规律的——鼠标会有微小的抖动和偏移,输入文字的速度是忽快忽慢的,滚动页面也不是匀速到底。而自动化脚本最常见的操作方式是:打开页面后直接执行某个元素的点击事件,输入框用send_keys一次性填入全部内容,然后瞬间跳转下一个页面。这种“过于稳定”的行为模式,在服务端的数据分析模型中非常显眼。

我见过一个比较极端的检测案例:某电商平台在登录环节统计同一个Session内“鼠标移动的路径长度”,如果从页面加载到点击登录按钮之间没有任何鼠标移动记录,直接就判定为机器人。这意味着即使所有JS属性都伪装好了,只要行为不够自然,同样会被识别。

1.3 别忽略环境指纹:无头模式、IP、时区

还有一类检测是针对浏览器运行环境的。比如用--headless模式启动Chrome时,User-Agent里往往会带上HeadlessChrome字样;ChromeDriver默认的Accept-Language可能和目标网站的用户群体不一致;有时候连时区、显卡渲染器、CPU核数、屏幕分辨率都会成为比对的线索。现代反爬系统通常采用“多特征加权打分”的策略,任何一个单独的特征都不会触发风控,但当十几个特征组合起来偏离正常用户画像时,就会被判定为可疑。

这就引出了规避检测的核心思路:不要试图掩盖单个特征,而是要让整套浏览器指纹形成一个自洽的、像真人的画像。

2. 三个“藏身份”动作:把自动化标记从根上抹掉

这部分我按照实际操作的顺序来讲,每个动作都是可以直接复制到代码里的。需要注意的是,这三个动作解决的是“身份”层面的问题,也就是让网站从技术特征上看不出你是自动化工具。

2.1 动作一:关掉Chrome的自动化开关

Selenium启动Chrome时,默认会带上一个叫enable-automation的开关,这个开关会让Chrome在右上角显示“Chrome正在受到自动测试软件的控制”提示条,同时在CDP(Chrome DevTools Protocol)里暴露自动化状态。通过excludeSwitches可以把这层标识去掉,配合disable-blink-features=AutomationControlled可以进一步关闭Blink内核中的自动化控制特性。

from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) driver = webdriver.Chrome(options=options) driver.get("https://example.com") print(driver.execute_script("return window.navigator.webdriver"))

这段代码跑完以后,navigator.webdriver的值应该会变成undefined。这里有一点要特别留意:useAutomationExtension这个参数在Selenium 4.0以上的版本中已经被标记为即将废弃,某些新版本的ChromeDriver已经不再接受它。如果你运行的时候控制台报错,把它注释掉即可,不影响主流程。

我在最初尝试这个方案时踩过一个坑:只加了excludeSwitches,但忽略了disable-blink-features。结果Chrome 88之后的版本中,navigator.webdriver还是返回true。后来查资料才知道,从Chrome 88开始Blink内核增加了一个独立的自动化控制特性开关,必须单独关闭。

2.2 动作二:用CDP在页面加载前注入伪装脚本

仅仅关掉自动化开关还不够。现在很多反爬脚本不仅查navigator.webdriver,还会查navigator.plugins、navigator.languages、window.chrome是否存在,甚至检查Permissions API。这些属性在手动打开的Chrome里都有正常的默认值,但在Selenium启动的浏览器里,有的被置空,有的直接被删除。

正确的做法是利用Chrome DevTools Protocol的Page.addScriptToEvaluateOnNewDocument命令,在浏览器加载任何页面之前注入一段初始化脚本,把缺失的属性和值补齐。这段脚本会作用于后续每一次页面跳转和iframe加载,比execute_script只作用于当前页面要可靠得多。

import json from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) driver = webdriver.Chrome(options=options) with open("stealth.min.js", "r", encoding="utf-8") as f: stealth_js = f.read() driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", { "source": stealth_js }) driver.get("https://example.com")

上面代码里我引用了stealth.min.js,这是接下来动作三的重要内容。如果你只是自测,也可以直接用一小段精简的注入脚本来理解这个过程:

Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); window.chrome = { runtime: {} }; Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'], set: () => {} });

这段脚本的原理其实很好理解:Object.defineProperty可以直接覆盖浏览器内置属性的getter方法,让目标页面在读取navigator.webdriver时得到一个“不存在”的假象。但要注意,仅仅伪造属性值是初级套路,进阶的反爬脚本会检查属性的“描述符”——比如真实浏览器里的navigator.webdriver属性是只读且不可配置的,如果你用Object.defineProperty重新定义它,specificity或者configurable的描述符特征可能和新版Chrome默认的实现不一致。这就是为什么完整版的stealth脚本需要模拟几百行代码,不只是覆盖几个属性。

2.3 动作三:直接用现成的stealth方案,别自己从头造轮子

如果你不想手动去补齐所有特征,可以直接使用现成的selenium-stealth库。这个库相当于把所有已知的自动化特征,包括WebDriver标记、Chrome对象、Permissions API、WebGL渲染器、动画帧特征等一次性修复,并且会跟随新版本特征持续更新。安装和调用都很方便:

# pip install selenium-stealth from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium_stealth import stealth options = Options() options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) driver = webdriver.Chrome(options=options) stealth(driver, languages=["zh-CN", "zh", "en"], vendor="Google Inc.", platform="Win32", webgl_vendor="Intel Inc.", renderer="Intel Iris OpenGL Engine", fix_hider=True) driver.get("https://example.com")

这里我还想提一个备选方案:undetected-chromedriver。这个库的思路和selenium-stealth不太一样,它是直接对ChromeDriver的启动过程做了一层深层次的patch,让WebDriver本身就不暴露出自动化特征。在应对某些检测比较严格的目标网站时,它的成功率往往比普通Selenium搭配stealth脚本更高。但它的劣势也比较明显:对Chrome和ChromeDriver的版本匹配要求比较苛刻,升级浏览器时需要同步升级库版本,否则可能启动失败。

我个人的实操经验是:普通的公开数据采集,selenium-stealth已经足够了;如果遇到那种开了WAF强校验、连页面都打不开的情况,再考虑切换到undetected-chromedriver。这两个库不要混用,否则脚本注入冲突反而会暴露。

3. 两个“装人样”动作:行为模拟不是慢,是自然

身份层面的伪装做完之后,接下来要解决的是行为层面的问题。这部分最难的不是技术,而是对于“自然度”的把握。模拟真人行为不是越慢越好,也不是动作越复杂越好,而是要符合人在真实浏览网页时的心理状态。

3.1 鼠标轨迹:别当“闪现侠”

默认情况下,Selenium的click()方法是触发浏览器底层的点击事件,它并不产生物理鼠标的移动轨迹。但在有鼠标轨迹检测的网站上,这种操作就相当于“瞬移”——一个真人要从页面右上角移动到左下角的登录按钮,中间必然有连续的运动曲线,而不是凭空触发点击。

处理方式有两种,一种是用ActionChains模拟移动:

from selenium.webdriver.common.action_chains import ActionChains import random element = driver.find_element_by_id("login") actions = ActionChains(driver) actions.move_to_element_with_offset(element, random.randint(1, 5), random.randint(1, 5)) actions.pause(random.uniform(0.1, 0.3)) actions.click() actions.perform()

另一种更精细的方式,是注入一段鼠标轨迹函数,按照贝塞尔曲线逐步把鼠标移动到目标位置。我封装过一个简单的版本:获取起点终点坐标后,通过JavaScript循环调用MouseEvent,每次移动2~3像素并加入随机抖动。

实战里其实不需要追求太复杂的轨迹算法。绝大多数检测系统看重的是“有没有轨迹”以及“轨迹是否过于直线”,而不关心你是不是用贝塞尔曲线。只要你有真实的移动过程、有抖动、有停顿,就已经通过了大部分行为检测。

3.2 输入节奏:别把send_keys当粘贴板

另一个明显特征是文本输入速度。send_keys("username")会把整个字符串一次性填入输入框,这在浏览器的性能记录里是非常异常的——真人打一串10个字符的账号,至少需要1到2秒,期间每一个字符的输入间隔都有差异。

推荐的做法是逐字发送,并在每个字符之间加入随机延迟:

import time import random element = driver.find_element_by_name("username") element.click() for char in "test_user_2025": element.send_keys(char) time.sleep(random.uniform(0.05, 0.2))

还有一个细节:在很多真实场景下,用户输入密码时偶尔会输入错误然后删除重新输入。有经验的爬虫工程师会在输入比较长的字段时,以一定的概率(比如5%)模拟一次“输入到中间删除两个字符再继续”的操作。虽然这看起来是增加复杂度,但它能明显降低行为轨迹的“机器味”。

另外输入前的等待绝对不能省。打开登录页面后,用户先是看了一眼页面、移动鼠标、定位到输入框、点击,这至少需要200到500毫秒。你如果页面刚加载完就立刻点击输入框并开始输入,从性能时间线上看就是一个异常点。

3.3 随机延迟与滚动行为:让整个浏览节奏“有人气”

页面滚动也是行为检测的重要维度。真人打开一个长页面时,很少会从头到尾匀速滚动到底,通常是在中途停留,有的段落看仔细些,有的一晃而过,然后在接近底部的位置停下来犹豫一会儿再继续。简单地随机执行几次scrollBy是不行的,更接近真实的做法是分段滚动,每段之间加入阅读时间:

import time import random height = driver.execute_script("return document.body.scrollHeight") current = 0 while current < height: step = random.randint(300, 700) driver.execute_script("window.scrollBy(0, arguments[0]);", step) current += step time.sleep(random.uniform(0.3, 1.2))

相对的是请求与页面切换之间的随机延迟。很多人喜欢在循环里加一个固定的time.sleep(1),这其实比不加延迟更危险——固定间隔的请求就像是节拍器一样有规律,很容易被统计模型识别。正确做法是每次都随机落在一定区间内,比如1到3秒之间的均匀分布或正太分布。

4. 一个“改身份”动作:把指纹从里到外都理顺

身份特征和行为特征都搞定之后,还有一个很容易被忽略的维度:环境指纹的一致性。简单说,你的浏览器不能同时既显示Windows系统、又说自己用的是Mac的User-Agent,也不能在IP归属地是北京的时区时,运行在东京的时区配置里。这些“内部矛盾”在不做检测的网站上看不出问题,但一旦对方跑指纹比对脚本,就会立刻暴露。

4.1 UA、语言、时区:先保住第一层

伪造UA是每个爬虫工程师最早学会的技能,但很多人只替换了User-Agent字符串,却忽略了其他配套参数。一套完整的浏览器声明应该包含:UA、Accept、Accept-Language、Sec-CH-UA这几个头,它们之间必须互相吻合。例如UA里声明自己是Chrome 126,但Sec-CH-UA返回的浏览器版本是Chrome 120,反爬系统一眼就能看出UA是被篡改的。

options.add_argument("--user-agent=") options.add_argument("--lang=zh-CN,zh;q=0.9,en;q=0.8")

时区可以通过CDP来覆盖:

driver.execute_cdp_cmd("Emulation.setTimezoneOverride", { "timezoneId": "Asia/Shanghai" })

时区的检测通常和Date对象、Intl.DateTimeFormat方法有关。如果目标网站是一个电商平台,它还可能通过时区判断你不是目标国家的用户,从而触发风控。

4.2 WebGL与Canvas:别让显卡“说话”

WebGL指纹是绕过大多数检测的关键。每个设备的显卡型号、驱动版本、渲染器字符串都是不同的,浏览器通过WebGLRenderingContext.getParameter可以取到这组信息。Selenium启动的Chrome和真实Chrome的渲染器字符串可能完全相同,但因为启动时缺少GPU相关的初始化数据,有些检测脚本会拿到空值。

在这里,我们可以在注入脚本中覆盖WebGL的vendor和renderer:

const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function(parameter) { if (parameter === 37445) return 'Intel Inc.'; if (parameter === 37446) return 'Intel Iris OpenGL Engine'; return getParameter.call(this, parameter); };

这段代码能让目标网站读取WebGL厂商信息时得到一个“常见显卡”的值。相比于显眼的“NVIDIA高端显卡”或“AMD Radeon专业卡”,大众的Intel核显反而是最不容易触发异常的。

Canvas指纹方面,不建议主动去伪造像素数据,因为不同系统、不同浏览器渲染同样Canvas内容时本来就有细微差异,只要不做特殊处理,一般检测系统很难只凭Canvas就断言你是机器人。

4.3 IP与请求头的整体协调

最后剩下的是IP层面的协调。如果你的目标网站在国内,建议使用国内住宅IP;如果是海外站点,尽量使用目标国或相近地区的IP。为什么说这块很重要?因为如果你用Selenium伪装了一套北京时区、中文语言、Windows系统的浏览器指纹,结果出口IP却显示位于法兰克福,这种矛盾本身就是最大的风险信号。

国内合法合规的代理服务商有很多,选择时优先看对方的IP池是否覆盖目标城市、是否支持会话保持、并发连接数是否足够。配置方式很简单,在ChromeOptions中加上代理参数即可:

options.add_argument("--proxy-server=http://username:password@ip:port")

需要注意,不要为了“保险”而一次性设置过长的IP使用时间。爬虫圈常说“短效IP容易封,长效IP被封就全完”,根据我的实践,一个IP在目标网站上的并发请求数控制在10个以内、单次存活时间不超过10分钟,整体稳定性会好很多。

5. 实操中的常见问题与排查技巧实录

写到这里,我把自己在实际操作中遇到过的几类经典问题整理一下。这些问题你在各种教程里未必看得到,但几乎每一个爬虫工程师都会在某个时间点遇上。

5.1 Chrome版本一升级,伪装就失效?

这是最频繁的问题。很多人头一天跑得好好的,第二天系统或者Chrome自动升级之后,同样的代码navigator.webdriver又变成了true。原因是新版本的Chrome可能会新增或改变检测特征,旧的伪装脚本没有覆盖到。

我的处理习惯是:在本地写一个检测页面,语言上用Python启动浏览器后读取一组关键参数(navigator.webdriver、navigator.plugins.length、window.chrome、navigator.languages.toString()、WebGL vendor),每次升级完Chrome或者Driver后先跑一遍检测,确认所有特征符合预期再上线爬虫。

5.2 无头模式一上就被封,怎么办

这是最有意思的一个问题。有朋友说自己的爬虫在本地有头模式跑得好好的,一到服务器上改用--headless模式,立刻被识别。原因主要有两个:一是旧的无头模式和有头模式在浏览器配置上存在差异,比如JS引擎、GPU支持、可用字体都不一致;二是无头模式下的请求头往往没有Sec-CH-UA这些客户端提示信息,这种缺失本身就是最大的特征。

如果服务器没有显示器,但你又需要无头模式,我推荐用--headless=new(Chrome 109+已经开始支持新无头模式)并手动补全UA和客户端提示头。如果还是不放心,可以使用Xvfb在Linux服务器上虚拟一个显示环境,继续跑有头模式。后者虽然更吃资源,但检测难度会大幅降低。

5.3 检测到iframe和window.top跳转

有个别网站会在页面里嵌入iframe,然后用脚本检测window.self !== window.top,如果发现当前窗口被嵌在iframe里,就会触发反制逻辑。Selenium默认进入iframe后可能会被认为是在模拟嵌套浏览。解决思路是优先使用主框架直接访问,避免在无必要时进入iframe。

另外我遇到过一次比较特殊的反制:页面脚本会在加载时检测用户是否在极短时间内访问了多个无关联页面。如果访问路径太乱——比如刚打开帮助中心,下一秒就跳转到了用户中心——这也不像真人行为。这种问题没法靠伪装手段解决,只能在业务逻辑上合理安排访问顺序。

5.4 双栈请求:requests拿cookie后Selenium打开还是会掉

这种操作很常见:先用requests请求目标网站,用获取到的Cookie再启动Selenium浏览器会话,以为这样能“继承登录态”。但这样做的结果是字体、安装列表、Canvas指纹、JS生成的特征等数据是断裂的——requests没有执行JS,自然没有生成这些浏览器动态特征,Selenium启动后反而出现了Cookie与指纹不匹配的情况。很多童鞋在这里反复排查都找不到原因,其实就是两个会话的指纹不一致。

如果必须共用Cookie,建议让Selenium自行完成登录流程,获取登录后的Cookie后再交给requests处理API请求。反过来也可以,但一定要保证两端使用相同的UA和指纹配置。

5.5 兼容Selenium 4与ChromeDriver新版的注意点

最后提醒一个兼容层面的问题。Selenium 4把驱动管理逻辑改成了基于Selenium Manager的自动化方案,启动时如果本机的ChromeDriver版本和Chrome不匹配,它会自动下载对应版本。这本来是一个便利功能,但如果你部署在服务器上,服务器没有外网权限,它就会启动失败。解决方法是手动下载匹配的ChromeDriver,放在指定目录里并在代码中声明Service路径。

还有一点:换到ChromeDriver 110以上之后,很多老的excludeSwitches参数用法需要调整。新版本对于自定义参数的校验更加严格,遇到报错要多看Selenium的release notes,百度上很多教程都已经过时了。

5.6 排查速查表

现象可能原因优先排查方向
navigator.webdriver返回true自动化开关未关闭检查excludeSwitches和disable-blink-features
请求头中带HeadlessChrome用了旧版无头模式改为--headless=new或手动改UA
页面加载后立即出现验证码IP指纹异常检查代理IP与浏览器时区、语言是否匹配
频繁要求登录WebGL与Canvas指纹缺失检查stealth脚本是否完整注入
有时成功有时失败行为特征波动不足增加随机延迟,模拟鼠标移动与分段滚动
使用了requests和Selenium共享Cookie失败指纹不一致统一两端的UA、TLS指纹、IP

写在最后的个人体会

这些诡计折腾下来,我的感悟是:没有一套放之四海而皆准的“反检测配置”。每个站点用的风控引擎不同,同一引擎在不同行业、不同流量层级下的灵敏度也不一样。所谓规避检测,本质上是把你能控制的所有特征统一起来,让风险模型找不到一个足够强的高危信号。

在我自己的实践中,最稳妥的组合是:excludeSwitches + disable-blink-features + selenium-stealth + 行为模拟 + 高质量代理,五个环节缺一不可。而且我会专门维护一个“浏览器探测页面”,每次项目上线前先跑一遍,把所有关键参数都打出来截图留档,这样下次出问题时能快速定位是哪个维度出了问题。

最后想认真说一句:这篇文章的目标是解决正常数据采集中被误伤的问题,不是鼓励你去突破那些明确设置了付费墙、需要登录协议才能访问的数据。很多大站的风控系统花了几千万迭代到今天的水平,靠几个脚本就去攻破既有法律风险又有技术风险。如果你的数据源很重要,先去查一查对方有没有官方API,很多时候付费API比你交的代理费还便宜,而且稳定性高一个数量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询