fix(captcha): не жечь персону, когда страница не дошла до вердикта

This commit is contained in:
samosvalishe
2026-08-25 01:56:31 +03:00
parent f61dc8c957
commit 1547aaeed7
5 changed files with 70 additions and 11 deletions
@@ -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. Формат
@@ -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("<html><head><title>429 Too Many Requests</title></head><body></body></html>")
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
}
@@ -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()
@@ -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")
}
}
@@ -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)