反爬虫与安全防护体系 — 设计手记

从一次自主发起的防护评估开始,到逐条分析威胁、否定不合适的方案、落地真正有效的防护,最终形成一个分层反爬体系。本文档完整记录了决策过程、取舍理由和架构设计。


一、为什么需要反爬——威胁分析

在实施任何防护前,需要先清点被保护对象的资产价值。不同资源被爬取的后果差异很大,统一防护等级会导致过度设计或防护不足。

1.1 资源分级

资源 价值 被爬取后果 优先级
文章内容(HTML 页面) 内容资产,公开可读 文章被一键搬运至盗版站
文章数据 JSON 接口 加速了批量抓取,数据本身同公开内容 同上,但便利了爬取者
实时金融行情接口 各交易所公开行情,有缓存机制 高频轮询加重数据库负载
LLM 对话接口 每次请求消耗 API 调用费用 直接经济损失——爬虫刷接口等于刷服务器费用
管理后台 需登录凭据 暴力破解密码、未授权操作

核心结论:如果只做一道防护,应优先保护 LLM 接口。这是唯一有直接成本的资源。

1.2 常见认知纠正

反爬设计中最容易出错的不是技术实现,而是对"什么值得防"的判断。

搜索引擎爬虫是流量来源,不应拦截。 Googlebot、Bingbot 等爬取文章页生成索引,直接带来搜索流量。反爬策略必须为搜索爬虫开放白名单。

AI 联网爬虫是额外曝光渠道,不应拦截。 GPTBot、Claude-Web、CCBot 等抓取页面后,内容可能出现在 AI 回答中,为网站带来额外的展示机会。

CORS 不防爬虫。 CORS(跨域资源共享)是浏览器端的安全机制,限制的是 一个网页中的 JavaScript 向另一个域名发起请求 的行为。使用 curl、Python requests、Puppeteer 等工具的爬虫绕过了浏览器环境,完全不受 CORS 影响。CORS 收紧在内容公开的站点上防护价值接近为零。

1.3 威胁模型矩阵

将风险按影响程度和发生概率排列,决定投入优先级:

影响程度 ↑
        │
     高 │  LLM 接口被高频刷费
        │  ┌─────────────────────────────────┐
        │  │ 防护:速率限制(核心防线)        │
        │  │ 检测:API 调用量监控 + 阈值告警  │
        │  └─────────────────────────────────┘
        │
     中 │  金融接口被轮询   管理员密码暴力破解
        │  ┌─────────────────┐  ┌──────────────────┐
        │  │ 防护:速率限制   │  │ 防护:服务端会话  │
        │  │ Redis 缓存兜底   │  │  bcrypt 密码哈希  │
        │  └─────────────────┘  └──────────────────┘
        │
     低 │  文章内容被批量搬运
        │  ┌─────────────────────────────────────┐
        │  │ 不设防——公开内容,无实际损失        │
        │  └─────────────────────────────────────┘
        │
        └───────────────────────────────────────────→ 发生概率
            低                 中                 高

二、防护方案对比与取舍

2.1 候选方案一览

每项方案均从三个维度评估:可拦截的对象、实施和维护成本、对真实用户的影响

方案 核心思路 放弃原因
CORS 收紧 限制 Access-Control-Allow-Origin 为目标域名 防护面仅限于浏览器跨站 JS 调用。后端爬虫不经过 CORS,因此防护价值接近为零
请求签名 / Token API 请求需附带 HMAC 签名,服务端验证有效性 实现和维护成本高。项目不存在付费 API,不值得为公开数据增加签名验证链路
IP 黑名单 收集恶意 IP 加入拒绝列表 云服务商(AWS、Azure、GCP 等)可以秒级更换出口 IP,黑名单永远滞后。维护成本远高于收益
验证码 / JS Challenge 敏感操作前弹验证码或 Cloudflare 五秒盾 加验证码显著降低浏览体验。项目没有高频交易或高价值需要这种保护级别
F12/右键前端拦截 客户端 JS 拦截键盘事件和上下文菜单 浏览器菜单可绕过、禁用 JS 后完全失效,不阻挡任何后端爬虫。定位为可选的低成本威慑

2.2 选定方案:速率限制

被选中的方案需要同时满足以下条件:

  • 能够防止高频批量请求
  • 不影响正常用户浏览
  • 不影响搜索引擎和 AI 爬虫的收录
  • 技术实现成本可控

