news 2026/10/2 10:08:14

Python爬虫反爬虫策略分层解析与Selenium自动化抗检测实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬虫反爬虫策略分层解析与Selenium自动化抗检测实战

先说一个我最近接到的真实需求。对方要爬一个中等规模的电商站点,页面结构看似平淡无奇,用requests两三行就能拿到首屏HTML。可等我真正写下去却发现,翻到第二页时接口返回的是一段被加密过的JSON,再往下请求直接出现403,响应头里还多了一个标记着风控命中的参数。那一刻我意识到,现在稍微有点规模的网站,反爬虫策略早就不再是单一手段了,而是从网关到应用层再到浏览器环境的一整套体系。Python爬虫真正的分水岭,不是你会不会写循环和正则,而是你懂不懂反爬虫策略的分层逻辑,以及能不能在必要时把Selenium自动化这套工具真正用好。

这篇文章没有教科书式的长篇大论,只讲我实际踩过、也实际解决过的问题:反爬虫策略通常分哪几层,每一层用什么思路去应对,requests与Selenium各自的边界在哪里,以及Selenium自动化进阶时那些文档里不会写的抗检测细节。适合刚越过入门阶段、准备认真玩爬虫的开发者,也适合接了爬虫任务但被各种验证码和风控卡住的人。

1. 先搞明白目标站点的反爬梯度:从网关到浏览器指纹

我见过太多人一上来就问"怎么绕过验证码",但验证码往往是最后一道防线,前面还有好几层关卡。如果连前面的基础层都没过,根本不会触发验证码。理解反爬虫策略的梯度,比急着写代码重要得多。

1.1 传输层:IP频率与地域限制

这是最底层也最无脑的一层。站点会统计每个IP在一定时间窗口内的请求次数,超过阈值就临时封禁或要求验证码。比如某个接口限制是每分钟60次,你单线程跑没问题,一上多线程就立刻触发限制。

应对思路也最直接:控制请求频率、使用代理池分散请求来源。但要提醒一句,免费代理质量非常差,很多本来就是肉鸡或数据中心IP,站点侧记录到这类IP的访问特征甚至会直接拉黑。自建代理池或者购买付费代理的时候,优先选住宅代理,数据中心的IP段在风控系统里往往有更高的风险权重。

地域限制稍微特殊一些。有些站点的内容针对特定地区开放,纯海外IP访问会被打回。这时候只关注IP类型还不够,还得看IP的归属地区是否匹配站点预期。

1.2 请求头与指纹一致性:UA、Referer和Sec-CH-UA

传输层过了之后就是请求头校验。很多站点并不只看User-Agent,还会看请求头组合是不是"活的浏览器"发出的。我知道一个判断技巧:一个正常的Chrome浏览器发出的Headers是高度配套的——UA版本和Sec-CH-UA里的版本号一致,Accept-Language、Referer、Origin、Cookie之间也有关联逻辑。

如果只换UA不换别的头,风控系统很快就能通过特征交叉验证判定你是脚本。举个例子,某个站点要求Sec-CH-UA里的Chromium版本和Sec-CH-UA-Full-Version-List完全匹配,而单纯手工构造的UA池根本不会关注到这些细节。这时候光靠requests硬怼,效率很低。

1.3 参数层:签名、加密字段与返回内容处理

再往上一层是参数签名。服务端下发一段脚本或前端逻辑,把某些参数通过加密算法算出一个动态sign值并加到请求里。你只改headers没用,因为每次请求的sign都不一样。

这一层的应对没有银弹。常见做法是逆向前端JS,找到加密入口,把算法搬运到Python里执行。但很多站点会做JS混淆、动态变量名、字符串拼接,甚至把关键逻辑放到WebAssembly里,逆向成本直线上升。

还有一个容易被忽略的点:动态渲染。数据根本不是出现在HTML源码里的,而是页面加载完后通过XHR请求获取,再用JS填充到DOM上。你用requests拿到的是空壳页面,啥也分析不出来。遇到这种情况,就该考虑换工具了。

1.4 行为层:浏览器环境与用户行为模拟

最高级的反爬虫策略已经走到了"行为分析"这一步:检测访问者是不是真人。判断依据包括鼠标轨迹、点击间隔、键盘输入速度、滚动节奏、浏览的停留时长,甚至是CPU并发能力、Canvas指纹、WebGL参数。

这些指标加起来,就让"看起来像浏览器"和"真的是浏览器"之间产生了区别。requests达不到这个层次,Selenium也有天然的暴露风险,这就是为什么很多爬虫资深玩家最终都会去研究浏览器自动化。

