CC防护的核心不是“封IP”这么简单,真正能精准拦截恶意爬虫的CC策略,必须建立在请求行为建模和特征指纹识别之上。多数站点配置的CC规则只做了频率限制,比如单IP每秒超过20次请求就触发拦截,这种一刀切的方式对分布式爬虫和低频慢速爬虫几乎无效。恶意爬虫现在普遍采用代理池轮换IP、随机化UA、模拟真实浏览器指纹、控制请求间隔在阈值以下,传统基于IP维度的频控很容易被绕过。要精准拦截,需要从请求序列特征、TLS指纹、HTTP头一致性、资源访问路径逻辑四个维度同时下手。

先看请求序列特征。正常用户访问一个页面后,会按照页面内资源引用顺序加载CSS、JS、图片,然后产生异步接口请求,整个过程有明确的时序依赖。而爬虫往往直接抓取HTML后跳过静态资源,或者并发请求大量列表页和详情页,缺少资源加载的先后关系。在WAF或Nginx层可以记录每个会话的请求序列,如果发现某个来源在短时间内只请求文档型URL、从不请求关联静态资源,或者请求的详情页ID呈连续递增、随机跳跃但无站内跳转来源,基本可以判定为爬虫。这个判断不依赖IP信誉库,对代理池轮换同样有效。

TLS指纹是另一个高可靠维度。JA3和JA3S指纹可以识别客户端在TLS握手阶段的密码套件顺序、扩展列表、椭圆曲线参数等特征。Python的requests、Scrapy、Go的http.Client、Java的OkHttp等常见爬虫库都有非常稳定的JA3指纹,与真实Chrome、Safari、Edge的指纹差异明显。即使爬虫设置了与浏览器一致的UA,TLS指纹仍然会暴露其底层HTTP库。在Nginx上可以通过nginx-module-vts或专用JA3模块提取指纹,在Cloudflare类服务中可以直接基于TLS指纹做托管规则。把JA3指纹加入CC判断条件,命中已知爬虫库指纹的请求直接拒绝,准确率远高于UA判断。

HTTP头一致性检查也值得做。真实浏览器发出的请求头有固定的键顺序和完整度,比如Sec-Ch-Ua、Sec-Fetch-Dest、Sec-Fetch-Mode、Sec-Fetch-Site这些现代浏览器几乎必带,而requests默认只发User-Agent、Accept、Accept-Encoding、Connection等少数几个头。更细的检查包括Accept-Language与IP归属地是否匹配、Accept-Encoding是否声明了br、Header键名大小写是否符合浏览器规范。很多爬虫开发者只改UA,不补全其他头,或者头顺序与浏览器明显不同。在CC规则里增加头完整性校验,对缺失Sec-Fetch系列头的请求提高风险评分,能过滤掉大量脚本化请求。

资源访问路径逻辑是业务层面的判断。一个正常用户从列表页进入详情页,再可能发起收藏、下单、评论等动作,路径有业务逻辑约束。爬虫为了效率,往往直接枚举详情页ID,或者从站点地图批量抓取,没有列表页到详情页的跳转关系。可以在Nginx的access_log中提取Referer和当前URL,建立访问路径图。如果某个来源的详情页请求中,超过80%没有来自站内列表页或搜索页的Referer,或者Referer字段为空的比例异常高,就触发风险标记。这个规则对低频爬虫尤其有效,因为低频爬虫可以控制请求频率,但很难伪造完整的站内跳转路径。

具体到Nginx配置层面,可以组合使用limit_req、map、if判断和自定义Lua脚本。下面是一段基于OpenResty的示例,演示如何结合JA3指纹、头完整性和路径逻辑做精准拦截:

# nginx.conf 中引入JA3模块后
http {
    # 定义已知爬虫库的JA3指纹(示例值,实际需采集后替换)
    map $ssl_ja3 $is_crawler_ja3 {
        default 0;
        "e0b9c8e7a5d3f2a1b4c6d8e9f0a1b2c3" 1;  # python requests
        "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9" 1;  # scrapy
        "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8" 1;  # go http client
    }

    # 检查Sec-Fetch头完整性
    map $http_sec_fetch_site $has_fetch_meta {
        default 0;
        "" 0;
        "same-origin" 1;
        "same-site" 1;
        "cross-site" 1;
        "none" 1;
    }

    server {
        listen 443 ssl;
        
        # 在access阶段做组合判断
        access_by_lua_block {
            local ja3 = ngx.var.ssl_ja3 or ""
            local is_crawler_ja3 = ngx.var.is_crawler_ja3 or "0"
            local has_fetch_meta = ngx.var.has_fetch_meta or "0"
            local referer = ngx.var.http_referer or ""
            local uri = ngx.var.uri
            
            local risk_score = 0
            
            -- JA3指纹命中已知爬虫库,直接高风险
            if is_crawler_ja3 == "1" then
                risk_score = risk_score + 60
            end
            
            -- 缺少Sec-Fetch头
            if has_fetch_meta == "0" then
                risk_score = risk_score + 20
            end
            
            -- 详情页无站内Referer
            if string.match(uri, "^/product/%d+") and 
               not string.match(referer, "^https?://[^/]*example%.com") then
                risk_score = risk_score + 20
            end
            
            -- 风险分超过阈值则拒绝
            if risk_score >= 60 then
                ngx.status = 403
                ngx.say("Access Denied")
                ngx.exit(403)
            end
        }
    }
}

