DEV Community

Cover image for Image CAPTCHA solver in Python: send the right image to an OCR API and get an answer the site accepts
Bassem Shahin
Bassem Shahin

Posted on

Image CAPTCHA solver in Python: send the right image to an OCR API and get an answer the site accepts

An image CAPTCHA solver in Python comes down to three steps. Capture the exact image the page showed, in the session that loaded the page. Send those bytes to an OCR solving API and poll until it returns the text. Then type that text into the form and POST it in the same session, along with every hidden field the form carries.

The OCR step gets most of the attention, but it's rarely where things break. The usual failure is step one: code downloads the CAPTCHA URL a second time, the server generates a new code for that request, and a correct answer to the old picture gets rejected. Below is all three steps with runnable code, plus a decision on when local OCR like Tesseract is enough.

The setup

You're automating a form you're allowed to automate: an internal tool, a test environment, a portal you have an account on. It shows distorted letters and digits in an <img> and wants them typed into a text box. The goal is plain requests where possible, Playwright where the page needs a browser, and an accepted POST at the end.

First, check it's a text image CAPTCHA

Three different things get called "image CAPTCHAs", and each needs a different API call:

What you see What it is What the solver returns
Wavy letters/digits in one picture, a text box below Text image CAPTCHA (OCR) The text, e.g. fadc5
"Select all images with crosswalks", 3x3 or 4x4 tiles Grid image challenge Tile numbers to click, e.g. 1,4,7
"I'm not a robot" checkbox, or nothing visible at all reCAPTCHA / Turnstile (token-based) A token for a hidden field; there's no image to OCR

This article covers the first row. If you're looking at the third, there's no picture to read; you need the site key and a token flow. I wrote up how to tell reCAPTCHA v2, v3 and Enterprise apart for that case.

Is Tesseract enough?

Try it first if the images are clean: one font, no overlapping characters, no strike-through lines, little rotation. Convert to grayscale, threshold to black and white, and run Tesseract in single-line mode with a character whitelist (--psm 7 -c tessedit_char_whitelist=ABCDEFGHJKLMNPQRSTUVWXYZ23456789). Some older or homemade CAPTCHAs are readable that way.

Tesseract is built to read documents, though. Distortion, characters that touch, and background noise are the features CAPTCHA generators add to defeat exactly that kind of OCR, and a whitelist doesn't fix them. Don't guess from one sample. Save 50 images from your target, label them by hand, and measure. If local OCR is right often enough for your retry budget, keep it. If not, send the image to a solving API, which is what the rest of this article does.

Step 1: capture the exact image the page showed

Most text CAPTCHAs work like this: the page HTML contains <img src="/captcha.php?r=0.123">, and each request to that URL generates a new random code, stores it in your server-side session, and returns its picture. The consequences:

  • Fetch the image exactly once, with the same cookie jar as the page and the form. Fetching it again (to save a copy, to retry a download, or with a fresh requests.get that has no cookies) replaces the code the server expects.
  • In a browser, don't download it at all. The browser has already fetched it once to render it. Downloading the src again counts as a second fetch. Screenshot the element instead; you get the exact pixels on screen.
  • If the src is a data:image/...;base64, URI, the image is already in the HTML. Decode it; there's nothing to download.
  • If it's drawn on a <canvas>, screenshot the canvas element the same way.

I tested the first rule with real solves against a local test form whose CAPTCHA endpoint stores one code per session. I downloaded the image, downloaded it a second time, then submitted the solver's answer for the first copy. All five tries got Incorrect captcha, including the three where the answer matched the first picture exactly (jjsva for an image showing JJSVA).

Step 2: send it to the solver

The API is the 2Captcha-style in.php/res.php pair: POST the image to in.php with method=post (multipart) or method=base64, get a task id back, then poll res.php until the text comes back. The docs say the image must be JPG, PNG or GIF, between 100 bytes and 100 KB. Enforce that on your side. In my tests the live in.php accepted a 50-byte fragment, a 250 KB PNG and a WebP file without complaint, and the problem only showed up when polling: a nonsense answer, or a non-JSON error page instead of a result.