2. 请求层对抗:UA池、代理与频率控制的正确打开方式

其实很多"被反爬"的案例,问题并不在于目标站点做了多高深的防护,而是爬虫代码本身的指纹太明显。有人拿requests默认UA去请求数据,如果我是网站方,一秒钟全站都是同一个浏览器标识,不封你封谁。

2.1 UA池不是随便拼字符串

UA池的设计原则是"从真实浏览器复制",而不是手写。我一般在本地开一个真实Chrome,在开发者工具里复制一份完整的UA字符串,然后再从UserAgentHistory之类的公开列表里补充一批不同系统、不同版本、不同内核的组合。

只换UA还不够,要配套替换Sec-CH-UA、Sec-CH-UA-Platform、Sec-CH-UA-Mobile等字段。这组字段在近几年的HTTP头里几乎是Chrome系浏览器的标配,缺了就有脚本特征。

import requests import random from fake_useragent import UserAgent ua = UserAgent() headers = { "User-Agent": ua.random, "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://example.com/list", "Sec-CH-UA": '"Google Chrome";v="120", "Chromium";v="120", "Not-A.Brand";v="99"', "Sec-CH-UA-Mobile": "?0", "Sec-CH-UA-Platform": '"Windows"', }

当你的UA和其余请求头保持高度一致时,基础过滤层基本就不会抓你了。

2.2 代理池的使用逻辑,以及为什么免费代理会拖垮你

代理池的核心在于"提取-校验-使用"三步循环。不能拿到一个代理就用,而是要先校验它当前的连通性、响应延迟、匿名等级。我通常维护一个小脚本,定时从代理源拉数据,再用一个低风险的目标URL做连通性测试,把延迟在3秒以内的存进Redis队列,供爬虫主程序消费。

使用上要注意两点。第一,代理切换频率不要过高。一个代理发20个请求不到就换,反而可能触发"短时间多地登录"的风控标记。第二,失效代理要自动剔除,否则重试逻辑会一直卡在同一个坏代理上。

session = requests.Session() session.proxies = { "http": "http://user:pass@proxy_ip:port", "https": "http://user:pass@proxy_ip:port", }

一个成熟的爬虫架构,代理是动态获取的,最好封装成一个get_proxy函数,请求前从队列里弹一个,失败自动还回队列或丢弃。

2.3 请求频率的随机化艺术

固定间隔3秒、固定间隔5秒的写法,比不间断请求更容易被识别。因为真实用户访问是有波动的,不会像节拍器一样精确。

我习惯用随机区间:

import time import random time.sleep(random.uniform(1.5, 4.0))

但随机化只是基础。更高级的做法是根据目标网站的响应时间做动态调整:如果响应慢了,说明服务器压力大或风控正在收紧,就主动增大间隔;如果响应快,可以适当提速。这个反馈式的节奏控制,既保护自己的IP,也大大降低触发风控的概率。

3. 到了动态渲染这层,requests的边界在哪里

我经常被问到一个问题:什么时候必须上Selenium?答案取决于你要的数据是怎么出现在页面上的。

3.1 数据藏在XHR里而不是HTML里

如果打开页面的HTML,发现内容区是空的,正文数据是在页面加载完成后通过异步请求拿到的,那requests直接请求页面URL毫无意义。这时候有两条路:

一是抓XHR接口。浏览器里按下F12,切到Network面板,刷新页面,找到那条返回JSON数据的请求,把URL、请求头、参数全部抠出来。这条路效率极高,但前提是接口没有做签名校验。一旦接口参数里有个加密的sign,这条路就很难走通。

二是上Selenium。用真实浏览器加载页面,等JavaScript执行完,直接从DOM里取值。这一招不依赖接口是否加密,因为浏览器自己就完成了全套解密流程。

3.2 数据在事件循环之后才生成

另一个常见场景是滚动加载。页面首屏只有20条数据,滚动到底部再触发下一次拉取。如果你需要的是当天全量数据,就必须不断地模拟滚动,等着新数据渲染出来。

这种场景用requests写会比较别扭,因为你得自己去复刻分页接口的参数,还要处理页码耗尽的条件。而Selenium天生擅长这种"等一等、滚一滚、再取值"的操作模式。

3.3 Selenium的性能代价与选择方法论

