okasi/bot-signal

GitHub: okasi/bot-signal

一个零外部依赖的 TypeScript 全栈机器人检测库,在浏览器和服务器端通过多层信号识别自动化工具与脚本流量。

Stars: 332 | Forks: 4

bot-signal logo # bot-signal **bot-signal** — 在浏览器和服务器端进行机器人信号检测。 提供简单的 `isHuman()` 函数,以及全面的多层信号(即时浏览器检查、行为分析和服务器端 IP/TLS/时区验证)。无需外部 API 密钥。 [![npm version](https://img.shields.io/npm/v/bot-signal.svg)](https://www.npmjs.com/package/bot-signal) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org) [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/ad/ad5834178f7599af9fdda11629d49cae07f2997beec49821b2920eff5bfd50e7.svg)](https://github.com/okasi/bot-signal/actions/workflows/ci.yml) [![IP data updates](https://static.pigsec.cn/wp-content/uploads/repos/cas/48/480733161f3da93e6e0f225fbb477071a7454ea63f659254d0f27fffa18ecb80.svg)](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。** [![GitHub stars](https://img.shields.io/github/stars/okasi/bot-signal?style=social)](https://github.com/okasi/bot-signal)
标签:GNU通用公共许可证, MITM代理, Node.js, TypeScript, 反自动化, 安全插件, 机器人检测, 浏览器指纹, 自动化攻击