Two optional flags help. Use regsense=1 when the CAPTCHA is case-sensitive: without it, answers came back in lowercase (fadc5 for an image showing FADC5). Use numeric=1 when it's digits only.

Prerequisites: Python 3.9+, pip install requests pillow, and an API key.

import io
import time
import requests
from PIL import Image

API_KEY = "YOUR_API_KEY"
API = "https://ocr.captchaai.com"
MAX_BYTES = 100 * 1024                       # the API takes 100 B to 100 KB
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


class SolveError(Exception):
    pass


def prepare(img: bytes) -> tuple[bytes, str]:
    """Return (bytes, extension) in a format and size the API accepts."""
    if len(img) < 100 or img.lstrip()[:1] == b"<":
        raise SolveError(f"not a captcha image ({len(img)} bytes): did you save an error page?")
    if img[:8] == b"\x89PNG\r\n\x1a\n" and len(img) <= MAX_BYTES:
        return img, "png"
    if img[:3] == b"\xff\xd8\xff" and len(img) <= MAX_BYTES:
        return img, "jpg"
    if img[:4] == b"GIF8" and len(img) <= MAX_BYTES:
        return img, "gif"
    # WebP, BMP, or too big: re-encode as JPEG, lowering quality until it fits
    try:
        im = Image.open(io.BytesIO(img)).convert("RGB")
    except OSError:                          # PIL can't read it: SVG, a truncated file
        raise SolveError("not a readable image; convert it to PNG or JPG first")
    for quality in (90, 75, 60, 45):
        buf = io.BytesIO()
        im.save(buf, "JPEG", quality=quality)
        if len(buf.getvalue()) <= MAX_BYTES:
            return buf.getvalue(), "jpg"
    raise SolveError("image is still over 100 KB after re-encoding; crop it to the captcha")


def _call(method: str, path: str, **kw) -> dict:
    r = requests.request(method, f"{API}/{path}", timeout=30, **kw)
    try:
        return r.json()
    except ValueError:
        raise SolveError(f"non-JSON reply from {path} (HTTP {r.status_code}): {r.text[:80]!r}")


def solve_image(img: bytes, case_sensitive: bool = False, digits_only: bool = False) -> str:
    data, ext = prepare(img)
    fields = {"key": API_KEY, "method": "post", "json": 1}
    if case_sensitive:
        fields["regsense"] = 1
    if digits_only:
        fields["numeric"] = 1
    sub = _call("POST", "in.php", data=fields, files={"file": (f"captcha.{ext}", data)})
    if sub.get("status") != 1:
        raise SolveError(sub.get("request"))
    task_id = sub["request"]

    time.sleep(5)                            # the docs suggest 5 s before the first poll
    deadline, backoff = time.time() + 120, 5
    while time.time() < deadline:
        try:
            res = _call("GET", "res.php", params={
                "key": API_KEY, "action": "get", "id": task_id, "json": 1})
        except requests.RequestException:
            res = {"request": "ERROR_SERVER_ERROR"}
        if res.get("status") == 1:
            return res["request"]            # the text in the image, e.g. "fadc5"
        code = res.get("request")
        if code == "CAPCHA_NOT_READY":
            backoff = 5
            time.sleep(5)
        elif code in TRANSIENT:
            time.sleep(backoff)
            backoff = min(backoff * 2, 30)
        else:
            raise SolveError(code or "empty error from res.php; check the image you sent")
    raise SolveError("timed out after 120 s")
Enter fullscreen mode Exit fullscreen mode

Save it as image_solver.py. prepare() exists because of two real-world inputs: WebP images, which the API doesn't take, and element screenshots on high-DPI screens, which can be well over 100 KB as PNG. In my tests, scaled-up PNGs of 150 to 200 KB came out under 50 KB on the first quality step, and the re-encoded JPEG got the same answer as the original image (xl2xl for both).