速率限制是唯一满足全部条件的方案。其核心理念不是 完全阻止爬虫——爬虫低频运行时无法和正常用户区分——而是 控制频率上限。批量抓取需要大量请求,一旦频率超过阈值就会被限,大幅提高采集的时间成本和 IP 轮换成本。

纯 IP 限流存在共享网络误伤问题。多个用户通过同一出口 IP(公司 NAT、学校宿舍、小区宽带)访问时,一人高频请求可能导致其他人被限。

引入第二维度——客户端标识 Cookie——可以将同一 IP 下的不同用户区分开。

双维度识别策略:

                  ┌── 携带 Cookie ──→ Cookie 维度(精确到个体)
                  │
  请求进入中间件 ──┤
                  │
                  └── 无 Cookie ──→ IP 维度(兜底)
维度 优势 局限
IP 覆盖所有请求,无需客户端配合 共享 IP 场景下误伤
Cookie 精确到浏览器个体,不依赖登录 爬虫可主动丢弃 Cookie

两者互补形成防御纵深:正常用户通过首页访问时被种下 Cookie,后续请求用 Cookie 维度精确计数;爬虫不保留 Cookie 则退回 IP 维度兜底。同一爬虫即使每次换 IP(代理池),每个 IP 的单独配额依然限制其总吞吐量。

2.4 友好爬虫白名单

速率限制中容易出现的失误是一刀切——将非人类流量全部限住。搜索引擎爬虫和 AI 联网爬虫对站点有益,应做例外放行。

白名单检测方式为 User-Agent 关键词匹配。该方式有被伪造的可能,但在"站点内容完全公开"的前提下,友好爬虫没有动机去伪装成其他爬虫——伪造 UA 并不能获得额外访问权限。这个权衡成立的前提是:不存在需通过 UA 校验才能访问的受限资源

2.5 配置管理:环境变量驱动

速率阈值和开关属于运行时可变的配置项,不应硬编码在代码中。配置与逻辑分离有以下好处:

  • 同一套代码可以部署在开发、预览、生产等不同环境,各环境使用独立配置
  • 修改阈值只需编辑环境变量或配置文件,无需修改代码和重新构建
  • 降低因改代码而引入逻辑错误的风险

反例(配置与逻辑耦合):

RATE_LIMITS = [
    ("/api/chat/", 5, 60),
]
# 修改阈值 → 修改代码 → 提交 → CI → 部署

正例(配置与逻辑分离):

# config.py
RATE_LIMIT_CHAT = os.getenv("RATE_LIMIT_CHAT", "5,60")

# .env
RATE_LIMIT_CHAT=10,30
# 修改阈值 → 编辑 .env → 重启服务

三、系统架构

3.1 请求处理序列

sequenceDiagram
    participant Client
    participant Middleware
    participant Redis
    participant App

    Client->>Middleware: HTTP Request

    rect rgb(240, 248, 255)
        Note right of Middleware: Step 1:识别客户端
        Middleware->>Middleware: 检查 Cookie
        alt 存在 rl_client_id
            Middleware->>Middleware: key = ck:{uuid}
        else 无 Cookie
            Middleware->>Middleware: key = ip:{address}
        end
    end

    rect rgb(255, 248, 240)
        Note right of Middleware: Step 2:路径匹配
        Middleware->>Middleware: 查找匹配的限流规则
        Middleware->>Middleware: 命中 → max_reqs, window
    end

    rect rgb(255, 240, 240)
        Note right of Middleware: Step 3:计数检查
        Middleware->>Redis: INCR key
        Redis-->>Middleware: 当前计数
        Middleware->>Redis: EXPIRE key window(首次请求)
        Note right of Middleware: 计数 ≤ max_reqs → 放行
    end

    Middleware->>App: 正常处理请求
    App-->>Middleware: HTTP Response
    Middleware-->>Client: Response + Set-Cookie(首次访问的页面请求)

3.2 识别与决策流程