Selenium最大的毛病是慢。一个无头浏览器加载一个中等页面,通常需要2到5秒,同样的时间requests可能已经发完几十个请求了。所以选择工具之前,先做评估:

  • 数据是否直接存在于初始HTML?用requests。
  • 数据是否在XHR中且无签名?优先用requests抓接口。
  • 数据是否经过JS动态渲染、接口加密、或有行为验证?踏踏实实上Selenium。

这个判断流程我写在了工作笔记的首页,每次接爬虫需求先回答这三个问题,能省掉一半试错时间。

4. Selenium自动化:从能用走向抗检测

很多人的Selenium停留在"能跑"阶段,一放到有风控的网站上就被秒识别。原因很简单:默认的Selenium打开的浏览器,在JavaScript环境里有一堆明显的暴露特征。

4.1 默认Selenium为什么容易被识别

先看几个核心检测点:

  • navigator.webdriver属性。正常浏览器里这个值是undefined,Selenium打开的窗口里值是true。这是最常见的暴露点。
  • window.chrome对象。Chrome浏览器里window.chrome是存在的,但Selenium新开窗口里这个对象可能存在也可能缺失,检测方会结合其他特征综合判断。
  • navigator.plugins和navigator.languages。无头模式下这些数组或列表经常是空的或者不全。
  • 浏览器自动化特征。比如navigator.permissions里某些权限接口的返回值异常。

4.2 基础配置三段式

在代码层面,至少要做三件事:关闭自动化控制标志、注入脚本覆盖webdriver属性、设置合理的加载策略。

from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() # 1. 关闭自动化控制 options.add_argument("--disable-blink-features=AutomationControlled") # 2. 加载策略:等页面主框架加载完即可 options.page_load_strategy = "eager" # 3. 常规隐藏 options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") driver = webdriver.Chrome(options=options) # 4. 注入脚本,覆盖webdriver标志 driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", { "source": """ Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); """ })

execute_cdp_cmd是DevTools Protocol的调用入口,能在页面任何脚本执行之前先注入我们的自定义脚本。这个时机的把握非常关键——如果你等页面加载完再去修改navigator.webdriver,检测脚本早就读完了。

4.3 拿到老版本Chrome与环境的兼容性问题

还有一个容易被忽略的坑:Selenium Manager虽然会帮你自动匹配chromedriver,但如果你用的是旧项目,chromedriver版本与浏览器版本差距太大,就会报启动失败。解决办法不是手动下一个驱动就完事,而是让Selenium自动管理driver版本,或者显式指定driver路径。

我踩过一次比较深的版本坑:服务器环境里Chrome自动升级到120,本地还是118的chromedriver,整个调度脚本全部瘫痪。后来我在启动脚本里加了版本检测逻辑:先读浏览器主版本,再判断驱动是否匹配,不匹配就直接静默更新。这套逻辑让我少熬了好几个夜。

4.4 selenium-stealth和undetected-chromedriver,我到底该选谁

说到抗检测,绕不开这两个工具。

selenium-stealth本质是一批JS注入补丁,它会修补navigator.webdriver、window.chrome、navigator.plugins等常见检测点。优点是轻量,配合普通Selenium就能用。

undetected-chromedriver则是在驱动层面做patch,启动的浏览器更加接近真实环境。它会把webdriver特征隐藏在Driver后端,同时自动匹配chromedriver版本。

我的经验是:普通数据采集场景,selenium-stealth加手动配置基本足够;遇到强风控场景,直接上undetected-chromedriver更省心。但不管用哪个,都不能只依赖库本身,行为层面的模拟才是最难解决的。

行为模拟方面,我比较朴素:不用ActionChains做机械的直线滑动,而是按一定曲线移动鼠标,并在目标区域短暂停留;滚动页面时用driver.execute_script("window.scrollBy(0, random_height)")模拟随机滚动位置。这些细节没法通过一个库全自动补齐,只能靠脚本逻辑自己控制。

5. 综合实操:一个"登录+动态数据+行为验证"场景的完整链路

理论讲了这么多,不跑一个完整场景说不过去。我以一个典型的内部系统页面为例:需要先登录,登录后页面通过XHR动态渲染一份工单表格,且在查询时偶尔会弹出滑块验证。需求是抓取全部工单的流水号与状态。

5.1 第一步:登录态获取与复用

能不走登录流程就别走登录流程。我一般是手动操作浏览器完成一次登录,然后把Cookies序列化保存下来,下次启动直接加载。

import pickle # 保存cookies with open("cookies.pkl", "wb") as f: pickle.dump(driver.get_cookies(), f) # 下次启动加载 with open("cookies.pkl", "rb") as f: cookies = pickle.load(f) for cookie in cookies: driver.add_cookie(cookie)

