diff --git a/internal/captcha/detect.go b/internal/captcha/detect.go new file mode 100644 index 0000000..50d4035 --- /dev/null +++ b/internal/captcha/detect.go @@ -0,0 +1,56 @@ +package captcha + +import ( + "regexp" + "strings" +) + +var ( + reRecaptchaV3 = regexp.MustCompile(`recaptcha/api\.js\?render=([\w-]+)`) + reSitekey = regexp.MustCompile(`data-sitekey="([^"]+)"`) + rePkey = regexp.MustCompile(`data-pkey="([^"]+)"`) +) + +// DetectHTML scans raw page HTML and returns deterministic detections for known +// captcha providers. v3/Turnstile are reported with LLMSolvable=false. +func DetectHTML(html string) []Detection { + var out []Detection + // reCAPTCHA v3 first (script render param). v3 has no visual challenge. + if m := reRecaptchaV3.FindStringSubmatch(html); m != nil { + out = append(out, Detection{ + Type: "recaptcha_v3", Sitekey: m[1], LLMSolvable: false, + Notes: "score/behavioral — not LLM-solvable; needs browser reputation", + }) + } + if strings.Contains(html, "g-recaptcha") || strings.Contains(html, "google.com/recaptcha/api2") { + out = append(out, Detection{Type: "recaptcha_v2", Sitekey: firstSitekey(html), LLMSolvable: true, + Notes: "image-grid; LLM returns tiles, caller clicks + harvests token"}) + } + if strings.Contains(html, "h-captcha") || strings.Contains(html, "hcaptcha.com") { + out = append(out, Detection{Type: "hcaptcha", Sitekey: firstSitekey(html), LLMSolvable: true, + Notes: "image-grid; LLM returns tiles, caller clicks + harvests token"}) + } + if strings.Contains(html, "cf-turnstile") || strings.Contains(html, "challenges.cloudflare.com") { + out = append(out, Detection{Type: "turnstile", Sitekey: firstSitekey(html), LLMSolvable: false, + Notes: "proof-of-work/behavioral — not LLM-solvable"}) + } + if strings.Contains(html, "arkoselabs") || strings.Contains(html, "funcaptcha") || strings.Contains(html, "arkose") { + out = append(out, Detection{Type: "funcaptcha", Sitekey: firstPkey(html), LLMSolvable: true, + Notes: "rotation/visual — best-effort"}) + } + return out +} + +func firstSitekey(html string) string { + if m := reSitekey.FindStringSubmatch(html); m != nil { + return m[1] + } + return "" +} + +func firstPkey(html string) string { + if m := rePkey.FindStringSubmatch(html); m != nil { + return m[1] + } + return "" +} diff --git a/internal/captcha/detect_test.go b/internal/captcha/detect_test.go new file mode 100644 index 0000000..1faa8cf --- /dev/null +++ b/internal/captcha/detect_test.go @@ -0,0 +1,29 @@ +package captcha + +import "testing" + +func TestDetectHTML(t *testing.T) { + tests := []struct { + name string + html string + wantType string + wantSolvable bool + }{ + {"recaptcha v2", `
`, "recaptcha_v2", true}, + {"recaptcha v3", ``, "recaptcha_v3", false}, + {"hcaptcha", ``, "hcaptcha", true}, + {"turnstile", ``, "turnstile", false}, + {"funcaptcha", ``, "funcaptcha", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := DetectHTML(tc.html) + if len(got) == 0 { + t.Fatalf("no detection") + } + if got[0].Type != tc.wantType || got[0].LLMSolvable != tc.wantSolvable { + t.Fatalf("got %+v", got[0]) + } + }) + } +} diff --git a/internal/captcha/types.go b/internal/captcha/types.go new file mode 100644 index 0000000..ddc77e1 --- /dev/null +++ b/internal/captcha/types.go @@ -0,0 +1,13 @@ +// Package captcha provides deterministic detection and LLM-backed solving of +// captcha challenges. It never drives a browser and never mints tokens. +package captcha + +// Detection describes one captcha found on a page. Fields are ordered +// largest-alignment-first for fieldalignment. +type Detection struct { + Type string `json:"type"` + Sitekey string `json:"sitekey,omitempty"` + IframeURL string `json:"iframe_url,omitempty"` + Notes string `json:"notes,omitempty"` + LLMSolvable bool `json:"llm_solvable"` +}