flowchart TD
      Request[请求到达] --> IsAPI{路径是否以 /api/ 开头}
      IsAPI -->|否| Bypass[跳过限流逻辑]
      IsAPI -->|是| IsCrawler{User-Agent 匹配友好爬虫?}

      IsCrawler -->|是| Bypass
      IsCrawler -->|否| HasCookie{存在 rl_client_id Cookie?}

      HasCookie -->|有| CookieKey["key = ck:{uuid}"]
      HasCookie -->|无| IPKey["key = ip:{address}"]

      CookieKey --> Check{当前计数 ≥ 阈值?}
      IPKey --> Check

      Check -->|否| Pass[正常响应]
      Check -->|是| Block[返回 429 + Retry-After 头]

      Pass --> NeedSeed{首次访问且是页面请求?}
      NeedSeed -->|是| SeedCookie[种下 rl_client_id Cookie]
      NeedSeed -->|否| Done[结束]
      SeedCookie --> Done

3.3 滑动窗口计数实现

Redis 版本(主路径):

count = redis.incr(key)       # 原子递增,返回新值
if count == 1:
    redis.expire(key, window) # 首次请求,设 TTL
return count > max_reqs       # True = 已超限

利用 Redis INCR 命令的原子性,无需加锁。EXPIRE 确保 key 在窗口期后自动清理。

内存版本(降级路径):

now = time.time()
timestamps = memory.get(key, [])
# 过滤出仍在窗口内的时间戳
timestamps = [t for t in timestamps if now - t < window]
if len(timestamps) >= max_reqs:
    return True  # 超限
timestamps.append(now)
memory[key] = timestamps
return False     # 未超限

内存版本适用于 Redis 不可用时的降级。因为数据仅存于单个进程,重启后会重置。定期清理超过 120 秒无活动的 key,防止内存泄漏。

3.4 存储层级

主路径:Redis(推荐)
    ├── 读取:INCR(原子递增 + 返回当前值)
    ├── 过期:EXPIRE(窗口结束后自动删除)
    ├── 优势:分布式多实例共享同一计数器
    └── 劣势:Redis 服务不可用时需要降级

降级路径:进程内存
    ├── 数据结构:dict[str, list[timestamp]]
    ├── 清理:每 100 次请求扫描并删除过期 key
    ├── 优势:零外部依赖,启动即可用
    └── 劣势:多实例各自独立计数、重启丢失

四、核心代码实现参考

以下代码摘自项目的实际实现,展示速率限制中间件的完整结构。

4.1 中间件入口与路由注册

# app/main.py — 注册中间件

from app.middleware.rate_limit import RateLimitMiddleware

# 中间件洋葱模型:RateLimitMiddleware 在最后注册,成为最外层
app.add_middleware(CORSMiddleware, allow_origins=["*"], ...)
app.add_middleware(SessionMiddleware, secret_key=...)
app.add_middleware(RateLimitMiddleware)  # 最外层,请求最先到达

4.2 配置导入与解析

# app/middleware/rate_limit.py — 从 config 导入并构建规则表

from app.config import (
    RATE_LIMIT_ENABLED,
    RATE_LIMIT_CHAT,
    RATE_LIMIT_INTERVIEW,
    RATE_LIMIT_MARKET,
    RATE_LIMIT_ADMIN,
    RATE_LIMIT_API,
    RATE_LIMIT_CRAWLER_UA,
)

def _parse_limit(val: str):
    """解析 'max_reqs,window_seconds' 格式,空字符串返回 None"""
    val = val.strip()
    if not val:
        return None
    parts = val.split(",")
    if len(parts) != 2:
        return None
    try:
        return int(parts[0].strip()), int(parts[1].strip())
    except ValueError:
        return None

def _build_rate_limits():
    """从配置逐条构建限流规则表"""
    rules = [
        ("/api/chat/", RATE_LIMIT_CHAT),
        ("/api/interview/", RATE_LIMIT_INTERVIEW),
        ("/api/market/", RATE_LIMIT_MARKET),
        ("/api/admin/", RATE_LIMIT_ADMIN),
    ]
    result = []
    for prefix, raw in rules:
        parsed = _parse_limit(raw)
        if parsed:
            result.append((prefix, parsed[0], parsed[1]))
    api_limit = _parse_limit(RATE_LIMIT_API)
    if api_limit:
        result.append(("/api/", api_limit[0], api_limit[1]))
    return result

# 模块加载时构建一次,后续配置变更需重启服务
RATE_LIMITS = _build_rate_limits()
CRAWLER_UA_KEYWORDS = [
    kw.strip().lower()
    for kw in RATE_LIMIT_CRAWLER_UA.split(",")
    if kw.strip()
]

4.3 请求处理核心

