爬虫必知:10个高频防爬坑位与工程化解法

发布时间:2026/9/20 11:16:45
爬虫必知:10个高频防爬坑位与工程化解法
跟”爬虫“打交道这些年我一个很深的感受是大多数新手根本不是栽在不会写代码上而是栽在”看起来能跑跑一会儿就挂“这种问题上。刚学爬虫时我也经历过请求头没写全被拒、User-Agent万年不变被识别、Cookie过期后数据全变成登录页面的情况第一次遇到时整个人都是懵的。后来才发现这些都属于防爬机制里的高频坑位基本每个入门者都会踩一轮。这篇文章就围绕10个高频防爬坑展开结合我自己实际调试时的现象、排查思路和最终解决办法把能直接复用的代码、参数和流程都整理出来。不管你是刚接触Python爬虫还是已经写了不少脚本正在跟反爬体系纠缠这篇内容应该都能帮你少走几段弯路。尤其是那些”看起来像是玄学“的封禁问题大多数背后都有明确规律。1. 先弄明白反爬这回事1.1 反爬的本质是成本对抗很多教程会把反爬描述成一道技术关卡好像只要学会了某个库、某个技巧就能一路畅通。但以我实际观察反爬的本质是一场成本对抗网站方投入资源来提升爬取门槛爬虫方则投入技术来降低成本。双方都在计算投入产出比。对你来说判断一个反爬方案好不好标准不是”能不能突破“而是”用多少成本换回多少有效数据“。同一个网站可能同时存在简单校验、行为分析、加密参数、验证码等多层防线。如果一上来就硬刚最难的加密算法往往浪费大量时间。先评估哪些数据必须拿、哪些接口可以绕、哪些页面允许低频率访问再决定投入多少技术资源这才是爬虫工程化的起点。1.2 新手掉坑的共性我复盘过很多卡住的案例发现新手踩坑高度集中在几个地方只关心状态码不关心请求头是否完整能用requests直接拿到HTML就忽略了页面实际是动态加载的脚本写好后不控制节奏一口气把几千个请求打过去遇到登录态失效、字段加密第一反应是去破解而不是先想有没有更简单的替代接口只关注”能不能爬到“忽略了目标网站的合法边界。这些问题单独看都不算复杂但串在一起就会让一个原本很简单的采集任务变成连续踩坑现场。1.3 十大高频坑位速查我按请求层、访问层、会话与渲染层、对抗层、解析与合规层五个维度整理了10个高频坑位先给一张速查表层级坑位典型现象核心原因快速解法请求层坑一请求头不完整403、405、被重定向服务端校验Headers补齐请求头字段请求层坑二UA常年不变访问正常但数据异常指纹特征明显建立UA池轮换访问层坑三IP被限制429、连接超时、封禁页同IP请求过多限速重试代理池访问层坑四频率失控验证码增多、数据不稳定无节流策略随机延时与限速器会话层坑五Cookie过期返回登录页会话未保持或过期Session保活自动恢复渲染层坑六动态页面无数据HTML有框架没内容JS渲染后才出数据接口直连或Playwright对抗层坑七验证码拦截滑块、点选、短信验证风控触发降频行为模拟打码对抗层坑八加密签名参数带sign、token接口签名校验逆向签名或找替代接口解析层坑九编码与解析异常乱码、字段缺失、JSON报错编码判断错误/结构变化编码探测容错解析合规层坑十只突破不守边界法律与业务风险忽略采集边界合规评估过程留痕下面我把每个坑逐个拆开讲配合代码和现场经验。2. 请求层两大坑请求头与UA2.1 坑一请求头不完整一上来就被拒之门外很多新手拿到URL后第一件事就是写requests.get(url)然后发现返回403或者被重定向到验证页。问题往往不在URL而在请求头。服务端收到请求时会先检查User-Agent、Accept、Referer这些基础字段。没有User-Agent的请求在服务端看起来基本等同于”非浏览器程序“直接拒绝是常规操作。我整理了一套能覆盖大多数网站的请求头模板import requests headers { 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Referer: https://example.com/, Upgrade-Insecure-Requests: 1, Cache-Control: max-age0, } resp requests.get(https://example.com/list, headersheaders, timeout10) print(resp.status_code)值得注意的是Accept-Encoding。如果你不手动设置requests默认不会发送这个头有些服务端会根据它来决定是否返回压缩内容。加上这个字段后响应可能是gzip压缩的而requests会自动解压所以不用重复处理但如果你用urllib等底层库就得自己解压了。2.2 坑二User-Agent 常年不变指纹一查一个准如果说请求头不完整是第一道坎那么UA固定就是第二道。服务端可以统计同一个UA在单位时间内的请求总量。就算你换很多IP只要UA不变依然能把你关联起来。解决思路是维护一个UA池每次请求随机取一个。UA池可以覆盖不同操作系统、不同浏览器版本import random UA_POOL [ # Windows Chrome Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, # Windows Edge Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0, # macOS Safari Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15, # Linux Firefox Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0, # iPhone Safari Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1, ] def build_headers(refererNone): headers { User-Agent: random.choice(UA_POOL), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, } if referer: headers[Referer] referer return headers2.3 请求层的统一解法请求头模板与UA池把坑一和坑二放一起说是因为它们通常一起出现也必须一起解决。我的建议是不要在每个请求里手动写headers而是封装一个公共的请求函数或Session工厂。把UA池、请求头模板、超时时间、重试逻辑全部放进去后续所有爬虫代码共用这一套。除了请求头本身还有一个容易被忽略的点Headers顺序。虽然大部分服务端不校验顺序但极少数风控严格的网站会校验。requests的Headers是dict不会保持写入顺序如果你遇到这类网站可以用requests.models.CaseInsensitiveDict或者直接在底层定制Header顺序。不过这种情况非常少先不用焦虑等到真遇到再处理。3. 访问层两大坑IP限制与请求频率3.1 坑三IP被限制直接断崖式封禁IP限制是最经典的防爬手段也是最容易判断的现象。表现通常是前几十个请求正常然后突然开始返回429、503或者直接跳到一个”访问频繁“的提示页。我在实际测试里遇到过封禁阈值很低的网站每IP每秒钟请求超过2次就触发限制。这种情况下唯一的长期方案是控制单一IP的并发和频率必要时再引入代理池。但代理池不是银弹质量差的代理反而会让请求更不稳定。一个比较稳妥的重试策略是遇到429或5xx时不立即重试而是指数退避等待。requests配合urllib3的Retry可以优雅实现import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry Retry( total3, # 总重试次数 connect3, # 连接失败重试次数 read3, # 读取失败重试次数 backoff_factor1, # 退避因子等待时间为 2^n * backoff_factor status_forcelist[429, 500, 502, 503, 504], respect_retry_after_headerTrue, ) adapter HTTPAdapter(max_retriesretry) session.mount(http://, adapter) session.mount(https://, adapter)这个方案里respect_retry_after_headerTrue很重要它会让请求等待服务端Return的Retry-After时间而不是立刻重试。对于429状态这个参数能显著降低被继续封禁的概率。如果目标网站的IP限制确实严格再考虑代理池。代理池的选型有几个要点稳定性、延迟、匿名度。优先选官方API维护的短效代理而不是从网上抓的免费列表后者经常失效反而影响数据质量。3.2 坑四请求频率失控比IP更早触动风控IP限制是结果请求频率失控是原因。但很多新手没意识到风控系统不只是按IP统计还会分析请求间隔的规律性。如果你的请求间隔都是精确的1秒机器人的特征非常明显。一个合理的人类访问节奏间隔应该在2到5秒之间随机波动有时还要有更长的停顿。我一般在项目里加一个简单的限速器import random import time class RateLimiter: def __init__(self, min_interval1.5, max_interval4.0): self.min_interval min_interval self.max_interval max_interval self._last_request_time {} def wait(self, keydefault): now time.time() last self._last_request_time.get(key, 0) elapsed now - last target_interval random.uniform(self.min_interval, self.max_interval) if elapsed target_interval: time.sleep(target_interval - elapsed) self._last_request_time[key] time.time()多线程采集时要给每个线程一个独立key避免所有线程共用同一个延迟计算否则一开始集体等后面又集体冲仍然会有波峰问题。更精细的做法是使用令牌桶或信号量控制并发上限但大多数中小型任务一个随机限速器加一个线程数限制就够了。3.3 节奏控制限速、重试、代理的组合实操把限速、重试和代理组合起来时我一般按这个优先级排优先降速。把单IP的QPS压到低频区间能不用代理就不用代理必要的时候上代理。代理的引入会带来连接延迟增加、失败率上升需要配合重试机制重试用于兜底。网络抖动、临时风控都会造成请求失败重试能提高成功率但不能滥用。判断是否需要代理可以看一个指标每天的采集任务量。如果目标是某个列表页总共几百个请求完全可以通过把间隔拉大来解决如果目标是全站数据动辄几万次请求那就必须做分布式IP管理。代理的另一个价值是地域切换。某些网站的搜索结果与地域强相关用不同地域的代理IP可以获得更多样的数据视角。但要注意代理IP的出口质量差别很大有的IP本身就被目标网站标记为高风险反而更容易引发验证码。4. 会话与渲染层两大坑Cookie过期和动态页面4.1 坑五Cookie与登录态过期会话断流很多网站需要登录后才能看到完整数据。新手最常见的操作是手动从浏览器复制Cookie粘到代码里然后第二天发现全部请求都返回登录页。问题在于Cookie是会话凭证有有效期。而且服务端可以主动让Cookie失效比如检测到异常IP归属地、异常操作频率时会强制踢下线。这就需要一个能自动维持会话的方案。requests库里的Session对象会自动保存服务端返回的Cookie并带到后续请求中session requests.Session() session.headers.update(build_headers()) # 第一次访问首页获取基础Cookie session.get(https://example.com/) # 登录 login_data { username: your_account, password: your_password, csrf_token: 先从页面里提出来, } resp session.post(https://example.com/login, datalogin_data) print(resp.json()) # 后续请求会自动带上登录Cookie data session.get(https://example.com/private/data).json()登录态过期还需要自动恢复。我在项目里常写一个ensure_login装饰器当检测到响应里出现登录页特征时自动重新登录一次再重放请求def ensure_login(func): def wrapper(*args, **kwargs): resp func(*args, **kwargs) if login in resp.url or resp.status_code in (401, 403): do_login() resp func(*args, **kwargs) return resp return wrapper这个方案的前提是你有一套稳定的登录逻辑。如果你的账号有验证码那每次自动登录都会触发验证码反而更麻烦。一个实用的经验是尽量降低登录频率靠Cookie的有效期撑住大部分请求。4.2 坑六动态渲染页面拿不到数据有些页面你直接用requests拿HTML能看到完整标签和结构但关键数据区域是空的。这种情况十有八九是页面使用JavaScript动态渲染。数据需要浏览器执行脚本后才出现在DOM里。新手遇到这类页面第一反应可能是用selenium或者playwright直接模拟浏览器打开页面再提取HTML。但这样做的问题很明显每次都要启动浏览器实例内存占用高速度慢而且容易被检测。更聪明的做法是先找接口。打开浏览器的开发者工具切到Network面板刷新页面重点关注XHR/Fetch请求。很快就能发现真正的数据来自某个接口返回的是JSON格式。抓到接口后直接requests请求这个JSON接口比渲染HTML效率高一个数量级。我通常的工作流是打开目标页面按F12进入开发者工具切到Network刷新页面筛选XHR或Fetch类型的请求逐个查看响应数据找到包含目标字段的接口分析接口的参数直接构造请求。这个流程同样适用于App抓包场景只是把浏览器换成了抓包工具。接口直连的好处是数据结构清晰、响应体积小、解析简单也不会因为DOM结构变化导致提取规则失效。4.3 Playwright的正确打开方式当然不是所有数据都能通过接口拿到。有些接口有很强的签名校验或者数据经过渲染后不可逆转换这时候再考虑浏览器渲染方案。Playwright比老牌Selenium更适合现代爬虫。它支持异步操作内置了等待机制还能拦截请求和响应。一个典型的动态页面抓取例子import asyncio from playwright.async_api import async_playwright async def fetch_dynamic_page(url): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) page await browser.new_page() # 拦截图片、样式等非必要资源减少资源消耗 await page.route(**/*.{png,jpg,jpeg,gif,svg,css,woff,woff2}, lambda route: route.abort()) await page.goto(url, timeout30000) # 等待目标选择器出现 await page.wait_for_selector(.product-list, timeout10000) html await page.content() await browser.close() return html html asyncio.run(fetch_dynamic_page(https://example.com/products))Playwright还有一个非常好用的能力可以保存真实浏览器的Cookie和LocalStorage供后续请求复用。比如你先手动登录一次保存状态之后代码里加载这个状态就能跳过登录。这比我之前手动拼Cookie靠谱太多了。需要注意的是即使使用浏览器渲染也不能忽略请求频率。无头浏览器只是cover了渲染层风控系统依然能根据你的IP、请求频率、浏览器指纹来判断是否正常。5. 对抗层两大坑验证码与加密签名5.1 坑七验证码拦截验证码可能是最让人头疼的一环。处理验证码的第一原则是尽量避免触发它。大部分验证码的出现不是因为目标网站针对你而是因为你触发了某个风控阈值。把频率降下来很多验证码会自然消失。如果验证码已经出现就得区分类型图形验证码纯文本识别可以用OCR方案也可以用打码平台滑块验证码需要模拟拖拽轨迹入门时可以用工具拖动但轨迹模拟不自然容易被检出点选验证码需要语义理解通常必须借助打码平台或专门模型短信验证码一般涉及业务流程不建议自动化处理。我处理滑块验证码的经验是轨迹数据比最终位置更重要。人类拖动时会有加速、减速、甚至轻微回弹的过程如果直接用固定速度直线拖过去几乎必然被识别。模拟轨迹时可以用带随机扰动的分段速度曲线def generate_slide_track(distance): track [] current 0 mid distance * 0.7 while current distance: if current mid: step random.randint(3, 8) else: step random.randint(1, 3) current step track.append(step) return track但这只是最基础的曲线模拟真正严格的环境还会检测鼠标移动的像素级坐标、时间戳间隔、触屏设备特征等。不到万不得已不建议把验证码作为主要攻克方向。打码平台是另一种选择把验证码图片或问题发给平台平台返回答案。使用打码平台时成本和速度都要心中有数部分繁琐的验证码处理可能需要几百毫秒到几秒的时间这会拉低整体采集效率。5.2 坑八加密签名与动态Token比起验证码加密签名更隐蔽。你找接口时明明看到JSON数据就摆在Network面板里但直接复现请求却报参数错误。仔细对比后发现请求URL里多了sign、token、ts这样的参数它们是动态生成的。常见的签名逻辑是把请求参数按照一定规则排序拼接成一个字符串加上一个固定的密钥然后用MD5/SHA256等算法计算摘要。举个例子import hashlib import time def make_sign(params: dict, secret_key: str) - str: # 除去签名本身按key排序 sorted_keys sorted(params.keys()) raw_string .join(f{k}{params[k]} for k in sorted_keys) raw_string fsecret_key{secret_key} return hashlib.md5(raw_string.encode(utf-8)).hexdigest() params { page: 1, size: 20, ts: int(time.time()), } params[sign] make_sign(params, your_secret_key)如果能通过分析前端JS代码看懂签名算法就能在Python里复现。但前端代码往往经过混淆直接看很吃力。我在实际项目中优先尝试三种方案在不改动业务的前提下找找有没有未加签的替代接口。有些网站服务端会同时提供简化版接口给特定页面用用浏览器环境补齐加密逻辑。在Playwright里执行页面自带的加密函数比自己逆向省力如果必须逆向先把混淆代码格式化搜索关键词定位加密函数比如查找sign、md5、encrypt、token等字段再逐步跟踪。不要在一开始就想着把整个网站的加密逻辑全部解出来。先解决当前任务需要的那一个接口把流程跑通后续有需要再扩展。5.3 逆向工程的价值与边界做加密逆向时我一直提醒自己保留边界意识。逆向本身是一种重要的安全研究手段但它的使用必须限定在合法合规的范围内。如果你要采集的目标网站有明确的服务条款禁止自动化访问逆向出来的接口又涉及用户隐私数据或付费内容这会带来很严重的合规问题。我的建议是把逆向看作“排查技术问题”的能力而不是“突破一切防线”的工具。能用业务逻辑解决的不上逆向能用低频策略绕开的不硬破这才是一个成熟爬虫工程师的判断力。6. 解析层与其他坑编码、数据结构和合规6.1 坑九编码与解析异常数据一片乱码爬虫最挫败的时刻之一就是千辛万苦拿到HTML结果中文全变成乱码。最常见的原因是字符编码判断错误。HTTP响应头里的charset和页面meta里声明的编码有时不一致有时干脆没声明。requests库默认会根据响应头猜测编码猜测错了就用resp.text直接取出乱码。一个靠谱的用法是用resp.content拿原始字节再通过apparent_encoding或者chardet来推断真实编码import requests import chardet resp requests.get(https://example.com, timeout10) content resp.content detected chardet.detect(content) encoding detected.get(encoding) or utf-8 text content.decode(encoding, errorsignore)如果遇到GBK/GB2312的中文页面chardet很多时候能正确识别。但有的页面编码很杂我一般还会手工指定一个编码候选列表挨个尝试直到解析结果里的中文字符比例达到阈值。编码之外解析异常还有一个常见原因页面结构里混入了不可见字符、注释内容或CDATA块。用BeautifulSoup或lxml解析时建议先清理这类内容再走正常的CSS选择器或XPath流程。我还习惯在解析函数外层加一层try-except某个节点解析失败时不影响整个采集任务from bs4 import BeautifulSoup def parse_item(node): try: title node.select_one(h2.title).get_text(stripTrue) price_text node.select_one(span.price).get_text(stripTrue) return {title: title, price: price_text} except AttributeError: return None6.2 坑十只想着突破忘了合规底线这个坑不会在运行时报错但它可能带来比封号更严重的后果。某些数据涉及个人信息、平台交易数据、受版权保护的内容未经充分授权地大量抓取和公开使用可能触碰法律红线。网络上因采集数据而卷入纠纷甚至刑事案件的例子并不少见新手往往在项目启动时缺乏这个概念等数据规模做大了才后悔。合规并不是让你别做爬虫而是让你以正确的方式做。我自己的项目至少会过一遍这几个问题数据是否涉及个人信息涉及的话采集、存储、使用都要非常谨慎尽量取最小化字段目标网站是否有robots.txt虽然不是所有场景都有强制力但它表明了一种访问义务你是否声明了爬虫身份在UA里带上项目名、联系方式或公司域名是很专业的做法也让对方有渠道联系你是否过度消耗目标资源如果目标网站是小站点单片服务器扛不住高频请求就算技术上能爬到也不该这么做。这些问题的答案会影响你的技术选型如果数据是公开的、低频的、非个人化的普通爬虫方案完全够用如果涉及敏感数据最简单安全的做法是寻找官方开放API或者数据授权合作渠道。6.3 把合规写进项目流程我现在的团队在做采集需求时会先填一张简单的检查表包含数据用途、数据字段、请求量级、目标站点性质等内容。这张表不是为了走形式而是让每个参与者从一开始就知道边界的所在。合格流程应当做到过程留痕日志记录里保存请求时间、目标URL、UA标识、数据保存路径。一旦被问询能如实还原做了哪些操作。即使不涉及争议留痕也能帮助你事后排查问题。7. 一个可以少踩坑的工程化思路把十个坑过完之后我想分享一套自己沉淀下来的工程化思路它可能比单独任何一个小技巧都有用。我们把一次采集任务拆成四层请求层、运行层、解析层、存储层。每一层各司其职互不干扰。请求层统一管理Headers、UA池、Cookie、代理和重试策略运行层负责请求频率控制、任务调度、日志记录解析层包含数据清洗、结构化、异常容错存储层负责去重、增量更新和格式统一。比如我写爬虫时会把请求处理集中在client.py把解析逻辑放在parser.py把调度逻辑放在runner.py。这样当网站改版导致解析失败时只需要改parser模块不需要动请求层代码。针对反爬升级则集中优化client模块。最后说一个我踩过很多次坑才记住的教训爬虫项目的第一版永远不要追求完整功能而是先跑通一个最小链路。从目标网站选一条最小的数据路径比如一个类目下的10条商品信息用最简单的方式拿到并保存。链路通了再逐步扩展。这个习惯能帮你避免在项目后期被一个隐藏的反爬问题卡死也能更快锁定每个坑位的具体位置。