Step 3: the whole flow with requests

This loads the form, grabs the hidden fields (CSRF tokens live there), fetches the CAPTCHA once, solves it, and submits, all on one requests.Session. Add pip install beautifulsoup4, then fill in the four constants from DevTools.

import base64
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from image_solver import solve_image, SolveError

PAGE_URL = "https://example.com/signup"     # the page with the form
CAPTCHA_IMG = "img#captcha"                 # CSS selector for the captcha <img>
CAPTCHA_FIELD = "captcha"                   # name of the input the answer goes in
REJECTED = "incorrect captcha"              # text the site shows on a wrong answer
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36"


def load_form(s: requests.Session) -> tuple[str, dict, bytes]:
    soup = BeautifulSoup(s.get(PAGE_URL, timeout=30).text, "html.parser")
    form = soup.find("form")
    action = urljoin(PAGE_URL, form.get("action") or PAGE_URL)
    fields = {i["name"]: i.get("value", "")              # CSRF token and other hidden inputs
              for i in form.select("input[type=hidden][name]")}
    src = urljoin(PAGE_URL, soup.select_one(CAPTCHA_IMG)["src"])
    if src.startswith("data:image"):
        image = base64.b64decode(src.split(",", 1)[1])  # inline image: nothing to download
    else:
        image = s.get(src, headers={"Referer": PAGE_URL}, timeout=30).content  # ONE fetch
    return action, fields, image


def main() -> None:
    s = requests.Session()
    s.headers["User-Agent"] = UA
    for attempt in range(1, 4):
        action, fields, image = load_form(s)
        try:
            answer = solve_image(image)
        except SolveError as e:
            if str(e) == "ERROR_CAPTCHA_UNSOLVABLE":
                continue                                 # new page, new image
            raise
        print(f"attempt {attempt}: {len(image)} byte image -> {answer!r}")
        fields.update({"email": "me@example.com", CAPTCHA_FIELD: answer})
        r = s.post(action, data=fields, timeout=30)
        if REJECTED not in r.text.lower():
            print(f"accepted: HTTP {r.status_code}")
            return
        print("rejected, loading a fresh captcha")
    raise SystemExit("3 rejections in a row: check the troubleshooting table")


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

Here's a real run against the local test form from Step 1, solved by the live API:

attempt 1: 8081 byte image -> '9x90x'
rejected, loading a fresh captcha
attempt 2: 9347 byte image -> 'zrf9y'
accepted: HTTP 200
Enter fullscreen mode Exit fullscreen mode

The retry isn't optional. The test images come from Python's captcha library, whose characters overlap heavily, and wrong reads were common on them: in another run all three attempts were rejected and the script stopped. On a wrong answer it reloads the page for a new image. Never resubmit a different answer for the same picture; most sites have already discarded that code.

The same thing in Playwright

When the page needs a browser, screenshot the element and keep everything else in the page:

from playwright.sync_api import sync_playwright
from image_solver import solve_image

with sync_playwright() as p:
    page = p.chromium.launch().new_page()
    for attempt in range(1, 4):
        page.goto("https://example.com/signup")              # fresh page, fresh image
        png = page.locator("img#captcha").screenshot()      # the pixels on screen, no second fetch
        page.fill("input[name=email]", "me@example.com")
        page.fill("input[name=captcha]", solve_image(png))
        page.click("button[type=submit]")
        page.wait_for_load_state()
        if "incorrect captcha" not in page.content().lower():
            print(f"accepted on attempt {attempt}")
            break
Enter fullscreen mode Exit fullscreen mode

Node.js version

Node 18+ ships fetch, FormData and Blob, so there are no dependencies:

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

function extOf(buf) {
  if (buf[0] === 0x89 && buf[1] === 0x50) return "png";
  if (buf[0] === 0xff && buf[1] === 0xd8) return "jpg";
  if (buf.subarray(0, 4).toString() === "GIF8") return "gif";
  throw new Error("convert the image to PNG, JPG or GIF first");
}

