From 21cf9fa91b8d8708337a01628de048811a6f8d38 Mon Sep 17 00:00:00 2001 From: Moroka8 Date: Wed, 6 May 2026 20:22:59 +0700 Subject: [PATCH] fix: port VK captcha v2 solver Co-Authored-By: Nikita <72886646+WINGS-N@users.noreply.github.com> --- client/captcha_v2.go | 570 +++++++++++++++++++++++++++++++++ client/captcha_v2_slider.go | 606 ++++++++++++++++++++++++++++++++++++ client/main.go | 19 ++ 3 files changed, 1195 insertions(+) create mode 100644 client/captcha_v2.go create mode 100644 client/captcha_v2_slider.go diff --git a/client/captcha_v2.go b/client/captcha_v2.go new file mode 100644 index 0000000..50ca5c9 --- /dev/null +++ b/client/captcha_v2.go @@ -0,0 +1,570 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + mathrand "math/rand" + "regexp" + "strconv" + "strings" + "sync" + "time" + + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" +) + +const ( + captchaV2APIVersion = "5.131" + captchaV2ScriptVersion = "1.1.1324" + captchaV2DeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1080,"innerWidth":1920,"innerHeight":951,"devicePixelRatio":1,"language":"en-US","languages":["en-US","en"],"webdriver":false,"hardwareConcurrency":8,"notificationsPermission":"denied"}` +) + +var ( + reCaptchaV2PowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) + reCaptchaV2Difficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`) + reCaptchaV2WindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`) + reCaptchaV2ScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`) + reCaptchaV2DebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`) + reCaptchaV2Version = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`) + + errCaptchaV2RateLimit = errors.New("captcha session rate limit reached") + errCaptchaV2Bot = errors.New("captcha bot challenge") + + captchaV2MaxAttempts = 2 + + captchaV2DebugCache sync.Map // scriptURL -> string + captchaV2HeaderOrder = []string{ + "host", + "content-length", + "sec-ch-ua-platform", + "accept-language", + "sec-ch-ua", + "content-type", + "sec-ch-ua-mobile", + "user-agent", + "accept", + "origin", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "referer", + "accept-encoding", + "priority", + } + captchaV2PHeaderOrder = []string{":method", ":path", ":authority", ":scheme"} +) + +type captchaV2Init struct { + Data captchaV2InitData `json:"data"` +} + +type captchaV2InitData struct { + ShowCaptchaType string `json:"show_captcha_type"` + CaptchaSettings []captchaV2InitSetting `json:"captcha_settings"` +} + +type captchaV2InitSetting struct { + Type string `json:"type"` + Settings string `json:"settings"` +} + +type captchaV2Page struct { + PowInput string + PowDifficulty int + ScriptURL string + Init *captchaV2Init +} + +type captchaV2Check struct { + Status string + SuccessToken string + ShowType string +} + +type captchaV2ShowTypeError struct { + ShowType string +} + +func (e *captchaV2ShowTypeError) Error() string { + return "captcha show type mismatch: " + e.ShowType +} + +type captchaV2Session struct { + ctx context.Context + client tlsclient.HttpClient + profile Profile + savedProfile *SavedProfile +} + +func solveVkCaptchaV2( + ctx context.Context, + captchaErr *VkCaptchaError, + streamID int, + client tlsclient.HttpClient, + profile Profile, + savedProfile *SavedProfile, +) (string, error) { + if captchaErr == nil || captchaErr.SessionToken == "" { + return "", fmt.Errorf("no session_token in redirect_uri") + } + log.Printf("[STREAM %d] [Captcha] Solving VK Smart Captcha automatically (v2)...", streamID) + + s := &captchaV2Session{ctx: ctx, client: client, profile: profile, savedProfile: savedProfile} + + for attempt := 1; attempt <= captchaV2MaxAttempts; attempt++ { + token, solveErr := s.solveOnce(captchaErr) + if solveErr == nil { + return token, nil + } + log.Printf("[STREAM %d] [Captcha] v2 captcha solve attempt %d failed: %v", streamID, attempt, solveErr) + if errors.Is(solveErr, errCaptchaV2RateLimit) { + return "", solveErr + } + + backoffSteps := attempt + if backoffSteps > 10 { + backoffSteps = 10 + } + timer := time.NewTimer(time.Duration(backoffSteps) * 500 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return "", ctx.Err() + case <-timer.C: + } + } + return "", fmt.Errorf("v2 captcha attempts exhausted") +} + +func (s *captchaV2Session) solveOnce(captchaErr *VkCaptchaError) (string, error) { + html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI) + if err != nil { + return "", err + } + + page, err := parseCaptchaV2Page(html) + if err != nil { + return "", err + } + if page.PowInput == "" { + return "", errors.New("failed to find PoW settings") + } + + sliderSettings := "" + if page.Init != nil { + for _, setting := range page.Init.Data.CaptchaSettings { + if setting.Type == "slider" { + sliderSettings = setting.Settings + } + } + } + if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderSettings == "" { + return "", errors.New("failed to find slider captcha settings") + } + + log.Printf("v2 captcha solving pow difficulty=%d", page.PowDifficulty) + hash := solveCaptchaPoWV2(s.ctx, page.PowInput, page.PowDifficulty) + if hash == "" { + return "", errors.New("captcha pow failed") + } + log.Printf("v2 captcha pow solved") + + base := captchaV2BaseValues(captchaErr.SessionToken) + if _, err := s.captchaRequest("captchaNotRobot.settings", base); err != nil { + return "", fmt.Errorf("captcha settings failed: %w", err) + } + + browserFP, err := captchaV2BrowserFP() + if err != nil { + return "", err + } + if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" { + browserFP = s.savedProfile.BrowserFp + } + + if m := reCaptchaV2Version.FindStringSubmatch(page.ScriptURL); len(m) > 1 { + if m[1] != captchaV2ScriptVersion { + log.Printf("v2 captcha script version drift: known=%s latest=%s", captchaV2ScriptVersion, m[1]) + } + } + + debugInfo, err := s.fetchDebugInfo(page.ScriptURL) + if err != nil { + return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaV2ScriptVersion) + } + + showType := "" + if page.Init != nil { + showType = page.Init.Data.ShowCaptchaType + } + var token string + for { + log.Printf("v2 captcha solving show_type=%s", showType) + switch showType { + case "slider": + token, err = s.solveSliderCaptcha(captchaErr.SessionToken, browserFP, hash, sliderSettings, debugInfo) + case "checkbox", "": + token, err = s.solveCheckboxCaptcha(captchaErr.SessionToken, browserFP, hash, debugInfo) + default: + return "", fmt.Errorf("unsupported captcha type: %s", showType) + } + if err == nil { + break + } + if errors.Is(err, errCaptchaV2Bot) && !strings.EqualFold(showType, "slider") && sliderSettings != "" { + log.Printf("v2 captcha checkbox returned BOT, trying slider challenge from page settings") + showType = "slider" + continue + } + var stErr *captchaV2ShowTypeError + if !errors.As(err, &stErr) || stErr.ShowType == "" { + return "", err + } + showType = stErr.ShowType + } + + _, _ = s.captchaRequest("captchaNotRobot.endSession", base) + return token, nil +} + +func captchaV2BaseValues(sessionToken string) [][2]string { + return [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"access_token", ""}, + } +} + +func captchaV2BrowserFP() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("browser fp generate: %w", err) + } + return hex.EncodeToString(b), nil +} + +func (s *captchaV2Session) fetchCaptchaHTML(redirectURI string) (string, error) { + body, err := s.doRaw(fhttp.MethodGet, redirectURI, nil, map[string]string{ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "cross-site", + }) + if err != nil { + return "", err + } + return string(body), nil +} + +func (s *captchaV2Session) fetchDebugInfo(scriptURL string) (string, error) { + if cached, ok := captchaV2DebugCache.Load(scriptURL); ok { + return cached.(string), nil + } + body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{ + "Accept": "text/javascript,*/*", + "Referer": "https://id.vk.com/", + }) + if err != nil { + return "", err + } + m := reCaptchaV2DebugInfo.FindSubmatch(body) + if len(m) < 2 { + return "", errors.New("debug_info match not found") + } + v := string(m[1]) + captchaV2DebugCache.Store(scriptURL, v) + log.Printf("v2 captcha debug_info fetched url=%s", scriptURL) + return v, nil +} + +func parseCaptchaV2Page(html string) (*captchaV2Page, error) { + page := &captchaV2Page{} + + match := reCaptchaV2WindowInit.FindStringSubmatch(html) + if len(match) < 2 { + return nil, errors.New("captcha init json not found") + } + var init captchaV2Init + if err := json.Unmarshal([]byte(match[1]), &init); err != nil { + return nil, fmt.Errorf("captcha init json parse: %w", err) + } + page.Init = &init + + match = reCaptchaV2ScriptSrc.FindStringSubmatch(html) + if len(match) < 2 { + return nil, errors.New("captcha script url not found") + } + page.ScriptURL = match[1] + + if m := reCaptchaV2PowInput.FindStringSubmatch(html); len(m) >= 2 { + page.PowInput = m[1] + } + if page.PowInput == "" { + return page, nil + } + + match = reCaptchaV2Difficulty.FindStringSubmatch(html) + if len(match) < 2 { + return nil, errors.New("captcha difficulty const not found") + } + difficulty, err := strconv.Atoi(match[1]) + if err != nil || difficulty <= 0 { + return nil, fmt.Errorf("invalid captcha difficulty %q", match[1]) + } + page.PowDifficulty = difficulty + return page, nil +} + +func (s *captchaV2Session) captchaRequest(method string, form [][2]string) (map[string]any, error) { + endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaV2APIVersion + body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{ + "Origin": "https://id.vk.com", + "Referer": "https://id.vk.com/", + "Priority": "u=1, i", + }) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("captcha api decode: %w", err) + } + return out, nil +} + +func (s *captchaV2Session) performCaptchaCheck( + sessionToken string, + browserFP string, + hash string, + answerJSON string, + cursor string, + debugInfo string, +) (*captchaV2Check, error) { + values := [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"accelerometer", "[]"}, + {"gyroscope", "[]"}, + {"motion", "[]"}, + {"cursor", cursor}, + {"taps", "[]"}, + {"connectionRtt", "[]"}, + {"connectionDownlink", "[]"}, + {"browser_fp", browserFP}, + {"hash", hash}, + {"answer", base64.StdEncoding.EncodeToString([]byte(answerJSON))}, + {"debug_info", debugInfo}, + {"access_token", ""}, + } + resp, err := s.captchaRequest("captchaNotRobot.check", values) + if err != nil { + return nil, fmt.Errorf("captcha check failed: %w", err) + } + check, err := parseCaptchaV2Check(resp) + if err != nil { + return nil, err + } + if check.ShowType != "" { + log.Printf("v2 captcha check status=%s show_type=%s", check.Status, check.ShowType) + } else { + log.Printf("v2 captcha check status=%s", check.Status) + } + return check, nil +} + +func parseCaptchaV2Check(raw map[string]any) (*captchaV2Check, error) { + resp, ok := raw["response"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid captcha check response: %v", raw) + } + out := &captchaV2Check{ + Status: captchaV2StringifyAny(resp["status"]), + SuccessToken: captchaV2StringifyAny(resp["success_token"]), + ShowType: captchaV2StringifyAny(resp["show_captcha_type"]), + } + if out.Status == "" { + return nil, fmt.Errorf("captcha check status missing: %v", raw) + } + return out, nil +} + +func (s *captchaV2Session) solveCheckboxCaptcha( + sessionToken string, + browserFP string, + hash string, + debugInfo string, +) (string, error) { + deviceJSON := captchaV2DeviceInfo + if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { + deviceJSON = s.savedProfile.DeviceJSON + } + if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"browser_fp", browserFP}, + {"device", deviceJSON}, + {"access_token", ""}, + }); err != nil { + return "", fmt.Errorf("captcha componentDone failed: %w", err) + } + + select { + case <-s.ctx.Done(): + return "", s.ctx.Err() + case <-time.After(time.Duration(400+mathrand.Intn(250)) * time.Millisecond): + } + + check, err := s.performCaptchaCheck(sessionToken, browserFP, hash, "{}", "[]", debugInfo) + if err != nil { + return "", err + } + if check.ShowType != "" && !strings.EqualFold(check.ShowType, "checkbox") { + return "", &captchaV2ShowTypeError{ShowType: check.ShowType} + } + if strings.EqualFold(check.Status, "error_limit") { + return "", errCaptchaV2RateLimit + } + if strings.EqualFold(check.Status, "bot") { + return "", fmt.Errorf("%w: checkbox captcha rejected: status=%s", errCaptchaV2Bot, check.Status) + } + if !strings.EqualFold(check.Status, "ok") { + return "", fmt.Errorf("checkbox captcha rejected: status=%s", check.Status) + } + if check.SuccessToken == "" { + return "", errors.New("captcha success token not found") + } + return check.SuccessToken, nil +} + +func solveCaptchaPoWV2(ctx context.Context, input string, difficulty int) string { + if input == "" || difficulty <= 0 { + return "" + } + target := strings.Repeat("0", difficulty) + for nonce := 1; nonce <= 10_000_000; nonce++ { + if nonce%4096 == 0 { + select { + case <-ctx.Done(): + return "" + default: + } + } + sum := sha256.Sum256([]byte(input + strconv.Itoa(nonce))) + hashHex := hex.EncodeToString(sum[:]) + if strings.HasPrefix(hashHex, target) { + return hashHex + } + } + return "" +} + +func (s *captchaV2Session) doRaw( + method string, + endpoint string, + form [][2]string, + extraHeaders map[string]string, +) ([]byte, error) { + var body []byte + if form != nil { + body = []byte(captchaV2EncodeForm(form)) + } + req, err := fhttp.NewRequestWithContext(s.ctx, method, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + applyBrowserProfileFhttp(req, s.profile) + req.Header.Set("Accept", "*/*") + req.Header.Set("Sec-Fetch-Site", "same-site") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Origin", "https://vk.com") + req.Header.Set("Referer", "https://vk.com/") + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + req.Header[fhttp.HeaderOrderKey] = captchaV2HeaderOrder + req.Header[fhttp.PHeaderOrderKey] = captchaV2PHeaderOrder + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.Printf("v2 captcha close body: %s", closeErr) + } + }() + return io.ReadAll(resp.Body) +} + +func captchaV2EncodeForm(values [][2]string) string { + if len(values) == 0 { + return "" + } + var sb strings.Builder + for i, kv := range values { + if i > 0 { + sb.WriteByte('&') + } + sb.WriteString(captchaV2QueryEscape(kv[0])) + sb.WriteByte('=') + sb.WriteString(captchaV2QueryEscape(kv[1])) + } + return sb.String() +} + +func captchaV2QueryEscape(s string) string { + const upper = "0123456789ABCDEF" + hexDigits := func(b byte) [3]byte { + return [3]byte{'%', upper[b>>4], upper[b&0xF]} + } + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == ' ': + out = append(out, '+') + case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~': + out = append(out, c) + default: + h := hexDigits(c) + out = append(out, h[:]...) + } + } + return string(out) +} + +func captchaV2StringifyAny(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case bool: + return strconv.FormatBool(v) + default: + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(data) + } +} diff --git a/client/captcha_v2_slider.go b/client/captcha_v2_slider.go new file mode 100644 index 0000000..ccea186 --- /dev/null +++ b/client/captcha_v2_slider.go @@ -0,0 +1,606 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "image" + "image/color" + _ "image/jpeg" + "log" + "math" + mathrand "math/rand" + "runtime" + "sort" + "strconv" + "strings" + "sync" +) + +type sliderPuzzleV2 struct { + Image image.Image + Size int + Swaps []int + Attempts int +} + +type sliderGuessV2 struct { + Index int + Swaps []int + Score int64 + ScoreRGB int64 + ScoreLuma int64 + ScoreText float64 + ConsensusRank int +} + +func (s *captchaV2Session) solveSliderCaptcha( + sessionToken string, + browserFP string, + hash string, + settings string, + debugInfo string, +) (string, error) { + values := [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"access_token", ""}, + {"captcha_settings", settings}, + } + + resp, err := s.captchaRequest("captchaNotRobot.getContent", values) + if err != nil { + return "", fmt.Errorf("slider getContent failed: %w", err) + } + puzzle, err := parseSliderPuzzleV2(resp) + if err != nil { + return "", err + } + log.Printf("v2 slider puzzle decoded: grid=%d attempts=%d swaps=%d", puzzle.Size, puzzle.Attempts, len(puzzle.Swaps)) + + guesses, err := rankSliderGuessesV2(puzzle.Image, puzzle.Size, puzzle.Swaps) + if err != nil { + return "", err + } + + limit := puzzle.Attempts + if limit > len(guesses) { + limit = len(guesses) + } + if limit <= 0 { + return "", errors.New("slider has no attempts available") + } + log.Printf("v2 slider guesses ranked: total=%d limit=%d", len(guesses), limit) + + deviceJSON := captchaV2DeviceInfo + if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.DeviceJSON) != "" { + deviceJSON = s.savedProfile.DeviceJSON + } + if _, err := s.captchaRequest("captchaNotRobot.componentDone", [][2]string{ + {"session_token", sessionToken}, + {"domain", "vk.com"}, + {"adFp", ""}, + {"access_token", ""}, + {"browser_fp", browserFP}, + {"device", deviceJSON}, + }); err != nil { + return "", fmt.Errorf("captcha componentDone failed: %w", err) + } + + for i := 0; i < limit; i++ { + log.Printf("v2 slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index) + answerData, err := json.Marshal(struct { + Value []int `json:"value"` + }{Value: guesses[i].Swaps}) + if err != nil { + return "", err + } + check, err := s.performCaptchaCheck( + sessionToken, + browserFP, + hash, + string(answerData), + buildSliderCursorV2(guesses[i].Index, len(guesses)), + debugInfo, + ) + if err != nil { + return "", err + } + if strings.EqualFold(check.Status, "ok") { + if check.SuccessToken == "" { + return "", errors.New("captcha success token not found") + } + log.Printf("v2 slider accepted on attempt %d", i+1) + return check.SuccessToken, nil + } + if strings.EqualFold(check.Status, "error_limit") { + return "", errCaptchaV2RateLimit + } + } + return "", errors.New("slider guesses exhausted") +} + +func parseSliderPuzzleV2(raw map[string]any) (*sliderPuzzleV2, error) { + resp, ok := raw["response"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid slider content response: %v", raw) + } + status := captchaV2StringifyAny(resp["status"]) + if !strings.EqualFold(status, "ok") { + return nil, fmt.Errorf("slider getContent status: %s", status) + } + rawImage := captchaV2StringifyAny(resp["image"]) + if rawImage == "" { + return nil, errors.New("slider image missing") + } + rawSteps, ok := resp["steps"].([]any) + if !ok { + return nil, errors.New("slider steps missing") + } + steps := make([]int, 0, len(rawSteps)) + for _, item := range rawSteps { + switch v := item.(type) { + case float64: + steps = append(steps, int(v)) + case int: + steps = append(steps, v) + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("invalid numeric value: %v", item) + } + steps = append(steps, n) + default: + return nil, fmt.Errorf("invalid numeric value: %v", item) + } + } + size, swaps, attempts, err := splitSliderStepsV2(steps) + if err != nil { + return nil, err + } + data, err := base64.StdEncoding.DecodeString(rawImage) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + return &sliderPuzzleV2{Image: img, Size: size, Swaps: swaps, Attempts: attempts}, nil +} + +func splitSliderStepsV2(steps []int) (int, []int, int, error) { + if len(steps) < 3 { + return 0, nil, 0, errors.New("slider steps payload too short") + } + size := steps[0] + if size <= 0 { + return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size) + } + tail := append([]int(nil), steps[1:]...) + attempts := 4 + if len(tail)%2 != 0 { + attempts = tail[len(tail)-1] + tail = tail[:len(tail)-1] + log.Printf("v2 slider payload had odd-length tail; fallback attempts=%d", attempts) + } + if attempts <= 0 { + attempts = 4 + } + if len(tail) == 0 || len(tail)%2 != 0 { + return 0, nil, 0, errors.New("invalid slider swap payload") + } + return size, tail, attempts, nil +} + +func rankSliderGuessesV2(img image.Image, gridSize int, swaps []int) ([]sliderGuessV2, error) { + candidateCount := len(swaps) / 2 + if candidateCount == 0 { + return nil, errors.New("slider has no candidates") + } + + guesses := make([]sliderGuessV2, candidateCount) + for idx := 1; idx <= candidateCount; idx++ { + active := activeSwapsForIndexV2(swaps, idx) + mapping, err := applySliderSwapsV2(gridSize, active) + if err != nil { + return nil, err + } + guesses[idx-1] = sliderGuessV2{Index: idx, Swaps: active} + guesses[idx-1].ScoreLuma = seamScoreLumaV2(img, gridSize, mapping) + } + + lumaOrder := append([]sliderGuessV2(nil), guesses...) + sort.SliceStable(lumaOrder, func(i, j int) bool { + if lumaOrder[i].ScoreLuma == lumaOrder[j].ScoreLuma { + return lumaOrder[i].Index < lumaOrder[j].Index + } + return lumaOrder[i].ScoreLuma < lumaOrder[j].ScoreLuma + }) + lumaRank := make(map[int]int, candidateCount) + for rank, g := range lumaOrder { + lumaRank[g.Index] = rank + } + + stage2Count := candidateCount + if stage2Count > 12 { + stage2Count = 12 + } + stage2Set := make(map[int]struct{}, stage2Count) + for i := 0; i < stage2Count; i++ { + stage2Set[lumaOrder[i].Index] = struct{}{} + } + + type stage2Result struct { + index int + rgb int64 + text float64 + err error + } + jobs := make([]int, 0, stage2Count) + for idx := range stage2Set { + jobs = append(jobs, idx) + } + jobCh := make(chan int, len(jobs)) + resCh := make(chan stage2Result, len(jobs)) + + workers := runtime.NumCPU() + if workers < 1 { + workers = 1 + } + if workers > len(jobs) { + workers = len(jobs) + } + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for index := range jobCh { + mapping, err := applySliderSwapsV2(gridSize, guesses[index-1].Swaps) + if err != nil { + resCh <- stage2Result{index: index, err: err} + continue + } + rgb, text := seamScoreRGBTextV2(img, gridSize, mapping) + resCh <- stage2Result{index: index, rgb: rgb, text: text} + } + }() + } + for _, idx := range jobs { + jobCh <- idx + } + close(jobCh) + wg.Wait() + close(resCh) + for r := range resCh { + if r.err != nil { + return nil, r.err + } + g := &guesses[r.index-1] + g.ScoreRGB = r.rgb + g.ScoreText = r.text + } + + stage2 := make([]sliderGuessV2, 0, stage2Count) + for _, g := range guesses { + if _, ok := stage2Set[g.Index]; ok { + stage2 = append(stage2, g) + } + } + + rgbOrder := append([]sliderGuessV2(nil), stage2...) + sort.SliceStable(rgbOrder, func(i, j int) bool { + if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB { + return rgbOrder[i].Index < rgbOrder[j].Index + } + return rgbOrder[i].ScoreRGB < rgbOrder[j].ScoreRGB + }) + rgbRank := make(map[int]int, len(rgbOrder)) + for rank, g := range rgbOrder { + rgbRank[g.Index] = rank + } + + textOrder := append([]sliderGuessV2(nil), stage2...) + sort.SliceStable(textOrder, func(i, j int) bool { + if textOrder[i].ScoreText == textOrder[j].ScoreText { + return textOrder[i].Index < textOrder[j].Index + } + return textOrder[i].ScoreText < textOrder[j].ScoreText + }) + textRank := make(map[int]int, len(textOrder)) + for rank, g := range textOrder { + textRank[g.Index] = rank + } + + for i := range guesses { + g := &guesses[i] + g.ConsensusRank = lumaRank[g.Index] + if _, ok := stage2Set[g.Index]; ok { + g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index] + } else { + g.ConsensusRank += candidateCount + } + g.Score = int64(g.ConsensusRank) + } + + sort.SliceStable(guesses, func(i, j int) bool { + if guesses[i].ConsensusRank == guesses[j].ConsensusRank { + if guesses[i].ScoreLuma == guesses[j].ScoreLuma { + return guesses[i].Index < guesses[j].Index + } + return guesses[i].ScoreLuma < guesses[j].ScoreLuma + } + return guesses[i].ConsensusRank < guesses[j].ConsensusRank + }) + return guesses, nil +} + +func activeSwapsForIndexV2(swaps []int, index int) []int { + if index <= 0 { + return []int{} + } + end := index * 2 + if end > len(swaps) { + end = len(swaps) + } + return append([]int(nil), swaps[:end]...) +} + +func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) { + tileCount := gridSize * gridSize + if tileCount <= 0 { + return nil, fmt.Errorf("invalid slider tile count: %d", tileCount) + } + if len(swaps)%2 != 0 { + return nil, fmt.Errorf("invalid slider swaps length: %d", len(swaps)) + } + mapping := make([]int, tileCount) + for i := range mapping { + mapping[i] = i + } + for i := 0; i < len(swaps); i += 2 { + left := swaps[i] + right := swaps[i+1] + if left < 0 || right < 0 || left >= tileCount || right >= tileCount { + return nil, fmt.Errorf("slider step out of range: %d,%d", left, right) + } + mapping[left], mapping[right] = mapping[right], mapping[left] + } + return mapping, nil +} + +func seamScoreLumaV2(img image.Image, gridSize int, mapping []int) int64 { + bounds := img.Bounds() + var score int64 + for row := 0; row < gridSize; row++ { + for col := 0; col < gridSize-1; col++ { + leftIdx := row*gridSize + col + rightIdx := leftIdx + 1 + leftDst := sliderTileRect(bounds, gridSize, leftIdx) + rightDst := sliderTileRect(bounds, gridSize, rightIdx) + leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx]) + rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx]) + h := leftDst.Dy() + if rightDst.Dy() < h { + h = rightDst.Dy() + } + for y := 0; y < h; y++ { + yy := leftDst.Min.Y + y + a := sampleLumaMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy) + b := sampleLumaMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy) + score += int64(absIntV2(int(a) - int(b))) + } + } + } + for row := 0; row < gridSize-1; row++ { + for col := 0; col < gridSize; col++ { + topIdx := row*gridSize + col + bottomIdx := (row+1)*gridSize + col + topDst := sliderTileRect(bounds, gridSize, topIdx) + bottomDst := sliderTileRect(bounds, gridSize, bottomIdx) + topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx]) + bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx]) + w := topDst.Dx() + if bottomDst.Dx() < w { + w = bottomDst.Dx() + } + for x := 0; x < w; x++ { + xx := topDst.Min.X + x + a := sampleLumaMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1) + b := sampleLumaMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y) + score += int64(absIntV2(int(a) - int(b))) + } + } + } + return score +} + +func seamScoreRGBTextV2(img image.Image, gridSize int, mapping []int) (int64, float64) { + bounds := img.Bounds() + height := float64(bounds.Dy()) + textCenters := []float64{ + float64(bounds.Min.Y) + 0.2*height, + float64(bounds.Min.Y) + 0.5*height, + float64(bounds.Min.Y) + 0.8*height, + } + sigma := height * 0.14 + if sigma < 1.0 { + sigma = 1.0 + } + weight := func(y int) float64 { + yf := float64(y) + best := absFloatV2(yf - textCenters[0]) + for i := 1; i < len(textCenters); i++ { + d := absFloatV2(yf - textCenters[i]) + if d < best { + best = d + } + } + return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma)) + } + + var rgbScore int64 + var textScore float64 + for row := 0; row < gridSize; row++ { + for col := 0; col < gridSize-1; col++ { + leftIdx := row*gridSize + col + rightIdx := leftIdx + 1 + leftDst := sliderTileRect(bounds, gridSize, leftIdx) + rightDst := sliderTileRect(bounds, gridSize, rightIdx) + leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx]) + rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx]) + h := leftDst.Dy() + if rightDst.Dy() < h { + h = rightDst.Dy() + } + for y := 0; y < h; y++ { + yy := leftDst.Min.Y + y + l := sampleColorMappedV2(img, leftDst, leftSrc, leftDst.Max.X-1, yy) + r := sampleColorMappedV2(img, rightDst, rightSrc, rightDst.Min.X, yy) + rgbScore += pixelDiff(l, r) + _, _, lb, _ := l.RGBA() + _, _, rb, _ := r.RGBA() + textScore += weight(yy) * float64(absIntV2(int(lb>>8)-int(rb>>8))) + } + } + } + for row := 0; row < gridSize-1; row++ { + for col := 0; col < gridSize; col++ { + topIdx := row*gridSize + col + bottomIdx := (row+1)*gridSize + col + topDst := sliderTileRect(bounds, gridSize, topIdx) + bottomDst := sliderTileRect(bounds, gridSize, bottomIdx) + topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx]) + bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx]) + w := topDst.Dx() + if bottomDst.Dx() < w { + w = bottomDst.Dx() + } + for x := 0; x < w; x++ { + xx := topDst.Min.X + x + t := sampleColorMappedV2(img, topDst, topSrc, xx, topDst.Max.Y-1) + b := sampleColorMappedV2(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y) + rgbScore += pixelDiff(t, b) + _, _, tb, _ := t.RGBA() + _, _, bb, _ := b.RGBA() + textScore += 0.65 * float64(absIntV2(int(tb>>8)-int(bb>>8))) + } + } + } + return rgbScore, textScore +} + +func sampleColorMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color { + dx := dstRect.Dx() + if dx < 1 { + dx = 1 + } + dy := dstRect.Dy() + if dy < 1 { + dy = 1 + } + sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx + sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy + return img.At(sx, sy) +} + +func sampleLumaMappedV2(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 { + c := sampleColorMappedV2(img, dstRect, srcRect, dstX, dstY) + r, g, b, _ := c.RGBA() + y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000 + return uint8(y) +} + +func absFloatV2(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func absIntV2(v int) int { + if v < 0 { + return -v + } + return v +} + +func buildSliderCursorV2(candidateIndex int, candidateCount int) string { + if candidateCount <= 0 { + return "[]" + } + if candidateIndex < 1 { + candidateIndex = 1 + } + if candidateIndex > candidateCount { + candidateIndex = candidateCount + } + + type cursorPoint struct { + X int `json:"x"` + Y int `json:"y"` + } + + startX := 570 + mathrand.Intn(40) + startY := 875 + mathrand.Intn(30) + + denom := candidateCount - 1 + if denom < 1 { + denom = 1 + } + baseTargetX := 734 + (937-734)*(candidateIndex-1)/denom + targetX := baseTargetX + mathrand.Intn(10) - 5 + targetY := 655 + mathrand.Intn(14) + + points := make([]cursorPoint, 0, 28) + + for i := 0; i < 1+mathrand.Intn(3); i++ { + points = append(points, cursorPoint{ + X: startX + mathrand.Intn(5) - 2, + Y: startY + mathrand.Intn(5) - 2, + }) + } + + transitSteps := 2 + mathrand.Intn(3) + arcOffX := mathrand.Intn(60) - 30 + arcOffY := -(mathrand.Intn(30) + 10) + for i := 1; i <= transitSteps; i++ { + t := float64(i) / float64(transitSteps+1) + cx := float64(startX+targetX)/2 + float64(arcOffX) + cy := float64(startY+targetY)/2 + float64(arcOffY) + bx := (1-t)*(1-t)*float64(startX) + 2*t*(1-t)*cx + t*t*float64(targetX) + by := (1-t)*(1-t)*float64(startY) + 2*t*(1-t)*cy + t*t*float64(targetY) + jitter := int((1-t)*8) + 2 + points = append(points, cursorPoint{ + X: int(math.Round(bx)) + mathrand.Intn(jitter*2+1) - jitter, + Y: int(math.Round(by)) + mathrand.Intn(jitter*2+1) - jitter, + }) + } + + approachSteps := 4 + mathrand.Intn(4) + prev := points[len(points)-1] + for i := 1; i <= approachSteps; i++ { + t := float64(i) / float64(approachSteps) + ax := prev.X + int(math.Round(t*float64(targetX-prev.X))) + mathrand.Intn(5) - 2 + ay := prev.Y + int(math.Round(t*float64(targetY-prev.Y))) + mathrand.Intn(5) - 2 + points = append(points, cursorPoint{X: ax, Y: ay}) + } + + settleCount := 3 + mathrand.Intn(5) + for i := 0; i < settleCount; i++ { + points = append(points, cursorPoint{ + X: targetX + mathrand.Intn(7) - 3, + Y: targetY + mathrand.Intn(7) - 3, + }) + } + + data, err := json.Marshal(points) + if err != nil { + return "[]" + } + return string(data) +} diff --git a/client/main.go b/client/main.go index 7f053d6..7305f37 100644 --- a/client/main.go +++ b/client/main.go @@ -12,6 +12,7 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "io" @@ -68,6 +69,7 @@ var ( isDebug bool manualCaptcha bool autoCaptchaSliderPOC bool + captchaSolverVersion string ) type captchaSolveMode int @@ -548,6 +550,18 @@ func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID in profile = sp.Profile // Use saved headers/UA } + if !useSliderPOC && !strings.EqualFold(captchaSolverVersion, "v1") { + successToken, v2Err := solveVkCaptchaV2(ctx, captchaErr, streamID, client, profile, savedProfile) + if v2Err == nil { + log.Printf("[STREAM %d] [Captcha] v2 solver succeeded", streamID) + return successToken, nil + } + if errors.Is(v2Err, errCaptchaV2RateLimit) { + return "", v2Err + } + log.Printf("[STREAM %d] [Captcha] v2 solver failed, falling back to legacy solver: %v", streamID, v2Err) + } + bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile) if err != nil { return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) @@ -2042,6 +2056,7 @@ func main() { vlessBond := flag.Bool("vless-bond", false, "bond one VLESS TCP connection across all active smux sessions") debugFlag := flag.Bool("debug", false, "enable debug logging") manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") + captchaSolverFlag := flag.String("captcha-solver", "v2", "auto captcha solver implementation: v1|v2") flag.Parse() if *peerAddr == "" { log.Panicf("Need peer address!") @@ -2056,6 +2071,10 @@ func main() { isDebug = *debugFlag manualCaptcha = *manualCaptchaFlag + captchaSolverVersion = strings.ToLower(strings.TrimSpace(*captchaSolverFlag)) + if captchaSolverVersion != "v1" && captchaSolverVersion != "v2" { + captchaSolverVersion = "v2" + } autoCaptchaSliderPOC = !manualCaptcha var link string