上面的逻辑是给每个请求打风险分,而不是简单的是非判断。这样做的意义在于:单一维度可能误判,但多维度叠加后准确率显著提高。比如某些企业内网出口的请求可能缺少Sec-Fetch头,但JA3指纹正常、路径逻辑正常,风险分只有20,不会被误拦。而一个使用requests库的爬虫,即使挂了代理IP、设置了完整UA,JA3指纹命中直接加60分,立刻被拦截。风险评分模型比硬规则灵活得多,后续可以根据误拦日志调整各维度的权重。

代理池轮换IP的爬虫还有一个特征:大量不同IP共享相同的请求模板。虽然IP在变,但请求头组合、TLS指纹、请求路径模式、请求间隔分布是高度一致的。可以在Redis中维护一个“行为指纹”计数,将JA3指纹、UA、Accept-Language、请求路径正则匹配结果组合成一个哈希值,统计不同IP但相同行为指纹的出现频率。如果同一个行为指纹在短时间内关联了超过50个不同IP,说明这是一个分布式爬虫在轮换代理。此时不需要封单个IP,而是直接对该行为指纹的所有请求进行拦截。这个思路解决了代理池的核心难题——IP维度失效时,行为维度仍然稳定。

慢速爬虫的识别需要看时间序列特征。正常用户的请求间隔是随机波动的,有浏览、阅读、思考的时间,而慢速爬虫为了控制频率,往往设置固定间隔,比如每3秒请求一次,间隔方差极小。在Nginx日志中按会话聚合请求时间戳,计算相邻请求间隔的标准差。如果标准差低于0.5秒且请求数量超过20次,说明是程序化定时请求。这个判断需要一定的日志分析能力,可以用Lua在内存中维护滑动窗口,或者将日志接入实时分析系统。对于中小站点,更实际的做法是在应用层记录每个会话的请求时间戳数组,在会话结束时做一次判断,命中则将会话标记为爬虫并加入黑名单。

还有一个常被忽略的维度是Accept头与响应内容的匹配。正常浏览器请求HTML时Accept会包含text/html,请求图片时Accept会是image/avif,image/webp等。很多爬虫不管请求什么资源都发*/*或者只发text/html。如果请求的是图片资源但Accept不包含image类型,或者请求的是API接口但Accept不包含application/json,说明客户端并不真正理解资源类型,大概率是脚本。在CC规则里对资源类型和Accept头做交叉校验,能抓出一批粗制滥造的爬虫。

验证码和JS挑战是最后的防线,但不要对所有可疑请求都上验证码,那样会严重影响用户体验。更合理的策略是:风险分在40到60之间的请求,返回一个JS挑战页面,要求客户端执行一段JavaScript并回传计算结果。真实浏览器会自动执行,爬虫如果用的是requests或Scrapy则无法执行JS,自然被过滤。风险分超过60的直接拒绝,低于40的正常放行。这样分层处理,既保证了拦截效果,又最小化了对正常用户的影响。JS挑战的实现可以在Nginx层用Lua生成一段简单的数学运算或时间戳签名,不需要引入完整的验证码服务。

精准拦截恶意爬虫的CC防护,本质上是把安全判断从“你是谁”升级为“你像不像真人”。IP、UA这些显性标识都可以伪造,但请求序列、TLS指纹、头完整性、路径逻辑、时间间隔分布这些行为特征很难同时伪造。攻击者可以花成本模拟其中一两项,但要全部模拟到与真实浏览器一致,成本会急剧上升,最终超过爬取数据的收益。这就是行为建模的核心价值——不是让爬虫完全进不来,而是让爬虫的绕过成本高到不划算。在实际部署中,建议先开启日志采集,分析现有流量的行为基线,再逐步上调风险阈值,避免一开始就误伤正常用户。