Cookie复用能大幅降低登录流程触发的风险,也能减少账号被风控盯上的概率。

如果必须走登录,我建议输入账号密码时用真实的时间间隔去模拟键盘,而不是send_keys一股脑灌进去:

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import time driver.get("https://example.com/login") WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "username")) ) # 模拟输入,字母间随机停顿 username_input = driver.find_element(By.ID, "username") for char in "your_account": username_input.send_keys(char) time.sleep(random.uniform(0.05, 0.2))

5.2 第二步:动态数据等待与抓取

登录完成之后,页面会发出XHR加载表格。此时如果立刻去取元素,大概率取不到,因为数据还没渲染完。

我习惯用显式等待,条件设置为页面中预期的数据行出现:

from selenium.webdriver.support import expected_conditions as EC WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, "table tbody tr")) ) rows = driver.find_elements(By.CSS_SELECTOR, "table tbody tr")

这里有个坑我必须强调:不要在一个循环里去反复创建WebDriverWait,那会白白增加等待开销。更好的思路是首次加载时等待一次,后续操作里用条件判断决定是否继续等待。

5.3 第三步:遇到滑块验证时的处理思路

滑块验证一旦出现,纯模拟是比较费力的。我的处理顺序是这样的:

  • 先判断滑块是否真的阻断后续操作。有些站点的滑块只挡一次,通过后一段时间内不再出现。
  • 如果只出现一次,就用ActionChains结合随机轨迹去滑动,成功概率在中低强度风控场景下尚可。
  • 如果频繁触发,就要考虑降低操作频率,避免一次会话里执行太多请求。从源头减少触发的概率,永远比解决触发结果要轻松。

需要说明的是,滑块拖拽理论上就是一次鼠标位移行为。不要用固定坐标直接拖,要分段移动并加入抖动和停顿:

from selenium.webdriver import ActionChains slider = driver.find_element(By.CLASS_NAME, "slider-btn") ActionChains(driver) \ .click_and_hold(slider) \ .pause(0.3) \ .move_by_offset(50, random.uniform(-2, 2)) \ .pause(0.2) \ .move_by_offset(90, random.uniform(-1, 1)) \ .pause(0.3) \ .release() \ .perform()

5.4 第四步:数据清洗与落盘

DOM里取出来的数据经常带着换行、空格和标签残留。清洗逻辑建议直接在解析阶段处理,别等入库后再折腾:

import csv data = [] for row in rows: cells = row.find_elements(By.TAG_NAME, "td") record = [] for cell in cells: text = cell.text.replace("\n", "").strip() record.append(text) data.append(record) with open("data.csv", "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerows(["流水号", "状态", "时间"]) writer.writerows(data)

整个过程最关键的地方在于"等待-判断-兜底"三步。等不到元素就判断是不是页面结构变了,判断完之后再决定是重试还是截图排查。

6. 爬虫稳定性的血泪经验:等待、重试与风控指纹管理

代码能跑只是最基础的一步,真正决定爬虫项目生命周期的是稳定性。我能连续跑几天的脚本和跑半小时就挂的脚本,差别都在这些细节里。

6.1 等待机制:显式等待优于隐式等待优于无脑sleep

无脑sleep最大的问题是时间不可控。页面快的时候浪费了时间,页面慢的时候又不够用。隐式等待的问题在于它是全局设置的,对每个find_element都生效,一旦某个条件始终不满足,反而拉长了整个会话的耗时。

显式等待最语义化,也最精准:

WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "submit-btn")) )

我一般来说不会用EC.visibility_of_element_located代替EC.presence_of_element_located来获取存在于DOM但不可见的元素。你要看你需要的是元素存在、可见还是可点击,这三个条件对应的EC函数完全不同。

6.2 定位元素的坑:动态ID、同名Class和CSS伪类

现在很多前端框架会生成动态class或id,每次刷新都不一样。如果你把元素定位写死,脚本很快会失效。我自己的写法是优先用相对稳定的属性组合,比如部分匹配:

driver.find_element(By.CSS_SELECTOR, "[class*='product-item']")

多个元素共用一个class时,我会先定位整个列表容器,再在容器内部按索引取值,而不是全页面乱找:

container = driver.find_element(By.CLASS_NAME, "list-wrapper") items = container.find_elements(By.CSS_SELECTOR, "div[data-id]")

6.3 异常重试与断点续抓