export async function solveImage(buf, { caseSensitive = false, digitsOnly = false } = {}) {
  if (buf.length < 100 || buf.length > 100 * 1024) {
    throw new Error(`image is ${buf.length} bytes; the API takes 100 B to 100 KB`);
  }
  const form = new FormData();
  form.set("key", KEY);
  form.set("method", "post");
  form.set("json", "1");
  if (caseSensitive) form.set("regsense", "1");
  if (digitsOnly) form.set("numeric", "1");
  form.set("file", new Blob([buf]), `captcha.${extOf(buf)}`);

  const sub = await fetch(`${API}/in.php`, { method: "POST", body: form }).then((r) => r.json());
  if (sub.status !== 1) throw new Error(`submit: ${sub.request}`);

  await sleep(5000);
  const deadline = Date.now() + 120000;
  let backoff = 5000;
  while (Date.now() < deadline) {
    const q = new URLSearchParams({ key: KEY, action: "get", id: sub.request, json: "1" });
    const text = await fetch(`${API}/res.php?${q}`).then((r) => r.text())
      .catch(() => JSON.stringify({ request: "ERROR_SERVER_ERROR" }));  // network error: retry
    let res;
    try {
      res = JSON.parse(text);
    } catch {
      throw new Error(`poll: non-JSON reply: ${text.slice(0, 80)}`);
    }
    if (res.status === 1) return res.request;
    if (res.request === "CAPCHA_NOT_READY") {
      backoff = 5000;
      await sleep(5000);
    } else if (TRANSIENT.has(res.request)) {
      await sleep(backoff);
      backoff = Math.min(backoff * 2, 30000);
    } else {
      throw new Error(`poll: ${res.request || "empty error; check the image you sent"}`);
    }
  }
  throw new Error("timed out after 120 s");
}

// Usage with Playwright: screenshot the element instead of downloading it again
// const png = await page.locator("img#captcha").screenshot();
// await page.fill("input[name=captcha]", await solveImage(png));
Enter fullscreen mode Exit fullscreen mode

Why a correct-looking answer still gets rejected

Symptom Likely cause Fix
Rejected every time, answer looks right Image fetched twice, or with a different session One session; one fetch, or an element screenshot
403 or "session expired" on submit Missing CSRF/hidden field, or the cookie jar changed Post every hidden input from the same <form>
Right letters, wrong case Site is case-sensitive Pass case_sensitive=True (regsense=1)
Letters where only digits belong Digits-only CAPTCHA Pass digits_only=True (numeric=1)
non-JSON reply from res.php The server couldn't use the upload: a fragment under 100 bytes, or WebP sent as-is. in.php accepted both in my tests Check the response's Content-Type before solving; prepare() rejects fragments and converts WebP
A short nonsense answer ('7de' for a noisy 250 KB PNG) Full-page or high-DPI screenshot over the documented 100 KB, which the server didn't reject Screenshot the element only; prepare() re-encodes under 100 KB
ERROR_CAPTCHA_UNSOLVABLE That picture couldn't be read Load a fresh image and try again

The first two rows are the same kind of failure as a Turnstile token that works in the browser but 403s from requests: the answer is fine, but the session around it changed between issue and use.

FAQ

What's the difference between an image CAPTCHA solver and an OCR library? An OCR library like Tesseract runs on your machine and is tuned for printed documents. A solver API is built for distorted CAPTCHA text, at the cost of a network round trip and a few seconds of polling. Many pipelines try local OCR first and fall back to the API on a rejection.

Can I send base64 instead of a file upload? Yes. Use method=base64 with the image in a body field instead of method=post with file. The size and format limits are the same.

Does this solve "select all images with traffic lights"? Not with this call. That's a grid challenge, which takes the image plus the instruction text and returns tile numbers; it's covered in the Grid Image guide.

I already have 2Captcha image-solving 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. If you want to run these scripts against your own form, claim a free thread: 1 thread for 30 days, no card. The Normal Captcha guide has the full parameter and error list.

Top comments (0)