mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-12 00:45:28 +00:00
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:
@@ -7,7 +7,6 @@ CAPTCHA_LLM_API_KEY=sk-...
|
|||||||
CAPTCHA_LLM_MODEL=claude-opus-4-8
|
CAPTCHA_LLM_MODEL=claude-opus-4-8
|
||||||
# Optional separate audio/transcription model (e.g. whisper-1). Empty = use MODEL.
|
# Optional separate audio/transcription model (e.g. whisper-1). Empty = use MODEL.
|
||||||
CAPTCHA_LLM_AUDIO_MODEL=
|
CAPTCHA_LLM_AUDIO_MODEL=
|
||||||
CAPTCHA_LLM_TEMPERATURE=0
|
|
||||||
CAPTCHA_LLM_MAX_TOKENS=1024
|
CAPTCHA_LLM_MAX_TOKENS=1024
|
||||||
CAPTCHA_LLM_TIMEOUT=60s
|
CAPTCHA_LLM_TIMEOUT=60s
|
||||||
CAPTCHA_LOG_LEVEL=info
|
CAPTCHA_LOG_LEVEL=info
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ func DetectHTML(html string) []Detection {
|
|||||||
Type: "recaptcha_v3", Sitekey: m[1], LLMSolvable: false,
|
Type: "recaptcha_v3", Sitekey: m[1], LLMSolvable: false,
|
||||||
Notes: "score/behavioral — not LLM-solvable; needs browser reputation",
|
Notes: "score/behavioral — not LLM-solvable; needs browser reputation",
|
||||||
})
|
})
|
||||||
}
|
} else if strings.Contains(html, "g-recaptcha") || strings.Contains(html, "google.com/recaptcha/api2") {
|
||||||
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,
|
out = append(out, Detection{Type: "recaptcha_v2", Sitekey: firstSitekey(html), LLMSolvable: true,
|
||||||
Notes: "image-grid; LLM returns tiles, caller clicks + harvests token"})
|
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,
|
out = append(out, Detection{Type: "turnstile", Sitekey: firstSitekey(html), LLMSolvable: false,
|
||||||
Notes: "proof-of-work/behavioral — not LLM-solvable"})
|
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,
|
out = append(out, Detection{Type: "funcaptcha", Sitekey: firstPkey(html), LLMSolvable: true,
|
||||||
Notes: "rotation/visual — best-effort"})
|
Notes: "rotation/visual — best-effort"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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},
|
{"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},
|
{"hcaptcha", `<div class="h-captcha" data-sitekey="hk_123"></div>`, "hcaptcha", true},
|
||||||
{"turnstile", `<div class="cf-turnstile" data-sitekey="0x4AAA"></div>`, "turnstile", false},
|
{"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 {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,27 @@ package captcha
|
|||||||
|
|
||||||
import "strings"
|
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 {
|
func extractJSON(s string) string {
|
||||||
start := strings.Index(s, "{")
|
start := strings.IndexByte(s, '{')
|
||||||
end := strings.LastIndex(s, "}")
|
if start < 0 {
|
||||||
if start >= 0 && end > start {
|
return s
|
||||||
return s[start : end+1]
|
|
||||||
}
|
}
|
||||||
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:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -10,16 +10,18 @@ import (
|
|||||||
|
|
||||||
// Config is the validated runtime configuration. Fields are ordered
|
// Config is the validated runtime configuration. Fields are ordered
|
||||||
// largest-alignment-first to satisfy the fieldalignment linter.
|
// 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 {
|
type Config struct {
|
||||||
Provider string
|
Provider string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
APIKey string
|
APIKey string
|
||||||
Model string
|
Model string
|
||||||
AudioModel string
|
AudioModel string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
Temperature float64
|
Timeout time.Duration
|
||||||
Timeout time.Duration
|
MaxTokens int
|
||||||
MaxTokens int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var validProviders = map[string]bool{
|
var validProviders = map[string]bool{
|
||||||
@@ -47,13 +49,6 @@ func Load() (Config, error) {
|
|||||||
if c.Model == "" {
|
if c.Model == "" {
|
||||||
return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required")
|
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 != "" {
|
if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" {
|
||||||
n, err := strconv.Atoi(v)
|
n, err := strconv.Atoi(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
|
|
||||||
var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
|
var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
|
||||||
|
|
||||||
|
var fetchClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
// Point is a pixel coordinate.
|
// Point is a pixel coordinate.
|
||||||
type Point struct {
|
type Point struct {
|
||||||
X int
|
X int
|
||||||
@@ -65,7 +67,7 @@ func fetchURL(url string) ([]byte, string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := fetchClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package llm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,9 +15,9 @@ type geminiClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *geminiClient) Vision(_ context.Context, _ string, _ []Image, _ Options) (string, error) {
|
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) {
|
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
@@ -73,11 +73,15 @@ type SolveAudioIn struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) solveAudio(ctx context.Context, _ *mcp.CallToolRequest, in SolveAudioIn) (*mcp.CallToolResult, captcha.AudioResult, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, captcha.AudioResult{}, err
|
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
|
var unsupported llm.ErrUnsupported
|
||||||
if errors.As(err, &unsupported) {
|
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)
|
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 {
|
type FingerprintIn struct {
|
||||||
OS string `json:"os,omitempty" jsonschema:"windows | macos | linux"`
|
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"`
|
Locale string `json:"locale,omitempty"`
|
||||||
Seed uint64 `json:"seed,omitempty" jsonschema:"seed for a stable identity across runs"`
|
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 {
|
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"`
|
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) {
|
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{
|
return nil, SolverInfoOut{
|
||||||
Provider: h.provider, Model: h.model,
|
Provider: h.provider, Model: h.model,
|
||||||
Capabilities: map[string]bool{
|
Capabilities: map[string]bool{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
// Action is a high-level target the caller wants to perform.
|
// Action is a high-level target the caller wants to perform.
|
||||||
type Action struct {
|
type Action struct {
|
||||||
Type string `json:"type"` // click | type | scroll
|
Type string `json:"type"` // click | type
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
X int `json:"x,omitempty"`
|
X int `json:"x,omitempty"`
|
||||||
Y int `json:"y,omitempty"`
|
Y int `json:"y,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user