DEV Community

Cover image for GeeTest v3 solver in Python: from gt and challenge to a seccode the site accepts
Bassem Shahin
Bassem Shahin

Posted on

GeeTest v3 solver in Python: from gt and challenge to a seccode the site accepts

A GeeTest v3 solver in Python takes four HTTP calls, and only the middle two talk to the solver. You get a fresh gt and challenge from the site's register endpoint. You send them to a solving API. You poll until it returns challenge, validate and seccode. Then you POST those three back to the site in the same session that fetched the challenge.

When the solve works but the site still says no, the bug is almost always in the first or last call. Below are all four with runnable code, plus a check to run first: v3 and v4 are not interchangeable.

The setup

You're automating a lookup form you're allowed to automate, and a GeeTest v3 slider sits in front of the submit button. The goal is plain requests, no browser, ending in an accepted form POST.

First, confirm it's GeeTest v3 and not v4

Most GeeTest confusion online comes from mixing guides for the two versions. They take different parameters and hand the site different values. Open DevTools, reload, and compare:

Signal GeeTest v3 GeeTest v4
Page init initGeetest({gt, challenge, ...}) initGeetest4({captchaId: ...})
What identifies it gt (32 hex chars) plus a one-time challenge captcha_id only
Network api.geetest.com or api-na.geetest.com (gettype.php, get.php, ajax.php) gcaptcha4.geetest.com, static.geetest.com/v4/gt4.js
What the site receives geetest_challenge, geetest_validate, geetest_seccode lot_number, captcha_output, pass_token, gen_time

If you'd rather script it, this loads the page once and classifies it by network traffic (needs pip install playwright and playwright install chromium):

import re
from playwright.sync_api import sync_playwright

def geetest_version(url: str) -> str:
    seen = []
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.on("request", lambda req: seen.append(req.url) if "geetest.com" in req.url else None)
        page.goto(url, wait_until="networkidle")
        browser.close()
    if any("gcaptcha4.geetest.com" in u or "/v4/" in u for u in seen):
        return "GeeTest v4 (captcha_id): this guide does not apply"
    if any(re.search(r"/(gettype|get|ajax)\.php", u) for u in seen):
        return "GeeTest v3 (gt + challenge)"
    return "no GeeTest traffic yet: it may only load when you click submit"
Enter fullscreen mode Exit fullscreen mode

Everything below is v3 only. It's the same habit I recommend for reCAPTCHA: identify the variant before writing solver code, since a request for the wrong variant fails in confusing ways.

Step 1: find the register endpoint

On v3 the challenge is not in the HTML. The site's backend mints it: it calls GeeTest's register.php, signs the result with its private key, and hands the page JSON shaped like this:

{"success": 1, "gt": "f1ab2cd4e5f6a7b8c9d0e1f2a3b4c5d6", "challenge": "6f3b2c...", "new_captcha": true}
Enter fullscreen mode Exit fullscreen mode

To find it, filter DevTools' Network tab by Fetch/XHR, reload, and look for a JSON response containing "challenge". It's usually named /register, /geetest/register or /captcha/init, often with a ?t=<timestamp> cache-buster. GeeTest's own demo uses /pc-geetest/register?t=.... Also check whether the page's initGeetest(...) call sets api_server (e.g. api-na.geetest.com); if so, pass it to the solver.

Two rules decide whether the final POST is accepted:

  1. Fresh. Per the API guide linked at the end, a challenge expires after about 10 minutes and becomes invalid once the widget has loaded it. Fetch it right before you solve, and don't let a browser render the widget with it.
  2. Same session. GeeTest's official v3 server SDK demo stores the register status in the server-side session and reads it back when validating. Register with one cookie jar and submit with another, and a site built like that has no record of your challenge.

A GeeTest v3 solver in Python, end to end

Prerequisites: Python 3.9+, pip install requests, your API key, the page and register URLs, and the form's POST URL (visible in DevTools after one manual solve). The request parameters follow the documented in.php/res.php flow (method=geetest, gt, challenge, pageurl, plus optional api_server and userAgent).

import json
import time
import requests

API_KEY = "YOUR_API_KEY"
API = "https://ocr.captchaai.com"
PAGE_URL = "https://example.com/lookup"                 # page that shows the slider
REGISTER_URL = "https://example.com/geetest/register"   # found in Step 1
SUBMIT_URL = "https://example.com/lookup"               # where the form POSTs
API_SERVER = None        # e.g. "api-na.geetest.com" if initGeetest() sets api_server
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36"
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


class SolveError(Exception):
    pass


def fresh_pair(s: requests.Session) -> dict:
    r = s.get(REGISTER_URL, params={"t": int(time.time() * 1000)}, timeout=30)
    r.raise_for_status()
    data = r.json()
    if not data.get("success"):
        raise SolveError("register returned success=0 (fallback mode), not a normal challenge")
    return data


