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)
}
})
}
}