目录

如何设计接口反爬虫:从 Token 到行为风控的完整方案

爬虫拿数据,服务端拦爬虫,爬虫绕过拦截——这场猫鼠游戏从互联网诞生就没停过。

本文从字节跳动系产品(抖音、Coze 等)的 msToken + a_bogus 机制出发,讲清楚接口反爬虫的完整设计思路,以及如何用 Go 落地实现。

/img/api-anti-crawler-design/0101.png
接口反爬虫整体防护链路


一、为什么单靠鉴权不够

大多数接口都有 OAuth token 或 Cookie 鉴权,但这只解决"是不是登录用户"的问题,解决不了"是不是真人在操作"。

一个自动化脚本完全可以:

  • 登录拿到合法 token
  • 高频调用接口批量拉数据
  • 换 IP 绕过简单的 IP 封禁

所以反爬虫要解决的核心问题是:区分真人浏览器和自动化程序


二、两层核心机制:msToken 与 a_bogus

字节系的方案把防护拆成两层,配合使用。

/img/api-anti-crawler-design/0102.png
msToken与a_bogus生成原理

2.1 msToken:服务端颁发的会话标识

msToken 本质是一段随机 bytes 的 Base64 编码,由服务端在页面加载时生成并写入 Cookie。

它本身不携带业务信息,只是服务端"认识"这个客户端的凭证。

服务端维护一个 token 状态表,校验时检查:

  • token 是否是自己签发的
  • 是否过期(通常几分钟到几十分钟)
  • 是否和请求的 IP、UA 绑定(防止被别人拿走复用)

Go 实现一个简单的 msToken 签发和校验:

package antibot

import (
    "crypto/rand"
    "encoding/base64"
    "sync"
    "time"
)

type tokenStore struct {
    mu     sync.RWMutex
    tokens map[string]tokenMeta
}

type tokenMeta struct {
    ip        string
    userAgent string
    issuedAt  time.Time
    ttl       time.Duration
}

var store = &tokenStore{tokens: make(map[string]tokenMeta)}

// Issue 生成并存储一个 msToken
func Issue(ip, ua string) string {
    b := make([]byte, 64)
    rand.Read(b)
    token := base64.URLEncoding.EncodeToString(b)

    store.mu.Lock()
    store.tokens[token] = tokenMeta{
        ip:        ip,
        userAgent: ua,
        issuedAt:  time.Now(),
        ttl:       30 * time.Minute,
    }
    store.mu.Unlock()
    return token
}

// Verify 校验 msToken 合法性
func Verify(token, ip, ua string) bool {
    store.mu.RLock()
    meta, ok := store.tokens[token]
    store.mu.RUnlock()

    if !ok {
        return false
    }
    if time.Since(meta.issuedAt) > meta.ttl {
        return false
    }
    // 绑定 IP 和 UA(可按需放宽)
    return meta.ip == ip && meta.userAgent == ua
}

单机版用 map 够用,生产环境换成 Redis,SETEX 控制过期时间即可。

2.2 a_bogus:客户端生成的请求签名

a_bogus 解决的是另一个问题:即使攻击者拿到了有效的 msToken,能不能直接构造请求?

答案是不能——因为每次请求的参数、时间戳都不同,签名结果也不同,服务端会重新计算并比对。

签名输入通常包括:

signature_input = sort(query_params) + msToken + User-Agent + timestamp + nonce

Go 实现服务端的签名校验逻辑:

package antibot

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "fmt"
    "net/url"
    "sort"
    "strings"
    "time"
)

const signSecret = "your-server-side-secret"

// BuildSignInput 按约定规则拼接待签名字符串
func BuildSignInput(params url.Values, msToken, ua, timestamp, nonce string) string {
    keys := make([]string, 0, len(params))
    for k := range params {
        keys = append(keys, k)
    }
    sort.Strings(keys)

    var parts []string
    for _, k := range keys {
        parts = append(parts, fmt.Sprintf("%s=%s", k, params.Get(k)))
    }
    paramStr := strings.Join(parts, "&")
    return strings.Join([]string{paramStr, msToken, ua, timestamp, nonce}, "|")
}

