如何用 Node.js 和 fetch 识别 DataDome 验证码

要在 Node.js 中识别 DataDome 验证码,你需要读取 403 封锁页面,由它构造出挑战地址,并用 dataDome() 把该地址发送给 CapSkip DataDome 识别工具 来识别,再用它返回的 cookie 重复你的请求。得到的答案是一个 cookie,而不是用于表单的 token,并且 CapSkip 文档说明,它通常只能配合获取它时所用的 User-Agent 和 IP 地址使用。本指南用 CapSkip Node.js SDK 和 fetch,以大约 100 行代码跑完整个流程。CapSkip 运行在你自己的 Windows PC 上,没有按次识别的费用,所以重试也没有识别费用。
你需要什么
- CapSkip 3.2.0 或更高版本,运行在 Windows PC 上。该版本新增了对滑块和设备检查的 DataDome 识别支持。 设置指南 详细介绍了首次启动的步骤。
- Node.js,以及 1.5.0 或更高版本的 capskip 包。更早的版本没有 dataDome 方法。该包声明需要 Node.js 18 或更高版本。我只在 Node.js 24.22.0 上运行过代码,使用 capskip 1.5.0 和 undici 8.12.3,所以这里没有试过 Node.js 18。undici 8 包声明需要 Node.js 22.19 或更高版本。按它们各自声明的 engines,undici 7 需要 Node.js 20.18.1 或更高版本,undici 6 需要 Node.js 18.17 或更高版本,所以较旧的 Node.js 需要搭配较旧的 undici。这两个我都没有运行过。
- 一个能运行 node 和 npm 的命令提示符。
- 一个你拥有或被允许自动化操作的页面,并且它受 DataDome 保护。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台 PC、VPS 或托管平台上的脚本就能通过 API 访问它。两种模式的设置位置都是 连接设置.
- 过了测试阶段之后,每个任务使用一个粘性代理(始终保持同一个 IP)。cookie 与获取它的 IP 地址绑定,第 3 步会解释这一点。
用 npm install capskip undici 安装这两个包。Node.js 自带 fetch,但它没有针对单个请求的代理选项。undici 包通过 ProxyAgent 补上了这个选项,所以代码也从 undici 导入 fetch。在我的环境里,把 undici 8.12.3 的 ProxyAgent 传给 Node.js 24.22.0 内置的 fetch 会失败,报错为 fetch failed,其原因是 invalid onRequestStart method(错误码 UND_ERR_INVALID_ARG),因为这个版本的 Node.js 内置的是 undici 7.29.1。两者都从同一个包里取,就避免了版本不匹配。
测试方式:代码是针对我自己写的三个模拟服务器运行的,而不是针对真实的 DataDome 站点。其中一个照搬了 CapSkip API 文档中 DataDome 部分列出的检查,一个扮演会返回 403 封锁页面的受保护站点,它只接受 CapSkip 签发的 cookie,并且要求配合签发时所用的 User-Agent,还有一个扮演正向代理。SDK 是真实的 capskip 1.5.0。CapSkip 应用在我的 PC 上运行着,但每个脚本都指向模拟服务器,所以应用什么也没有识别。这里没有任何内容能说明一次真实识别要花多久,也不能说明线上的 DataDome 站点接受什么。封锁页面的解析只在照搬 CapSkip 文档中格式的页面上试过。代理这条路径是用一个 http 代理测试的,它把普通请求转发到一个 http 站点。https 站点经代理所需的隧道没有测试,https 代理也没有测试。关于 cookie 的规则来自 CapSkip 文档。DataDome 自己的 cookie 文档 称 datadome cookie 的有效期为一年,并提醒不要更改它的 Domain 或 Path。但它没有说明这个 cookie 绑定到什么。
第 1 步:读取封锁页面并构造 captcha_url
把这个代码块和后面三个代码块,按所示顺序放进同一个名为 datadome-demo.js 的文件。到第 4 步之前什么都不会运行。
当 DataDome 对某个请求存疑时,站点会返回状态码 403(有时是 401)和一个小小的 HTML 页面,而不是真正的内容。该页面里有一个名为 dd 的脚本对象,并在一个 iframe 中从 geo.captcha-delivery.com 加载挑战。CapSkip 需要的就是这个 iframe 的地址,文档把它称为 captchaUrl。如果页面里已经包含这个 iframe,可以直接取它的 src。大多数封锁页面是用脚本来构建 iframe 的,所以下面的函数会根据 dd 对象拼出同样的地址。
rt 的值决定路径。字母 c 表示 /captcha/ 下的滑块,字母 i 表示 /interstitial/ 下的设备检查。t 的值也很重要:值 fe 表示这是一个挑战,值 bv 表示 DataDome 已经封禁了获取该页面的 IP 地址。第一个请求使用一个普通的 Chrome User-Agent 发出。这个字符串从不发送给 CapSkip,所以它的 Chrome 版本不必与任何东西匹配。我没有测试过线上的 DataDome 站点会不会把同样的挑战发给不同的 User-Agent,所以这部分请当作未经测试。
get 函数在返回之前会读完整个响应体,所以不会留下一直等待的连接。给它传一个代理地址,它就会通过 undici 的 ProxyAgent 发送请求,ProxyAgent 接受 http 或 https 代理。我没有试过 SOCKS 代理。
// npm install capskip undici
const { fetch, ProxyAgent } = require("undici");
const PAGE_URL = "https://www.example.com/"; // a page behind DataDome
const BROWSER = { // for the first request only; the cookie comes with its own User-Agent
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36",
};
const agents = new Map(); // one ProxyAgent per proxy, so sockets are reused
async function get(headers, proxyUrl) {
const options = { headers };
if (proxyUrl) {
if (!agents.has(proxyUrl)) agents.set(proxyUrl, new ProxyAgent(proxyUrl));
options.dispatcher = agents.get(proxyUrl);
}
const resp = await fetch(PAGE_URL, options);
return { status: resp.status, text: await resp.text(), setCookies: resp.headers.getSetCookie() };
}
// Build the challenge address from a DataDome block page, or null.
function captchaUrlFrom(page) {
if (![401, 403].includes(page.status)) return null;
const found = page.text.match(/var\s+dd\s*=\s*(\{[^}]*\})/);
if (!found) return null;
const dd = JSON.parse(found[1].replace(/'/g, "\""));
const path = dd.rt === "i" ? "interstitial" : "captcha";
const query = new URLSearchParams({ initialCid: dd.cid, hash: dd.hsh, cid: dd.cookie });
if (dd.t) query.set("t", dd.t); // fe is a challenge, bv means the address is banned
query.set("referer", PAGE_URL);
query.set("s", String(dd.s));
for (const key of ["e", "b"]) {
if (dd[key]) query.set(key, String(dd[key]));
}
query.set("dm", "cd");
return `https://${dd.host || "geo.captcha-delivery.com"}/${path}/?${query}`;
}对于不是封锁页面的任何响应,该函数都返回 null,这样脚本就能区分 “无需识别”和“已识别”。不受 DataDome 保护的页面(例如代码里的占位地址)只会打印 200。每个封锁页面都是一个全新的挑战。请尽快使用它的 captchaUrl,下一个任务要重新请求页面,而不是复用旧的地址。
第 2 步:用 dataDome() 识别
SDK 方法是 dataDome(captchaUrl, url, options),参数顺序与 2Captcha 一致。前两个是必需的。在 CapSkip 中,userAgent 和 proxy 是可选的,放在 options 对象里。如果你不发送 User-Agent,CapSkip 会使用它自己浏览器的那个,并在结果中以 userAgent 返回。如果你确实要发送,它必须是 Windows 版 Chrome,并且主版本号与 CapSkip 的浏览器相同,所以省略它是最省事的选择。因为 CapSkip 是在它自己运行的真实浏览器中通过挑战的,所以该方法使用与 reCAPTCHA 相同的、更长的 300 秒超时来等待。API 文档说一次识别通常需要 3 到 20 秒,而我没有计时过。
这里的代理,就是你之后的请求要用的那个。如果你的脚本通过代理出网,就传入它;如果请求是从运行 CapSkip 的那台机器发出的,就什么也不传。如果 CapSkip 在服务器上,那台机器就是服务器。值为 undefined 的选项不会被发送,所以在没有代理的情况下,proxyOption 可以返回 undefined。
const { CapSkip } = require("capskip");
// Local mode. In Server mode, use your server's address instead.
const solver = new CapSkip({ host: "127.0.0.1", port: 8080 });
function proxyOption(proxyUrl) {
if (!proxyUrl) return undefined; // your requests leave from the PC that runs CapSkip
const [scheme, rest] = proxyUrl.split("://");
return { type: scheme.toUpperCase(), uri: rest }; // e.g. HTTP, user:pass@ip:port
}
function solve(captchaUrl, proxyUrl) {
// No userAgent option: CapSkip uses its own browser's and reports it.
return solver.dataDome(captchaUrl, PAGE_URL, { proxy: proxyOption(proxyUrl) });
}如果设备检查无法通过,DataDome 会转到滑块。根据 CapSkip 文档,同一个调用会一路跟进到滑块,所以你不需要为这两种挑战写不同的代码。
第 3 步:把 cookie 连同它的 User-Agent 和 IP 地址一起发送
结果是一个对象,其中有四个重要字段。code 字段是 DataDome 设置的原样 cookie,带有 Max-Age、Domain 和其他属性,这是 cookie jar 所需要的。cookie 字段只是 datadome 的名称和值这一对,这是 Cookie 请求头所需要的。userAgent 字段是获得这个 cookie 的 User-Agent。只有当你没有发送代理,并且 CapSkip 通过它自己的 DataDome 代理列表完成识别时,才会出现 proxy 字段。CapSkip 文档说,DataDome 通常会拒绝来自其他 IP 地址或使用其他 User-Agent 的 cookie,所以每次都要发送同一个 User-Agent,并且当结果指明了代理时,要通过它发送请求。这个 proxy 字段只有主机和端口,没有协议前缀,也没有登录信息。函数会加上 http://,因此假定列表中的代理不需要登录。如果你的列表代理需要登录,请自行把它写进地址里,格式为 http://user:pass@ 后面接主机和端口。
DataDome 也可能在之后的响应中发来一个替换用的 cookie。函数会读取 Set-Cookie 各行,并保留最新的那个,就像浏览器那样。
async function sendWithCookie(result, proxyUrl) {
// The cookie works only from the address and User-Agent that earned it.
let via = proxyUrl;
if (!via && result.proxy) {
via = `http://${result.proxy}`; // CapSkip solved through its own list
}
const headers = { "User-Agent": result.userAgent, Cookie: result.cookie };
const page = await get(headers, via);
for (const line of page.setCookies) { // DataDome may rotate the cookie
const fresh = line.match(/^datadome=([^;]+)/);
if (fresh) headers.Cookie = `datadome=${fresh[1]}`;
}
return { page, headers, via };
}cookie 是手动设在请求头里的,所以没有任何东西替你管理它。这是有意为之:它把 cookie、User-Agent 和代理放在同一个地方。协议前缀之后的文本会按你写的原样发给 CapSkip,所以代理的登录信息中不要包含需要百分号编码的字符。
第 4 步:完整的可运行示例
最后一个代码块把各个步骤整合成一个完整的流程,并加上重试。把它保存在同一个 datadome-demo.js 文件的末尾,用 node datadome-demo.js 运行。每次尝试都会重新请求页面,所以每次都带有新的挑战。识别失败会抛出 ApiException,循环会再试一次。带着 cookie 仍然得到 403,也会让循环重新来一轮,并带上新的封锁页面。如果页面返回 403,却不是 DataDome 的封锁页面,循环会带着错误停下来,因为没有什么可识别的。每次重试都会从站点再取一个封锁页面,所以尝试次数要控制得少一些。
const { ApiException } = require("capskip");
const PROXY_URL = null; // e.g. "http://user:[email protected]:8080"; null means no proxy
async function fetchProtected(proxyUrl) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
const block = await get(BROWSER, proxyUrl); // a fresh block page, a fresh challenge
const captchaUrl = captchaUrlFrom(block);
if (captchaUrl === null) {
if (block.status >= 400) {
throw new Error(`HTTP ${block.status} without a DataDome block page`);
}
return { page: block, headers: BROWSER, via: proxyUrl }; // no block page here
}
let result;
try {
result = await solve(captchaUrl, proxyUrl);
} catch (err) {
if (!(err instanceof ApiException)) throw err;
if (err.message.includes("ERROR_PROXY_")) throw err; // the address is the problem
console.log(`attempt ${attempt}: ${err.message}`); // the next attempt gets a new page
continue;
}
const sent = await sendWithCookie(result, proxyUrl);
if (sent.page.status !== 200) {
console.log(`attempt ${attempt}: HTTP ${sent.page.status} with the cookie`);
continue;
}
return sent;
}
throw new Error("DataDome not passed in 3 attempts");
}
async function main() {
const { page, headers, via } = await fetchProtected(PROXY_URL);
console.log(page.status, page.text.slice(0, 40));
const again = await get(headers, via); // reuse the cookie while it lasts
console.log(again.status);
}
main().catch((err) => {
console.error(`${err.name}: ${err.message}`);
process.exitCode = 1;
});SDK 在发送任何东西之前,就会拒绝 t=bv 的 captchaUrl,因为从那个地址发起的任何识别都不可能通过。这个 ValidationException 不是 ApiException,所以循环会重新抛出它,而不是重试。让它抛出来:它是在提示你更换 IP 地址并重新开始。循环还会重新抛出另外两个与该 IP 地址有关的错误,即 ERROR_PROXY_BANNED 和 ERROR_PROXY_CONNECT_REFUSED,因为 CapSkip 文档说,通过同一个 IP 地址重新提交会再次失败。ApiException 的消息就是应用回复的文本,所以循环用 includes() 来匹配它,而不是做完全相等的比较。看到其中任何一个错误时,请先更换代理再运行。轮换代理的内容见 代理轮换指南.
在模拟运行中,脚本在 Node.js 24.22.0 上的表现如下:
| 模拟设置 | 脚本的表现 |
|---|---|
| 滑块封锁页面,每次识别都得到了答案 | 一个任务,然后是带 cookie 的状态 200,复用时再次返回 200 |
| 设备检查封锁页面 | 运行的是同一份代码:captchaUrl 指向 /interstitial/,结果相同 |
| 前两次识别被拒绝 | 两行 attempt 输出,然后第三个任务返回状态 200 |
| 前三次识别被拒绝 | 三行 attempt 输出,然后是 Error “DataDome not passed in 3 attempts”,退出码为 1 |
| 结果指明了代理,就像 CapSkip 自己的列表那样 | 带 cookie 的请求通过该代理发出,得到 200 |
| 把你自己的代理传给识别 | in.php 收到了代理地址和类型 HTTP,并且每个请求都通过该代理发出 |
| t=bv 的封锁页面 | ValidationException,并且模拟的 CapSkip 根本没有收到任何请求 |
| 在脚本中强制识别失败,返回 ERROR_PROXY_BANNED | 循环在一个任务之后把它重新抛出,没有第二次尝试 |
| 不是 DataDome 封锁页面的 403 页面 | Error “HTTP 403 without a DataDome block page” 和退出码 1,没有向 CapSkip 发出任何请求 |
| 用另一个 User-Agent 发送 cookie | 模拟站点返回 403 |
| 通过代理签发的 cookie,不经过该代理发送 | 模拟站点返回 403,而同一个 cookie 通过该代理发送时返回 200 |
最后两行测试的是我的模拟服务器的规则,这些规则遵循 CapSkip 文档。它们不是关于真实服务的证据。
常见错误
| 错误 | 原因 | 修复 |
|---|---|---|
| ValidationException | 不是 DataDome 挑战地址的 captchaUrl、t=bv 的封锁页面、不是 http 或 https 的 pageurl,或者不是标准 Windows 版 Chrome 的 userAgent。消息会说明属于其中哪一种 | 从新的封锁页面重新构造该地址,如果 IP 地址被封禁就更换它,并去掉 userAgent 选项 |
| 消息为 ERROR_WRONG_USERAGENT 的 ApiException | User-Agent 是 Windows 版 Chrome,但它的主版本号与 CapSkip 的浏览器不同。模拟服务器对 Chrome 120 给出了这个错误 | 不要发送 userAgent,改用结果中报告的那个 |
| 带有 ERROR_BAD_PARAMETERS 的 ApiException | 我手动发送一个不是挑战地址的地址时,运行中的 CapSkip 应用给出了这个错误。SDK 会在发送任何东西之前就拒绝这样的地址 | 从新的封锁页面重新构造该地址 |
| 带 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException | 没有通过挑战 | 重新请求页面,并提交它新的 captchaUrl |
| 带有 ERROR_PROXY_BANNED 的 ApiException | 识别过程中 DataDome 封禁了该 IP 地址。写着 t=bv 的封锁页面会更早被拦住,表现为 ValidationException | 更换代理,并重新请求页面 |
| 带有 ERROR_PROXY_CONNECT_REFUSED 的 ApiException | CapSkip 无法通过代理建立连接时会报告它。我没有复现过 | 检查代理的地址、登录信息和类型,然后再次运行脚本 |
| TimeoutException | CapSkip 在 300 秒的等待时间内没有给出答案。它不是 ApiException,所以循环会重新抛出它 | 检查 CapSkip 是否在运行并在识别,然后再次运行脚本 |
| NetworkException | CapSkip 没有在运行,或者主机或端口不对。连接一个没有任何监听的端口时,会在 NetworkException 中得到 ECONNREFUSED,而它不是 ApiException | 启动 CapSkip 并检查连接设置 |
| 带着 cookie 仍然返回 403 | 请求是从另一个 IP 地址发出的、用了另一个 User-Agent,或者 cookie 已过期 | 每次都发送结果中的 userAgent 和 proxy,或者带着新的封锁页面重新运行循环 |
把识别工具跑在服务器上
用 Node.js 识别 DataDome 验证码的脚本,需要 CapSkip 提供两样东西:主机和端口。在 Local 模式下,那就是 127.0.0.1 和你自己的 PC。在 Server 模式下,CapSkip 监听你的网络地址或公网 IP,所以另一台 PC、VPS 或托管平台上的脚本可以通过同样的 API 调用它。在 CapSkip 构造函数中更改主机和端口。建议使用静态公网 IP,以获得稳定的远程访问。Server 模式仍然是你自己的硬件,仍然不按次计费。它确实会对任何地址开放这个端口,所以请为不应访问它的地址添加 Windows Firewall 阻止规则。有一点确实会变:识别是在那台机器上运行的,所以 cookie 是从服务器的 IP 地址获得的。除非你传入代理,否则携带这个 cookie 的请求也必须从那台服务器发出。如果使用代理,请把同一个代理同时传给识别和你的请求。 连接设置 说明了如何切换模式。
常见问题
DataDome 识别工具会返回 token 吗?
不会。它返回的是 datadome cookie。你要把这个 cookie 放在被拦截请求的 Cookie 请求头里发送,并带上同一个 User-Agent。没有需要填写的表单字段。
使用 DataDome 需要代理吗?
测试时不需要。第一次运行用一个 IP 地址就可以。根据 CapSkip 文档,cookie 只在获得它的 IP 地址上有效,所以一旦发送大量请求,就请为每个任务规划一个粘性代理,并把同一个代理同时传给识别和你的请求。代理是本指南唯一需要 SDK 之外的库的地方,因为 fetch 本身没有针对单个请求的代理选项。
这对滑块和设备检查都适用吗?
适用,通过同一个调用。滑块的 captchaUrl 位于 /captcha/ 下,设备检查的位于 /interstitial/ 下。CapSkip 文档说,如果设备检查升级为滑块,调用会一路跟进到滑块。
在 Node.js 中,不用浏览器也能识别 DataDome 验证码吗?
你的脚本不需要浏览器。它向 CapSkip 和站点发送的都是普通的 HTTP 请求,而 CapSkip 会在它所运行的那台机器上,用一个真实的浏览器通过挑战,SDK 文档里就是这样描述的。
托管平台能针对我的 CapSkip 运行这个流程吗?
可以,通过 Server 模式。把 CapSkip 设置为监听你的网络 IP 或公网 IP,让 CapSkip 构造函数指向那个主机和端口,循环的工作方式就一样,只有一处不同:识别此时在服务器上运行,所以要给识别和你的请求使用同一个代理。识别工具仍然在你自己的硬件上。
简短版结论
在 Node.js 中识别 DataDome 验证码分四步:读取封锁页面、构造 captchaUrl、调用 dataDome(),然后带着 cookie、User-Agent 以及它所属的 IP 地址重复请求。在你自己的机器上识别不花一分钱,这正是你希望从 无限量验证码识别工具 那里得到的,尤其是在受保护的站点不断给你新的挑战时。不过每次重试都会再取一个封锁页面,所以重试次数要控制得少一些。关于 SDK 的其余部分,请参阅 Node.js 验证码识别页面。同样的流程用 Python 写的版本见 Python DataDome 指南.
