我最早写爬虫的时候,跟大多数新手一样,以为只要会requests.get(url)再加个解析就完事了。直到第一次写一个招聘网站的采集脚本,刚跑不到五分钟,对方直接弹了 403,紧接着 IP 被封了十分钟。那一刻我才意识到,爬虫这行,真正拉开差距的不是谁的正则写得更溜,而是谁能突破反爬、谁能在保证不惹恼对方的前提下把采集效率拉满。到今天,我用 Python 爬虫做过行业数据监测、商品价格追踪、公开资讯聚合,也趟过不少反爬的坑,这篇文章就把我这几年的经验梳理一遍,重点说反爬突破和效率优化这两个方向,适合已经把 requests、BeautifulSoup 基础玩熟、想往进阶走的同学。
1. 反爬的世界观:先搞清攻防逻辑
1.1 反爬的本质与层级拆解
很多人一提反爬就想到验证码、IP 封禁、JS 加密,其实这些都是表面的“招式”。反爬的底层逻辑只有一句话:服务器想确认“你是真人,并且不打算给我造成压力”。如果它判断你不是真人,或者说你有压力风险,就会通过限流、封禁、混淆等手段把你挡在外面。所以做爬虫,本质上是在做“信任模拟”和“成本控制”两件事。
我习惯把反爬措施按层级拆开看:
- 请求级反爬:检测请求头(User-Agent、Referer、Accept-Language)、检测请求频率、检测 IP 来源。这是最基础也最常见的一层,大多数中小网站只做到这一层。
- 行为级反爬:分析访问路径、停留时间、点击间隔、鼠标轨迹、账号活跃规律。这一层主要靠数据埋点来实现,典型表现是“你访问得像个人,但行为序列不像”。
- 前端级反爬:通过 JavaScript 动态渲染、动态参数加密、Cookie 植入等手段,让数据不直接暴露在静态 HTML 里。前端级反爬的下一步就是 WebDriver 检测、浏览器指纹识别。
- 数据级反爬:在返回数据里掺入蜜罐数据、乱序数据、图片/字体混淆,让你抓到的内容和真实内容对不上号。
理解这个分层有什么用?用处大了去了。当你面对一个 403 报错时,你得先判断它属于哪一层反爬——是单纯请求头不对,还是行为被锁,还是前端参数没带上?不同层级的应对方案完全不同。我见到太多新手一上来就怼验证码、搞大规模代理,结果网站从头到尾只用了最简单的 UA 校验,那纯粹是杀鸡用牛刀。
1.2 动手前先做合规与风险判断
这个观念我必须放在最前面讲,因为现在整个行业的合规要求越来越严,我认识不少做爬虫的朋友就是把事情搞大了才追悔莫及。做爬虫有一个基本底线:只采集公开数据,不碰隐私数据,遵守 robots 协议,控制访问频率,不把数据用于商业竞争或恶意用途。
具体到实操层面,我给自己定了几条硬规矩:
- 采集前先看目标站点的
robots.txt,如果有明确禁止的路径,绝不碰。虽然 robots 协议在技术上不强制,但它是一个重要的合规参考。 - 登录后才能看的数据,原则上不采;即使有账号,也不会用自动化脚本暴力遍历用户信息。
- 控制请求频率,单 IP 下 QPS 不超过 1-2,观察目标服务器的响应情况,被人警告立即停手。
- 只采集公开页面和公开接口,不尝试破解加密协议、不逆向客户端签名逻辑去盗取数据。
这条底线划清楚之后,所谓的“反爬突破”到底突破什么?突破的是“被误伤”——你不是机器人,但要证明自己是正常的访客;你要高效采集,但不是把对方服务器打趴下。这个定位摆正了,下面所有技术手段才不会走偏。
2. 请求层伪装:从 Headers 到代理的实战
2.1 请求头伪装与 Cookie 管理
请求头伪装是最入门的手段,但最入门恰恰最容易出错。很多新手以为随便填一个User-Agent就行,实际上服务器校验的请求头远不止 UA 这一个字段。一个最容易被忽略的字段是Sec-Fetch-*系列,它告诉服务器你的请求是如何发起的。浏览器发起的请求会带Sec-Fetch-Dest、Sec-Fetch-Mode、Sec-Fetch-Site,而 requests 默认根本不发这些头。如果目标站对请求头校验严格,直接就被识别了。
我这里贴一个实际可用的请求头模板,是我日常采集的基础配置:
import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0", ] def build_headers(referer=None): headers = { "User-Agent": random.choice(UA_POOL), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Cache-Control": "no-cache", "Pragma": "no-cache", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Upgrade-Insecure-Requests": "1", "Connection": "keep-alive", } if referer: headers["Referer"] = referer headers["Cookie"] = load_cookie_from_cache() return headers这里有几个细节需要注意:
- Chrome 的 UA 版本要和实际浏览器版本保持大致同步,老掉牙的 UA 版本很容易被标记。
Accept-Language要按目标站的受众调整,中文站就写中文优先,英文站就写英文优先。Cookie不要每次都重新登录获取,建议把首次登录拿到的有效 Cookie 序列化保存到本地文件里,设置过期时间,失效了再去重新登录一次。
2.2 代理 IP 轮换与重试机制
请求头伪装能解决八成的基础反爬,但如果你需要高频率采集,IP 限制迟早会找上门来。常见的表现是:一开始正常,跑了 200 个请求后突然开始大量 403,或者出现验证码。这就是 IP 被盯上了。
代理 IP 这块,我直接说实用结论:付费代理 > 免费代理 > 自建代理池。免费代理的可用率低到你怀疑人生,而且容易把目标站的“仇恨值”拉满。自建代理池成本高、维护复杂,适合真正的分布式爬虫项目。普通进阶者用付费代理足够,关键是学会管理代理的生命周期。
我的代理调度逻辑很简单但很有效:
import requests from requests.adapters import HTTPAdapter class ProxyManager: def __init__(self, proxy_list): self.valid_proxies = [] self.failed_count = {} for proxy in proxy_list: self.valid_proxies.append(proxy) self.failed_count[proxy] = 0 def get_proxy(self): if not self.valid_proxies: raise RuntimeError("代理池已耗尽") return random.choice(self.valid_proxies) def mark_failed(self, proxy): self.failed_count[proxy] += 1 if self.failed_count[proxy] >= 3: self.valid_proxies.remove(proxy)每次请求前随机取一个代理,请求失败时把该代理的失败次数加一,连续失败三次就移出可用列表。这个机制可以在目标站还没完全封死一个代理的时候提前止损。
另一个容易被忽略的点是重试策略。不要一失败就重试,更不要无限重试。我常用的重试策略是:
- 第一次失败后等 2 秒重试,第二次失败后等 5 秒,第三次失败后等 15 秒,超过 3 次直接放弃,换下一个代理。
- 重试时换 UA、换代理,不要带着同一个指纹死磕。
- 区分状态码:403/429 说明被限流,该换代理;500/502 是服务器问题,换不换代理意义不大,可能要等对方恢复;404 说明 URL 有问题,重试没有意义。
3. 行为建模与前端动态解析
3.1 Session、登录态与真实用户轨迹
很多网站的数据不是登录就能拿到的,但登录态确实能减少很多反爬干扰。Python 的requests.Session()可以帮我们维持 Cookie、自动管理连接池,这是爬虫进阶的第一个重要工具。我见过太多新手把 Cookie 硬编码在代码里,过期一次就慌半天,这就是没有理解 Session 的作用。
一个典型 Session 使用场景是这样的:
import requests session = requests.Session() session.headers.update(build_headers()) # 第一次访问首页,获取初始 Cookie session.get("https://example.com/", timeout=5) # 模拟登录流程(加密参数的处理此处略过) login_resp = session.post("https://example.com/login", data={"username": "xxx", "password": "yyy"}) # 后续所有请求都自动携带登录态 detail_resp = session.get("https://example.com/user/detail", timeout=5)Session 的价值不只是自动带 Cookie,它还能保持 TCP 连接复用,在大规模请求场景下显著降低握手开销,这就已经算效率优化的一部分了。
但登录态有了,行为轨迹也要像人才行。所谓行为轨迹,就是你的请求序列要符合人类访问习惯。人类不会 0.5 秒刷新一次页面,也不会从列表页第 1 页直接跳到第 50 页,再跳回第 3 页。我在写采集脚本时,会给每次请求之间加入随机延时,时长的分布模拟真实阅读节奏:
import time import random def human_delay(): # 基础停留时间 1-3 秒,偶尔长一点 delay = random.uniform(1, 3) # 20% 概率出现长时间阅读 if random.random() < 0.2: delay += random.uniform(3, 8) time.sleep(delay)这里要强调一下:随机延时是“成本控制”的核心。你可以在 5 分钟内采完 1000 页,但这基本等于告诉对方“我有病且不正常”。把 1000 页拉长到 30-60 分钟,虽然时间长了,但对方服务器的压力小、账号和 IP 的风险也低。这个节奏的取舍是良心工程,也是技术工程。
3.2 处理 JS 渲染与动态数据接口
现在的网站,数据很少直接写在 HTML 里了。要么用 Vue/React 这类框架做客户端渲染,要么把真实数据隐藏在一个动态接口里,HTML 里只有一堆带加密参数的 JS 逻辑。这两种情况,靠requests.get()拿到的只是空壳。
处理 JS 渲染,行业里最常见的方案是 Selenium/Playwright。Playwright 是我现在的主力,比 Selenium 更现代,自带等待机制、浏览器上下文管理,而且能很方便地绕过很多基础的 WebDriver 检测。对于新手,我建议从 Playwright 入手,别在 Selenium 上浪费时间了。当然,使用自动化浏览器要克制,它效率低、资源占用大,能不用就不用。
更高效的方案是直接找数据接口。这里的思路是:打开浏览器的开发者工具,切到 Network 面板,刷新页面,找到返回 JSON 的那个 XHR 请求,分析它的 URL、请求头、请求参数。有些站点的接口参数很简单,只有页码和数据 ID,这种直接构造请求就能拿到,效率远高于渲染浏览器。
但严格来说,直接绕前端找接口这种方式,对于公开数据的抓取属于常规技术手段,只要不逆向破解签名、不盗取需要权限的数据,风险是可控的。对于那些参数带加密逻辑的接口,我的态度是:如果加密参数是用于防篡改、防机器人,而且我没有合法授权,那就放弃这个目标,转头找数据是否在别的公开入口存在。可别去搞逆向破解那一套,那是把自己往火坑里推。
4. 效率优化:从单线程到分布式
4.1 并发模型选型:线程池、异步与限速
爬虫的效率瓶颈通常不在“网速”,而在“等待”。一次 HTTP 请求的耗时大头是往返延迟,而不是数据量本身。如果你一个个请求串行发,每个耗时 1 秒,1000 个请求就要 1000 秒,约 17 分钟。但如果能同时发 10 个请求,理论上只需要 100 秒左右。这就是并发优化的核心价值。
Python 爬虫的并发方案,我推荐按场景选择:
- 任务数量少(几百个以内)、逻辑简单:
ThreadPoolExecutor,代码直观、调试容易。 - 任务数量大(几千到几十万)、以 IO 为主:
asyncio+aiohttp,性能最优,不需要多线程的锁和上下文切换。 - 页面复杂、需要渲染:Playwright 自带的并发上下文,或者用多进程隔离任务。
线程池是我日常最常用的方案,因为爬虫的绝大多数场景都是 IO 密集型,线程池已经能把效率拉满,代码还简单:
from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_page(url): resp = session.get(url, timeout=5) if resp.status_code != 200: raise Exception(f"HTTP {resp.status_code}: {url}") return parse_page(resp.text) urls = [f"https://example.com/list?page={i}" for i in range(1, 101)] results = [] with ThreadPoolExecutor(max_workers=8) as executor: future_map = {executor.submit(fetch_page, url): url for url in urls} for future in as_completed(future_map): url = future_map[future] try: results.append(future.result()) except Exception as e: print(f"任务失败 {url}: {e}")这里max_workers=8是重点,不是越大越好。线程开太多,对方服务器的 TCP 连接数会激增,很容易触发流量清洗和 WAF 拦截,你的本地网络也可能先扛不住。我自己的经验值是:单 IP 下 4-8 个并发是安全的甜点区,配合随机延时可以把采集速度提升 5-10 倍,同时不引起注意。
如果你要走异步路线,我补充一个 aiohttp 的经典框架,供你对比选型:
import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: if resp.status == 200: return await resp.text() return None async def main(): connector = aiohttp.TCPConnector(limit=10) timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession(connector=connector, timeout=timeout, headers=build_headers()) as session: tasks = [fetch(session, f"https://example.com/item/{i}") for i in range(100)] results = await asyncio.gather(*tasks, return_exceptions=True) asyncio.run(main())TCPConnector(limit=10)的作用是限制并发连接数,相当于线程池的max_workers。异步方案的优势是内存占用低、单机可以跑到上千并发(如果你敢的话),但调试起来比线程池麻烦,异常处理也更考验基本功。新手我还是建议先线程池,等理解透彻了再转异步。
4.2 去重、断点续爬与数据落地提速
爬虫跑得再快,如果做了很多重复劳动,效率也是浪费。去重是爬虫工程里最容易偷懒也最值得优化的地方。我见过不少人的去重方案是:
if url not in visited: visited.append(url)这个写法的毛病有两个:list的查找是 O(n),几千条数据时性能还能忍,几十万条时每次判断都卡到怀疑人生;而且visited只存在内存里,程序一重启就全丢了。改进方向是换用set或 Bloom Filter,并做持久化。
Bloom Filter 是新手指不太懂但很实用的东西。它用少量内存就能判断“这个元素以前见过没”,代价是可能误判——把一个没见过的元素说成见过的,但绝不会把见过的说成没见过的。对于 URL 去重这种场景,极低的误判率完全可接受,因为误判的后果只是漏采一个页面,不值得为它消耗大量内存。如果你采集规模在百万以内,直接用redis的 SET 或者sqlite的 UNIQUE 索引就足够,数据量再大才需要引入 Bloom Filter 方案。
断点续爬这块,很多新手没概念。脚本跑了一半挂了,重启后从头再来,前面的进度全部作废。解决思路是把已完成的页面 ID 记录到本地文件或数据库里,每次启动前读取进度,从断点继续:
import os import json PROGRESS_FILE = "progress.json" def load_progress(): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, "r") as f: return json.load(f) return {"last_page": 0} def save_progress(page): with open(PROGRESS_FILE, "w") as f: json.dump({"last_page": page}, f) # 启动时加载进度 progress = load_progress() for page in range(progress["last_page"] + 1, 1001): fetch_page(page) save_progress(page) # 每完成一页就记录一次注意保存进度的频率要适中,每页都写文件对磁盘不太友好,我通常每完成 10-20 页批量更新一次,这样即使中途挂了,最多丢失一小段进度。
最后说数据落地。采集到的数据别一锅端写 CSV,编码问题、字段错位、中断半行都会让你想砸键盘。我建议所有数据先入 SQLite,最后再统一导出成需要格式。SQLite 单文件、零配置、支持事务,是爬虫数据落地的绝佳中转站。采集完再SELECT出来做清洗、转换、导出,整个过程稳定得多。
5. 常见问题与排查技巧实录
5.1 高频故障速查表
我把这几年实操中最常摔的跟头整理成了一张速查表,建议收藏。每个问题我都给出判断思路和解决路径,而不是只丢一个“重试”的万能答案。
| 现象 | 常见原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 突然全部 403 | 单 IP 触发频控或已封禁 | 换无痕浏览器手动访问同一页面,若正常说明 IP 问题 | 切换代理;降低并发;观察封禁时长 |
| 间歇性 403/429 | 请求频率过高,触发限流 | 检查日志中错误率的时间分布 | 增加随机延时;降低max_workers;加入指数退避重试 |
| 返回 HTML 没有数据 | 数据由 JS 动态渲染 | 开发者工具 Network 面板查找 XHR 接口 | 构造接口直接请求,或改用 Playwright |
| Cookie 频繁失效 | 登录态过期被踢 | 手动登录后观察 Cookie 有效期 | 实现 Cookie 自动续期;降低请求频率,别频繁触发风控 |
| 返回乱码 | 编码判断错误 | 查看响应头Content-Type的 charset | 用resp.encoding = resp.apparent_encoding兜底 |
| 目标站验证码频繁出现 | 行为轨迹不够像人 | 检查访问间隔、页面顺序 | 优化随机延时;增加“浏览页面”的中间步骤,别一上来就精准命中目标数据 |
| 采集速度上不去 | 并发太低,或解析耗时过长 | 分析耗时组成(请求 vs 解析) | 线程池 + 异步解析;解析改用 lxml 而非 BeautifulSoup(如需);用连接复用(Session) |
| 数据库写入报错 | 数据格式脏、字段超长 | 抓取原始数据打印前 50 条 | 清洗规则前置;写入前做类型校验;用 SQLite 事务批提交 |
5.2 三个少有人提的进阶技巧
技巧一:别用同一个指纹打天下。UA 要随机、Cookie 要管理、HTTP/2 的指纹也要尽量模拟。如果一个网站用了 TLS 指纹检测,那么 requests 这种基于 OpenSSL 的客户端很容易被认出来。遇到这种情况,进阶的解法是curl_cffi这个库,它能在 Python 层模拟浏览器的 TLS 指纹,比手动折腾底层 OpenSSL 简单得多。当然,用得最多的地方还是那些对反爬要求比较高的公开数据接口,前提仍然是不影响对方服务稳定。
技巧二:解析阶段用 lxml 替代 BeautifulSoup。BeautifulSoup 胜在易用,但性能上被 lxml 甩开几条街。同样一个 HTML 解析任务,lxml 通常快 5-10 倍。采集百万级页面时,解析速度就是实打实的效率优势。我第一次把解析引擎切到 lxml 时,同样一批数据,耗时直接砍了将近六成,那天的感受就四个字:相见恨晚。
技巧三:建一个请求日志流水。大多数人写爬虫不加日志,出问题了靠print救火。我的习惯是用标准库logging打结构化日志,包含时间、URL、状态码、耗时、代理 IP。排查问题时,翻日志比翻代码快得多。尤其是代理封禁率突然增高的时候,日志能告诉你是不是某个代理在拖后腿,而不是让你盲目抓瞎。
写在最后的一点体会
爬虫写了这几年,我最大的感受是:这个技术方向的天花板不在代码,而在分寸感。你知道目标站能容忍什么样的访问节奏,知道哪些数据能碰、哪些数据碰不得,知道什么时候该停下来,这些判断力才是经验积累下来最值钱的东西。技术层面的反爬突破和效率优化,说白了都是为“用合理的成本拿到公开数据”这个目标服务。当初我在那个招聘网站被 403 拍了一脸的时候,如果有前辈告诉我这些思路,我可能能少走两个星期的弯路。希望这篇文章能帮你把这段路走得顺一点。如果你在实操中遇到什么奇葩反爬手段,也欢迎回来交流,我大概率也踩过同一个坑。