先说一个我最近接到的真实需求。对方要爬一个中等规模的电商站点,页面结构看似平淡无奇,用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前端都会有一种更通透的感觉。