一个长跑脚本必须有重试机制。我的封装习惯是写一个retry装饰器,把每次操作包进去:

import functools import time def retry(max_tries=3, delay=2): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(max_tries): try: return func(*args, **kwargs) except Exception as e: print(f"第{i+1}次执行失败: {e}") time.sleep(delay) raise return wrapper return decorator

断点续抓的意思是:把已经成功抓取的数据记录在本地,每次启动时加载已经完成的集合,跳过它们。避免跑到一半断了,又要从头再来。

6.4 风控指纹管理的思维框架

把上面所有内容串起来,能看到一个完整的风控指纹管理框架:

  • 网络层:IP的质量与稳定性。
  • 请求层:UA、Headers、Sec-CH-UA的配套一致。
  • 会话层:Cookie的持久化与温度。
  • 行为层:输入速度、滚动节奏、鼠标轨迹。
  • 环境层:webdriver属性、插件列表、Canvas指纹。

反爬虫策略不是某个单一机制,而是一整套基于指纹相似度的概率评分。爬虫能做的就是尽可能让所有维度的特征都趋近于一个真实用户,同时保持合规合理的使用场景。我一直坚持一个原则:爬取公开数据、只用于学习和研究、尊重目标网站的robots声明和服务条款。爬虫这个技能,边界感越清晰,路才走得越远。

最后说一个我自己的切身体会:如果你刚接触Selenium,别一上来就追求复杂的反检测配置,先老老实实把定位、等待、取值这三个基本功打扎实。因为再强的隐藏手段也救不了一个连元素都定位不准的脚本。等基础稳住之后,再去研究那些高级玩法,你会发现所谓"反爬虫策略",本质上就是在和对方比谁更能理解浏览器。而理解浏览器这件事,做多了之后,看整个Web前端都会有一种更通透的感觉。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 10:07:35

HER算法详解:用后见之明破解强化学习稀疏奖励难题

第一次看到Hindsight Experience Replay(HER)这个名字,我就觉得这篇论文把“hindsight(后见之明)”用得太妙了。在目标条件强化学习(goal-conditioned RL)里,最折磨人的往往不是算法…

作者头像 李华
网站建设 2026/10/2 10:06:55

端侧AI系统工程实战:从模型选型到监控迭代的闭环设计

1. 端侧 AI 系统工程到底在解决什么问题1.1 从“能跑起来”到“跑得久”的认知转变很多人第一次接触端侧 AI,脑子里想的都是“把模型塞进设备里跑通就行”。我刚开始做这块的时候也是这个心态,拿一个开源模型,转成 ONNX,量化一下&…

作者头像 李华
网站建设 2026/10/2 10:06:40

风光火储联合调频Simulink仿真:一次调频与AGC建模实战

1. 风光火储联合调频,为什么突然成了Simulink仿真的主战场做电力系统仿真这行的人,最近几年感受应该都很明显:以前搭个"发电机负荷"的简化频率响应模型就够写论文了,现在不行了。风电、光伏占比一路往上走,电…

作者头像 李华
网站建设 2026/10/2 10:06:15

西门子S7-1200 PLC与变频器Modbus通讯在溢流水循环系统中的实战应用

接手这个项目的时候,厂里的需求一句话就能说清:冷却水池的溢流水不能再哗哗排地沟了,得收回蓄水箱,再用变频泵打到循环用水点。但真做起来,水位稳不稳、泵会不会烧、变频器跟PLC怎么对得上话、触摸屏怎么让操作工一眼看懂——每一件事都在考验方案的细节。这套系统我用的硬件是…

作者头像 李华
网站建设 2026/10/2 10:05:52

Codex接入Jev模型实战:代理解决端点报错与模型兼容问题

最近 AI 编程圈的玩法越来越“硬核”了,今天聊一个实际上手很爽的组合:给 Codex 配上 Jev。这里说的 Codex 是那类既能和你对话、又能直接操作终端执行命令的编程代理工具,而 Jev 则是一个在数据构建、推理和长上下文场景下表现很亮眼的模型体…

作者头像 李华
网站建设 2026/10/2 10:05:02

Codex CLI实战指南:从安装配置到企业级落地与报错排查

最近很多人私信问我,Codex到底怎么学,尤其是“闪学it-小白也能学会的Codex实战课”完结之后,我身边不少同事、同学都开始把Codex当成日常开发工具。我算是第一批把Codex CLI用进项目里的人,从最开始拿它改单文件脚本,到…

作者头像 李华