def solve(gt: str, challenge: str) -> dict:
    params = {"key": API_KEY, "method": "geetest", "gt": gt, "challenge": challenge,
              "pageurl": PAGE_URL, "userAgent": UA, "json": 1}
    if API_SERVER:
        params["api_server"] = API_SERVER
    sub = requests.get(f"{API}/in.php", params=params, timeout=30).json()
    if sub.get("status") != 1:
        raise SolveError(sub.get("request"))
    task_id = sub["request"]
    print(f"task {task_id} submitted, polling...")

    time.sleep(15)                      # the docs suggest 15-20 s before the first poll
    deadline = time.time() + 120
    while time.time() < deadline:
        try:
            res = requests.get(f"{API}/res.php", timeout=30, params={
                "key": API_KEY, "action": "get", "id": task_id, "json": 1}).json()
        except requests.RequestException:
            time.sleep(10)
            continue
        if "seccode" in res:            # solved: {"challenge", "validate", "seccode"}
            return res
        if res.get("status") == 1:      # same three values wrapped in "request"
            sol = res["request"]
            return json.loads(sol) if isinstance(sol, str) else sol
        code = res.get("request")
        if code in TRANSIENT:
            time.sleep(10)
        elif code == "CAPCHA_NOT_READY":
            time.sleep(5)
        else:
            raise SolveError(code)
    raise SolveError("timed out after 120 s")


def main() -> None:
    s = requests.Session()
    s.headers["User-Agent"] = UA
    s.get(PAGE_URL, timeout=30)         # pick up the cookies a normal visit sets
    for attempt in (1, 2):
        pair = fresh_pair(s)            # fresh challenge, same session
        print(f"register: gt={pair['gt'][:8]}... challenge={pair['challenge'][:8]}...")
        try:
            sol = solve(pair["gt"], pair["challenge"])
        except SolveError as e:
            if str(e) == "ERROR_CAPTCHA_UNSOLVABLE" and attempt == 1:
                continue                # never resubmit an old challenge
            raise
        print(f"solved: seccode={sol['seccode'][:12]}...")
        form = {"q": "example"}         # the form's own fields
        form.update({
            "geetest_challenge": sol["challenge"],   # the RETURNED challenge
            "geetest_validate": sol["validate"],
            "geetest_seccode": sol["seccode"],
        })
        r = s.post(SUBMIT_URL, data=form, timeout=30)
        print(f"POST {SUBMIT_URL} -> {r.status_code}")
        return


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Expected output:

register: gt=f1ab2cd4... challenge=6f3b2c1e...
task 73919012345 submitted, polling...
solved: seccode=2a9c04b7e1f3...
POST https://example.com/lookup -> 200
Enter fullscreen mode Exit fullscreen mode

Two details in that code matter more than they look:

  • Use the challenge the solver returns, not the one you sent. It can come back changed, and the site checks the three values as a set.
  • The API returns short keys; the site expects the geetest_ prefix, and some sites rename fields in their own JS. Solve once by hand with DevTools open and copy the exact names.

The same solve step in Node.js

Node 18+ has fetch built in. Keep the register and submit calls on one cookie-keeping client, for the same session reason.

const API = "https://ocr.captchaai.com";
const KEY = "YOUR_API_KEY";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function solveGeetestV3({ gt, challenge, pageurl, userAgent }) {
  const q = new URLSearchParams({ key: KEY, method: "geetest", gt, challenge, pageurl, json: "1" });
  if (userAgent) q.set("userAgent", userAgent);
  const sub = await fetch(`${API}/in.php?${q}`).then((r) => r.json());
  if (sub.status !== 1) throw new Error(`submit: ${sub.request}`);

  await sleep(15000);
  const deadline = Date.now() + 120000;
  while (Date.now() < deadline) {
    const res = await fetch(`${API}/res.php?` + new URLSearchParams({
      key: KEY, action: "get", id: sub.request, json: "1",
    })).then((r) => r.json());
    if (res.seccode) return res; // solved: { challenge, validate, seccode }
    if (res.status === 1) {
      return typeof res.request === "string" ? JSON.parse(res.request) : res.request;
    }
    if (res.request !== "CAPCHA_NOT_READY") throw new Error(`poll: ${res.request}`);
    await sleep(5000);
  }
  throw new Error("timed out after 120 s");
}
Enter fullscreen mode Exit fullscreen mode

Why a correct seccode still gets rejected

Sites behind GeeTest v3 rarely say why they rejected you. Check these in order:

Symptom Likely cause Fix
Rejected every time Register and submit used different sessions One requests.Session for both
Rejected after a slow run Challenge expired or the widget already loaded it Register right before solving; never render the widget for it
Rejected despite a fresh pair Original challenge sent instead of the returned one Send sol["challenge"]
Field missing (400/422) Wrong names or a JS rename Copy names from a manual solve
success: 0 from register The site fell back to offline mode Log it; retry register later
ERROR_CAPTCHA_UNSOLVABLE The solve failed for this challenge New register call, then resubmit

The first two rows are the same kind of failure as a Turnstile token that works in the browser but 403s from requests. In both cases the solved values are fine; the context around them changed between issue and use.

FAQ

Does this work for GeeTest v4? No. v4 is a different flow (captcha_id in, four different values out), and none of the code above applies.

Do I need a browser? Not for the solve. If the register endpoint needs a token only the page's JS can produce, call it through Playwright's page.request, which shares the page's cookies, and pass the pair to the same solve().

I already have 2Captcha-style GeeTest v3 code. What changes? The in.php/res.php shape is the same, so it's mostly the base URL. I covered that swap in migrating solver code off 2Captcha.


I'm Bassem, and I run CaptchaAI, the API behind ocr.captchaai.com in the code above. It solves GeeTest v3 and not v4, and I'd rather say so here than have you find out in testing. If you want to run this script against your own target, claim a free thread: 1 thread for 30 days, no card. The GeeTest v3 guide has the full parameter and error list.

Top comments (0)