// Sign 生成签名(服务端预期值)
func Sign(input string) string {
    mac := hmac.New(sha256.New, []byte(signSecret))
    mac.Write([]byte(input))
    return base64.URLEncoding.EncodeToString(mac.Sum(nil))
}

// VerifyABogus 校验请求携带的 a_bogus
func VerifyABogus(params url.Values, msToken, ua, timestamp, nonce, aBogus string) bool {
    // 时间戳防重放:只接受 5 分钟以内的请求
    ts, err := time.Parse(time.RFC3339, timestamp)
    if err != nil || time.Since(ts).Abs() > 5*time.Minute {
        return false
    }

    input := BuildSignInput(params, msToken, ua, timestamp, nonce)
    expected := Sign(input)
    return hmac.Equal([]byte(expected), []byte(aBogus))
}

实际工程里,signSecret 不会硬编码,而是通过配置中心下发,并定期轮转。


三、行为风控:签名通过了还不算完

签名只能保证"请求格式合法",爬虫完全可以把签名算法逆向出来,照样跑。所以还需要第三层:行为风控。

/img/api-anti-crawler-design/0103.png
行为风控多维度检测指标

3.1 频率限制

最基础的一层,用滑动窗口限流:

package ratelimit

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

var rdb *redis.Client

// Allow 判断 key(如 IP 或 uid)在 window 内是否超过 limit 次
func Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
    now := time.Now().UnixMilli()
    windowStart := now - window.Milliseconds()

    redisKey := fmt.Sprintf("rl:%s", key)
    pipe := rdb.Pipeline()

    // 移除窗口外的旧记录
    pipe.ZRemRangeByScore(ctx, redisKey, "0", fmt.Sprintf("%d", windowStart))
    // 记录本次请求
    pipe.ZAdd(ctx, redisKey, redis.Z{Score: float64(now), Member: now})
    // 统计窗口内请求数
    countCmd := pipe.ZCard(ctx, redisKey)
    // 设置 key 过期
    pipe.Expire(ctx, redisKey, window*2)

    pipe.Exec(ctx)

    return countCmd.Val() <= int64(limit)
}

按接口、按用户分别限流,核心接口可以设得很严(比如 1 秒 5 次),通用接口可以宽松一些。

3.2 设备指纹

浏览器环境有很多可以采集的特征:Canvas 渲染结果、WebGL 信息、安装的字体列表、屏幕分辨率等。把这些特征哈希成一个"设备指纹",即使换 IP 也能识别同一台机器。

服务端拿到指纹后的处理逻辑:

// FingerprintScore 根据指纹特征打风险分
// 返回 0-100,越高越可疑
func FingerprintScore(fp Fingerprint) int {
    score := 0

    // 无头浏览器通常没有 WebGL 渲染器信息
    if fp.WebGLRenderer == "" || fp.WebGLRenderer == "unknown" {
        score += 30
    }
    // Canvas 指纹全为空白是 Headless 的典型特征
    if fp.CanvasHash == "empty" {
        score += 30
    }
    // 字体数量异常少
    if fp.FontCount < 5 {
        score += 20
    }
    // UA 声称是桌面浏览器但没有触摸支持
    if fp.Platform == "desktop" && !fp.HasTouch && fp.MaxTouchPoints == 0 {
        score += 10
    }
    return score
}

3.3 行为序列分析

真人用浏览器的行为有明显的随机性:鼠标移动不是直线、点击有轻微的坐标偏移、页面停留时间分布接近正态。爬虫的行为往往精确得反常。

把用户的操作序列做成时序特征,送进规则引擎或简单的模型打分,高风险的请求要求做验证码或直接拒绝。


四、把三层组合成中间件

