diff --git a/internal/provider/vk/internal/captcha/captcha.go b/internal/provider/vk/internal/captcha/captcha.go index c731138..d54387f 100644 --- a/internal/provider/vk/internal/captcha/captcha.go +++ b/internal/provider/vk/internal/captcha/captcha.go @@ -42,6 +42,8 @@ var ( errCaptchaRateLimit = errors.New("captcha session rate limit reached") errCaptchaBot = errors.New("captcha bot challenge") + ErrUnavailable = errors.New("captcha unavailable") + captchaMaxAttempts = 2 ) @@ -148,12 +150,14 @@ func Solve( started: time.Now(), } + var solveErr error for attempt := 1; attempt <= captchaMaxAttempts; attempt++ { - token, solveErr := s.solveOnce(captchaErr) + var token string + token, solveErr = s.solveOnce(captchaErr) if solveErr == nil { return token, nil } - l.Warnf("[STREAM %d] [Captcha] solve attempt %d failed: %v", streamID, attempt, solveErr) + l.Debugf("[STREAM %d] [Captcha] solve attempt %d failed: %v", streamID, attempt, solveErr) // Повторяем только то, что не дошло до check. if s.checked || errors.Is(solveErr, errCaptchaRateLimit) || errors.Is(solveErr, errCaptchaBot) { return "", solveErr @@ -168,7 +172,7 @@ func Solve( case <-timer.C: } } - return "", fmt.Errorf("captcha attempts exhausted") + return "", fmt.Errorf("captcha attempts exhausted: %w", solveErr) } func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) { @@ -184,7 +188,7 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) { page, err := parseCaptchaPage(html) if err != nil { - s.logger().Warnf("[Captcha] page parse failed: %v; pow script near: %s", err, powSnippet(html)) + s.logger().Debugf("[Captcha] page parse failed: %v (bytes=%d); pow script near: %s", err, len(html), powSnippet(html)) return "", err } @@ -384,7 +388,7 @@ func captchaDomainFromRedirectURI(redirectURI string) string { } func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) { - body, err := s.doRaw(fhttp.MethodGet, redirectURI, nil, map[string]string{ + body, status, err := s.doRawStatus(fhttp.MethodGet, redirectURI, nil, map[string]string{ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", @@ -398,17 +402,20 @@ func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) { if err != nil { return "", err } + if status < 200 || status > 299 { + return "", fmt.Errorf("%w: captcha page http %d (bytes=%d)", ErrUnavailable, status, len(body)) + } return string(body), nil } func parseCaptchaPage(html string) (*captchaPage, error) { pow, err := parsePowParams(html) if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %w", ErrUnavailable, err) } debugInfo := parseCaptchaDebugInfo(html) if debugInfo == "" { - return nil, errors.New("captcha debug_info not found on page") + return nil, fmt.Errorf("%w: captcha debug_info not found on page", ErrUnavailable) } return &captchaPage{Pow: pow, DebugInfo: debugInfo, Init: parseCaptchaInitGlobal(html)}, nil } @@ -661,13 +668,23 @@ func (s *captchaSession) doRaw( form [][2]string, extraHeaders map[string]string, ) ([]byte, error) { + data, _, err := s.doRawStatus(method, endpoint, form, extraHeaders) + return data, err +} + +func (s *captchaSession) doRawStatus( + method string, + endpoint string, + form [][2]string, + extraHeaders map[string]string, +) ([]byte, int, error) { var body []byte if form != nil { body = []byte(captchaEncodeForm(form)) } req, err := fhttp.NewRequestWithContext(s.ctx, method, endpoint, bytes.NewReader(body)) if err != nil { - return nil, err + return nil, 0, err } req.Header.Set("Accept", "*/*") req.Header.Set("Sec-Fetch-Site", "same-site") @@ -689,7 +706,7 @@ func (s *captchaSession) doRaw( resp, err := s.client.Do(req) if err != nil { s.logger().Debugf("[Captcha] http %s %s failed t=%s after=%s %s form=%s err=%v", method, SafeURL(endpoint), s.elapsed(), time.Since(start).Truncate(time.Millisecond), navSummary(req), captchaFormSummary(form), err) - return nil, err + return nil, 0, fmt.Errorf("%w: %w", ErrUnavailable, err) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil { @@ -698,7 +715,10 @@ func (s *captchaSession) doRaw( }() data, readErr := io.ReadAll(resp.Body) s.logger().Debugf("[Captcha] http %s %s status=%d bytes=%d t=%s after=%s %s form=%s", method, SafeURL(endpoint), resp.StatusCode, len(data), s.elapsed(), time.Since(start).Truncate(time.Millisecond), navSummary(req), captchaFormSummary(form)) - return data, readErr + if readErr != nil { + return nil, resp.StatusCode, fmt.Errorf("%w: %w", ErrUnavailable, readErr) + } + return data, resp.StatusCode, nil } // Навигационный контекст: с какой страницы виджет якобы ходит в API. Формат diff --git a/internal/provider/vk/internal/captcha/captcha_test.go b/internal/provider/vk/internal/captcha/captcha_test.go index 08fba32..ac28154 100644 --- a/internal/provider/vk/internal/captcha/captcha_test.go +++ b/internal/provider/vk/internal/captcha/captcha_test.go @@ -1,6 +1,7 @@ package captcha import ( + "errors" "testing" ) @@ -269,3 +270,11 @@ func TestParseCaptchaDebugInfoAmbiguous(t *testing.T) { t.Fatalf("debug_info = %q", got) } } + +func TestParseCaptchaPageRejectsNonCaptchaHTML(t *testing.T) { + t.Parallel() + _, err := parseCaptchaPage("429 Too Many Requests") + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } +} diff --git a/internal/provider/vk/internal/vkauth/client.go b/internal/provider/vk/internal/vkauth/client.go index 9cad956..2256819 100644 --- a/internal/provider/vk/internal/vkauth/client.go +++ b/internal/provider/vk/internal/vkauth/client.go @@ -12,6 +12,7 @@ import ( "github.com/samosvalishe/free-turn-proxy/internal/logx" "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile" + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha" "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/personanet" "github.com/samosvalishe/free-turn-proxy/internal/randx" @@ -270,7 +271,7 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string, if errors.Is(err, ErrCaptchaWaitRequired) || errors.Is(err, ErrFatalCaptchaNoStreams) || errors.Is(err, ErrInvalidJoinLink) || errors.Is(err, ErrAnonymousBlocked) || - errors.Is(err, ErrCallFull) { + errors.Is(err, ErrCallFull) || errors.Is(err, captcha.ErrUnavailable) { return "", "", nil, err } es := err.Error() diff --git a/internal/provider/vk/internal/vkauth/persona_test.go b/internal/provider/vk/internal/vkauth/persona_test.go index 4aa73dd..deb3ea7 100644 --- a/internal/provider/vk/internal/vkauth/persona_test.go +++ b/internal/provider/vk/internal/vkauth/persona_test.go @@ -3,6 +3,7 @@ package vkauth import ( "context" "errors" + "fmt" "net" "reflect" "strings" @@ -145,3 +146,25 @@ func TestFetchBoundsPersonaRestarts(t *testing.T) { t.Fatalf("tokenChain called %d times, want %d", calls, want) } } + +func TestCaptchaUnavailableKeepsPersona(t *testing.T) { + c := newTestClient(t, nil) + c.autoSolver = func(context.Context, *captcha.Error, int, tlsclient.HttpClient, browserprofile.Profile) (string, error) { + return "", fmt.Errorf("%w: captcha page http 429 (bytes=120)", captcha.ErrUnavailable) + } + + kept := c.currentPersona() + _, err := c.solveCaptcha(context.Background(), nil, kept, 1, "lnk", "name", "t1", testCaptchaErr()) + if !errors.Is(err, captcha.ErrUnavailable) { + t.Fatalf("err = %v, want captcha.ErrUnavailable", err) + } + if c.currentPersona().VisitorID != kept.VisitorID { + t.Fatal("persona burned on an unavailable captcha page") + } + if c.captchaAttempt != 0 { + t.Fatalf("solve-mode ladder advanced to %d", c.captchaAttempt) + } + if c.LockoutUntilUnix() != 0 { + t.Fatal("lockout engaged on an unavailable captcha page") + } +} diff --git a/internal/provider/vk/internal/vkauth/token_call.go b/internal/provider/vk/internal/vkauth/token_call.go index 2a53acc..2a43c80 100644 --- a/internal/provider/vk/internal/vkauth/token_call.go +++ b/internal/provider/vk/internal/vkauth/token_call.go @@ -180,6 +180,12 @@ func (c *Client) solveCaptcha( return "", solveErr } + if solveErr != nil && errors.Is(solveErr, captcha.ErrUnavailable) { + c.captchaAttempt = attempt + c.log.Warnf("[STREAM %d] [Captcha] captcha unavailable, persona kept: %v", streamID, solveErr) + return "", solveErr + } + if solveErr != nil { c.log.Warnf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, CaptchaSolveModeLabel(solveMode), attempt+1, solveErr)