简介:这份PDF资料聚焦selenium配合chromedriver在爬虫实战中被目标站点识别并拦截的典型问题,面向已有一定Python爬虫基础、正遭遇反爬困扰的开发者。内容以爬取某夕夕商城为真实场景,记录了从正常刷取到突然跳转登录页的排查过程,通过同机浏览器对比、抓包分析请求头等方式,逐步定位到webdriver特征被服务端JS检测这一关键原因,并给出借助mitmproxy拦截响应、替换JS中webdriver关键字的可行解决思路,同时提及修改webdriver参数等国外方案的实测效果。资源包内含1个PDF文件,大小约50KB,篇幅精炼但信息密度较高,适合作为反爬排查的参考笔记。目前已有7380人学习下载,读者可从中获得一套完整的检测定位与绕过思路,理解selenium被识别的底层逻辑,并迁移应用到其他同类反爬场景中。
1. 详解 selenium + chromedriver 被反爬的解决方法:为什么你的浏览器一启动就露馅
做过网页自动化采集的人大多经历过这个场景:本地跑得好好的 selenium + chromedriver 脚本,换到目标站点上,页面要么直接返回一个空白验证页,要么加载出来的内容跟浏览器里手动打开完全不一样,日志里连个像样的报错都没有。这不是脚本写错了,而是浏览器在启动的那一刻就已经被对方识别出来了。selenium + chromedriver 被反爬的解决方法,核心要解决的就是「怎么让自动化浏览器看起来像一个真人打开的浏览器」。这套方案适合两类人:一类是刚接触 selenium、被验证页卡住不知道从哪下手的新手;另一类是已经加了 UA、加了等待,但依然被拦、想搞清楚检测点到底在哪的熟手。下面按「检测原理 → 环境伪装 → 行为伪装 → 排错 → 进阶验证」的顺序,把这条链路拆开讲透,每一步都给到能直接抄的代码和参数。
2. 先搞清楚对方在检测什么:chromedriver 的四个暴露面
在动手改代码之前,得先知道对面到底在看什么。很多人一上来就无脑加--user-agent,结果发现没用,就是因为没定位到真正的检测点。selenium 驱动 chromedriver 启动的 Chrome,和手动双击打开的 Chrome,在页面 JavaScript 眼里是有本质区别的。这些区别分布在四个层面,从易到难依次是:浏览器指纹属性、驱动进程特征、网络请求特征、行为特征。理解这四个层面,后面的每一步伪装才有针对性,而不是碰运气。
2.1 navigator.webdriver 与一串被改写的浏览器属性
最经典也最容易被检测的就是navigator.webdriver。用 selenium 启动的 Chrome,这个值默认是true,而正常浏览器是false或undefined。目标站点只要一行if (navigator.webdriver) { ... }就能把你拦下来。除了它,还有一批属性在自动化模式下会露馅:navigator.plugins长度异常、navigator.languages为空、window.chrome对象缺失或结构不对、navigator.permissions查询结果不一致。这些属性单看一个可能不算致命,但组合起来就形成了一个很稳定的自动化指纹。
检测方通常不会只查一个属性,而是把十几个属性打包成一个指纹向量,跟正常浏览器的基线做比对。所以伪装也要成体系地做,改一个navigator.webdriver只是入门。下面这段是在页面加载前注入脚本、批量修正这些属性的最小示例,用的是 Chrome DevTools Protocol 的Page.addScriptToEvaluateOnNewDocument,它能在页面任何脚本执行之前生效,比加载完再改要可靠得多。
from selenium import webdriver from selenium.webdriver.chrome.options import Options # 需要在页面脚本执行前注入的伪装脚本 STEALTH_JS = """ // 抹掉 webdriver 标记 Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); // 补上正常的插件数量(正常浏览器一般有 PDF 查看器等) Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]}); // 补上语言列表 Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh', 'en']}); // 补上 window.chrome,正常 Chrome 一定有这个对象 window.chrome = { runtime: {} }; // 修正 permissions 查询,避免 headless 下返回不一致 const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => ( parameters.name === 'notifications' ? Promise.resolve({ state: Notification.permission }) : originalQuery(parameters) ); """ options = Options() driver = webdriver.Chrome(options=options) # 关键:在每次新文档创建时都注入,而不是只注入一次 driver.execute_cdp_cmd( "Page.addScriptToEvaluateOnNewDocument", {"source": STEALTH_JS} ) driver.get("https://example.com")这段代码的逻辑是:execute_cdp_cmd直接调用 Chrome 的调试协议,把伪装脚本注册成「每个新文档创建时自动执行」。参数上,Page.addScriptToEvaluateOnNewDocument的source就是那段 JS 字符串,它会在页面自身的任何 JS 之前跑,所以目标站点读到的navigator.webdriver已经是undefined。注意plugins这里用[1,2,3,4,5]只是占位,真实场景里更稳妥的做法是读取一个正常浏览器的真实值再回填,否则长度对了、内容不对,一样可能被识破。
2.2 chromedriver 进程与 CDP 端口留下的痕迹
属性伪装做完,还有一层更底层的暴露面:chromedriver 本身。selenium 的工作模式是「Python 进程 → chromedriver 进程 → Chrome 进程」,中间多了一个 chromedriver。这个进程名、它监听的调试端口、以及 Chrome 启动时带的一堆--enable-automation之类的命令行参数,都可能被检测。比如 Chrome 启动参数里如果带着--enable-automation,页面里某些行为会不一样;再比如调试端口如果暴露在固定端口上,扫描一下就能发现。
应对思路有两个方向。一是尽量削减自动化痕迹,把--enable-automation这类参数关掉,用excludeSwitches排除掉;二是干脆不用 chromedriver 这条链路,改用直接连 CDP 的方式启动浏览器,减少中间层。前者改动小、上手快,后者更彻底但代码要重写。对大多数采集场景,先把参数清理干净就能挡掉一批初级检测。
options = Options() # 去掉「Chrome 正受到自动测试软件的控制」提示条和对应标记 options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) # 关闭一些在自动化下容易露馅的特性 options.add_argument("--disable-blink-features=AutomationControlled") # 指定一个独立的用户数据目录,避免和日常浏览器配置冲突 options.add_argument("--user-data-dir=/tmp/chrome-profile-01")excludeSwitches里的enable-automation是重点,它去掉的是那条显眼的提示条和背后的标记;useAutomationExtension设为False是关掉旧版自动化扩展;--disable-blink-features=AutomationControlled则是从 Blink 渲染引擎层面关掉自动化控制相关的特性开关。--user-data-dir建议每次任务用独立目录,避免多个实例抢同一个配置目录导致启动失败,也方便隔离 cookie。
2.3 请求头、TLS 指纹与加载时序的破绽
就算浏览器属性全伪装好了,网络层还是可能露馅。目标站点服务端能看到的东西包括:请求头顺序、Accept-Language是否和navigator.languages一致、有没有sec-ch-ua系列头、TLS 握手的指纹(JA3 之类)。selenium 默认发出的请求头,和真实 Chrome 是有差异的,尤其是sec-ch-ua这类客户端提示头,版本对不上就很可疑。
另一个常被忽略的是加载时序。自动化脚本往往get完立刻就开始抓元素,而真人会有一个自然的停顿和滚动。有些站点会记录「从页面加载到第一次交互」的时间差,太短就判定为机器。所以除了改请求头,还要在关键节点插入符合人类节奏的等待,而不是清一色的time.sleep(1)。
| 检测维度 | 正常浏览器表现 | 自动化默认表现 | 处理方向 |
|---|---|---|---|
| 请求头顺序 | 固定且符合版本 | 顺序可能不同 | 用真实抓包结果对齐 |
| sec-ch-ua | 与 Chrome 版本一致 | 可能缺失或过时 | 手动设置或升级内核 |
| 首次交互延迟 | 数百毫秒以上 | 常常接近 0 | 加入随机等待 |
| Accept-Language | 与 navigator 一致 | 可能不一致 | 两处统一设置 |
这张表里的四个维度,前两个属于「静态可对齐」,后两个属于「动态要模拟」。静态的用配置解决,动态的用行为脚本解决,别混在一起调,否则出了问题很难定位是哪一层导致的。
3. 环境伪装落地:从启动参数到指纹注入的完整配置
上一章讲的是「检测什么」,这一章讲「怎么改」。环境伪装的目标是让自动化浏览器在静态特征上尽量贴近真实浏览器。这里给一套我常用的完整配置,从启动参数、指纹注入到请求头对齐,按顺序拼起来就能跑。需要说明的是,没有任何一套配置能通吃所有站点,具体参数要根据目标站点的检测强度微调,但骨架是通用的。
3.1 一套可直接复用的 ChromeOptions 配置
先看启动参数。下面这份配置覆盖了窗口尺寸、语言、图片加载策略、自动化标记清理等常见项。窗口尺寸建议设成常见分辨率,别用默认的 800x600,那个尺寸本身就很少见。
from selenium import webdriver from selenium.webdriver.chrome.options import Options def build_options(profile_dir: str) -> Options: options = Options() # 常见桌面分辨率,避免默认小窗口引起怀疑 options.add_argument("--window-size=1920,1080") # 语言和时区,要和后续请求头保持一致 options.add_argument("--lang=zh-CN") options.add_argument("--timezone=Asia/Shanghai") # 清理自动化标记 options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) options.add_argument("--disable-blink-features=AutomationControlled") # 独立配置目录,隔离 cookie 和缓存 options.add_argument(f"--user-data-dir={profile_dir}") # 可选:无头模式,但无头更容易被检测,非必要不开 # options.add_argument("--headless=new") return options参数逐个说:--window-size影响window.innerWidth/innerHeight,很多指纹脚本会读这两个值;--lang要和后面请求头里的Accept-Language对齐,不一致是常见破绽;--timezone影响Intl.DateTimeFormat的时区输出,和 IP 归属地不匹配也会被记一笔;--user-data-dir用独立目录,好处是每次任务环境干净,坏处是首次访问没有历史 cookie,某些站点会因此判定为新设备,这个要权衡。无头模式我一般不开,--headless=new虽然比老版无头好很多,但仍有若干属性差异,除非跑在无显示环境的服务器上,否则优先用有头模式。
3.2 用 CDP 注入指纹并统一请求头
启动参数解决的是进程层,指纹注入解决的是页面层。把 2.1 里的伪装脚本扩展一下,再加上请求头对齐。请求头这块,selenium 本身不直接暴露「设置任意请求头」的接口,常见做法是用 CDP 的Network.setExtraHTTPHeaders,或者用代理中间层。这里用 CDP 的方式,简单直接。
def apply_stealth(driver): # 页面脚本执行前注入指纹修正 driver.execute_cdp_cmd( "Page.addScriptToEvaluateOnNewDocument", {"source": STEALTH_JS} ) # 统一请求头,注意 sec-ch-ua 要和实际内核版本匹配 headers = { "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "sec-ch-ua": '"Chromium";v="120", "Not(A:Brand";v="24"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', } driver.execute_cdp_cmd("Network.enable", {}) driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": headers}) driver = webdriver.Chrome(options=build_options("/tmp/chrome-profile-01")) apply_stealth(driver) driver.get("https://example.com")逻辑上,Page.addScriptToEvaluateOnNewDocument管页面内可见的属性,Network.setExtraHTTPHeaders管服务端可见的请求头,两者配合才能把「页面里读到的」和「服务端收到的」对齐。参数上要特别注意sec-ch-ua里的版本号,它必须和你实际用的 Chrome 大版本一致,写错了反而更可疑——一个声称是 120 的内核却发出 118 的客户端提示头,等于自报家门。Network.enable要先调用,否则setExtraHTTPHeaders不生效,这个顺序坑过不少人。
3.3 用真实浏览器配置反推参数,而不是凭感觉填
上面这些参数值,最忌讳的就是凭感觉填。我一般的做法是:先用一台正常浏览器访问目标站点,通过页面控制台把关键指纹读出来,再把这些真实值回填到伪装脚本里。比如navigator.plugins的真实内容、navigator.hardwareConcurrency的核数、screen.colorDepth的位数,这些都能在控制台一行行读出来。
// 在正常浏览器控制台执行,把结果抄回伪装脚本 console.log(JSON.stringify({ webdriver: navigator.webdriver, plugins: Array.from(navigator.plugins).map(p => p.name), languages: navigator.languages, hardwareConcurrency: navigator.hardwareConcurrency, colorDepth: screen.colorDepth, chromeKeys: Object.keys(window.chrome || {}) }, null, 2));把这段输出保存下来,作为你伪装脚本的「基线」。每次目标站点更新检测策略,就重新采一次基线做对比。这个习惯能省掉大量瞎猜的时间。需要提醒的是,不同机器、不同系统上这些值本来就有差异,所以基线最好来自和目标环境接近的机器,别拿一台 Mac 的值去伪装 Windows 环境。
4. 行为伪装:让操作节奏不像机器
环境伪装做到位,能过掉大部分静态检测,但还有一类检测是看行为的。真人操作有停顿、有滚动、有鼠标移动轨迹,而脚本往往是「定位元素 → 点击 → 立刻下一步」,节奏过于规整。这一章讲怎么把行为做得自然一些。核心原则是:不要追求「快」,要追求「像」。很多脚本被拦不是因为技术不行,而是因为太快了,快到不像人。
4.1 随机等待与滚动:把固定 sleep 换掉
time.sleep(2)这种固定等待,在检测方眼里就是规律性极强的信号。改成随机区间,并且把等待拆散到多个动作之间,效果会好很多。下面这个辅助函数把「等待」和「滚动」打包,模拟真人浏览时的停顿。
import random import time from selenium.webdriver.common.action_chains import ActionChains def human_pause(driver, low=0.8, high=2.5): """随机停顿,模拟阅读节奏""" time.sleep(random.uniform(low, high)) def human_scroll(driver, steps=3): """分步滚动,每步之间停顿,避免一次性跳到底""" for _ in range(steps): delta = random.randint(200, 600) driver.execute_script(f"window.scrollBy(0, {delta});") time.sleep(random.uniform(0.3, 1.2)) # 偶尔回滚一点,更像真人 if random.random() < 0.3: driver.execute_script("window.scrollBy(0, -120);") time.sleep(random.uniform(0.2, 0.6))human_pause的low和high是等待区间的上下限,单位秒,建议根据页面内容量调整,内容多的页面区间拉大。human_scroll里每步滚动距离随机,步与步之间停顿,最后有 30% 概率回滚一点——真人浏览时确实会来回看。这些细节单看很小,但累积起来能显著降低行为特征的规律性。注意别把区间设得太夸张,一次停顿十几秒反而拖慢效率,得不偿失。
4.2 鼠标轨迹与点击位置偏移
点击也是重灾区。element.click()是直接命中元素中心,而真人点击会落在元素范围内的随机位置,鼠标移动也有轨迹。用 ActionChains 可以模拟得更自然一些。
def human_click(driver, element): """带偏移和轨迹的点击""" actions = ActionChains(driver) # 先移动到元素附近,再移动到元素上,形成两段轨迹 actions.move_to_element_with_offset(element, random.randint(-5, 5), random.randint(-5, 5)) actions.pause(random.uniform(0.1, 0.4)) actions.move_to_element(element) actions.pause(random.uniform(0.05, 0.2)) actions.click() actions.perform()move_to_element_with_offset的偏移量别超过元素本身尺寸,否则会点到元素外面。两段移动加两次随机暂停,是为了制造「先靠近再精确点击」的轨迹感。pause的参数是秒,别设太长。这套动作比直接click()慢,但换来的是更低的被拦概率,在关键操作(比如登录、提交)上值得用。
4.3 页面加载策略与资源拦截的取舍
driver.get()默认等页面load事件,但很多站点资源多、加载慢,脚本容易卡住。可以改成eager策略,等 DOM 就绪就返回,再配合显式等待抓元素。同时,拦截掉图片、字体等无关资源能提速,但要注意:拦截资源本身也是一种特征,某些站点会检测「图片请求是否发出」。所以拦截策略要谨慎,别为了快把特征暴露了。
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By # 设置页面加载策略为 eager,DOM 就绪即返回 options.page_load_strategy = "eager" # 用显式等待替代固定 sleep,等目标元素出现 element = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, ".content")) )page_load_strategy可选normal、eager、none,eager是折中方案。WebDriverWait的超时设 15 秒是个经验值,太短容易误判超时,太长出问题时排查慢。presence_of_element_located只等元素出现在 DOM 里,如果还要等它可点击,换成element_to_be_clickable。资源拦截这块,如果确实要拦,建议只拦字体和媒体,图片尽量放行,因为图片请求是页面正常加载的一部分。
5. 避坑与排查:被拦时到底该看哪里
前面几章把该做的都做了,但实际跑起来还是可能被拦。这一章列几个我踩过的坑,按「现象 → 原因 → 解决」写,方便对照排查。排查的核心思路是:先确认是哪一层被检测,再针对性处理,别一上来就大改配置。
5.1 现象:加了伪装脚本还是被识别
原因通常是注入时机不对。如果用driver.execute_script在get之后注入,页面自身的检测脚本早就跑完了,改也白改。另一个可能是伪装脚本本身有语法错误,静默失败了。解决:确认用的是Page.addScriptToEvaluateOnNewDocument,并且在get之前调用;注入后在控制台手动读一次navigator.webdriver验证是否生效。
5.2 现象:本地能跑,服务器上必被拦
原因多半是环境差异。服务器上常用无头模式,无头模式的指纹和正常浏览器差异更大;另外服务器的 IP 段、时区、语言设置也可能和伪装值不匹配。解决:优先用有头模式加虚拟显示;把时区、语言、sec-ch-ua全部对齐到目标地区;检查 IP 归属地和时区是否矛盾。
5.3 现象:请求头改了但服务端还是返回验证页
原因是请求头没真正生效,或者顺序不对。Network.setExtraHTTPHeaders必须在Network.enable之后调用,且要在导航之前。另外,某些头(比如User-Agent)用 CDP 设置可能被 Chrome 自身覆盖。解决:用抓包工具确认实际发出的请求头,而不是只看代码里写了什么;User-Agent建议通过启动参数--user-agent设置,更稳。
5.4 现象:脚本跑一会儿就被封,换 IP 也没用
原因是行为特征太规律,或者 cookie 被关联。固定间隔、固定滚动距离、固定点击位置,跑久了必然被聚类。解决:把所有等待、滚动、偏移都改成随机;每个任务用独立的--user-data-dir;控制单 IP 的请求频率,别把并发拉满。
5.5 现象:元素定位不到,报超时
原因不一定是被反爬,可能是页面结构变了或加载策略问题。先手动打开页面确认元素选择器是否还有效,再检查page_load_strategy和显式等待条件。解决:用eager策略配合WebDriverWait;选择器尽量用稳定的属性,别依赖会变的 class 名。
提示:排查时养成「先复现、再定位、后修改」的习惯,每次只改一个变量,否则改完不知道是哪个改动起的作用。
6. 进阶验证:怎么确认伪装真的生效了
伪装做完,不能靠「没被拦」就认为成功了,因为目标站点可能只是暂时没触发检测。更靠谱的做法是主动验证。我一般会用一个自建的检测页,把常见检测点全列出来,跑一遍看哪些还露馅。下面这个检测脚本可以在目标站点上执行,也可以在本地起一个页面执行,输出一份「指纹体检报告」。
// 在自动化浏览器里执行,输出当前环境的检测结果 (function () { const report = { webdriver: navigator.webdriver, pluginsCount: navigator.plugins.length, languages: navigator.languages.join(','), hasChrome: typeof window.chrome !== 'undefined', hardwareConcurrency: navigator.hardwareConcurrency, colorDepth: screen.colorDepth, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, ua: navigator.userAgent, }; console.log(JSON.stringify(report, null, 2)); return report; })();把这份报告和 3.3 里采的正常浏览器基线逐项对比,差异项就是还没处理干净的暴露面。重点看webdriver是否为undefined、pluginsCount是否和基线接近、timezone是否和 IP 归属地一致、ua里的版本是否和sec-ch-ua对得上。这套对比方法比盲目试参数高效得多。
再进一步,可以做一个「回归检测」:把伪装配置固定下来,每天定时跑一次检测页,记录各项值的变化。一旦某项突然变了(比如 Chrome 自动升级导致ua和sec-ch-ua版本错位),就能第一时间发现。这个习惯帮我省过好几次「昨天还好好的今天全挂」的排查时间。
最后说个我自己的教训:早期我总想着一步到位,把所有能加的伪装全加上,结果配置越来越复杂,出了问题根本不知道是哪一项导致的。后来改成「最小可用配置 + 按需叠加」,先保证基础项(webdriver、请求头、时区)对齐,被拦了再针对性加,反而更稳。伪装这件事,够用就好,堆太多参数本身就是一种特征。希望帮到你。
本文还有配套的精品资源,点击获取