在 Go 的 HTTP 中间件里把上面三层串起来:

package middleware

import (
    "net/http"
    "net/url"
)

func AntiCrawler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := realIP(r)
        ua := r.UserAgent()

        // 第一层:频率限制
        if !ratelimit.Allow(r.Context(), ip, 30, 60*time.Second) {
            http.Error(w, "too many requests", http.StatusTooManyRequests)
            return
        }

        // 第二层:msToken 校验
        msToken := r.Header.Get("X-Ms-Token")
        if msToken == "" {
            msToken = r.URL.Query().Get("msToken")
        }
        if !antibot.Verify(msToken, ip, ua) {
            http.Error(w, "invalid token", http.StatusForbidden)
            return
        }

        // 第三层:a_bogus 签名校验
        q := r.URL.Query()
        aBogus := q.Get("a_bogus")
        timestamp := q.Get("_ts")
        nonce := q.Get("_nonce")
        // 从 query 中移除签名参数本身,再参与验签
        verifyParams := make(url.Values)
        for k, v := range q {
            if k != "a_bogus" && k != "_ts" && k != "_nonce" {
                verifyParams[k] = v
            }
        }
        if !antibot.VerifyABogus(verifyParams, msToken, ua, timestamp, nonce, aBogus) {
            http.Error(w, "invalid signature", http.StatusForbidden)
            return
        }

        next.ServeHTTP(w, r)
    })
}

func realIP(r *http.Request) string {
    if ip := r.Header.Get("X-Real-IP"); ip != "" {
        return ip
    }
    if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
        return strings.Split(ip, ",")[0]
    }
    return r.RemoteAddr
}

五、客户端签名的密钥保护

服务端的 signSecret 好藏,客户端的签名逻辑就尴尬了——JS 代码终究要发给浏览器,有心人总能逆向。字节系的做法是:

  1. 混淆 + 定期换版本:算法藏在打包混淆的 bundle 里,每次发版都换,让逆向成本持续很高
  2. 签名算法依赖运行时环境:在 Node.js 或 Puppeteer 里跑同一段 JS,拿到的 Canvas 指纹和真实浏览器不同,签名结果也不同——服务端可以识别
  3. 不追求完全不可破解:只要让破解成本高于爬虫收益,攻击者就会放弃

这是反爬虫设计的核心哲学:不是要建造无法被攻破的城墙,而是让翻墙的代价比数据的价值更贵。


常见问题

msToken 和 JWT 有什么区别?

JWT 是自包含的,服务端无需存储就能验证;msToken 是服务端存储的随机标识,必须查询才能验证。msToken 的优势是可以随时吊销,JWT 在过期前无法主动失效。

a_bogus 签名算法泄漏了怎么办?

一旦签名算法被逆向公开,应立即发版更新算法,同时在服务端加强行为风控——爬虫通常会在短时间内大量请求,行为特征很明显,可以用频率 + 设备指纹快速识别。

反爬虫会不会误伤真实用户?

会,而且这是设计时必须权衡的。频率限制太严会影响正常的高频用户,设备指纹对换了设备的用户不友好。通常的做法是分级处理:低风险的先警告或触发验证码,高风险的才直接拦截,保留申诉通道。

小型项目需要这么复杂吗?

不需要。中小项目做好两点就够了:接口鉴权 + 频率限制。复杂的签名方案和行为风控只有在数据价值高、爬虫攻击烈度大的场景下才值得投入。


反爬虫没有银弹,每一层防护都能被绕过,但多层叠加之后综合成本就很高了。在真实的工程里,频率限制 + 简单签名 + 监控报警通常已经能挡住 90% 的爬虫,剩下那 10% 的专业对手再考虑更复杂的方案。

遇到具体的反爬场景或者有什么实现上的疑问,欢迎在评论区留言,一起聊聊。

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/api-anti-crawler-design/

备用原文链接: https://blog.fiveyoboy.com/articles/api-anti-crawler-design/