
# bot-signal
**bot-signal** — 在浏览器和服务器端进行机器人信号检测。
提供简单的 `isHuman()` 函数,以及全面的多层信号(即时浏览器检查、行为分析和服务器端 IP/TLS/时区验证)。无需外部 API 密钥。
[](https://www.npmjs.com/package/bot-signal)
[](LICENSE)
[](https://nodejs.org)
[](https://github.com/okasi/bot-signal/actions/workflows/ci.yml)
[](https://github.com/okasi/bot-signal/actions/workflows/update-ip-data.yml)
[快速开始](#quick-start) · [检测模式](#detection-modes) · [信号](#signals) · [API](#api) · [示例](#examples) · [常见问题](#faq)
## 为什么选择 bot-signal?
大多数机器人检测代码片段都是复制粘贴的检查,很快就会失效。`bot-signal` 包提供了一个经过维护、具备完整类型定义且可测试的工具包,涵盖了全栈检测:
**[在线演示](https://okasi.github.io/bot-signal/)** — 在您的浏览器中运行即时和行为检查。
| 层级 | 运行位置 | 捕获目标 |
|-------|------------|---------|
| **即时** | 浏览器(同步) | WebDriver、Selenium、Playwright、无头 Chrome、异常的 WebGL/WebGPU |
| **行为** | 浏览器(持续观察) | 机器化的鼠标/滚动/打字、合成事件 |
| **服务器** | Node >= 22 | 数据中心 IP、AbuseIPDB、TLS 指纹不匹配、时区欺骗 |
- **无需 API 密钥** — GeoIP 和 IP 黑名单已内置并每周更新(注意:完整包的 tarball 大小约为 1.8 MB / 解压后约为 16 MB,主要由于黑名单数据)
- **TypeScript 优先** — 完整的类型定义、ESM + CJS、`sideEffects: false`
- **打包工具安全** — 根导入在浏览器打包工具中会解析为仅限浏览器的构建版本;当您需要精确控制时,可使用显式的 `/browser` 和 `/server` 子路径
- **IPv4 + IPv6** — 黑名单匹配可处理 IPv6 范围和 IPv4 映射地址,全部通过二进制搜索实现(每次查询约 1µs)
- **可组合** — 使用单层或组合全部三层
- **可解释** — 每个标志都有名称、权重和置信度级别
- **单一依赖** — 仅依赖离线 GeoIP 数据库
## 快速开始
```
npm install bot-signal
```
### 浏览器 — 页面加载时阻止自动化
```
import { isHuman } from "bot-signal";
if (!isHuman(window)) {
window.location.href = "/blocked";
}
```
### 服务器 — 一次调用评估请求
```
import { detectServerClientAsync } from "bot-signal";
const result = await detectServerClientAsync({
clientIp: req.ip,
clientTimezone: req.headers["x-timezone"],
userAgent: req.headers["user-agent"],
tlsFingerprint: req.headers["x-ja3-hash"],
});
if (!result.isLegitClient) {
return res.status(403).json({ signals: result.signals });
}
```
### 行为 — 捕获脚本交互
```
import { createBehavioralClientDetector } from "bot-signal";
const result = await createBehavioralClientDetector({ context: window }).observe(10_000);
if (!result.isLegitClient) {
console.warn("Robotic behavior", result.suspicionScore);
}
```
### 入口点
| 导入 | 内容 | 运行环境 |
|--------|----------|---------|
| `bot-signal` | 包含所有内容(浏览器打包工具中使用浏览器构建版本)+ `isHuman()` | 浏览器 + Node |
| `bot-signal/browser` | 仅包含即时 + 行为检测 | 浏览器 |
| `bot-signal/server` | 仅包含服务器检测 | Node ≥ 22 |
没有打包工具?通过 CDN 加载全局构建版本:
```
```
## 检测模式
```
flowchart LR
subgraph Browser
A[Instant] --> B{Pass?}
B -->|yes| C[Behavioral]
B -->|no| X[Block]
C --> D{Pass?}
D -->|yes| E[Allow]
D -->|no| X
end
subgraph Server
S[detectServerClientAsync] --> T{Pass?}
T -->|yes| E
T -->|no| X
end
Browser -->|beacon + headers| Server
```
| 模式 | API | 速度 | 环境 |
|------|-----|-------|-------------|
| **即时** | `isHuman`(或 `detectInstantClient`) | 立即 | 浏览器 |
| **即时+** | `isHumanAsync`(或 `detectInstantClientAsync`) | ~50ms | 浏览器(增加 WebGPU 检查) |
| **行为** | `createBehavioralClientDetector` | 5–30秒 | 浏览器 |
| **服务器** | `detectServerClientAsync` | 每个 IP 约 1–5ms | Node >= 22 |
### 即时
`bot-signal` 的即时模式针对 `window` 同步运行,并返回加权后的 `suspicionScore`
(对触发的信号执行 `1 - Π(1 - weight)`)。明确的自动化标记
权重为 1.0 并会直接阻断;在真实客户端(应用内浏览器、F11 全屏、无 GPU 的 VM)上也会触发的模糊检查权重为 0.25–0.45,因此它们只有在组合出现时才会阻断。`isLegitClient` 的条件是 `suspicionScore < scoreThreshold`(默认 0.5)— 可根据需要调整。异步变体在 Chromium 上增加了 WebGPU `shader-f16` 验证。
```
if (!isHuman(window)) {
// block
}
// full result if you need details
const result = detectInstantClient(window);
// result.suspicionScore, result.confidence, result.signals[], result.isLegitClient
// result.automation.kind, .confidence, .evidence, .alternatives
// stricter: block on any single soft signal
const strict = detectInstantClient(window, { scoreThreshold: 0.3 });
const withWebGpu = await isHumanAsync(window); // or detectInstantClientAsync
```
### 行为
`bot-signal` 的行为模式会观察鼠标、滚动和键盘事件。评分:对触发的信号执行 `1 - Π(1 - weight)`。
```
const detector = createBehavioralClientDetector({
context: window,
scoreThreshold: 0.55,
onUpdate: (r) => console.log(r.suspicionScore),
});
await detector.observe(8_000);
```
### 服务器
`bot-signal` 的服务器模式接收 `clientIp` 以自动执行 GeoIP 查找、数据中心范围检查、AbuseIPDB 黑名单检查、iCloud Private Relay 检查、TLS 验证和时区比较。
```
const result = await detectServerClientAsync({
clientIp: req.ip,
clientTimezone: req.headers["x-timezone"],
tlsFingerprint: req.headers["x-ja3-hash"],
userAgent: req.headers["user-agent"],
secChUa: req.headers["sec-ch-ua"],
secFetchSite: req.headers["sec-fetch-site"],
secFetchMode: req.headers["sec-fetch-mode"],
secFetchDest: req.headers["sec-fetch-dest"],
});
```
`tlsFingerprint` 必须来自真正终止 TLS 连接的基础设施(例如,受信任的反向代理元数据)。在添加受信任的值之前,必须在边缘剥离任何客户端提供的指纹 header;任意的请求 header 是由攻击者控制的,不能作为 JA3/JA4 证据。
由于指纹会因 TLS 栈和版本而异,且不能证明调用的库,因此没有内置客户端家族的 TLS 哈希。请在您自己的受信任环境中捕获 JA3 或 JA4 值,并用于 `suspiciousTlsFingerprints`。
即时和服务器结果都包含 `automation` 评估。当暴露页面域(page-realm)特征时,它们可以识别出 `playwright`、`puppeteer`、`selenium`、`phantomjs` 或 `nightmare`。脚本客户端的 User-Agent 可以提示 `curl`、`python`、`go` 或 `java`;TLS 指纹仅用于增加风险或提供佐证,绝不会单独确定家族。当指纹重叠时,结果会使用 `browser-automation` 加上 `alternatives`。当存在 Chromium 自动化模式时,Patchright 可能会作为替代方案出现,但一般性的环境异常绝不会单独识别出 Patchright。
`automation.isAutomated` 独立于配置的执行阈值记录证据;`isLegitClient` 仍然是策略判定。
`automation` 的 `kind` 为 `unknown` 表示未找到客户端家族证据,而不是证明该请求是人类发出的。
这是有意设计为概率性的:毕竟,在移除了所有品牌特定的特征后,页面无法证明是哪个 Node/Python 包在控制浏览器。服务器信誉/地理信号可以在不设置 `automation.isAutomated` 的情况下使 `isLegitClient` 为 false;存在风险的 IP 并不一定是浏览器机器人。
内置 IP 数据每周刷新。本地运行命令:`npm run update:ip-data`。
## 信号
### 即时(加权)
每项检查都会将其权重计入 `suspicionScore`;`isLegitClient` 的条件是
`suspicionScore < scoreThreshold`(默认 0.5)。每个布尔标志仍然保留在结果中供检查,并附带包含各项检查权重的 `signals[]`。
仅用于归因的 `isPlaywright`、`isPuppeteer` 和 `isChromeDriver` 布尔值用于细化加权后的 `isAutomationArtifacts` 总体标志,而不会增加重复的权重。
| 标志 | 权重 | 触发条件 |
|------|--------|---------------|
| `isWebDriver` | 1.0 | `navigator.webdriver === true` |
| `isAutomationArtifacts` | 1.0 | ChromeDriver / Puppeteer / Playwright 标记 |
| `isSelenium` | 1.0 | Selenium 文档标记 |
| `isPhantomJS` | 1.0 | 存在 PhantomJS 全局变量 |
| `isNightmare` | 1.0 | Nightmare.js 标记 |
| `isDomAutomation` | 1.0 | Chrome DOM 自动化全局变量 |
| `isHeadless` | 0.9 | WebDriver 或 HeadlessChrome UA |
| `isSuspiciousWebDriverDescriptor` | 0.9 | 被修补/删除的 `navigator.webdriver` |
| `isSuspiciousResolution` | 0.7 | 屏幕 < 136×170 |
| `isUserAgentValid` | 0.7 | UA 格式错误或包含脚本客户端 token |
| `isSoftwareRenderer` | 0.6 | SwiftShader / llvmpipe WebGL |
| `isUserAgentDataMismatch` | 0.65 | UA 版本/移动端/平台与 Client Hints 冲突 |
| `isLanguageInconsistent` | 0.45 | `language` 与 `languages[0]` 不一致 |
| `isPluginMimeTypeInconsistent` | 0.45 | 插件和 MIME 类型修补不一致 |
| `isMissingChromeObject` | 0.35 | 没有 `window.chrome` 的 Chromium(应用内浏览器) |
| `isWebGLSupported` | 0.35 | 无 WebGL context(无 GPU 的 VM,无头 Chromium 139+) |
| `isSuspiciousWindowDimensions` | 0.3 | 无浏览器 chrome + 源放置(F11 全屏) |
| `isModern` | 0.3 | 低于 Chrome 121 / Firefox 128 / Safari 16.4 |
| `isEmptyPlugins` | 0.25 | **桌面版** Chromium 上插件为零 |
| `isShaderF16Supported` | 0.3 | 异步 — Chromium 上缺少 WebGPU `shader-f16` |
最后一组是弱信号:单独低于 0.5 的阈值,它们会触发标记但不会阻断,因此常见的误报情况(应用内浏览器、信息亭全屏、VM)除非叠加出现,否则都能通过。`isEmptyPlugins` 在移动版 Chrome 上被完全跳过,因为其合法报告没有插件。
### 行为(加权)
| ID | 权重 | 置信度 | 描述 |
|----|--------|------------|-------------|
| `no-mouse-activity` | 0.20 | 低 | 指针点击但没有任何鼠标/触摸事件 |
| `click-without-mouse-movement` | 0.35 | 高 | 点击前 2 秒内没有鼠标或触摸活动 |
| `linear-mouse-movement` | 0.25 | 中 | 直线轨迹,匀速 |
| `teleport-mouse` | 0.40 | 高 | 密集事件之间出现不合理的鼠标瞬移 |
| `linear-scroll` | 0.30 | 中 | 均匀的滚动增量和时机 |
| `linear-typing` | 0.35 | 高 | 机器化或超人般的间隔(不包括按键自动重复) |
| `synthetic-events` | 0.50 | 高 | `isTrusted === false` |
触摸点击、由键盘触发的点击(`detail === 0`)以及光标离开窗口后的重新进入都会被识别,并且绝不会被计入用户的负面行为。
启发式算法中的确切数值阈值(线性度 CV 截断值、距离/时间瞬移规则等)都是经过调整的常数。为了保持公开 API 的精简和可预测性,它们目前没有作为选项公开。如果您需要修改逻辑,请查看源代码中已记录的常量。
### 服务器(加权)
| ID | 权重 | 置信度 | 描述 |
|----|--------|------------|-------------|
| `scripting-user-agent` | 0.75 | 中 | UA 声明是 curl/Python/Go/Java |
| `client-hints-mismatch` | 0.65 | 高 | Chromium UA 版本与 `sec-ch-ua` 冲突 |
| `missing-browser-headers` | 0.35 | 中 | 浏览器 UA 缺少 Fetch Metadata header(可选) |
| `timezone-mismatch` | 0.45 | 高 | 客户端时区 ≠ GeoIP 时区(低于阈值:VPN/旅行者不会单独被阻断) |
| `known-suspicious-tls` | 0.55 | 高 | JA3/JA4 匹配调用方提供的可疑值 |
| `tls-user-agent-mismatch` | 0.50 | 高 | JA3 与 User-Agent 冲突 |
| `missing-tls-fingerprint` | 0.25 | 中 | 浏览器 UA 没有 TLS 指纹 |
| `accept-language-geo-mismatch` | 0.20 | 低 | 没有可接受的 Accept-Language 国家/地区与IP 匹配(无地区、数字地区以及仅 q=0 的 header 可通过) |
| `datacenter-browser-mismatch` | 0.35 | 中 | 数据中心 IP + 浏览器 UA |
| `abuse-listed-ip` | 0.60 | 高 | AbuseIPDB 30 天黑名单 |
| `icloud-private-relay` | 0.15 | 低 | iCloud Private Relay 出口 |
**内置 IP 数据:** `data/datacenter_ip_ranges.csv` (ipcat), `data/abuse_ip_db_30d_ips.csv` (AbuseIPDB), `data/icloud_private_relay_ip_ranges.csv` (Apple, IPv4 + IPv6)。
名单会在首次 `clientIp` 检查时被解析一次并转化为排序好的区间(约 0.5 秒,延迟加载);随后的每次查询都是一次二分搜索(约 1µs)。IPv4 映射的 IPv6 输入(`::ffff:1.2.3.4`)会在匹配前标准化为 IPv4。在启动时调用一次 `preloadIpLists()`,可以将这一次性的解析成本转移到第一次请求之外。
## API
所有 API 均从 `bot-signal` 包导出:
```
// Browser (also available from the root import)
import {
isHuman,
isHumanAsync,
detectInstantClient,
detectInstantClientAsync,
buildInstantSignals,
createBehavioralClientDetector,
analyzeBehavioralSamples,
isAutomationArtifacts,
isSoftwareRenderer,
VERSION,
} from "bot-signal/browser";
// Server (also available from the root import in Node)
import {
detectServerClient,
detectServerClientAsync,
enrichServerContext,
lookupClientIpGeo,
createIpListChecker,
preloadIpLists,
parseIp,
isTimezoneMismatch,
isTlsUserAgentMismatch,
isValidJa3Hash,
KNOWN_SUSPICIOUS_TLS_FINGERPRINTS,
VERSION,
} from "bot-signal/server";
```
### 服务器选项
```
detectServerClientAsync(context, {
dataDir: "./custom-data",
lookupGeo: true,
checkIpLists: true,
timezoneToleranceMinutes: 60,
scoreThreshold: 0.5,
requireTlsFingerprint: false,
requireBrowserHeaders: false,
suspiciousTlsFingerprints: [],
});
```
### 行为选项
```
createBehavioralClientDetector({
context: window,
minObservationMs: 3_000,
scoreThreshold: 0.55,
pollIntervalMs: 1_000,
sampleWindowMs: 60_000, // retain only recent samples (Infinity = keep all)
onUpdate: (result) => {},
});
```
一个长生命周期的检测器(调用 `start()` 而不调用 `stop()`)仅保留最后 `sampleWindowMs` 时间内的事件,因此内存占用是有界的。如果在观察正在进行时调用,`observe()` 将会拒绝请求。
## 示例
### 纵深防御
```
if (!isHuman(window)) block();
fetch("/api/beacon", {
headers: { "X-Timezone": Intl.DateTimeFormat().resolvedOptions().timeZone },
});
const behavioral = await createBehavioralClientDetector({ context: window }).observe(10_000);
if (!behavioral.isLegitClient) challenge();
const server = await detectServerClientAsync({ clientIp: req.ip /* ... */ });
if (!server.isLegitClient) return res.status(403).end();
```
### Express 中间件
```
import { detectServerClientAsync } from "bot-signal";
app.use(async (req, res, next) => {
const result = await detectServerClientAsync({
clientIp: req.ip,
clientTimezone: req.headers["x-timezone"],
userAgent: req.headers["user-agent"],
tlsFingerprint: req.headers["x-ja3-hash"],
});
if (!result.isLegitClient) {
return res.status(403).json({ signals: result.signals });
}
next();
});
```
### Next.js 客户端守卫
```
"use client";
import { useEffect } from "react";
import { isHuman } from "bot-signal";
export function BotGuard({ children }) {
useEffect(() => {
if (!isHuman(window)) {
window.location.href = "/blocked";
}
}, []);
return children;
}
```
## 常见问题
**客户端检查可以被绕过吗?**
可以。使用即时 + 行为检查来增加阻力;使用服务器检测来做出权威判定。
**出现误报?**
每一层都是经过加权的,因此单一的模糊信号(应用内浏览器、F11 全屏、无 GPU 的 VM、VPN 时区不匹配)会触发标记但不会直接阻断 — 它们只有在组合出现时才会超过阈值。针对每一层调整 `scoreThreshold`,以在增加阻力和覆盖范围之间取得平衡。
**IP 数据多久更新一次?**
每周更新(UTC 时间周一 04:00)。随时可以在本地运行 `npm run update:ip-data`。
**在没有打包工具的情况下能用吗?**
可以 — 支持 ESM + CJS + 类型,此外在 unpkg/jsdelivr 上还有一个全局 IIFE 构建版本(`DetectBotClient.*`)。
**为什么无头 Chrome 会无法通过 WebGL 检查?**
Chromium 139+ 移除了软件 WebGL 回退机制,因此无 GPU 的无头会话根本不提供 WebGL — 这正是 `isWebGLSupported` 标记的情况。拥有正常工作 GPU 的真实桌面浏览器可以通过。
**你能确定性地识别出 Patchright 吗?**
在隐身驱动程序移除了其独特的泄漏特征后,任何客户端库都无法可靠地证明控制包。测试套件验证了,由 Patchright 启动的默认无头 Chromium 会被页面级 JavaScript 识别为通用的 `browser-automation`;触发此情况的 `HeadlessChrome` 标记并不是 Patchright 独有的。请结合即时、行为、TLS/header、IP 和速率限制信号来执行拦截,而不是仅依赖于框架标签。
## 开发
```
git clone https://github.com/okasi/bot-signal.git
cd bot-signal
npm install
npx patchright install chromium # once, for browser tests
npm test # unit tests
npm run test:coverage # unit tests + 100% coverage gate
npm run test:patchright # real Chromium via patchright
npm run build
npm run lint:package # publint + Are The Types Wrong
npm run check # typecheck + coverage + patchright + build + package lint
npm run build:site # generate the GitHub Pages artifact in .pages/
```
在线演示:https://okasi.github.io/bot-signal/ (在推送至 `main` 分支时从 `.pages/` 部署)。
**GitHub Pages 设置(一次性):** Settings → Pages → Build and deployment → **GitHub Actions**。
### 发布到 npm
npm 包:**`bot-signal`** — 简单场景请使用 `isHuman()`,高级信号和评分请使用完整的 `detect*` / `create*` API。
#### 第 1 步 — 首次发布(在您的计算机上执行一次)
```
git clone https://github.com/okasi/bot-signal.git
cd bot-signal
npm install
npm run check
npm login
npm publish --access public
```
#### 第 2 步 — 启用受信任发布(用于 GitHub Actions)
1. https://www.npmjs.com/package/bot-signal → **Settings** → **Trusted publishing**
2. **GitHub Actions** → 用户 `okasi`,仓库 `bot-signal`,工作流 `publish.yml`
3. 保存
#### 第 3 步 — 通过 Actions 进行后续发布
```
npm version patch
git push origin main --follow-tags
```
或者重新运行 **Actions → Publish npm → Run workflow**。
有关本地开发和拉取请求检查,请参见 [CONTRIBUTING.md](CONTRIBUTING.md),
有关私下报告漏洞,请参见 [SECURITY.md](SECURITY.md),有关架构指导,请参见 [AGENTS.md](AGENTS.md)
## 许可证
[MIT](LICENSE) © [okasi](https://github.com/okasi)
**如果这个项目为您节省了时间,请考虑给仓库点个 Star。**
[](https://github.com/okasi/bot-signal)