Skip to content

Tutorial

Go Colly and Cloudflare Turnstile: A Scraper Tutorial

A Go Colly collector that finds a Cloudflare Turnstile sitekey with OnHTML, gets a token from a solving API over net/http, and posts the form.

By 4 min readPublished Updated

Colly does not execute JavaScript, so it never sees a Cloudflare Turnstile token, but a Colly scraper can still submit a Turnstile-protected form: read the widget’s data-sitekey in an OnHTML callback, ask a solving API for a token over plain net/http, and send it as cf-turnstile-response with Collector.Post. This tutorial builds that collector in one file, with no dependencies beyond Colly.

Scrape only sites you are allowed to. See responsible captcha automation.

Set up

Terminal window
mkdir search-scraper && cd search-scraper
go mod init example.com/search-scraper
go get github.com/gocolly/colly/v2
export ZEROCAPTCHA_API=… # the API's base URL
export ZEROCAPTCHA_KEY=… # your API key

The collector

Save this as main.go:

package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/gocolly/colly/v2"
)
var (
apiURL = os.Getenv("ZEROCAPTCHA_API")
apiKey = os.Getenv("ZEROCAPTCHA_KEY")
api = &http.Client{Timeout: 15 * time.Second}
)
type reply struct {
ErrorID int `json:"errorId"`
ErrorCode string `json:"errorCode"`
TaskID string `json:"taskId"`
Status string `json:"status"`
Solution struct {
Token string `json:"token"`
} `json:"solution"`
}
func call(method string, body map[string]any, idempotencyKey string) (reply, error) {
body["clientKey"] = apiKey
payload, err := json.Marshal(body)
if err != nil {
return reply{}, err
}
req, err := http.NewRequest(http.MethodPost, apiURL+"/"+method, bytes.NewReader(payload))
if err != nil {
return reply{}, err
}
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := api.Do(req)
if err != nil {
return reply{}, err
}
defer resp.Body.Close()
var r reply
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return reply{}, fmt.Errorf("%s: %w", method, err)
}
if r.ErrorID != 0 {
return r, fmt.Errorf("%s: %s", method, r.ErrorCode)
}
return r, nil
}
func solveTurnstile(pageURL, sitekey, action, cdata string) (string, error) {
// The widget's data-action and data-cdata go in metadata, only when it sets them: many sites
// check both when they verify the token.
metadata := map[string]string{}
if action != "" {
metadata["action"] = action
}
if cdata != "" {
metadata["cdata"] = cdata
}
task := map[string]any{"type": "TurnstileTaskProxyless", "websiteURL": pageURL, "websiteKey": sitekey, "metadata": metadata}
// One Idempotency-Key per task: a retried create with it returns the same task.
created, err := call("createTask", map[string]any{"task": task}, rand.Text())
if err != nil {
return "", err
}
deadline := time.Now().Add(180 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
result, err := call("getTaskResult", map[string]any{"taskId": created.TaskID}, "")
if err != nil {
return "", err
}
if result.Status == "ready" {
return result.Solution.Token, nil
}
}
return "", errors.New("no token within 180 seconds")
}
func main() {
c := colly.NewCollector(colly.AllowedDomains("shop.example.com"), colly.Async(true))
if err := c.Limit(&colly.LimitRule{DomainGlob: "*", Parallelism: 4}); err != nil {
log.Fatal(err)
}
c.OnHTML("form#search", func(e *colly.HTMLElement) {
sitekey := e.ChildAttr("[data-sitekey]", "data-sitekey")
if sitekey == "" {
log.Printf("no Cloudflare Turnstile widget on %s", e.Request.URL)
return
}
token, err := solveTurnstile(e.Request.URL.String(), sitekey,
e.ChildAttr("[data-sitekey]", "data-action"), e.ChildAttr("[data-sitekey]", "data-cdata"))
if err != nil {
log.Print(err)
return
}
target := e.Request.AbsoluteURL(e.Attr("action"))
if err := c.Post(target, map[string]string{"q": "running shoes", "cf-turnstile-response": token}); err != nil {
log.Print(err)
}
})
c.OnHTML(".result a[href]", func(e *colly.HTMLElement) {
fmt.Println(e.Text, e.Request.AbsoluteURL(e.Attr("href")))
})
if err := c.Visit("https://shop.example.com/search"); err != nil {
log.Fatal(err)
}
c.Wait()
}

Run it with go run ..

How it works

  • OnHTML("form#search", …) fires once for the form. ChildAttr reads the sitekey and the optional action from the widget’s div inside it, the attributes Cloudflare’s implicit rendering uses.

  • solveTurnstile is a plain net/http client for the compatible createTask and getTaskResult calls. It checks errorId on every reply, because the format answers HTTP 200 even when a call fails, and it stops after three minutes instead of waiting forever.

  • c.Post sends the form URL-encoded, like a browser. Colly keeps cookies between requests of the same collector, so the submission carries the session the form page set.

  • The second OnHTML runs on the results page that the post returns.

  • c.Wait() blocks until every request, the post and its results page included, is done.

Going parallel

With colly.Async(true) the collector fetches several pages at once, and each callback runs in its own goroutine, so one callback waiting on a token does not hold up the others. The LimitRule caps the collector at four requests at a time, so the post for a solved form goes out at once rather than behind a long queue. To crawl many forms, call c.Visit for each start page before c.Wait(); raise Parallelism only as far as the site allows.

A Cloudflare Turnstile token is valid for 300 seconds and for one use: solve in the callback that posts, as above, and never share a token between two posts. See Cloudflare Turnstile token expiry.

Errors you may see

  • createTask: ERROR_ZERO_BALANCE: add funds; nothing was held.
  • getTaskResult: ERROR_CAPTCHA_UNSOLVABLE: the task failed and costs nothing. Check the page URL and sitekey.
  • The post returns the form again with an error: the site rejected the token or another field. Solve again before retrying; the token has been spent.
  • Every page answers HTTP 403 with “Just a moment…”: that is a Cloudflare challenge page, not a Turnstile form. See Cloudflare challenge page vs Cloudflare Turnstile.

The Go page of the Cloudflare Turnstile solver shows the tested net/http program and the coming Go SDK, which add retries and idempotency keys to the same calls. Every error code is in the errors reference.

Sources

The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.

Questions

Can Colly solve Cloudflare Turnstile?

Not by itself: Colly does not run JavaScript, so the widget never produces a token. Colly can read the sitekey from the HTML, and a solving API returns a token to post with the form.

Does the Turnstile token go in a cookie or in the form?

In the form, as the field cf-turnstile-response. Colly's Post sends it with the other fields, and Colly's cookie handling keeps the session the form page set.

How do I keep an async Colly collector from wasting tokens?

Solve inside the callback that submits the form, and limit parallelism with a LimitRule so submissions are not queued behind other requests while the token's 300 seconds run out.

Read next

This article is part of the Cloudflare Turnstile solver hub. Every task is charged only when a token is ready.

Get an API key