{"id":27033,"date":"2026-10-31T18:02:55","date_gmt":"2026-10-31T18:02:55","guid":{"rendered":"https:\/\/capskip.com\/?p=27033"},"modified":"2026-11-03T03:35:59","modified_gmt":"2026-11-03T03:35:59","slug":"captcha-detector-nodejs","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/captcha-detector-nodejs\/","title":{"rendered":"\u5982\u4f55\u7528 Node.js \u6784\u5efa\u9a8c\u8bc1\u7801\u68c0\u6d4b\u5668\u5e76\u8bc6\u522b\u9a8c\u8bc1\u7801"},"content":{"rendered":"<p>A CAPTCHA detector in Node.js reads a page&#8217;s HTML for the widget&#8217;s script and container, pulls the sitekey out of them, and maps the family to the CapSkip method that solves it. A reCAPTCHA v2 box, a v3 script and a Turnstile widget need three different calls with different options, and a wrong guess wastes a solve and returns a result you cannot use. This guide builds a detector of about 165 lines with no packages besides capskip. It returns the type, the method and the arguments, or says plainly that it cannot tell. CapSkip runs on your own Windows PC with no per-solve fee, so a script that checks a hundred pages costs nothing extra. The same detector in Python is in the <a href=\"https:\/\/capskip.com\/detect-captcha-type\/\">CAPTCHA type detection guide<\/a>.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>CapSkip running on a Windows PC, with the solvers you need switched on. The <a href=\"https:\/\/capskip.com\/setup-guide\/\">setup guide<\/a> covers the first start.<\/li>\n<li>Node.js 20 or later. The code below ran on Node.js 24.22.0, and the detector and the solve step also ran on 20.11.1 and 25.2.1, with capskip 1.3.0. The package itself needs Node.js 18 or later, but Node.js 18 was not tried here.<\/li>\n<li>A PowerShell prompt. The commands use PowerShell syntax.<\/li>\n<li>A page that you own or may automate, with a CAPTCHA on it.<\/li>\n<li>An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a script on another PC, a VPS or a hosted platform can reach it over the API. Both are set under <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a>.<\/li>\n<\/ul>\n<p>How this was tested: the detector ran against the same 66 saved pages as the Python one and returned the same result for every page. Of those, 59 were built by hand (about 40 follow the vendors&#8217; documentation, the rest are edge cases I made up) and 7 are snapshots of public pages taken on 29 October 2026. The solves ran through the real capskip package against a mock CapSkip that copies the app&#8217;s input checks. The real app was not running, so no number below is a solve time.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Why you detect CAPTCHA type before you solve<\/h2>\n<p>CapSkip has a different method for each family: recaptcha, turnstile, geetest, altcha, capy, captchafox, friendlyCaptcha and normal for images in the SDK, plus hcaptcha on the HTTP API, which the SDK does not wrap yet. Within reCAPTCHA the options matter too. Invisible, Enterprise and v3 are flags on the same call. Send the wrong ones and the best case is a failed job, the worst a token the site rejects with no hint why. I could not check which one the real app does for a wrong reCAPTCHA version. Either way the solve is spent.<\/p>\n<p>Other mistakes fail fast and loudly. Reading the sitekey from a page that renders reCAPTCHA explicitly gives the word explicit, and CapSkip refuses it with ERROR_GOOGLEKEY. The <a href=\"https:\/\/capskip.com\/error-googlekey-pageurl\/\">ERROR_GOOGLEKEY and ERROR_PAGEURL guide<\/a> covers that error from the other side. To detect CAPTCHA type reliably, a detector has to check the signals in a fixed order, and that order avoids both kinds of mistake. A detector is also the first thing to build before a scraper that meets more than one site. If you want the background on what each type is, <a href=\"https:\/\/capskip.com\/what-is-captcha\/\">what a CAPTCHA is<\/a> explains the families without code.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: install and fetch the page<\/h2>\n<p>Create a folder, initialise a project and install capskip. The detector uses only regular expressions, and the fetch calls use the fetch that Node.js ships with.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\">npm init -y\nnpm install capskip<\/pre>\n<\/div>\n<p>A CAPTCHA detector in Node.js works on text, so it needs only two inputs: the page&#8217;s HTML and its address. A plain fetch shows what the server sends before any JavaScript runs. Google&#8217;s reCAPTCHA demo pages, Cloudflare&#8217;s Turnstile demo and the hCaptcha demo all carry their widget in that HTML. Pages that add the widget with JavaScript do not, and the section &#8220;When the HTML says nothing&#8221; covers that case. Node.js&#8217;s fetch keeps no cookies, which matters for the image CAPTCHA step, so the script below passes them on by hand.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: write the helpers<\/h2>\n<p>Save everything from here to Step 9 in one file called detect.js, one block after another. The first block writes the small readers. Each returns null when nothing matches, so a missing attribute never throws.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">'use strict';\nconst SITEKEY_JS = \/sitekey['&quot;]?\\s*:\\s*['&quot;]([^'&quot;]+)\/; \/\/ sitekey: '...' inside a render() call\nconst NAMED = { '&amp;amp;': '&amp;', '&amp;lt;': '&lt;', '&amp;gt;': '&gt;', '&amp;quot;': '&quot;', '&amp;apos;': &quot;'&quot; };\nconst unescapeHtml = (s) =&gt; s.replace(\/&amp;(?:#x([0-9a-f]+)|#(\\d+)|[a-z]+);\/gi, (m, hex, dec) =&gt;\n  hex || dec ? String.fromCodePoint(parseInt(hex || dec, hex ? 16 : 10)) : NAMED[m.toLowerCase()] || m);\nfunction grab(re, text) { \/\/ first capture group that matched, or null\n  const m = new RegExp(re.source, 'is').exec(text || '');\n  return m ? m.slice(1).find((g) =&gt; g !== undefined) ?? null : null;\n}\nconst tag = (cls, h) =&gt; grab(new RegExp( \/\/ opening tag that has the CSS class cls\n  `(&lt;\\\\w+[^&gt;]*\\\\sclass\\\\s*=\\\\s*[&quot;'][^&quot;']*(?&lt;![\\\\w-])${cls}(?![\\\\w-])[^&quot;']*[&quot;'][^&gt;]*&gt;)`), h);\nfunction attr(t, name) { \/\/ attribute value from a tag string, either quote style, unescaped\n  const v = grab(new RegExp(`\\\\s${name}\\\\s*=\\\\s*(?:&quot;([^&quot;]*)&quot;|'([^']*)')`), t);\n  return v === null ? null : unescapeHtml(v);\n}\nconst renderOpts = (call, js) =&gt; grab(new RegExp(`${call}\\\\([^,]+,\\\\s*\\\\{(.{0,400})`), js);<\/pre>\n<\/div>\n<p>The grab function returns the first capture group that matched. The tag function finds an opening tag by CSS class and refuses a class that only contains the name inside a longer one, so a class called cf-turnstile-response is not a Turnstile widget. The attr function reads an attribute written with either quote style and unescapes HTML entities, which matters for a challenge held in an attribute. Unquoted attributes are not read.<\/p>\n<p>The second block turns a page into something the checks can share. It strips comments and style blocks, so a CSS rule that mentions a widget class cannot match, and it collects the script addresses and the inline JavaScript separately. The two helpers after parse build the answers: hit, and browser, which carries the needsBrowser reason. A third answer, unsupported, is built by the checks at the end of this block, which the next step explains, together with the hCaptcha check.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">function parse(html, url) {\n  const h = html.replace(\/&lt;!--.*?--&gt;|&lt;style\\b.*?&lt;\\\/style&gt;\/gis, '');\n  const srcs = [...h.matchAll(\/&lt;script\\b[^&gt;]*?\\ssrc\\s*=\\s*[&quot;']([^&quot;']+)\/gi)].map((m) =&gt; m[1]).join(' ');\n  const js = [...h.matchAll(\/&lt;script\\b[^&gt;]*&gt;(.*?)&lt;\\\/script&gt;\/gis)].map((m) =&gt; m[1]).join('\\n');\n  return { h, srcs: unescapeHtml(srcs), js, url };\n}\nconst hit = (type, method, args) =&gt; ({ type, method, args });\nconst browser = (type, method, p, why) =&gt; ({ type, method, args: { url: p.url }, needsBrowser: why });\n\nconst UNSUPPORTED = { arkose: \/arkoselabs\\.com\\\/v2\\\/|funcaptcha\\.com\\\/fc\\\/\/, geetest_v4: \/gt4\\.js|initGeetest4\/ };\nfunction unsupported(p) { \/\/ CapSkip cannot solve these: say so instead of misleading the reader\n  const type = Object.keys(UNSUPPORTED).find((k) =&gt; UNSUPPORTED[k].test(p.srcs + ' ' + p.js));\n  if (type) return { type, method: null, args: {}, unsupported: true };\n}\nconst cfChallenge = (p) =&gt; \/_cf_chl_opt\\s*=\\s*\\{\/.test(p.js) \/\/ the full-page &quot;Just a moment...&quot; page\n  ? browser('turnstile_challenge', 'turnstile', p, 'data and pagedata (Cloudflare calls them cData and chlPageData) only exist at run time') : null;\nfunction hcaptcha(p) { \/\/ before reCAPTCHA: a site that moved to hCaptcha may keep the g-recaptcha class\n  if (!\/hcaptcha\\.com\\\/1\\\/api\\.js\/.test(p.srcs + ' ' + p.js)) return null;\n  if (\/\\.setData\\s*\\(|rqdata\/.test(p.js)) \/\/ Enterprise rqdata is issued per challenge\n    return browser('hcaptcha_enterprise', 'hcaptcha', p, 'rqdata is issued for one challenge at run time');\n  const w = tag('h-captcha', p.h) || tag('g-recaptcha', p.h);\n  const key = attr(w, 'data-sitekey') || grab(SITEKEY_JS, renderOpts('hcaptcha\\\\.render', p.js));\n  if (!key) return browser('hcaptcha', 'hcaptcha', p, 'no sitekey in the static HTML');\n  const args = { sitekey: key, url: p.url };\n  if (attr(w, 'data-size') === 'invisible') args.invisible = 1;\n  return hit('hcaptcha', 'hcaptcha', args);\n}<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: hCaptcha, and what CapSkip cannot solve<\/h2>\n<p>These three checks sit at the end of the second block above, so there is nothing new to paste in this step. Start with the pages where the right answer is no. CapSkip does not solve FunCaptcha or Arkose, and GeeTest v4 is outside the GeeTest method, which is for v3. If the detector skipped those, a page with an Arkose box would fall through to the next check and might come back as something it is not. So this check runs first and returns a result with no method.<\/p>\n<p>The same block handles the Cloudflare full-page check, the &#8220;Just a moment&#8221; interstitial. That page is not a Turnstile widget. The turnstile method needs two values from it, data and pagedata, and a plain fetch does not hold them. The detector looks for the page&#8217;s inline challenge options and answers needsBrowser. The <a href=\"https:\/\/capskip.com\/cf-clearance-cookie\/\">cf_clearance guide<\/a> covers what comes after a challenge page is passed.<\/p>\n<p>hCaptcha comes next, before reCAPTCHA. CapSkip solves it, whether the widget is a checkbox, invisible or Enterprise, but a site that moved from reCAPTCHA to hCaptcha often keeps the g-recaptcha class on its widget, and only the hCaptcha script tells the two apart. The check reads the sitekey from the widget or from an hcaptcha.render call, and adds invisible when the widget has data-size set to invisible. An Enterprise page that hands the widget an rqdata value through hcaptcha.setData gets needsBrowser instead, because that value is issued for one challenge and expires quickly.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 4: reCAPTCHA, in the right order<\/h2>\n<p>reCAPTCHA needs the most care because several CapSkip variants share two script names. The order of questions is what keeps them apart. First the script name: enterprise.js means the Enterprise flag, api.js does not. Then the render parameter in the script address. A key after render= means v3, but the word explicit after render= means a v2 page that renders its widget from code, and it must never be used as a key. Then the markup. A button with the g-recaptcha class and a data-action attribute is Google&#8217;s v3 button binding. A button without data-action, or any element with data-size set to invisible, is the invisible v2 widget. Anything else with the class is the checkbox.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">function recaptcha(p) { \/\/ Enterprise flag first, then v3 (render=KEY), then invisible, then v2\n  const m = \/\\\/recaptcha\\\/(api|enterprise)\\.js(\\?\\S*)?\/.exec(p.srcs);\n  if (!m) return null;\n  const ent = m[1] === 'enterprise' ? { enterprise: 1 } : {};\n  const render = grab(\/[?&amp;]render=([^&amp;\\s]+)\/, m[2]); \/\/ a v3 sitekey, or &quot;explicit&quot; (a v2 page)\n  const t = tag('g-recaptcha', p.h), o = renderOpts('\\\\.render', p.js);\n  const button = (t || '').toLowerCase().startsWith('&lt;button'), dataAction = attr(t, 'data-action');\n  const v3 = Boolean(render &amp;&amp; render !== 'explicit') || Boolean(button &amp;&amp; dataAction);\n  const key = (render !== 'explicit' ? render : null) || attr(t, 'data-sitekey') || grab(SITEKEY_JS, o);\n  if (!key) return browser('recaptcha', 'recaptcha', p, 'no sitekey in the static HTML (set by JavaScript)');\n  const small = attr(t, 'data-size') === 'invisible' || grab(\/size['&quot;]?\\s*:\\s*['&quot;](invisible)\/, o);\n  const invisible = !v3 &amp;&amp; Boolean(button || small);\n  const action = dataAction || grab(\/execute\\(\\s*[^,)]+,\\s*\\{\\s*['&quot;]?action['&quot;]?\\s*:\\s*['&quot;]([^'&quot;]+)\/, p.js);\n  const args = { sitekey: key, url: p.url, ...ent };\n  if (v3) Object.assign(args, { version: 'v3' }, action ? { action } : {});\n  if (invisible) args.invisible = 1;\n  const kind = 'recaptcha_' + (v3 ? 'v3' : invisible ? 'v2_invisible' : 'v2');\n  return hit(kind + (ent.enterprise ? '_enterprise' : ''), 'recaptcha', args);\n}<\/pre>\n<\/div>\n<p>Each outcome maps to a call you already know. A checkbox is solver.recaptcha with a sitekey, the page URL and an empty options object. Invisible adds invisible set to 1, Enterprise adds enterprise set to 1, and v3 adds the version set to v3 and the action. The action name comes from the data-action attribute or from the page&#8217;s execute call; if neither is in the HTML, the option is left out and CapSkip uses its default of verify. See the <a href=\"https:\/\/capskip.com\/recaptcha-solver\/\">reCAPTCHA solver page<\/a> for every variant it handles.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 5: Turnstile and GeeTest<\/h2>\n<p>A Turnstile widget needs two things on the page: Cloudflare&#8217;s api.js and a sitekey. The key sits in a data-sitekey attribute on a div with the cf-turnstile class, or in a turnstile.render call. The check requires the script, so a stray class name on an unrelated page cannot trigger it. It also reads data-action and data-cdata when they are present and passes them on. The <a href=\"https:\/\/capskip.com\/cloudflare-turnstile-solver\/\">Turnstile solver page<\/a> lists the options.<\/p>\n<p>GeeTest v3 gives you two values, and one of them is a trap. The gt value is fixed for the site and can sit in the page. The challenge value is single-use, and sites fetch it from a register endpoint at run time. If the page carries both, the detector returns them. If it carries only the initGeetest call, it answers needsBrowser, because a challenge found in old HTML would already be dead.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">function turnstile(p) { \/\/ widget only: implicit (div.cf-turnstile) or explicit (turnstile.render)\n  if (!p.srcs.includes('challenges.cloudflare.com\/turnstile\/v0\/api.js')) return null;\n  const t = tag('cf-turnstile', p.h), o = renderOpts('turnstile\\\\.render', p.js);\n  const sitekey = attr(t, 'data-sitekey') || grab(SITEKEY_JS, o);\n  if (!sitekey) return browser('turnstile', 'turnstile', p, 'no sitekey in the static HTML');\n  const action = attr(t, 'data-action') || grab(\/\\baction['&quot;]?\\s*:\\s*['&quot;]([^'&quot;]+)\/, o);\n  const data = attr(t, 'data-cdata') || grab(\/\\bcData['&quot;]?\\s*:\\s*['&quot;]([^'&quot;]+)\/, o);\n  return hit('turnstile', 'turnstile', { sitekey, url: p.url, ...(action &amp;&amp; { action }), ...(data &amp;&amp; { data }) });\n}\nfunction geetest(p) { \/\/ v3 only: gt is static, challenge comes from the register endpoint\n  if (!(\/initGeetest\\s*\\(\/.test(p.js) || \/\\\/gt(\\.[\\d.]+)?\\.js\/.test(p.srcs))) return null;\n  const gt = grab(\/\\bgt['&quot;]?\\s*:\\s*['&quot;]([0-9a-f]{32})\/, p.js);\n  const challenge = grab(\/\\bchallenge['&quot;]?\\s*:\\s*['&quot;]([0-9a-f]{32,34})\/, p.js);\n  if (!(gt &amp;&amp; challenge)) return browser('geetest', 'geetest', p, 'gt and challenge come from the register endpoint');\n  return hit('geetest', 'geetest', { gt, challenge, url: p.url });\n}<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 6: ALTCHA and Capy<\/h2>\n<p>ALTCHA changed its attribute names between generations. Versions 1 and 2 use challengeurl, or challengejson for an inline challenge. Version 3 and later use one attribute called challenge, which takes either a URL or the challenge data. The check reads all three and joins a relative URL to the page address. In Node.js the options are named challengeUrl and challengeJson.<\/p>\n<p>Capy needs no widget div for this check. Its key starts with PUZZLE_ and sits in the k parameter of the widget script&#8217;s address, and a host other than the default is passed as apiServer.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">function altcha(p) { \/\/ challengeurl \/ challengejson (v1, v2) or challenge (v3+)\n  const t = grab(\/(&lt;altcha-widget\\b[^&gt;]*&gt;)\/, p.h);\n  if (!t) return null;\n  const c = attr(t, 'challenge');\n  const asJson = attr(t, 'challengejson') || ((c || '').trimStart().startsWith('{') ? c : null);\n  const asUrl = attr(t, 'challengeurl') || (asJson ? null : c);\n  if (asJson) return hit('altcha', 'altcha', { url: p.url, challengeJson: asJson });\n  if (asUrl) return hit('altcha', 'altcha', { url: p.url, challengeUrl: new URL(asUrl, p.url).href });\n  return browser('altcha', 'altcha', p, 'no challenge attribute');\n}\nfunction capy(p) { \/\/ the widget script is ...\/puzzle\/get_js\/?k=PUZZLE_...\n  const m = \/((?:https?:)?\\\/\\\/[^\\s&quot;']+?)\\\/puzzle\\\/get_js\\\/\\S*?(PUZZLE_[\\w-]+)\/.exec(p.srcs);\n  if (!m) return null;\n  const host = m[1].startsWith('http') ? m[1] : 'https:' + m[1];\n  const more = host === 'https:\/\/jp.api.capy.me' ? {} : { apiServer: host };\n  return hit('capy', 'capy', { sitekey: m[2], url: p.url, ...more });\n}<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 7: CaptchaFox and Friendly Captcha<\/h2>\n<p>CaptchaFox comes in two builds. The plain one loads from cdn.captchafox.com. The other is bundled by a hosting platform under a shared CDN path, so the detector looks for the CaptchaFox package name in that path and not for the path alone, which other widgets also use.<\/p>\n<p>Friendly Captcha is the trickiest. Version 1 and version 2 use the same div and the same sitekey namespace, but different protocols, and a token for the wrong one can be rejected by the site. The scripts tell them apart: version 2 loads the @friendlycaptcha\/sdk package, version 1 loads friendly-challenge. When the page gives no sign, the detector refuses to guess and says needsBrowser.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">const MAM = 'mampkg\/@mamdev\/core.frontend.libs.captchafox';\nfunction captchafox(p) { \/\/ plain build from cdn.captchafox.com, or the bundled build from s.uicdn.com\n  const t = tag('captchafox', p.h), key = grab(\/captchafox[^&quot;']*\\\/api\\.js\\?key=([^&quot;'&amp;]+)\/, p.srcs);\n  if (!t &amp;&amp; !key) return null;\n  const more = p.srcs.includes(MAM) ? { apiServer: 'https:\/\/s.uicdn.com\/' + MAM + '\/' } : {};\n  const sitekey = attr(t, 'data-sitekey') || key;\n  if (!sitekey) return browser('captchafox', 'captchafox', p, 'no sitekey in the static HTML');\n  return hit('captchafox', 'captchafox', { sitekey, url: p.url, ...more });\n}\nfunction friendly(p) { \/\/ v2 loads @friendlycaptcha\/sdk (site.min.js), v1 loads friendly-challenge\n  const t = tag('frc-captcha', p.h);\n  if (!t) return null;\n  const v2End = attr(t, 'data-api-endpoint'), v1End = attr(t, 'data-puzzle-endpoint');\n  const v2 = v2End !== null || \/friendlycaptcha\\\/sdk|site(\\.compat)?\\.min\\.js\/.test(p.srcs);\n  const v1 = v1End !== null || \/friendly-challenge|widget(\\.module)?\\.min\\.js\/.test(p.srcs);\n  if (v2 === v1) return browser('friendly_captcha', 'friendlyCaptcha', p, 'cannot tell v1 from v2 in the HTML');\n  const server = v2End || v1End;\n  if (!attr(t, 'data-sitekey')) return browser('friendly_captcha', 'friendlyCaptcha', p, 'no sitekey in the static HTML');\n  return hit('friendly_captcha_' + (v2 ? 'v2' : 'v1'), 'friendlyCaptcha',\n    { sitekey: attr(t, 'data-sitekey'), url: p.url, version: v2 ? 'v2' : 'v1', ...(server &amp;&amp; { apiServer: server }) });\n}<\/pre>\n<\/div>\n<p>Each of these has its own solver page with the options the method takes:<\/p>\n<ul>\n<li><a href=\"https:\/\/capskip.com\/capy-puzzle-solver\/\">Capy Puzzle solver page<\/a>: the three values a Capy answer returns.<\/li>\n<li><a href=\"https:\/\/capskip.com\/captchafox-solver\/\">CaptchaFox solver page<\/a>: the options the CaptchaFox method takes.<\/li>\n<li><a href=\"https:\/\/capskip.com\/friendly-captcha-solver\/\">Friendly Captcha solver page<\/a>: the Friendly Captcha method.<\/li>\n<\/ul>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 8: the image CAPTCHA, a guess, and the checks<\/h2>\n<p>An image CAPTCHA has no vendor script to find, so this check is a guess and runs only when nothing else matched. The check wants an image whose tag says captcha, and a text box elsewhere on the page whose tag says captcha or security code. It missed in every case where the markup did not use those words, and a drawn canvas is invisible to it. Treat a hit as likely, not certain.<\/p>\n<p>The same block lists the checks in order and defines detect, which is exported at the end of Step 9. The order is the logic: unsupported vendors first, then the Cloudflare page, then hCaptcha, then each other family, and the image guess only when the list found nothing. The detect function returns the first result and adds an also list when the page carries more than one family, for example a v3 script plus a Turnstile box. The list names families, not a second reCAPTCHA widget with another key.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">function image(p) { \/\/ a guess: an &lt;img&gt; that says captcha, next to a text box that says captcha\n  const box = \/&lt;input\\b(?![^&gt;]*type\\s*=\\s*[&quot;']?(?:hidden|checkbox|submit))[^&gt;]*(?:captcha|security.?code)\/i;\n  const img = (p.h.match(\/&lt;img\\b[^&gt;]*&gt;\/gi) || []).find((i) =&gt; box.test(p.h) &amp;&amp; \/captcha\/i.test(i) &amp;&amp; attr(i, 'src'));\n  if (img) return hit('image', 'normal', { image: new URL(attr(img, 'src'), p.url).href });\n}\nconst CHECKS = [unsupported, cfChallenge, hcaptcha, recaptcha, turnstile, geetest, altcha, capy, captchafox, friendly];\n\nfunction detect(html, pageUrl) {\n  const p = parse(html, pageUrl);\n  const found = CHECKS.map((check) =&gt; check(p)).filter(Boolean);\n  const best = found[0] || image(p) || null; \/\/ the image guess comes last\n  if (found.length &gt; 1) best.also = found.slice(1).map((r) =&gt; r.type);\n  return best;\n}<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 9: solve<\/h2>\n<p>The last block dispatches the result to the right CapSkip method. It refuses anything unsupported or marked needsBrowser with an Error. The CapSkip methods take their main values by position and the rest as an options object, so a small table says which values go where. Node.js options are camelCase, with one exception: if you add a per-call polling interval, the name is polling_interval, because a per-call pollingInterval throws a ValidationException. The image method takes no per-call options at all, so set pollingInterval on the CapSkip constructor for that one. An image is downloaded with the cookies you pass in, because fetch has no cookie jar and the SDK&#8217;s own download of a URL sends none. Those cookies go to whatever host the image is on, so if your CAPTCHA image sits on a different host, leave the cookie out.<\/p>\n<p>The SDK has no hCaptcha call yet, so an hCaptcha result goes to solveHcaptcha instead. It posts the sitekey and the page address to in.php with method=hcaptcha, then polls res.php every 5 seconds until the token is ready, as the <a href=\"https:\/\/capskip.com\/api-docs\/#hcaptcha\">hCaptcha section of the API docs<\/a> describes. Pass the solver&#8217;s address as api in the last argument of solve, and your API key as key if API key validation is switched on. The result carries the token in code and the User-Agent it was earned with in userAgent.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">const POSITIONAL = { normal: ['image'], recaptcha: ['sitekey', 'url'], turnstile: ['sitekey', 'url'],\n  geetest: ['gt', 'challenge', 'url'], altcha: ['url'], capy: ['sitekey', 'url'], captchafox: ['sitekey', 'url'],\n  friendlyCaptcha: ['sitekey', 'url'] };\n\nasync function solveHcaptcha(args, api, key) { \/\/ no SDK call yet: post to in.php, poll res.php\n  const form = { method: 'hcaptcha', sitekey: args.sitekey, pageurl: args.url, invisible: args.invisible || 0, json: 1 };\n  if (args.data) form.data = args.data;\n  if (key) form.key = key;\n  const job = await (await fetch(`${api}\/in.php`, { method: 'POST', body: new URLSearchParams(form) })).json();\n  if (job.status !== 1) throw new Error(job.request);\n  for (let i = 0; i &lt; 36; i++) { \/\/ a solve takes 10 to 30 seconds, so give up after 3 minutes\n    await new Promise((done) =&gt; setTimeout(done, 5000));\n    const q = new URLSearchParams({ action: 'get', id: job.request, json: 1, ...(key ? { key } : {}) });\n    const r = await (await fetch(`${api}\/res.php?${q}`)).json();\n    if (r.request !== 'CAPCHA_NOT_READY') {\n      if (r.status !== 1) throw new Error(r.request);\n      return { code: r.request, userAgent: r.useragent };\n    }\n  }\n  throw new Error('hCaptcha was not solved within 3 minutes');\n}\n\nasync function solve(info, solver, fetchOpts = {}, { api = 'http:\/\/127.0.0.1:8080', key } = {}) {\n  if (info.unsupported || info.needsBrowser) throw new Error(info.needsBrowser || `${info.type} is not supported`);\n  const args = { ...info.args };\n  if (info.method === 'hcaptcha') return solveHcaptcha(args, api, key);\n  if (info.method === 'normal' &amp;&amp; !args.image.startsWith('data:')) { \/\/ fetch has no cookie jar: pass a Cookie header\n    const res = await fetch(args.image, fetchOpts);\n    if (!res.ok) throw new Error(`image download failed: HTTP ${res.status}`);\n    args.image = 'data:image\/png;base64,' + Buffer.from(await res.arrayBuffer()).toString('base64');\n  }\n  const names = POSITIONAL[info.method];\n  const options = Object.fromEntries(Object.entries(args).filter(([k]) =&gt; !names.includes(k)));\n  return solver[info.method](...names.map((k) =&gt; args[k]), options);\n}\nmodule.exports = { detect, solve };<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Run the CAPTCHA detector in Node.js<\/h2>\n<p>Save this as use.js next to detect.js. It fetches your page, prints what the detector found, and only calls CapSkip when there is a method to call and nothing is missing. It sends a browser-like User-Agent, because many sites refuse Node.js&#8217;s default one. It prints the HTTP status when the page does not answer 200 and carries on, because a Cloudflare challenge page arrives as a 403 or 503. Run it with the page address in quotes, because an address with an ampersand is a parse error in PowerShell. The port 8080 is the default; set the CAPSKIP_PORT environment variable, which this script reads itself, if yours differs. The host stays 127.0.0.1 in this script, so edit the host line for Server mode.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\">$env:CAPSKIP_PORT = &quot;9090&quot;   # only if your port is not 8080\nnode use.js &quot;https:\/\/example.com\/login&quot;<\/pre>\n<\/div>\n<p>Note that fetch keeps no cookies, and this script reads Set-Cookie from the final response only, so a cookie set on a redirect is lost. To keep it, call fetch with the redirect option set to manual, read Set-Cookie from the redirect answer, and request the Location address yourself. The script itself is below.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">'use strict';\nconst { CapSkip } = require('capskip');\nconst { detect, solve } = require('.\/detect');\n\nasync function main(pageUrl) {\n  const res = await fetch(pageUrl, { headers: { 'user-agent': 'Mozilla\/5.0 (Windows NT 10.0; Win64; x64) Chrome\/130.0 Safari\/537.36' } });\n  if (!res.ok) console.error(`page answered HTTP ${res.status}`); \/\/ a challenge page is a 403 or 503, so keep going\n  const html = await res.text();\n  const info = detect(html, res.url); \/\/ res.url is the final address after any redirect\n  console.log(JSON.stringify(info, null, 2));\n  if (!info || info.unsupported || info.needsBrowser) return;\n  const cookie = res.headers.getSetCookie().map((c) =&gt; c.split(';')[0]).join('; ');\n  const host = '127.0.0.1', port = Number(process.env.CAPSKIP_PORT || 8080);\n  const solver = new CapSkip({ host, port });\n  console.log(await solve(info, solver, cookie ? { headers: { cookie } } : {}, { api: `http:\/\/${host}:${port}` }));\n}\n\nmain(process.argv[2]).catch((err) =&gt; {\n  console.error(`${err.constructor.name}: ${err.message}`);\n  process.exit(1);\n});<\/pre>\n<\/div>\n<p>On a page with a reCAPTCHA v2 checkbox the first print shows the type, the method and the arguments, and the second shows the result. For reCAPTCHA the token is in the code field; the other methods use different fields, for example userAgent beside the Turnstile and hCaptcha tokens. A reCAPTCHA token goes into the g-recaptcha-response field of the form, and an hCaptcha token into h-captcha-response. In the mock run the detector returned these results:<\/p>\n<table>\n<thead>\n<tr>\n<th>Page<\/th>\n<th>detect returns<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>reCAPTCHA v2 checkbox<\/td>\n<td>type recaptcha_v2, method recaptcha, sitekey and url<\/td>\n<\/tr>\n<tr>\n<td>hCaptcha widget<\/td>\n<td>type hcaptcha, method hcaptcha, sitekey and url<\/td>\n<\/tr>\n<tr>\n<td>Cloudflare &#8220;Just a moment&#8221; page<\/td>\n<td>type turnstile_challenge, needsBrowser set<\/td>\n<\/tr>\n<tr>\n<td>v3 script plus a Turnstile widget<\/td>\n<td>type recaptcha_v3, also lists turnstile<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">When the HTML says nothing: the browser console<\/h2>\n<p>Some pages add their widget with JavaScript after loading. A plain fetch then finds no CAPTCHA at all, and the detector returns null for a page that has one. Two cases are known and expected: a Google or Cloudflare script added by code, and a sitekey set by code. For those, open the page in Chrome, press F12 and paste this into the Console. If Chrome blocks the paste, type allow pasting and press Enter first. It reads the sitekeys from the resources the page has already loaded, so it sees what the browser built.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">(() =&gt; {\n  const iframes = [...document.querySelectorAll('iframe')].map((f) =&gt; f.src);\n  const urls = performance.getEntriesByType('resource').map((r) =&gt; r.name).concat(iframes);\n  const found = new Map(), add = (o) =&gt; found.set(JSON.stringify(o), o);\n  for (const u of urls) {\n    let m;\n    if ((m = u.match(\/recaptcha\\\/(?:api2|(enterprise))\\\/anchor\\?.*?\\bk=([\\w-]+)\/)))\n      add({ type: 'recaptcha', sitekey: m[2], enterprise: !!m[1], invisible: \/size=invisible\/.test(u) });\n    else if ((m = u.match(\/recaptcha\\\/(?:api|enterprise)\\.js\\?.*?\\brender=([\\w-]+)\/)) &amp;&amp; m[1] !== 'explicit')\n      add({ type: 'recaptcha v3', sitekey: m[1], enterprise: \/enterprise\\.js\/.test(u) });\n    else if ((m = u.match(\/challenges\\.cloudflare\\.com\\\/.*turnstile\\\/.*\\\/(0x4[\\w-]+|[123]x0+[A-Za-z]+)\\\/\/)))\n      add({ type: 'turnstile', sitekey: m[1] });\n    else if ((m = u.match(\/hcaptcha\\.com\\\/captcha\\\/.*[#&amp;?]sitekey=([\\w-]+)\/))) add({ type: 'hcaptcha', sitekey: m[1] });\n    else if (\/arkoselabs\\.com\\\/fc\\\/\/.test(u)) add({ type: 'arkose: CapSkip cannot solve' });\n  }\n  if (window._cf_chl_opt) add({ type: 'cloudflare challenge page' });\n  document.querySelectorAll('altcha-widget').forEach((e) =&gt; add({ type: 'altcha', challenge: e.getAttribute('challenge') || e.getAttribute('challengeurl') }));\n  document.querySelectorAll('.h-captcha[data-sitekey]').forEach((e) =&gt; add({ type: 'hcaptcha', sitekey: e.dataset.sitekey }));\n  document.querySelectorAll('.frc-captcha, .captchafox').forEach((e) =&gt; add({ type: e.classList.contains('captchafox') ? 'captchafox' : 'frc-captcha', sitekey: e.dataset.sitekey }));\n  const v3 = [...found.values()].filter((o) =&gt; o.type === 'recaptcha v3').map((o) =&gt; o.sitekey);  \/\/ a v3 badge also adds an invisible anchor frame\n  const res = [...found.values()].filter((o) =&gt; !(o.type === 'recaptcha' &amp;&amp; v3.includes(o.sitekey)));\n  return res.length ? res : 'no known CAPTCHA found (GeeTest, Capy and CaptchaFox scripts are not checked)';\n})();<\/pre>\n<\/div>\n<p>It found the sitekey and flags on a reCAPTCHA v2 page, an invisible one, a v3 page, a Turnstile page and the hCaptcha demo, and it printed a message on a page with no CAPTCHA. Three limits: the browser keeps only about 250 resource entries by default, so a long-running single page app can lose early ones, and the snippet cannot tell Friendly Captcha version 1 from version 2, and it does not look for GeeTest, Capy or CaptchaFox scripts. On Cloudflare&#8217;s full-page check it can show the sitekey from a resource address, but not the data and pagedata values, which need a hook on the render call. That hook was not tested here.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Where detection fails<\/h2>\n<table>\n<thead>\n<tr>\n<th>Situation<\/th>\n<th>What detect returns<\/th>\n<th>What to do<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Widget script added by JavaScript<\/td>\n<td>null<\/td>\n<td>Load the page in a browser and run the console snippet<\/td>\n<\/tr>\n<tr>\n<td>Sitekey set by JavaScript<\/td>\n<td>needsBrowser<\/td>\n<td>Read the key from the live page<\/td>\n<\/tr>\n<tr>\n<td>Cloudflare full-page check<\/td>\n<td>needsBrowser<\/td>\n<td>Read the values in a browser, see the cf_clearance guide. The page arrives as a 403 or 503, which use.js reports and then still checks<\/td>\n<\/tr>\n<tr>\n<td>CaptchaFox, Friendly Captcha or Capy script with no widget in the HTML<\/td>\n<td>null<\/td>\n<td>Run the console snippet or read the page source<\/td>\n<\/tr>\n<tr>\n<td>A block page (403, 503) with no widget<\/td>\n<td>null<\/td>\n<td>use.js prints the status first: a block page holds no CAPTCHA to find<\/td>\n<\/tr>\n<tr>\n<td>Arkose, GeeTest v4<\/td>\n<td>unsupported<\/td>\n<td>CapSkip cannot solve these<\/td>\n<\/tr>\n<tr>\n<td>hCaptcha Enterprise that sets rqdata<\/td>\n<td>needsBrowser<\/td>\n<td>Read a fresh rqdata from the live page for each task and pass it to solveHcaptcha as data<\/td>\n<\/tr>\n<tr>\n<td>Friendly Captcha with no script hint<\/td>\n<td>needsBrowser<\/td>\n<td>Check which script the page loads<\/td>\n<\/tr>\n<tr>\n<td>Image CAPTCHA without the word captcha<\/td>\n<td>null<\/td>\n<td>Add your own rule for that site<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common errors<\/h2>\n<table>\n<thead>\n<tr>\n<th>Error<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>ERROR_GOOGLEKEY<\/td>\n<td>The key was the word explicit, or a key that is not a reCAPTCHA key<\/td>\n<td>Read the key from the widget, not from the render parameter<\/td>\n<\/tr>\n<tr>\n<td>ERROR_CAPTCHA_UNSOLVABLE<\/td>\n<td>A failed job reads this way and the answer does not name the cause. A wrong reCAPTCHA version may end here, but I did not confirm that on the real app<\/td>\n<td>Check the detected type first, then see the <a href=\"https:\/\/capskip.com\/api-docs\/\">API docs<\/a><\/td>\n<\/tr>\n<tr>\n<td>ValidationException<\/td>\n<td>An option the method does not take, such as an action on a v2 call, a per-call pollingInterval, or any per-call option on the image method<\/td>\n<td>Use the arguments the detector returned<\/td>\n<\/tr>\n<tr>\n<td>NetworkException<\/td>\n<td>CapSkip is not running, or the host or port is wrong<\/td>\n<td>Start CapSkip and check the connection settings<\/td>\n<\/tr>\n<tr>\n<td>Error from solve<\/td>\n<td>The result is unsupported or needs a browser, or CapSkip refused an hCaptcha job and the message is its error code<\/td>\n<td>Handle the first two cases before calling solve, and check the sitekey and the page address for the third<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Running the solver on a server<\/h2>\n<p>The detector only needs two things from CapSkip: a host and a port. In Local mode that is 127.0.0.1 and your own PC. In Server mode CapSkip listens on your network address or a public IP, so a detector running on another PC, a VPS or a hosted platform can call it over the same API. Change the host and port in use.js, and nothing else in the code changes. A static public IP is recommended for stable remote access. Server mode is still your own hardware and still unmetered. Note that Server mode opens the port to any address, so add a Windows Firewall block rule for the addresses that should not reach it. See the <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a> for how to switch modes.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">FAQ<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can I tell which CAPTCHA a site uses without code?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes. Open DevTools, go to the Network tab, reload the page and look at the script addresses. A google.com\/recaptcha or recaptcha.net script is reCAPTCHA, challenges.cloudflare.com is Turnstile, and js.hcaptcha.com is hCaptcha. The Console snippet above does the same lookup for you.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Why does detect return needsBrowser instead of a guess?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Because a wrong guess wastes a full solve and returns a token the site rejects with no hint why. When the static HTML does not hold the sitekey, a GeeTest challenge or a Friendly Captcha version, saying so is faster than a failed job.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Does CapSkip solve hCaptcha?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes. CapSkip solves hCaptcha checkbox, invisible and Enterprise widgets through its HTTP API with method=hcaptcha, as the <a href=\"https:\/\/capskip.com\/api-docs\/#hcaptcha\">API docs<\/a> describe. The Node.js SDK has no hCaptcha call yet, so solveHcaptcha above posts the job to in.php itself. FunCaptcha, Arkose and GeeTest v4 are still not supported, and the detector reports those as unsupported so a script can skip the page instead of failing on it.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can a hosted platform use the detector with my CapSkip?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, through Server mode. Set CapSkip to listen on your network or public IP, set the host and port in use.js to that address, and the detector and the solve call work the same as on one PC. The solver stays on your hardware.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The short version<\/h2>\n<p>A CAPTCHA detector in Node.js earns its place by running first: detect CAPTCHA type, then solve: read the script and the markup in a fixed order, hand the arguments to the right method, and treat null, needsBrowser and unsupported as answers, not errors. With CapSkip on your own machine there is no meter running while you check a page, which is what makes a <a href=\"https:\/\/capskip.com\/\">local captcha solver<\/a> practical for a crawl that meets a new site every few minutes. For the Node.js SDK itself, see the <a href=\"https:\/\/capskip.com\/nodejs-captcha-solver\/\">Node.js CAPTCHA solver page<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>reCAPTCHA v2 \u6846\u3001v3 \u811a\u672c\u548c Turnstile \u5c0f\u7ec4\u4ef6\u9700\u8981\u4e09\u79cd\u4e0d\u540c\u7684\u8c03\u7528\u3002\u4e0b\u9762\u662f\u4e00\u4e2a Node.js \u68c0\u6d4b\u5668\uff0c\u4e0d\u9700\u8981\u989d\u5916\u7684\u5305\uff1a\u5b83\u80fd\u8bc6\u522b\u9a8c\u8bc1\u7801\u7c7b\u578b\uff0c\u63d0\u53d6 sitekey \u5e76\u8c03\u7528\u5bf9\u5e94\u7684 CapSkip \u65b9\u6cd5\uff0c\u8bc6\u522b\u4e0d\u4e86\u65f6\u4e5f\u4f1a\u660e\u786e\u544a\u8bc9\u4f60\u3002<\/p>","protected":false},"author":1,"featured_media":27032,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"CAPTCHA Detector in Node.js, Then Solve It | CapSkip","rank_math_description":"Build a CAPTCHA detector in Node.js: read the page, name the CAPTCHA, pull the sitekey and call the right CapSkip method. Tested on 66 pages.","rank_math_focus_keyword":"captcha detector in node.js","footnotes":""},"categories":[70],"tags":[],"class_list":["post-27033","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/27033","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=27033"}],"version-history":[{"count":3,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/27033\/revisions"}],"predecessor-version":[{"id":27227,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/27033\/revisions\/27227"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/27032"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=27033"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=27033"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=27033"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}