fix(captcha): разносить попытки слайдера по дорожке

Соседние кандидаты отличаются одним свапом и получают почти равный скор,
поэтому топ-2 ранжирования - всегда пара соседей, и вторая попытка
промахивается вместе с первой. Вторая берётся не ближе трёх позиций от первой,
с откатом на соседей, если дальних кандидатов не хватило.
This commit is contained in:
samosvalishe
2026-08-04 10:37:08 +03:00
parent ee1356308b
commit 8eb3873d27
2 changed files with 76 additions and 4 deletions
@@ -137,3 +137,49 @@ func TestCaptchaDomainFromRedirectURI(t *testing.T) {
})
}
}
func TestPickSliderAttempts(t *testing.T) {
tests := []struct {
name string
indexes []int
limit int
want []int
}{
{
name: "skips neighbours of the top guess",
indexes: []int{20, 19, 21, 18, 40, 5},
limit: 2,
want: []int{20, 40},
},
{
name: "falls back to neighbours when nothing is far enough",
indexes: []int{20, 19, 21},
limit: 3,
want: []int{20, 19, 21},
},
{
name: "limit above candidate count",
indexes: []int{7},
limit: 4,
want: []int{7},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guesses := make([]sliderGuess, 0, len(tt.indexes))
for _, idx := range tt.indexes {
guesses = append(guesses, sliderGuess{Index: idx})
}
got := pickSliderAttempts(guesses, tt.limit)
if len(got) != len(tt.want) {
t.Fatalf("len = %d, want %d", len(got), len(tt.want))
}
for i := range tt.want {
if got[i].Index != tt.want[i] {
t.Fatalf("attempts = %v, want %v", got, tt.want)
}
}
})
}
}
@@ -11,6 +11,7 @@ import (
_ "image/jpeg" // регистрация JPEG-декодера для image.Decode
"math"
"runtime"
"slices"
"sort"
"strconv"
"strings"
@@ -75,6 +76,7 @@ func (s *captchaSession) solveSliderCaptcha(
if limit <= 0 {
return "", errors.New("slider has no attempts available")
}
attempts := pickSliderAttempts(guesses, limit)
s.logger().Debugf("[Captcha] slider guesses ranked: total=%d limit=%d", len(guesses), limit)
if err := s.sendComponentDone(sessionToken); err != nil {
@@ -87,15 +89,15 @@ func (s *captchaSession) solveSliderCaptcha(
if !s.profile.Touch() {
handle = approachFrom(s.profile, s.layout, handle)
}
for i := 0; i < limit; i++ {
s.logger().Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, limit, guesses[i].Index)
for i, guess := range attempts {
s.logger().Debugf("[Captcha] slider attempt %d/%d (guess #%d)", i+1, len(attempts), guess.Index)
answerData, err := json.Marshal(struct {
Value []int `json:"value"`
}{Value: guesses[i].Swaps})
}{Value: guess.Swaps})
if err != nil {
return "", err
}
target := s.layout.sliderTarget(guesses[i].Index, len(guesses))
target := s.layout.sliderTarget(guess.Index, len(guesses))
g := gesture{
from: handle,
to: target,
@@ -352,6 +354,30 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
return guesses, nil
}
// pickSliderAttempts разносит попытки по дорожке: соседние кандидаты отличаются
// одним свапом и получают почти равный скор, поэтому вторая попытка рядом с
// первой промахивается вместе с ней.
func pickSliderAttempts(guesses []sliderGuess, limit int) []sliderGuess {
const minGap = 3
out := make([]sliderGuess, 0, limit)
taken := func(index, gap int) bool {
return slices.ContainsFunc(out, func(g sliderGuess) bool {
return absInt(g.Index-index) < gap
})
}
for _, gap := range []int{minGap, 1} {
for _, g := range guesses {
if len(out) == limit {
return out
}
if !taken(g.Index, gap) {
out = append(out, g)
}
}
}
return out
}
func activeSwapsForIndex(swaps []int, index int) []int {
if index <= 0 {
return []int{}