反爬虫与安全防护体系 — 设计手记
从一次自主发起的防护评估开始,到逐条分析威胁、否定不合适的方案、落地真正有效的防护,最终形成一个分层反爬体系。本文档完整记录了决策过程、取舍理由和架构设计。
一、为什么需要反爬——威胁分析
在实施任何防护前,需要先清点被保护对象的资产价值。不同资源被爬取的后果差异很大,统一防护等级会导致过度设计或防护不足。
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 轮换成本。
2.3 为什么是 Cookie + 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 协议与标准
- HTTP 429 Too Many Requests — RFC 6585, Section 4。定义了
429状态码的语义和Retry-After响应头的使用方式 - Redis INCR — https://redis.io/commands/incr/。原子递增命令,服务端限流中最通用的计数原语
- Redis EXPIRE — https://redis.io/commands/expire/。设置键的生存时间,配合 INCR 实现窗口自动过期
7.2 框架与中间件
- FastAPI Middleware — https://fastapi.tiangolo.com/tutorial/middleware/。FastAPI 中间件注册方式与生命周期说明
- Starlette Middleware — https://www.starlette.io/middleware/。FastAPI 底层的中间件体系,
BaseHTTPMiddleware在此定义 - Starlette JSONResponse — https://www.starlette.io/responses/#jsonresponse。中间件中返回 429 时使用的响应类
- Jinja2 Template Designer Documentation — https://jinja.palletsprojects.com/。模板全局变量的注入与控制流语法
7.3 工程实践与部署
- Cloudflare Bot Management — https://www.cloudflare.com/bot-management/。Cloudflare 的机器人识别与管理方案,Free 套餐包含 Bot Fight Mode,可作为应用层限流的前置补充
- Nginx ngx_http_limit_req_module — https://nginx.org/en/docs/http/ngx_http_limit_req_module.html。Nginx 反向代理层的请求速率限制模块,与应用层限流形成两层防护
- Nginx ngx_http_limit_conn_module — https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html。Nginx 并发连接数限制,与速率限制互补使用
- OWASP Automated Threats to Web Applications — https://owasp.org/www-project-automated-threats-to-web-applications/。Web 自动化攻击的分类框架,偏企业级,但分类方法对系统化思考反爬有参考价值
7.4 算法与设计
- Kong — How to Design a Scalable Rate Limiting Algorithm — https://konghq.com/blog/how-to-design-a-scalable-rate-limiting-algorithm。滑动窗口、令牌桶、漏桶三种算法的对比和适用场景分析
- Redis 限流模式模式 — https://redis.com/glossary/rate-limiting/。Redis 在速率限制场景中的几种典型实现模式
- Okta — Rate Limiting Best Practices — https://developer.okta.com/docs/reference/rl-best-practices/。API 速率限制的最佳实践建议,包括响应头设计(
X-RateLimit-Limit、Retry-After)和错误处理
文档版本: 1.0 | 最后更新: 2026-06-23
本文档是一份决策复盘,所有配置参考中的具体阈值应根据实际部署环境调整。
No comments yet. Be the first!