ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

爬虫进阶:突破反爬机制与提升抓取效率的实战指南

爬虫进阶:突破反爬机制与提升抓取效率的实战指南 学爬虫这件事我见过太多人死在了同一个地方本地随便找个小网站练手requests.get()一发入魂自信心爆棚。结果一上真实项目对方返回 403 都算给面子搞不好直接给你一个蜜罐数据让你拿着错误信息分析半天。更常见的是爬着爬着突然被限流IP 被临时封禁或者对方上了 JS 动态渲染源码里什么都没有。这时候你才会意识到爬虫真正的分水岭不是会不会用 requests 和 XPath而是能不能搞定反爬机制以及能不能在合规的框架下把抓取效率提上来。这篇文章就围绕这两个核心话题展开——反爬突破和效率优化。我尽量用自己实际踩坑、复盘过的经验来写不堆概念给的都是能直接抄作业的思路和代码片段。适合刚入门 requests/XPath 级别、想往进阶走的读者也适合那些写爬虫经常被 403 搞到怀疑人生的朋友。1. 先把敌情摸清反爬机制到底有哪几个层面很多人听到反爬就以为只是改个 User-Agent 的事太天真了。反爬不是一个点而是一套层层递进的识别体系。我习惯把它拆成三个维度去理解这样排查问题的时候思路会清晰很多。1.1 第一层请求头与指纹识别这一层是最基础的也是新手最先遇到的。网站服务端会检查请求头里的 User-Agent、Accept、Accept-Language、Referer 等字段判断请求是不是来自真实的浏览器。这里有个常见的误区以为只要把 User-Agent 换成浏览器的就行了。实际上很多网站的 WAF 会校验请求头字段的完整性和顺序。比如正常浏览器的请求头里会有Accept: text/html,application/xhtmlxml,...但很多爬虫脚本只带了User-Agent和Accept: */*这本身就是明显的非浏览器特征。再往上一点是 TLS 指纹和 HTTP/2 指纹这个比较深但原理不复杂。服务端可以通过 TLS 握手时的特征比如 ClientHello 中密码套件的顺序、扩展字段来判断请求到底来自浏览器还是一个写死的 HTTP 客户端库。这就是为什么有些网站你用requests怎么写都被拦截但换个库或者用浏览器自动化工具就没事。1.2 第二层IP 维度与频率控制如果请求头这关过了下一步就是频率和 IP 维度。服务端会统计单个 IP 在单位时间内的请求数、失败率、并发连接数等指标。一旦超过阈值轻则返回验证码重则直接封禁 IP。这里我特别想强调一个现象频率限制不一定是固定阈值。现在很多反爬系统会结合行为序列来判断比如你每次请求的间隔如果是固定的 1 秒、2 秒反而更容易触发风控真实用户的访问间隔是有随机波动性的。所以随机延时这个操作不只是礼貌问题也是绕过频率统计的有效手段。1.3 第三层内容动态化与前端安全技术这一层是现在的主流方向。网站不把数据直接放在 HTML 源码里而是通过 JavaScript 动态渲染或者对关键字段做字体反爬、CSS 偏移、图片混淆、WebSocket 推送。前端安全技术这块很多网站会做防止查看页面源码防止打开开发者工具之类的操作。比如监听 F12 快捷键、检测debugger语句、对点击右键做拦截。这些手段在防小白用户上有效但对爬虫来说其实只是增加了一点干扰。真正麻烦的是数据根本不在 HTML 里而是在 JS 文件里通过加密接口返回或者渲染在 Canvas 上。理解这三个层面之后你就能明白反爬突破不是一个技巧走天下而是要根据对方用了哪一层的防护针对性出招。接下来我就按照这三个层面讲对应的突破思路。2. 请求层的突破实践从 UA 伪装到 Cookie 工程这一部分讲第一层和第二层的应对策略也是日常爬虫项目中最常用的三板斧。2.1 UA 伪装与请求头补全的完整姿势先说请求头。不推荐只改 UA建议你直接用浏览器开发者工具完整复制一份真实请求的 Headers 到代码里。以 Chrome 为例打开开发者工具切到 Network刷新页面找到文档请求Doc 类型右键 Copy as cURL然后转换成 Python 字典。复制下来的 Headers 里有几个字段是容易被忽略但很重要的Accept这个字段其实从侧面暴露了客户端的类型。Accept-Encodingrequests默认会发gzip, deflate但浏览器通常是gzip, deflate, br, zstd。Accept-Language很多爬虫只带en-US,en;q0.9这种反而容易被识别为脚本因为真实中文环境浏览器通常带的是zh-CN,zh;q0.9。Referer有些站点会校验来源页面尤其是图片接口和详情页接口。有一次我爬一个资讯站的列表页怎么都返回 403后来发现就是缺了Accept-Language和Referer。把这两个字段补上之后立刻恢复正常。这个坑我记忆非常深。提示不要把 Headers 里的字段一股脑全塞进代码里。有些字段是浏览器每次请求动态生成的比如sec-ch-ua这种客户端提示头写死反而会显得可疑。核心补User-Agent、Accept、Accept-Language、Referer这四个就够用。2.2 随机 UA 池与指纹一致性进阶一点的做法是维护一个 UA 池每次请求随机选一个。但是要注意UA 是随机了其他 Headers 如果还是固定的反而会自相矛盾。比如 UA 是 Chrome on Windowssec-ch-ua-platform却是 macOS这种组合一看就是伪造的。我自己的做法是准备几套完整的浏览器指纹模板每一套包括 UA、Platform、Accept-Language、Sec-Ch-Ua 等字段然后整体轮换而不是单独随机一个 UA。这样指纹是自洽的被识别的概率会低很多。import random UA_TEMPLATES [ { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Sec-Ch-Ua: \Not_A Brand\;v\8\, \Chromium\;v\120\, \Google Chrome\;v\120\, Sec-Ch-Ua-Mobile: ?0, Sec-Ch-Ua-Platform: \Windows\, }, { User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Sec-Ch-Ua: \Not_A Brand\;v\8\, \Chromium\;v\120\, \Google Chrome\;v\120\, Sec-Ch-Ua-Mobile: ?0, Sec-Ch-Ua-Platform: \macOS\, }, ] def get_headers(): return random.choice(UA_TEMPLATES).copy()2.3 Cookie 与 Session登录态的维护是门工程很多数据接口需要登录后才能访问这就涉及 Cookie 的获取与维护。新手最常见的做法是手动从浏览器复制 Cookie 字符串写到代码里但 Cookie 有过期时间过期后又要手动重来一遍非常痛苦。更好的方案是两步走第一步用 Session 对象建立会话先访问一次登录页拿到初始 Cookie。 第二步用账号密码模拟登录或者直接使用开发者工具的登录接口抓包信息把登录后的 Cookie 保存到本地文件或 Redis。在 Python 中我通常这样写import requests import pickle session requests.Session() # 先访问登录页获取初始 Cookie session.get(https://example.com/login, headersheaders) # 模拟登录 login_data {username: xxx, password: xxx} resp session.post(https://example.com/api/login, datalogin_data) if resp.status_code 200: # 序列化保存 Cookie with open(cookies.pkl, wb) as f: pickle.dump(session.cookies, f)下次启动爬虫时直接加载with open(cookies.pkl, rb) as f: session.cookies.update(pickle.load(f))这里有个经验保存 Cookie 之前先确认登录接口返回的响应里有没有 Set-Cookie 字段。有的话requests会自动帮你存进 Session序列化保存才有意义。2.4 IP 代理池与频率控制的配合如果目标网站对单个 IP 的请求频率管控很严你就需要代理 IP 池。这里说的代理池指的是维护一批可用代理 IP按策略轮换使用。不过我得说句实话免费的代理 IP 质量很差响应慢、失效快甚至会有内容污染返回错误数据。如果你只是学习用途建议优先通过控制请求频率来规避封禁而不是一上来就上代理池。频率控制的正确姿势是随机延时 限速import time import random def random_sleep(base2, offset3): time.sleep(base random.random() * offset)在循环请求之间调用这个函数每次间隔在 2-5 秒之间波动。这样既不会太慢也不会因为间隔规律被识别。更精细的做法是结合请求响应时间来动态调整间隔——如果某次请求特别慢说明服务端可能在对你限流这时候主动把间隔拉大。2.5 第三层突破动态渲染与 JS 参数思路到了第三层数据在 JS 里渲染源码拿不到这时候有两条路第一条是分析接口。虽然页面是 JS 渲染的但数据往往还是通过 HTTP 接口返回只是这个接口带了一些加密参数比如 sign、token。打开开发者工具切到 Network筛选 XHR 请求就能看到页面加载时调用了哪些接口。难点在于这些接口的加密参数是怎么生成的。通常加密逻辑写在 JS 文件里。你需要在 Sources 面板里找到对应的 JS 文件打断点、读逻辑、推导出加密规则然后用 Python 复现。这是JS 反爬实战的核心工作。不过具体的加密破解是个大话题而且涉及和前端安全开发者攻防对抗我不能在这篇文章里教大家去逆向破解特定网站的签名算法。我建议的思路是如果是学习用途先找那些公开接口、不需要复杂签名的网站练手。如果目标网站对签名做了严格校验先确认你有合法的访问授权。不要直接对抗验证码识别、绕过付费墙这类行为这是明确的红线。第二条是用浏览器自动化方案比如 Playwright、Selenium。用浏览器渲染页面等待 JS 执行完再抓取。这会牺牲不少效率但胜在不用逆向 JS适合数据量不大、结构复杂的场景。Playwright 还有一个好处是能模拟鼠标移动、滚动等行为这些行为轨迹也能帮你降低被识别为机器人的概率。不过要注意浏览器自动化方案占用内存大并发能力弱后续你还要考虑怎么缩放。3. 效率优化的三个层次连接、并发与分布式反爬搞定了下一个问题就是效率。很多人的爬虫能跑但慢一个页面一个页面地请求几十万 URL 可能要跑好几天。效率优化有三个层次我建议从第一层开始逐层往上。3.1 第一层连接复用与超时控制很多人习惯这样写for url in urls: resp requests.get(url, headersheaders) parse(resp.text)这段代码的问题在于每次请求都新建连接、执行 TCP 握手、完成 TLS 协商然后关闭连接。当请求量上千上万时握手开销会占据相当大比例。正确的做法是用requests.Session()代替裸的requests.get()。Session 底层使用 urllib3 的 HTTP 连接池可以复用 TCP 连接。session requests.Session() session.headers.update(headers) for url in urls: resp session.get(url, timeout(5, 10)) parse(resp)再加一个细节设置超时。timeout(connect_timeout, read_timeout)这个元组写法会让超时控制更精细。连接超时设短一点3-5 秒读取超时设长一点10-15 秒避免某个慢请求卡住整个循环。我见过很多爬虫运行到一半就卡死其实不是被封了而是某个请求一直挂着没有超时。加了合理超时之后稳定性明显提升。3.2 第二层并发模型——线程、协程还是异步连接复用带来的提升有限真正的提速靠并发。Python 里的并发方案主要三种多线程用concurrent.futures.ThreadPoolExecutor简单直观适合 IO 密集型任务。协程用asyncioaiohttp单线程内做并发开销更小支持高并发连接。进程用在 CPU 密集型解析任务上但爬虫领域用得不多。如果你之前只写过同步代码我建议先学多线程因为思路简单改造成本低from concurrent.futures import ThreadPoolExecutor import requests session requests.Session() session.headers.update({User-Agent: Mozilla/5.0}) def fetch_and_parse(url): resp session.get(url, timeout(5, 10)) return parse(resp.text) with ThreadPoolExecutor(max_workers8) as executor: results list(executor.map(fetch_and_parse, urls))这里max_workers8不是随便写的。8 个并发对大多数轻量级网站来说已经不小了再高容易触发频率限制。具体数值要根据目标网站的响应速度和你的代理数量来调。协程方案在高并发场景下优势更大。aiohttpasyncio可以轻松跑几百个并发连接但代码复杂度更高需要处理异步上下文和连接池复用import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(urls): connector aiohttp.TCPConnector(limit50) async with aiohttp.ClientSession(connectorconnector) as session: tasks [fetch(session, url) for url in urls] return await asyncio.gather(*tasks)单看并发能力aiohttp 可以轻松到达几百 QPS但目标网站往往扛不住也必然触发反爬。所以把并发控制在 20-50 之间比较稳妥。3.3 分布式与消息队列什么时候才需要它如果单机并发已经跑满带宽或者需要爬取的数据量巨大千万 URL 级别单进程就成瓶颈了。这时候考虑分布式爬虫。分布式爬虫的核心思路是把 URL 列表放到一个共享的任务队列中多个机器或多个进程从队列里取任务来执行。Scrapy Scrapy-Redis 是这类场景的经典组合Redis 作为任务队列和去重集合。新浪微博、电商商品这类超大站点的全量数据采集往往需要分布式。但如果你只是爬几万条数据我建议不要上分布式成本和复杂度完全不成正比。分布式带来的管理问题任务分配、节点监控、去重策略、故障恢复会让一个新手直接崩溃。先用好单机并发等摸到性能天花板再考虑。这里提一下去重。爬虫项目里 URL 去重一定要做否则重复请求会浪费大量带宽。最简单的是内存 Set但数据量大了之后内存吃不消可以用布隆过滤器Bloom Filter来压缩内存占用。Redis 的SADD和SCARD也可以做去重指令精确且支持持久化。3.4 数据写入批量操作比单条插入快一个数量级很多人忽略的优化点是数据写入。每解析完一条数据就执行一次 INSERT当数据量到十万条级别数据库连接开销会拖累整体效率。我建议把数据攒够一批比如 100-500 条然后用批量插入写入。def batch_insert(items, batch_size200): for i in range(0, len(items), batch_size): chunk items[i:ibatch_size] cursor.executemany( INSERT INTO articles (title, url, content) VALUES (%s, %s, %s), chunk )MySQL 的executemany或 SQLite 的executemany都能显著减少数据库往返次数。对于高频写入也可以考虑先用 Redis 做缓冲队列再由后台线程批量刷到数据库。4. 解析层的坑与提速XPath 的 text() 和其他细节反爬和网络层面的优化做得差不多了我们再看解析层。这一层的坑往往比网络层更多尤其是 XPath 里的text()函数我见过无数新手在这里栽跟头。4.1 text() 到底是干啥的XPath 的text()是节点轴函数用来选取元素节点的文本子节点。但很多人在写 XPath 时会纠结下面的区别假设 HTML 是这样div idintro 欢迎来到我的博客 span这是副标题/span /div//div[idintro]/text()返回的是div 直接子文本节点也就是欢迎来到我的博客注意 span 里的文本不会包含。//div[idintro]//text()返回的是div 下所有后代文本节点包括欢迎来到我的博客这是副标题。string(//div[idintro])返回的是拼接后的完整文本欢迎来到我的博客这是副标题。我用一个实际场景帮你理解。爬文章列表时标题通常在a标签里但如果标题里混着em加粗标签用//a/text()只能拿到一部分文字用string(//a)或者normalize-space(//a)才能拿到完整标题。# 错误示范标题里有内嵌标签时拿到不完整内容 title html.xpath(//a[classtitle]/text())[0] # 正确示范取元素完整文本 title html.xpath(string(//a[classtitle]))用 lxml 库时还要注意xpath()返回的是列表就算只有一个结果也是列表。很多人忘了取下标[0]直接把列表当字符串用然后各种摸不着头脑的报错。4.2 解析库性能到底差多少解析库的性能差距比大多数人想象中大得多。我实际测过同样的 HTML 片段re正则最快但只能应对结构稳定的场景lxml XPath第二快性能约为正则的 70%-80%BeautifulSoup比 lxml 慢 3-5 倍Selenium/ Playwright 渲染比上面的慢 2-3 个数量级所以我的建议是能用正则就用正则结构稍复杂就用 lxml尽量不要全程 BeautifulSoup除非你不在乎效率。BeautifSoup 的优势是语法友好、容错性好适合快速开发和小数据量场景但大规模采集时解析开销会被放大。lxml 还有一个隐性优势它对 HTML 解析的容错性很好即使网页结构不标准也能解析出结果。不过它的HTMLParser会做自动补全导致解析结果和浏览器里有细微差别自己写 XPath 时要注意属性名大小写的问题HTML 标签会被转成小写。4.3 用 text() 做条件筛选的正确方式text()还有一个很常用的场景是根据文本内容定位元素。比如要抓取所有价格对应的节点//div[span[text()价格]]这里text()价格表示精确匹配。如果要模糊匹配有两个选择contains()函数或者starts-with()//div[span[contains(text(), 价格)]] //div[label[starts-with(text(), 促销)]]这个用法在做表格类数据抓取时非常管用。比如房价网站里每个小区的标签字段用 text 匹配能省去大量遍历逻辑。5. 一次真实的排障复盘爬虫从 403 到正常运行的链路分析最后这部分我把一次真实的排障经历完整复盘一遍。有个读者在做资讯聚合项目时遇到了问题症状是爬虫正常运行了半小时后突然大量返回 403而且网站访问量也明显下降。我把排查过程整理成了一条链路你可以直接照着这个思路来。5.1 第一步区分 IP 被封还是请求头被识别出现 403 后我让他先做了一个实验用浏览器直接访问被拦截的 URL如果能正常打开说明 IP 大概率没有封禁问题出在请求特征上。如果浏览器访问也拦截说明是 IP 维度的问题需要换 IP 或降低频率。这个实验的区分非常关键能帮你快速缩小问题范围。很多新手一看到 403 就急着换代理结果问题根本不在 IP。5.2 第二步还原真实请求头实验结果是浏览器能正常访问于是我们把问题锁定在请求头。我让他把开发者工具里的完整请求头复制下来和代码里的请求头逐字段对比很快就发现了问题代码里的请求头缺了Accept-Language而且User-Agent是很老的版本。我们补全字段并换用一套最新的 Chrome UA 模板重新请求。结果请求正常了。这个案例其实暴露了一个普遍问题很多爬虫代码的 UA 一写就是几年不换浏览器已经更新好几个大版本了服务端自然认为这不是真实用户。5.3 第三步检查频率与并发请求正常后我们又遇到一个新问题爬取几百条数据后再次 403。这次我们有经验了直接查看日志发现时间戳非常规律——每次请求间隔都是精确的 1 秒。这种固定频率是典型的脚本特征很容易被风控识别。我们改成了随机延时同时控制单线程并发数问题就不再出现了。我再强调一下这个排查链条的顺序是有逻辑的先看 IP再看请求头再看频率。倒过来排查会让你浪费大量时间。5.4 这类问题的一般排查方法论把上述过程抽象成方法论就是以下四步症状先查什么后查什么对应手段首次请求就 403请求头完整性Cookie 是否缺失复制真实请求头、补 Accept 与 Referer爬一会后才 403频率与并发单 IP 请求量随机延时、限制并发、代理 IP返回 200 但数据为空内容是否 JS 渲染接口是否需签名分析 XHR 接口、启用浏览器渲染出现验证码频率过高行为轨迹可疑降低频率、模拟真实操作轨迹5.5 合规红线必须划清楚既然文章的标题是反爬突破我必须把合规的边界说清楚。这部分不是说教而是技术人必须有的底线。爬虫技术本身是中性的但使用场景必须有边界只爬取公开数据不碰需要登录才能查看的非授权数据。尊重 robots 协议即使技术上能绕过也要评估是否合适。不绕过付费墙、不破解验证码、不伪造身份信息获取受限资源。控制请求频率不以影响目标网站正常服务为代价。不采集个人隐私数据、不涉及账号密码等敏感信息。爬取的数据用于学习研究和正当用途不用于非法牟利。如果目标站点的数据和反爬机制不允许爬取但你需要这些数据正确做法是寻找官方 API 或联系数据方获取授权而不是强行突破。这也是我从一个纯粹的技术爱好者转变成从业者之后体会最深的一点项目能跑不算本事能合规地长期跑才算本事。写在最后回到开头的问题——爬虫的能力分水岭到底在哪里我的答案是不在你会多少库而在于你能不能用系统化的思路定位并解决为什么被拦和怎么跑得更快这两个问题。反爬与爬虫的技术博弈一直在升级但底层的思考方式是稳定的先分类识别防护类型根据类型选择对应的突破策略同时在效率和合规之间找到平衡。最后分享一个我自己的操作习惯每次写爬虫项目我都会先用浏览器开发者工具完整记录一次真实的访问请求把请求头、Cookie、接口参数全部截图或存成文件再开始写代码。这个习惯在排障时能帮你节省大量时间——因为当你被反爬搞到焦头烂额时手头有一份真实请求样本作为参照排查速度是完全不同的。如果你的爬虫也被某个反爬机制卡住了不妨从这份请求样本开始逐字段对比大概率能找到突破口。
返回列表