# app/middleware/rate_limit.py — dispatch 方法

class RateLimitMiddleware(BaseHTTPMiddleware):

    async def dispatch(self, request: Request, call_next):
        path = request.url.path

        # 先判断是否需要种 cookie(独立于限流逻辑)
        needs_cookie = (
            not request.cookies.get(CLIENT_ID_COOKIE)
            and not path.startswith("/api/")
            and not path.startswith("/static/")
        )

        # 限流逻辑:仅对 API 路径生效
        if RATE_LIMIT_ENABLED and RATE_LIMITS and path.startswith("/api/"):
            if not self._is_crawler(request):
                limit = self._match_limit(path)
                if limit:
                    max_reqs, window = limit
                    client_key = self._get_client_key(request)
                    if self._is_limited(client_key, max_reqs, window):
                        return JSONResponse(
                            status_code=429,
                            content={"detail": "请求过于频繁,请稍后再试"},
                            headers={"Retry-After": str(window)},
                        )

        response = await call_next(request)

        # 首次访问页面时种下 Cookie
        if needs_cookie:
            self._seed_client_cookie(response)

        return response

4.4 双维度客户端识别

# app/middleware/rate_limit.py — 客户端标识生成

def _get_client_key(self, request: Request) -> str:
    """优先 Cookie 维度,兜底 IP 维度"""
    cid = request.cookies.get(CLIENT_ID_COOKIE)
    if cid:
        return f"ck:{cid}"

    # IP 取 x-forwarded-for 中的真实客户端地址
    ip = request.client.host if request.client else "0.0.0.0"
    forwarded = request.headers.get("x-forwarded-for")
    if forwarded:
        ip = forwarded.split(",")[0].strip()
    return f"ip:{ip}"

def _seed_client_cookie(self, response: Response):
    """种下 30 天有效期的客户端标识 Cookie"""
    import uuid
    response.set_cookie(
        key=CLIENT_ID_COOKIE,
        value=str(uuid.uuid4()),
        max_age=86400 * 30,
        path="/",
        httponly=True,
        samesite="lax",
    )

4.5 滑动窗口计数器

# app/middleware/rate_limit.py — 限流状态检查

def _is_limited(self, key: str, max_reqs: int, window: int) -> bool:
    now = time.time()

    # 主路径:Redis 原子操作
    redis = get_redis()
    if redis:
        try:
            count = redis.incr(key)
            if count == 1:
                redis.expire(key, window)
            return count > max_reqs
        except Exception:
            pass  # 降级到内存

    # 降级路径:内存滑动窗口
    timestamps = self._memory.get(key, [])
    timestamps = [t for t in timestamps if now - t < window]
    if len(timestamps) >= max_reqs:
        self._memory[key] = timestamps
        return True
    timestamps.append(now)
    self._memory[key] = timestamps
    return False

4.6 友好爬虫白名单

# app/middleware/rate_limit.py — 爬虫检测

def _is_crawler(self, request: Request) -> bool:
    ua = (request.headers.get("user-agent") or "").lower()
    return any(kw in ua for kw in CRAWLER_UA_KEYWORDS)

# 关键词列表(从环境变量 RATE_LIMIT_CRAWLER_UA 解析):
# googlebot, bingbot, duckduckbot, baiduspider,
# yandexbot, sogou, applebot, slurp,
# gptbot, claude-web, anthropic-ai, ccbot,
# oai-searchbot, perplexitybot, facebookexternalhit

4.7 前端 F12 拦截(模板层)

<!-- app/templates/base.html — 默认不启用,由 DEVTOOLS_POLICY 控制 -->
{% if devtools_policy == 'deny' or (devtools_policy == 'admin-only' and not is_admin) %}
<script>
(function(){
    function blockKey(e) {
        if (e.key === 'F12' ||
            (e.ctrlKey && e.shiftKey && ['I','J','C'].includes(e.key.toUpperCase())) ||
            (e.ctrlKey && e.key === 'U')) {
            e.preventDefault();
            e.stopPropagation();
            return false;
        }
    }
    document.addEventListener('keydown', blockKey, true);
    document.addEventListener('contextmenu', function(e){ e.preventDefault(); return false; }, true);
})();
</script>
{% endif %}

Jinja2 全局变量 devtools_policy 在路由模块中注入:

# app/routes/pages.py — 注册全局模板变量
from app.config import DEVTOOLS_POLICY

templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.globals["devtools_policy"] = DEVTOOLS_POLICY

五、配置参考

5.1 配置项说明

配置项 描述 取值范围 说明
RATE_LIMIT_ENABLED 限流全局开关 true / false 关闭后中间件不执行任何限流逻辑
RATE_LIMIT_CHAT LLM 对话接口阈值 {max},{window_sec} 或空字符串 项目唯一有直接成本的接口,建议从严
RATE_LIMIT_INTERVIEW 面试助手接口阈值 同上 同上,也会调用 LLM
RATE_LIMIT_MARKET 金融行情接口阈值 同上 实时数据,中等频率
RATE_LIMIT_ADMIN 管理后台接口阈值 同上 管理员操作,可适当宽松
RATE_LIMIT_API 通用 API 兜底阈值 同上 未匹配到以上规则的 /api/* 路径
RATE_LIMIT_CRAWLER_UA 友好爬虫 UA 关键字 逗号分隔的关键词列表 匹配到的请求直接放行,不限流
DEVTOOLS_POLICY 浏览器开发者工具策略 allow / admin-only / deny 前端 JS 拦截,纯威慑,非安全措施

阈值格式说明: {max},{window_sec} 例如 20,60 表示 60 秒内最多 20 次请求。设为空字符串或无效格式时,该路径不限流。

匹配优先级: 按列表顺序从精确到模糊——/api/chat/ > /api/interview/ > /api/market/ > /api/admin/ > /api/(兜底)。

5.2 典型部署配置

开发环境:

关闭所有限流功能,避免频繁重启调试时被误限。

RATE_LIMIT_ENABLED=false
DEVTOOLS_POLICY=allow

生产环境(最小防护):

仅保护有直接成本的 LLM 接口,其余接口保持开放。

RATE_LIMIT_ENABLED=true
RATE_LIMIT_CHAT={从5到15次每分钟,视用户量调整}
RATE_LIMIT_INTERVIEW={同上}
# 其他路径使用默认不限流

生产环境(全开):

所有 API 路径均配置限流,同时启用前端低威慑策略。

RATE_LIMIT_ENABLED=true
RATE_LIMIT_CHAT={适当严格}
RATE_LIMIT_INTERVIEW={适当严格}
RATE_LIMIT_MARKET={中等频率}
RATE_LIMIT_ADMIN={适当宽松}
RATE_LIMIT_API={通用兜底}
DEVTOOLS_POLICY=admin-only

六、效果评估与局限

6.1 有效防御的场景

场景 能否阻止 实现机制
高频批量调用 LLM 接口刷费 速率限制,窗口内超阈值即返回 429
无 Cookie 管理的简单爬虫 退回到 IP 维度,每个 IP 独立计数
浏览器端查看网络请求(策略开启时) JS 拦截键盘事件和右键菜单
低频但持续的单 IP 抓取 请求量在单位窗口内仍受阈值约束

6.2 无法阻止的行为

行为 无法阻止的原因
搜索引擎爬虫索引页面 白名单直接放行,不限流
AI 联网爬虫收录内容 同上,对站点有曝光价值
从浏览器菜单打开开发者工具 菜单操作无 JS 事件可拦截
低频率、长间隔的手动抓取 频率处于阈值以下,与正常用户不可区分
Puppeteer / Playwright 等无头浏览器 完整浏览器环境,携带 Cookie,行为与真人一致
大型代理 IP 池轮换抓取 每个代理 IP 独立计数,需大量 IP 才有足够吞吐
禁用 JavaScript 后访问页面 前端拦截脚本不执行,全部失效

6.3 设计底线

反爬策略的目标不是"无法被抓取"——这在公开 Web 上不可能实现。目标是将批量、高频率的自动化访问限制在可控范围内,防止单一资源(尤其是付费 API 接口)被集中刷量导致实质损失。

对于以内容展示为核心的站点,文章内容被搬运不会造成直接损失。与其在防爬上持续投入,不如将资源用于内容质量和原创性——这是别人无法通过爬取复制的东西。


七、参考资源

7.1 协议与标准

7.2 框架与中间件

7.3 工程实践与部署

7.4 算法与设计


文档版本: 1.0 | 最后更新: 2026-06-23

本文档是一份决策复盘,所有配置参考中的具体阈值应根据实际部署环境调整。