fix: address code review (v3/v2 detect, gemini caps, fetch timeout, audio mime, json extract, dead temp config)

- detect: recaptcha_v3 now suppresses recaptcha_v2 (hidden g-recaptcha div on v3 pages no longer mis-detected as LLM-solvable); funcaptcha drops over-broad bare "arkose" substring
- llm/gemini: stub methods return ErrUnsupported{Provider:"gemini"} so audio handler errors.As guard catches it; drop unused fmt import
- mcp/tools: remove gemini from audio capability (stub); pick audio filename (.wav/.mp3) from sniffed media type; schema notes for browser/actions no longer advertise unimplemented options
- imageutil: fetchURL uses a package client with 30s Timeout instead of http.DefaultClient
- captcha/json: extractJSON brace-counts to return the first balanced object (handles prose, markdown fences, example-before-real)
- config: remove dead CAPTCHA_LLM_TEMPERATURE / Config.Temperature; solving is fixed at temperature 0
- stealth: Action.Type doc comment drops nonexistent scroll case
This commit is contained in:
2026-06-07 14:15:43 +01:00
parent 9265bc55ec
commit 8bfd64f7ed
10 changed files with 105 additions and 37 deletions
+2 -3
View File
@@ -21,8 +21,7 @@ func DetectHTML(html string) []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") {
} else 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"})
}
@@ -34,7 +33,7 @@ func DetectHTML(html string) []Detection {
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") {
if strings.Contains(html, "arkoselabs") || strings.Contains(html, "funcaptcha") {
out = append(out, Detection{Type: "funcaptcha", Sitekey: firstPkey(html), LLMSolvable: true,
Notes: "rotation/visual — best-effort"})
}
+17 -1
View File
@@ -13,7 +13,7 @@ func TestDetectHTML(t *testing.T) {
{"recaptcha v3", `<script src="https://www.google.com/recaptcha/api.js?render=6Lc_v3key"></script>`, "recaptcha_v3", false},
{"hcaptcha", `<div class="h-captcha" data-sitekey="hk_123"></div>`, "hcaptcha", true},
{"turnstile", `<div class="cf-turnstile" data-sitekey="0x4AAA"></div>`, "turnstile", false},
{"funcaptcha", `<div id="arkose" data-pkey="ABC"></div>`, "funcaptcha", true},
{"funcaptcha", `<div class="arkoselabs" data-pkey="ABC"></div>`, "funcaptcha", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -27,3 +27,19 @@ func TestDetectHTML(t *testing.T) {
})
}
}
// A v3 page often also carries a hidden g-recaptcha div; only v3 must fire.
func TestDetectHTMLV3SuppressesV2(t *testing.T) {
html := `<script src="https://www.google.com/recaptcha/api.js?render=6Lc_v3key"></script>` +
`<div class="g-recaptcha" data-sitekey="6Lc_hidden" style="display:none"></div>`
got := DetectHTML(html)
if len(got) != 1 {
t.Fatalf("want exactly 1 detection, got %d: %+v", len(got), got)
}
if got[0].Type != "recaptcha_v3" {
t.Fatalf("want recaptcha_v3, got %q", got[0].Type)
}
if got[0].LLMSolvable {
t.Fatalf("recaptcha_v3 must not be LLMSolvable")
}
}
+21 -6
View File
@@ -2,12 +2,27 @@ package captcha
import "strings"
// extractJSON returns the first {...} JSON object found in s, or s unchanged.
// extractJSON returns the first balanced {...} object found in s, or s unchanged
// if no opening brace is present. It scans by brace depth so prose, markdown
// fences, or an example object before the real one do not corrupt parsing.
// Note: it does not account for braces inside string literals; that is
// acceptable for the short, structured captcha responses this handles.
func extractJSON(s string) string {
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start >= 0 && end > start {
return s[start : end+1]
start := strings.IndexByte(s, '{')
if start < 0 {
return s
}
return s
depth := 0
for i := start; i < len(s); i++ {
switch s[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return s[start : i+1]
}
}
}
return s[start:]
}
+38
View File
@@ -0,0 +1,38 @@
package captcha
import "testing"
func TestExtractJSON(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"plain", `{"text":"ab12"}`, `{"text":"ab12"}`},
{"prose wrapped", `Sure, here is the answer: {"text":"ab12"} hope it helps`, `{"text":"ab12"}`},
{
"markdown fenced",
"```json\n{\"tiles\":[1,2,3]}\n```",
`{"tiles":[1,2,3]}`,
},
{
"nested object",
`prefix {"a":{"b":1},"c":2} suffix`,
`{"a":{"b":1},"c":2}`,
},
{
"first balanced object wins",
`For example {"text":"EXAMPLE"} but the real one is {"text":"REAL"}`,
`{"text":"EXAMPLE"}`,
},
{"no brace", `no json here`, `no json here`},
{"unbalanced returns from start", `tail {"text":"x"`, `{"text":"x"`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := extractJSON(tc.in); got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
+11 -16
View File
@@ -10,16 +10,18 @@ import (
// Config is the validated runtime configuration. Fields are ordered
// largest-alignment-first to satisfy the fieldalignment linter.
//
// Temperature is intentionally not configurable: captcha solving is fixed at
// temperature 0 for deterministic results (see the LLM provider request bodies).
type Config struct {
Provider string
BaseURL string
APIKey string
Model string
AudioModel string
LogLevel string
Temperature float64
Timeout time.Duration
MaxTokens int
Provider string
BaseURL string
APIKey string
Model string
AudioModel string
LogLevel string
Timeout time.Duration
MaxTokens int
}
var validProviders = map[string]bool{
@@ -47,13 +49,6 @@ func Load() (Config, error) {
if c.Model == "" {
return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required")
}
if v := os.Getenv("CAPTCHA_LLM_TEMPERATURE"); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return Config{}, fmt.Errorf("CAPTCHA_LLM_TEMPERATURE: %w", err)
}
c.Temperature = f
}
if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
+3 -1
View File
@@ -16,6 +16,8 @@ import (
var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
var fetchClient = &http.Client{Timeout: 30 * time.Second}
// Point is a pixel coordinate.
type Point struct {
X int
@@ -65,7 +67,7 @@ func fetchURL(url string) ([]byte, string, error) {
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
resp, err := fetchClient.Do(req)
if err != nil {
return nil, "", err
}
+2 -3
View File
@@ -2,7 +2,6 @@ package llm
import (
"context"
"fmt"
"net/http"
)
@@ -16,9 +15,9 @@ type geminiClient struct {
}
func (c *geminiClient) Vision(_ context.Context, _ string, _ []Image, _ Options) (string, error) {
return "", fmt.Errorf("gemini provider not yet implemented")
return "", ErrUnsupported{Provider: "gemini"}
}
func (c *geminiClient) Transcribe(_ context.Context, _ []byte, _ string, _ Options) (string, error) {
return "", fmt.Errorf("gemini provider not yet implemented")
return "", ErrUnsupported{Provider: "gemini"}
}
+10 -5
View File
@@ -73,11 +73,15 @@ type SolveAudioIn struct {
}
func (h *Handlers) solveAudio(ctx context.Context, _ *mcp.CallToolRequest, in SolveAudioIn) (*mcp.CallToolResult, captcha.AudioResult, error) {
data, _, err := imageutil.Decode(in.Audio)
data, mt, err := imageutil.Decode(in.Audio)
if err != nil {
return nil, captcha.AudioResult{}, err
}
res, err := captcha.SolveAudio(ctx, h.llm, data, "audio.wav", in.Language)
filename := "audio.wav"
if mt == "audio/mpeg" {
filename = "audio.mp3"
}
res, err := captcha.SolveAudio(ctx, h.llm, data, filename, in.Language)
var unsupported llm.ErrUnsupported
if errors.As(err, &unsupported) {
return nil, captcha.AudioResult{}, fmt.Errorf("audio captcha unsupported: configured provider %q has no transcription; set CAPTCHA_LLM_PROVIDER=openai|gemini and CAPTCHA_LLM_AUDIO_MODEL", h.provider)
@@ -137,7 +141,7 @@ func (h *Handlers) stealthScript(_ context.Context, _ *mcp.CallToolRequest, in S
type FingerprintIn struct {
OS string `json:"os,omitempty" jsonschema:"windows | macos | linux"`
Browser string `json:"browser,omitempty" jsonschema:"chrome | firefox"`
Browser string `json:"browser,omitempty" jsonschema:"chrome (firefox not yet differentiated)"`
Locale string `json:"locale,omitempty"`
Seed uint64 `json:"seed,omitempty" jsonschema:"seed for a stable identity across runs"`
}
@@ -152,7 +156,7 @@ func (h *Handlers) fingerprint(_ context.Context, _ *mcp.CallToolRequest, in Fin
}
type PlanInteractionIn struct {
Actions []stealth.Action `json:"actions" jsonschema:"clicks/types/scrolls to humanize"`
Actions []stealth.Action `json:"actions" jsonschema:"actions to humanize (click | type)"`
Seed uint64 `json:"seed,omitempty"`
}
@@ -170,7 +174,8 @@ type SolverInfoOut struct {
}
func (h *Handlers) solverInfo(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, SolverInfoOut, error) {
audio := h.provider == "openai" || h.provider == "openai-compatible" || h.provider == "gemini"
// gemini is a stub; re-add when implemented
audio := h.provider == "openai" || h.provider == "openai-compatible"
return nil, SolverInfoOut{
Provider: h.provider, Model: h.model,
Capabilities: map[string]bool{
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// Action is a high-level target the caller wants to perform.
type Action struct {
Type string `json:"type"` // click | type | scroll
Type string `json:"type"` // click | type
Text string `json:"text,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`