
用Python写Selenium自动化的人几乎都会在某个时间点遇到同一个尴尬本机调试时一切正常脚本一旦部署出去或者连续跑上几十次目标网站就开始给你脸色看。轻则多出几道滑块验证码重则直接把会话标记为异常返回一堆没有意义的数据。很多人第一反应是“我用了无头模式应该很隐蔽了吧”但实际情况恰恰相反——无头模式本身就是最显眼的破绽之一。“Python用Selenium怎么规避检测”这个问题本质上要解决的是让一个由Selenium驱动的浏览器实例在各种网络环境下表现得和一个普通用户的真实浏览器足够接近从而不被风控体系单独拎出来。这篇内容会按照“先知道对方怎么认你、再把可识别的特征一个个去掉、最后在行为上装得像真人”这个顺序来展开全程用Python示例兼顾刚上手的人和已经写了很久脚本但总被拦截的人。同时我也会垫一句这类手段请只用于你自己有测试权限的站点、或者明确授权过的数据采集场景别拿去做任何违法或破坏性的事。技术本身是中性的边界得靠自己把控。1. 网站到底靠什么认出Selenium1.1 最经典的一行JavaScriptnavigator.webdriver先讲流传最广也是最容易理解的一个检测点navigator.webdriver。正常情况下用户用Chrome手工打开任意网页在开发者工具里输入navigator.webdriver拿到的值是undefined或false。但在Selenium驱动起来的浏览器里这个属性几乎总是true。为什么Selenium会让这个值变成true因为WebDriver规范里定义了这个属性用来告诉页面“当前浏览器正被自动化工具控制”。规范的目的本来是方便网站开发者识别自动化会话、做兼容性处理但后来它成了反爬的第一道关卡。几乎所有的风控脚本都会先查它一眼如果是true后面的验证码、人机校验、甚至直接拉黑就都来了。单看这个特征很好解决后面第2章会给出具体代码。但我要先提醒一句只改navigator.webdriver是远远不够的。现在稍微成熟一点的风控系统早就不靠单一特征下结论了。它们会收集十几个甚至几十个维度的信号然后综合打分。所以第1章先把整套识别逻辑讲清楚后面再逐个破。1.2 CDP协议和浏览器实例在网络层留下的痕迹第二个检测方向很多人会忽略是Chrome DevTools ProtocolCDP层面的痕迹。Selenium之所以能控制Chrome本质上是Chrome启动时开启了调试端口Selenium通过这个端口和浏览器通信。只要走这条通道有些信号是藏不住的。比如浏览器启动时带有--remote-debugging-port参数这个参数在页面的navigator.userAgentData或某些WebRTC接口中可能被间接探到。自动化工具和浏览器之间的WebSocket连接特征跟普通用户完全不同。页面里某些只有调试模式下才会暴露的JavaScript执行环境差异。这里我不展开到底怎么探测WebSocket因为不同反爬系统的实现差异很大但方向是明确的你不仅要让JavaScript层的属性看起来正常还要让浏览器本身的运行方式看起来是“被用户双击启动”的而不是“被一段代码拉起”的。这也是为什么后面第3章要介绍更底层的管线替代方案。1.3 无头模式、User-Agent和其他指纹破绽第三个识别维度是浏览器指纹。Selenium跑的Chrome和真实用户跑的Chrome在指纹上会有不少可量化的差异。最明显的是无头模式。老版本的--headless会让User-Agent里带上HeadlessChrome字样几乎等于直接举牌自首。Chrome 109之后推出了--headlessnewUA里的Headless标志去掉了但别高兴太早——它在字体列表、Canvas渲染结果、GPU信息、navigator.plugins这些细节上跟有头模式仍然有细微差别识别难度虽然提高了但远没到“隐形”的程度。除了UA还有几个常见指纹位navigator.plugins真实Chrome里有PDF插件等条目某些自动化环境里是空数组。navigator.languages真实用户一般有浏览器语言列表自动化环境可能是[zh-CN, zh, en-US]手动设定时容易和UA的Accept-Language不一致。window.chrome真实Chrome窗口上有window.chrome对象部分WebDriver环境下这个对象缺失或内容不完整。WebGL渲染器信息、字体枚举结果、Canvas指纹稳定性这些属于高级指纹普通教程提得少但真正难缠的网站会在这些地方做关联分析。把这一章总括成一句话网站识别Selenium不是靠某一个神奇的特征而是靠一整套环境快照的一致性。你改了navigator.webdriver但如果UA、插件列表、语言设置、渲染结果彼此矛盾风控系统照样能把你从好人堆里挑出来。2. 第一步先抹掉最明显的自动化特征2.1 关闭自动化提示条和相关开关先动手处理最基础的一层。用Selenium启动Chrome时页面上经常会看到一个黄色条幅“Chrome正在受到自动测试软件的控制”。这个提示条本身就是自动化身份的标志。关掉它的标准写法是from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() # 去掉“Chrome正在受到自动测试软件的控制”提示条 options.add_experimental_option(excludeSwitches, [enable-automation]) # 禁用自动化扩展 options.add_experimental_option(useAutomationExtension, False) # 禁用Blink层级的自动化控制功能 options.add_argument(--disable-blink-featuresAutomationControlled) driver webdriver.Chrome(optionsoptions) driver.get(https://example.com)excludeSwitches里的enable-automation是ChromeDriver注入的一组自动化开关去掉之后提示条没了部分自动化标志也会被弱化。--disable-blink-featuresAutomationControlled则是在Chromium的Blink渲染引擎层面关掉自动化控制相关的特性可以避免一些JavaScript特征被检测到。新版Selenium和Chrome组合下提示条可能已经不出现了但这两行配置保留下来没有坏处。它属于成本最低、收益最明确的第一层防护。注意这只是去掉了浏览器UI层面的提示JavaScript世界里那个navigator.webdriver还没改接着看下一节。2.2 在页面加载前注入JavaScript提前抹掉webdriver属性要用JavaScript修改navigator.webdriver属性这个操作必须在目标页面的脚本执行之前完成。Selenium提供了一种机制通过CDP命令注册一段“每次新文档开始解析时都先执行的脚本”。用execute_cdp_cmd调Page.addScriptToEvaluateOnNewDocument就能做到这一点。driver webdriver.Chrome(optionsoptions) driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); }) driver.get(https://example.com)这段代码的关键点是执行顺序先注册注入脚本再driver.get加载页面。顺序反了就白搭。为什么因为Page.addScriptToEvaluateOnNewDocument的意思是“在每个新文档加载的一开始就运行”你如果先get再注入页面已经加载完了属性改动触发不了页面里的风控逻辑。修改navigator.webdriver的原理并不复杂Object.defineProperty直接重新定义了那个属性的getter让它永远返回undefined。效果等同于页面里查navigator.webdriver时得到“这个属性不在了”。这一步是基础中的基础但也只是基础。2.3 补齐navigator对象上的其他指纹破绽只改一个webdriver属性就好比换了上衣但裤子还是工作服安检员一眼就能看出来。接下来要顺手把旁边的几个可疑属性一起修掉。driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); Object.defineProperty(navigator, languages, { get: () [zh-CN, zh, en-US, en] }); Object.defineProperty(navigator, plugins, { get: () [1, 2, 3, 4, 5] }); // 真实Chrome环境里通常有这个对象 window.chrome window.chrome || { runtime: {} }; })navigator.plugins的这段处理有点“作弊”它返回一个长度为5的数组但数组里没有真实元素因为直接访问数组项会得到undefined。这不是完美方案却在很多场景下足够骗过那些只判断navigator.plugins.length 0的低级检测。navigator.languages要跟User-Agent里的Accept-Language搭配。UA如果是zh-CN,zh;q0.9,en;q0.8那languages列表也应该以zh-CN开头保持一致才可信。至于window.chrome真实Chrome里它带着一堆子对象光是{ runtime: {} }比较简陋但对付常见脚本也够了。这一层做完你的Selenium脚本已经从“一眼假”进步到“仔细看才假”了。但离“真人”还有距离因为浏览器的启动方式、底层渲染特征仍然和真实用户有差异这就轮到第3章登场的方案。3. 隐藏指纹不够时换掉整条运行管线3.1 undetected-chromedriver到底改了什么如果目标网站的反爬强度比较高光靠ChromeOptions和注入JS可能还是过不了。这时候就该考虑换掉Selenium默认驱动和标准ChromeDriver之间的“配合方式”。社区里的方案比较成熟的是undetected-chromedriver这个库安装后用法非常简单pip install undetected-chromedriverimport undetected_chromedriver as uc import time driver uc.Chrome() driver.get(https://example.com) time.sleep(2) print(driver.title) driver.quit()这个库到底改了什么它做的事情不只是改一个属性而是从ChromeDriver的层面做了patch。具体来说有几层去掉ChromeDriver在Chromium内部留下的自动化相关标记。重写和新版浏览器之间的握手流程让浏览器启动的形式更接近真实用户双击Chrome。自动处理navigator.webdriver等常见检测项。规避了直接通过--remote-debugging-port启动的明显特征。很多开发者从标准Selenium切换到uc后的第一反应是“居然这么顺利”。这个库解决的是“整条管道有问题”这个事实而不是靠表面补丁。不过也要泼一盆冷水undetected-chromedriver不是银弹。它跟Chrome和ChromeDriver的版本绑定比较紧新版Chrome发布后如果库还没跟上很可能会报版本不兼容的错。所以用之前建议先确认它支持你本机Chrome的版本或者锁定一个已知稳定的Chrome版本。3.2 stealth.min.js的作用与使用位置另一个常用的方案是stealth.min.js。这本来是给Puppeteer用户准备的但Selenium同样可以通过CDP注入使用。它的作用是集中抹掉常见自动化指纹比如补全window.chrome、修正navigator.plugins、去掉webdriver属性、清理权限描述符等。GitHub上可以找到开源的版本下载下来读取成字符串注入方式和前面一致。import pathlib stealth_js pathlib.Path(stealth.min.js).read_text() driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: stealth_js })执行顺序还是那句老话注入必须在driver.get之前。很多人把stealth.min.js塞进去之后发现没效果十有八九是顺序写反了。stealth.min.js和undetected-chromedriver不是互斥关系可以叠加使用。但叠加不是无脑的如果uc自己已经处理了一部分属性你再注入一遍stealth有极小概率出现重复定义导致的异常。实际使用中建议先用stealth.min.js做基础隐藏再用uc接管浏览器实例两者冲突的情况很少但值得知道。3.3 版本匹配是最大的稳定性问题这个方法最大的坑在于版本。undetected-chromedriver要匹配当前机器上的Chrome版本如果版本相差太远要么创建实例时卡住要么跑着跑着浏览器突然崩溃。而Chrome的自动更新又是默认开启的这会让“昨天还好好的今天怎么不行了”的事故反复上演。处理思路有两条锁定Chrome版本禁用自动更新同时让undetected-chromedriver使用指定的chromedriver路径。这样环境稳定但牺牲了新功能。每次部署时先读取Chrome版本再安装对应版本的WebDriver用脚本做版本校验。如果你所在团队有专门的采集环境我更推荐第一条稳定压倒一切。毕竟这类任务的目标是长期稳定不触发风控而不是一直追着最新版本跑。4. 行为模拟让鼠标和滚动别出卖你4.1 固定sleep是最容易被看穿的行为特征指纹层面处理完之后还有一个比指纹更容易暴露机器人的层面行为节奏。人访问网站时的动作不是匀速的你打字有快慢看页面有停顿滚动有加速和减速。而脚本如果写成time.sleep(1) driver.find_element(...).click() time.sleep(1) driver.find_element(...).click()这种固定间隔在风控系统眼里就是教科书级的机器人每隔1000毫秒触发一次操作偏差不超过几毫秒正常人做不到这种精度。正确做法是引入随机延迟。但“随机”也有讲究最好用指数分布的随机值直觉上就是短停顿多、长停顿少偶尔来一个比较久的思考时间。简单实现import time import random def human_delay(base_ms900, random_range600): # 大部分停顿落在base附近偶尔会拉开 delay_ms base_ms random.uniform(0, random_range) # 还有约10%的几率触发一次额外长停顿模拟“阅读”行为 if random.random() 0.1: delay_ms random.uniform(500, 1200) time.sleep(delay_ms / 1000)这种分布规律不是严格意义上的指数分布但在工程里已经完全够用。要点是没有固定节奏偶尔一个反常停顿反而更像人。4.2 鼠标轨迹和滚动节奏的模拟思路比点击间隔更复杂的是鼠标轨迹。真人操作鼠标时指针从A点到B点走的是一条平滑、带点弧度和抖动的曲线而ActionChains的move_to_element有时候是瞬移有时候是直线匀速移动这对检测系统来说非常明显。处理办法有两种。简单一点的是把一次移动拆成多段每段之间sleep很小的时间from selenium.webdriver.common.action_chains import ActionChains import random import time element driver.find_element(...) actions ActionChains(driver) # 把移动拆成5步每步之间加随机小间隔 for i in range(5): actions.move_by_offset(random.randint(-2, 2), random.randint(-2, 2)) actions.perform() time.sleep(random.uniform(0.02, 0.08))这种写法还不够真但已经比一次瞬移好太多。更精细的做法是用CDP直接派发Input.dispatchMouseEvent手动构造贝塞尔曲线路径再按曲线上的坐标点逐个触发鼠标事件。这个工程量大如果目标网站没有强到用鼠标轨迹识别没必要上。滚动节奏也一样。一次性scrollTo(0, document.body.scrollHeight)是典型的机器人行为真人滚动是一段一段的中间有停顿、往回滚一滚看看。所以建议把滚动写成带随机段的循环每滚一段就停留一会儿import random import time for _ in range(random.randint(3, 6)): driver.execute_script(window.scrollBy(0, arguments[0]);, random.randint(300, 600)) time.sleep(random.uniform(0.3, 0.8))这个模式同样适用于“查看文章列表”“翻看评论区”的场景。记住风控系统一定会在后台记录交互时序把交互时序做到“像人”比单纯改指纹更重要。4.3 请求序列与资源加载节奏也要像真人还有一个层面的检测藏在浏览器网络面板里driver.get(url)打开页面时请求并不是瞬间全部加载完的。真实用户的浏览器会在几百毫秒内发出所有页面资源请求顺序大体是HTML、CSS、JS、图片、字体。如果脚本刚get完就立刻执行click很多资源还没加载完这个节奏本身就透露了自动化特征。更稳妥的做法是给首屏加载留出时间# 等页面进入“基本加载完”的状态再继续动作 time.sleep(random.uniform(1.5, 3.0))此外访问站点的路径也要自然。一个真实用户很少会直接打开深层页面通常是先访问首页再点进列表页再进详情页。脚本如果每次都是直接driver.get(https://site.com/detail/123456)检测系统会看到所有流量都直取深层URL没有自然导航路径这种模式也很容易被聚类模型发现。解决办法是在会话开始时先访问一次首页保留Cookie再按导航路径进入目标页。5. 一次“被拦截”的完整排查链路5.1 从返回内容判断是哪一层被识破了规避检测这件事没法指望一次配置就永久有效重要的是出问题时你能快速定位是哪一层泄露了。我的排查习惯是先把“被拦截”的返回内容保存下来分门别类地看返回一个正常HTTP 200但页面里出现验证码组件这通常是行为层或指纹层触发风控。返回HTTP 403或者带着“访问异常”字样的纯文本页一般是IP层被标记。返回200但内容里的一些关键字段全被替换成假数据比如页面里商品价格变成0或没意义字符这多半是整机指纹被标记服务器端做了反制。返回一个空白页面或无限重定向可能是CDP或浏览器特征被识别后直接拒了会话。保存原始响应有一个简单方法用driver.execute_script(return document.documentElement.outerHTML)把当前页面HTML落盘再在本地搜关键字判断是验证码还是错误页。别只盯着控制台输出。5.2 逐项核对指纹用浏览器特征检测页验证第二步用独立的环境检测页面来验证当前浏览器实例暴露出的指纹。常见的浏览器指纹检测页面会列出User-Agent、WebGL信息、Canvas指纹、navigator.webdriver、navigator.plugins、navigator.languages、window.chrome、音频上下文指纹等。这些页面本身不是反爬专用但能很好地帮你验证自己的隐藏是否生效。验证时建议这样做用Selenium打开检测页看看页面显示的字段是否和一个普通Chrome一致。特别注意那些你改过的字段navigator.webdriver是否确实为undefinednavigator.plugins长度是否正常。对比两次打开的结果看Canvas指纹是否稳定。如果每次重启浏览器指纹都变说明环境伪装没有做到“稳定关联”长期会话容易被跟踪。这一步很重要因为很多开发者改完代码后就急着跑业务忽略了“伪装是否真的生效”这一验证环节。多花两分钟做核对能省掉后面一晚上的排查时间。5.3 代理IP和会话策略里容易疏忽的坑如果指纹已经查过没大问题拦截依然存在问题很可能在会话和IP维度。这里有三个常见的坑IP质量太差很多便宜代理IP是数据中心网段早就被风控标记了你浏览器伪装得再像出口IP一查一个准。这属于“换汤不换药”。IP跳动和浏览器指纹不匹配一个稳定的浏览器指纹却频繁切换不同城市甚至不同国家的出口IP这种组合本身就是异常模式。真人不可能上一秒在A城下一秒在B城。Cookie冷启动一个全新浏览器环境没有历史Cookie第一次请求就访问目标站深层页面这种“冷启动”行为也很可疑。正常用户多少都会先访问一次主页。处理建议优先保证单会话内IP稳定至少保持同一城市代理切换频率不要远低于会话时长每次开新浏览器实例时可以先用一个“浏览首页并停留几十秒”的预热操作把基础会话状态建立起来再干活。5.4 为什么过了一晚又失效很多人的方案白天调试都是好的睡一觉起来再跑就被拦了。这种情况我遇到过太多次根源无非几种目标站点的风控策略更新了新增了一个你没覆盖的检测维度比如开始检查某个新暴露的浏览器特性。你的出口IP在昨晚被标记了。同一个IP高频次低IP池的数量不管浏览器伪装多好一旦IP进入黑名单整条链路都会被卡住。你的浏览器环境发生了变化比如Chrome自动更新了版本undetected-chromedriver的patch失效或者指纹属性被新版浏览器改了默认值。排查“隔夜失效”问题别一上来就重装驱动、换代码先做两步第一用同一套代码换一个干净IP和干净Profile手工访问同一个页面看是否被拦截第二把Chrome手动更新关掉锁死版本。这两步通常能过滤掉八成的原因。6. 把这些手段组合成一套能落地的配置骨架6.1 推荐的基础配置代码骨架总结一下一套比较完整的Selenium反检测配置至少应该包含四块启动参数、CDP注入、行为节奏、会话管理。下面给一个可以直接跑通的骨架这里没有引入undetected-chromedriver用官方Selenium也能跑import random import time from selenium import webdriver from selenium.webdriver.chrome.options import Options def build_driver(profile_dir): options Options() options.add_argument(--disable-blink-featuresAutomationControlled) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) # 固定用户数据目录保持会话一致 options.add_argument(f--user-data-dir{profile_dir}) driver webdriver.Chrome(optionsoptions) # 在页面加载前注入改动 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] }); window.chrome window.chrome || { runtime: {} }; }) return driver def human_pause(): delay_ms random.uniform(700, 1400) if random.random() 0.1: delay_ms random.uniform(600, 1500) time.sleep(delay_ms / 1000) driver build_driver(profile_dir/tmp/profile_auto) driver.get(https://target-site.com/) human_pause() # 这里再开始你的业务操作这个骨架把第2章的隐藏手段和第4章的行为节奏都带上了算是一个及格线水平的配置。要在生产环境长期稳定跑还得接上代理管理和任务调度。6.2 实例隔离多任务时的浏览器环境管理多任务并行是最容易触发风控的场景之一。如果你用同一个浏览器Profile并发跑几十个线程每个线程的Cookie、LocalStorage和指纹完全一样这在风控眼里就是“一个用户不可能同时在五十个地方操作”。正确的隔离思路是每个会话单独一个--user-data-dir目录目录里放一套独立的浏览器配置和Cookie。你可以预先准备几个Profile目录任务启动时随机分配一个跑完再挂起。这样每个任务看起来都像是一个独立用户在固定设备上操作。指纹管理更谨慎的团队会配合使用虚拟浏览器容器或指纹浏览器工具把Canvas、WebGL、UA、字体等信息做成稳定的虚拟指纹。这类工具在市面上有不少成熟方案我不是在这里给哪个产品打广告只是说架构上要理解稳定性优先于隐蔽性。一个固定不变的普通指纹比一个每次重启都变化的“完美指纹”更安全。因为风控系统会做关联分析变化的指纹意味着“你的设备在频繁更换硬件”这本身就不像真人。6.3 验证码这种兜底风控别硬碰最后说一个很多新人容易走偏的地方验证码。当你把指纹、行为、IP都做得比较像真人之后目标站点仍然可能在风险评分较高的情况下弹出验证码这属于风控模型的兜底策略不代表你的方案彻底失败了。我的态度很明确不要花大力气去做自动破解验证码这件事。一是技术上吃力不讨好滑块缺口识别、点选语义分析都需要机器学习模型支撑维护成本极高二是合规风险陡增。验证码是网站明确设立的访问门槛绕过去跟直接突破访问控制没什么区别。更稳妥的方式是调整任务本身降低采集频率、增加会话间歇、或者把任务排队到人少的时间段执行。有些验证码是“一批IP被标记后全体弹窗”这时候等一两天再跑可能就好了硬刚反而会把IP段越用越脏。我在实际项目中见过太多这样的场景开发者把80%的时间花在研究验证码破解上最后发现换一批干净IP、把访问频率砍掉一半问题自己就消失了。这也印证了整篇文章的核心——规避检测是个系统工程木桶效应明显短板决定你能跑多久。与其盯着某个单一魔法功能不如老老实实把每一层都做得像一个普通用户然后接受“没有一条流水线能永久有效”这个事实保持排查和调整的节奏。