10 Commits

Author SHA1 Message Date
samokrutka 1dcaa46659 Merge pull request #56 from samosvalishe/release-please--branches--master
chore(master): release 2.1.1
2026-08-04 12:17:47 +03:00
samokrutka 7dc23ce3f0 chore(master): release 2.1.1 2026-08-04 12:11:53 +03:00
samosvalishe 1ca3451b76 refactor(captcha): убрать трассировку запросов
Разовая диагностика под сверку с виджетом; расхождения, которые она нашла,
закрыты коммитом выше.
2026-08-04 12:05:54 +03:00
samosvalishe 59a8c84157 fix(captcha): линтер в трассировке запросов 2026-08-04 12:01:46 +03:00
samosvalishe 4174f29c63 fix(captcha): блокировать рекламные хосты в ручном режиме
Внешние трекеры резались allowlist-ом и раньше, но ads.vk.com и ads.vk.ru
проходили по суффиксу домена. Captcha без них решается: adFp остаётся пустым,
ровно как у браузера с блокировщиком.
2026-08-04 11:56:03 +03:00
samosvalishe 4fa7810afa fix(captcha): привести запросы к поведению живого виджета 1.1.1388
Сверка протокола с бандлом вскрыла три расхождения.

Телеметрия: указатель виджет пишет только через externalCoords, а их заполняет
хост-страница iframe или нативный бридж мобильного SDK. Мы ходим на captcha
прямой навигацией, где живой браузер оставляет cursor и taps пустыми, - вместе с
ними ушла синтетическая траектория руки, недостижимая для этого режима. A/B на
живой captcha: 10/10 сессий против 9/10 со старым поведением.

Заголовки: навигация документа не несла Upgrade-Insecure-Requests и
Sec-Fetch-User, приоритет всем запросам ставился как у fetch, Origin уходил на
GET-ы, где Chrome его не шлёт, Referer навигации указывал на саму цель вместо
страницы-источника, а бандл тянулся профилем fetch вместо тега script. Cookie
отсутствовал в списке порядка и уезжал последним заголовком.

Куки: tls-client отбрасывает удалённые куки только по Max-Age - проверка Expires
в его jar закомментирована. Погашенная сервером remixir=DELETED из-за этого
уходила в каждом запросе. Обёртка в personanet транслирует протухший Expires в
Max-Age, который фильтр библиотеки понимает.

Трассировка запросов под FREETURN_CAPTCHA_TRACE - ей расхождения и найдены.
2026-08-04 11:56:03 +03:00
samosvalishe 8eb3873d27 fix(captcha): разносить попытки слайдера по дорожке
Соседние кандидаты отличаются одним свапом и получают почти равный скор,
поэтому топ-2 ранжирования - всегда пара соседей, и вторая попытка
промахивается вместе с первой. Вторая берётся не ближе трёх позиций от первой,
с откатом на соседей, если дальних кандидатов не хватило.
2026-08-04 10:37:08 +03:00
samosvalishe ee1356308b fix(captcha): не жечь персону на эскалации checkbox -> slider
Эскалация происходит в каждой сессии - слайдер в той же сессии проходит с тем
же browser_fp и device, значит отпечаток VK не отвергал. Сжигание на ней меняло
VisitorID каждый визит, хотя персона выводится из seed именно ради стабильности
между визитами. Сжигание по факту отказа в token_call.go сохранено.
2026-08-04 10:34:16 +03:00
samosvalishe d9a0554489 fix(captcha): убрать reverse-скор из ранжирования slider-кандидатов
Консенсус складывал ранги четырёх метрик с равным весом, но reverse-скор с
остальными не коррелирует: на корпусе из 8 пазлов luma/rgb/text сходились на
одном кандидате 8/8, reverse - 1/8. Кандидат, первый по всем трём рабочим
метрикам, уезжал в десятый ранг консенсуса и не попадал в две доступные
попытки. Побочно перестал считаться второй seam-скор на каждого кандидата.
2026-08-04 10:24:39 +03:00
samosvalishe ea477a3bc2 fix(captcha): обновить пин debug_info под виджет 1.1.1388 2026-08-04 10:24:39 +03:00
13 changed files with 252 additions and 203 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.1.0"
".": "2.1.1"
}
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [2.1.1](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.0...v2.1.1) (2026-08-04)
### Bug Fixes
* **captcha:** блокировать рекламные хосты в ручном режиме ([4174f29](https://github.com/samosvalishe/free-turn-proxy/commit/4174f29c63c0d5c7ffa645de9b0717a60d6849cb))
* **captcha:** линтер в трассировке запросов ([59a8c84](https://github.com/samosvalishe/free-turn-proxy/commit/59a8c84157750be7b6219d57891c120eb2393230))
* **captcha:** не жечь персону на эскалации checkbox -> slider ([ee13563](https://github.com/samosvalishe/free-turn-proxy/commit/ee1356308bb2a1b67b48c11d841a330d61a1b0cd))
* **captcha:** обновить пин debug_info под виджет 1.1.1388 ([ea477a3](https://github.com/samosvalishe/free-turn-proxy/commit/ea477a3bc21d7fd0b8dedfe6ae201b6950fda301))
* **captcha:** привести запросы к поведению живого виджета 1.1.1388 ([4fa7810](https://github.com/samosvalishe/free-turn-proxy/commit/4fa7810afa35bd086f30b66e3e0660df702ba3fe))
* **captcha:** разносить попытки слайдера по дорожке ([8eb3873](https://github.com/samosvalishe/free-turn-proxy/commit/8eb3873d27ffb679eddc2a2f230b5a58f9d6708a))
* **captcha:** убрать reverse-скор из ранжирования slider-кандидатов ([d9a0554](https://github.com/samosvalishe/free-turn-proxy/commit/d9a05544891e8f7354758bfbc06b0e5e9ea7eba7))
### Refactoring
* **captcha:** убрать трассировку запросов ([1ca3451](https://github.com/samosvalishe/free-turn-proxy/commit/1ca3451b7690179e4d00f318f471251062a9ee65))
## [2.1.0](https://github.com/samosvalishe/free-turn-proxy/compare/v2.0.2...v2.1.0) (2026-08-01)
@@ -94,8 +94,9 @@ type device struct {
NotificationsPermission string `json:"notificationsPermission"`
}
// Порядок заголовков h2 для fetch/XHR. Отсутствующие в запросе имена fhttp
// пропускает, поэтому один список покрывает GET и POST.
// Порядок заголовков h2. Отсутствующие в запросе имена fhttp пропускает, поэтому
// один список покрывает и навигацию (upgrade-insecure-requests, sec-fetch-user),
// и fetch/XHR (origin, content-type).
var chromeHeaderOrder = []string{
"content-length",
"sec-ch-ua-platform",
@@ -103,14 +104,17 @@ var chromeHeaderOrder = []string{
"sec-ch-ua",
"content-type",
"sec-ch-ua-mobile",
"upgrade-insecure-requests",
"accept",
"origin",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"referer",
"accept-encoding",
"accept-language",
"cookie",
"priority",
}
@@ -228,7 +232,12 @@ func ApplyFhttp(req *fhttp.Request, profile Profile) {
req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile)
req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform)
req.Header.Set("Accept-Language", profile.AcceptLanguage)
req.Header.Set("Priority", "u=1, i")
// Навигация документа идёт с нулевым приоритетом, u=1 - профиль fetch/XHR.
if req.Header.Get("Sec-Fetch-Dest") == "document" {
req.Header.Set("Priority", "u=0, i")
} else {
req.Header.Set("Priority", "u=1, i")
}
req.Header[fhttp.HeaderOrderKey] = chromeHeaderOrder
}
@@ -35,10 +35,10 @@ const (
captchaAPIOrigin = "https://id.vk.ru"
captchaDomain = "vk.ru"
// captchaDebugInfoFallback - константа виджета из not_robot_captcha.js 1.1.1387.
// captchaDebugInfoFallback - константа виджета из not_robot_captcha.js 1.1.1388.
// Ротируется вместе с версией бандла; используется, только если бандл не скачался
// (UUID тут был бы аномалией - VK всегда шлёт 64-hex).
captchaDebugInfoFallback = "b50d237c4ab98510721070cdbafe5dd88691e8883aa2458968040d70f870d91f"
captchaDebugInfoFallback = "0dd096fc7f8d86664f9e8789cf34a99977d39090c0477affead7437709c7990f"
)
var (
@@ -102,8 +102,7 @@ type captchaSession struct {
sensors sensorConfig
// sensorsStart - момент ответа settings: с него виджет начинает тикать
// таймером телеметрии.
sensorsStart time.Time
onCompromised func()
sensorsStart time.Time
}
func (s *captchaSession) logger() logx.Logger {
@@ -121,7 +120,6 @@ func Solve(
client tlsclient.HttpClient,
profile browserprofile.Profile,
log logx.Logger,
onCompromised func(),
) (string, error) {
if captchaErr == nil || captchaErr.SessionToken == "" {
return "", fmt.Errorf("no session_token in redirect_uri")
@@ -130,15 +128,14 @@ func Solve(
l.Infof("[STREAM %d] [Captcha] Solving VK Smart Captcha automatically...", streamID)
s := &captchaSession{
ctx: ctx,
client: client,
profile: profile,
layout: layoutFor(profile),
domain: captchaDomain,
log: l,
browserFP: profile.VisitorID,
sensors: defaultSensorConfig(),
onCompromised: onCompromised,
ctx: ctx,
client: client,
profile: profile,
layout: layoutFor(profile),
domain: captchaDomain,
log: l,
browserFP: profile.VisitorID,
sensors: defaultSensorConfig(),
}
for attempt := 1; attempt <= captchaMaxAttempts; attempt++ {
@@ -253,9 +250,6 @@ func (s *captchaSession) escalate(sessionToken string, initContent captchaConten
if content.Value == "" {
return "", cause
}
if s.onCompromised != nil {
s.onCompromised()
}
s.logger().Debugf("[Captcha] escalated to slider in-session (content source=%s)", content.Source)
// Перерисовка виджета плюс пауза на осознание.
if err := s.dwell(500, 1100); err != nil {
@@ -342,10 +336,16 @@ func captchaDomainFromRedirectURI(redirectURI string) string {
func (s *captchaSession) 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",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
// Переход по редиректу считается пользовательской активацией.
"Sec-Fetch-User": "?1",
"Sec-Fetch-Site": "cross-site",
// Referer кросс-сайтового перехода режется политикой до origin страницы,
// с которой ушли, а ей была страница звонка.
"Referer": "https://" + s.domain + "/",
})
if err != nil {
return "", err
@@ -373,9 +373,13 @@ func (s *captchaSession) fetchDebugInfoJS(scriptURL string) (string, error) {
return v, nil
}
}
// Бандл тянет тег <script> страницы captcha, а не fetch: другой Dest и no-cors.
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
"Accept": "text/javascript,*/*",
"Referer": captchaAPIOrigin + "/",
"Accept": "*/*",
"Sec-Fetch-Dest": "script",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "same-site",
"Referer": captchaAPIOrigin + "/",
})
if err != nil {
return "", err
@@ -451,9 +455,8 @@ func (s *captchaSession) captchaRequest(method string, form [][2]string) (map[st
func (s *captchaSession) performCaptchaCheck(
sessionToken string,
answerJSON string,
g gesture,
) (*captchaCheck, error) {
data := buildAnalytics(s.profile, s.sensors, g, time.Since(s.sensorsStart))
data := buildAnalytics(s.profile, s.sensors, time.Since(s.sensorsStart))
values := make([][2]string, 0, 15)
values = append(values,
[2]string{"session_token", sessionToken},
@@ -472,8 +475,8 @@ func (s *captchaSession) performCaptchaCheck(
if err != nil {
return nil, fmt.Errorf("captcha check failed: %w", err)
}
s.logger().Debugf("[Captcha] check payload answer_bytes=%d pointer_samples=%d conn_samples=%d",
len(answerJSON), len(data.cursor)+len(data.taps), len(data.connRtt))
s.logger().Debugf("[Captcha] check payload answer_bytes=%d conn_samples=%d accel_samples=%d",
len(answerJSON), len(data.connRtt), len(data.accelerometer))
check, err := parseCaptchaCheck(resp)
if err != nil {
return nil, err
@@ -548,7 +551,7 @@ func (s *captchaSession) solveCheckboxCaptcha(sessionToken string) (string, erro
return "", err
}
check, err := s.performCaptchaCheck(sessionToken, "{}", g)
check, err := s.performCaptchaCheck(sessionToken, "{}")
if err != nil {
return "", err
}
@@ -613,9 +616,11 @@ func (s *captchaSession) doRaw(
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", captchaAPIOrigin)
req.Header.Set("Referer", captchaAPIOrigin+"/")
if form != nil {
// Origin ставится только на CORS-запросы виджета; GET навигации и тега
// <script> его не несут.
req.Header.Set("Origin", captchaAPIOrigin)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for k, v := range extraHeaders {
@@ -138,15 +138,48 @@ func TestCaptchaDomainFromRedirectURI(t *testing.T) {
}
}
func TestReverseSwapPairs(t *testing.T) {
got := reverseSwapPairs([]int{1, 2, 3, 4, 5, 6})
want := []int{5, 6, 3, 4, 1, 2}
if len(got) != len(want) {
t.Fatalf("len = %d, want %d", len(got), len(want))
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 i := range want {
if got[i] != want[i] {
t.Fatalf("reverseSwapPairs = %v, want %v", got, want)
}
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)
}
}
})
}
}
@@ -68,7 +68,22 @@ func localCaptchaHosts() []string {
}
}
// blockedProxyHosts - реклама и внешняя телеметрия. Всё, что не в allowed, и так
// не проксируется; список нужен для хостов под доменами VK, которые иначе прошли
// бы по суффиксу. Captcha без них решается: adFp тогда пустой, ровно как у
// браузера с блокировщиком.
var blockedProxyHosts = []string{
"ads.vk.com", "ads.vk.ru",
"top-fwz1.mail.ru", "r0.mradx.net",
"sdk-api.apptracer.ru", "stats.vk-portal.net",
}
func isAllowedProxyHost(hostname string) bool {
for _, blocked := range blockedProxyHosts {
if strings.EqualFold(hostname, blocked) {
return false
}
}
allowed := []string{
".vk.com", ".vk.ru", ".vkontakte.ru",
".userapi.com", ".okcdn.ru", ".mycdn.me",
@@ -95,3 +95,33 @@ func TestRewriteProxyRedirectLocation(t *testing.T) {
})
}
}
func TestIsAllowedProxyHost(t *testing.T) {
t.Parallel()
tests := []struct {
hostname string
want bool
}{
{"id.vk.ru", true},
{"api.vk.com", true},
{"vk.com", true},
{"sun9-1.userapi.com", true},
{"ads.vk.com", false},
{"ads.vk.ru", false},
{"top-fwz1.mail.ru", false},
{"sdk-api.apptracer.ru", false},
{"stats.vk-portal.net", false},
{"evil.com", false},
}
for _, tc := range tests {
t.Run(tc.hostname, func(t *testing.T) {
t.Parallel()
if got := isAllowedProxyHost(tc.hostname); got != tc.want {
t.Fatalf("isAllowedProxyHost(%q) = %v, want %v", tc.hostname, got, tc.want)
}
})
}
}
+28 -32
View File
@@ -11,6 +11,7 @@ import (
_ "image/jpeg" // регистрация JPEG-декодера для image.Decode
"math"
"runtime"
"slices"
"sort"
"strconv"
"strings"
@@ -33,7 +34,6 @@ type sliderGuess struct {
Score int64
ScoreRGB int64
ScoreLuma int64
ScoreReverse int64
ScoreText float64
ConsensusRank int
}
@@ -76,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 {
@@ -88,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,
@@ -108,7 +109,7 @@ func (s *captchaSession) solveSliderCaptcha(
}
handle = target
check, err := s.performCaptchaCheck(sessionToken, string(answerData), g)
check, err := s.performCaptchaCheck(sessionToken, string(answerData))
if err != nil {
return "", err
}
@@ -230,15 +231,6 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
}
guesses[idx-1] = sliderGuess{Index: idx, Swaps: active}
guesses[idx-1].ScoreLuma = seamScoreLuma(img, gridSize, mapping)
if len(active) > 2 {
reverseMapping, err := applySliderSwaps(gridSize, reverseSwapPairs(active))
if err != nil {
return nil, err
}
guesses[idx-1].ScoreReverse = seamScoreLuma(img, gridSize, reverseMapping)
} else {
guesses[idx-1].ScoreReverse = guesses[idx-1].ScoreLuma
}
}
lumaOrder := append([]sliderGuess(nil), guesses...)
@@ -339,21 +331,9 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
textRank[g.Index] = rank
}
reverseOrder := append([]sliderGuess(nil), guesses...)
sort.SliceStable(reverseOrder, func(i, j int) bool {
if reverseOrder[i].ScoreReverse == reverseOrder[j].ScoreReverse {
return reverseOrder[i].Index < reverseOrder[j].Index
}
return reverseOrder[i].ScoreReverse < reverseOrder[j].ScoreReverse
})
reverseRank := make(map[int]int, len(reverseOrder))
for rank, g := range reverseOrder {
reverseRank[g.Index] = rank
}
for i := range guesses {
g := &guesses[i]
g.ConsensusRank = lumaRank[g.Index] + reverseRank[g.Index]
g.ConsensusRank = lumaRank[g.Index]
if _, ok := stage2Set[g.Index]; ok {
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
} else {
@@ -374,10 +354,26 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
return guesses, nil
}
func reverseSwapPairs(swaps []int) []int {
out := make([]int, 0, len(swaps))
for i := len(swaps) - 2; i >= 0; i -= 2 {
out = append(out, swaps[i], swaps[i+1])
// 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
}
@@ -3,11 +3,13 @@ package captcha
// Телеметрия captcha.
//
// Виджет заводит setInterval(sensors_delay) и на каждом тике кладёт в аккумулятор
// последнюю известную позицию указателя (cursor - мышь, taps - палец) плюс
// navigator.connection.rtt/downlink. Акселерометр пишется отдельным потоком
// событий Generic Sensor API с частотой 1000/sensors_delay. Позиция обновляется
// только по событию ввода, поэтому неподвижный указатель даёт цепочку одинаковых
// сэмплов, а до первого события массив пуст.
// событий Generic Sensor API с частотой 1000/sensors_delay.
//
// Указатель (cursor - мышь, taps - палец) пишется только через externalCoords, а
// их заполняет либо нативный бридж мобильного SDK, либо хост-страница, если
// виджет открыт в iframe. Мы ходим на captcha прямой навигацией, без хоста, -
// в этом режиме виджет живого браузера оставляет оба массива пустыми.
//
// В captchaNotRobot.check уходят все семь массивов, даже пустые: значения,
// которых браузер не дал, виджет просто не кладёт в массив, но сам ключ
@@ -133,32 +135,13 @@ type gesture struct {
// buildAnalytics собирает аккумулятор так, как его собрал бы живой браузер за
// elapsed с момента ответа settings: тиков ровно столько, сколько успел сделать
// таймер, а непокрытые персоной сенсоры остаются пустыми.
func buildAnalytics(p browserprofile.Profile, cfg sensorConfig, g gesture, elapsed time.Duration) analytics {
func buildAnalytics(p browserprofile.Profile, cfg sensorConfig, elapsed time.Duration) analytics {
ticks := ticksIn(elapsed, cfg.delay)
if ticks == 0 {
// Таймер не успел тикнуть ни разу - живой виджет отправил бы пустые массивы.
return analytics{}
}
moveTicks := min(max(ticksIn(g.move, cfg.delay), 1), ticks)
settleTicks := min(ticksIn(g.settle, cfg.delay), ticks-moveTicks)
idleTicks := ticks - moveTicks - settleTicks
track := make([]point, 0, ticks)
if !p.Touch() {
// Мышь видна виджету с первого тика: до движения сыплются одинаковые
// сэмплы стартовой позиции.
track = appendRepeat(track, g.from, idleTicks)
}
track = append(track, samplePath(g.from, g.to, moveTicks)...)
track = appendRepeat(track, track[len(track)-1], settleTicks)
out := analytics{}
switch {
case p.Touch() && cfg.taps:
out.taps = track
case !p.Touch() && cfg.cursor:
out.cursor = track
}
out.connRtt, out.connDownlink = sampleConnection(p.IsMobile(), ticks)
if p.Accelerometer() && cfg.accelerometer {
out.accelerometer = sampleAccelerometer(ticks)
@@ -173,52 +156,6 @@ func ticksIn(d time.Duration, delay time.Duration) int {
return min(int(d/delay), sensorTicksMax)
}
func appendRepeat(dst []point, value point, count int) []point {
for range count {
dst = append(dst, value)
}
return dst
}
// samplePath снимает count позиций с дуги from->to: живая рука идёт не по прямой
// и тормозит у цели, а таймер снимает позицию через равные интервалы.
func samplePath(from, to point, count int) []point {
if count <= 0 {
return nil
}
ctrl := point{
X: (from.X+to.X)/2 + randx.Intn(61) - 30,
Y: (from.Y+to.Y)/2 - randx.Intn(30) - 10,
}
points := make([]point, 0, count)
for i := 1; i <= count; i++ {
t := easeInOut(float64(i) / float64(count))
x := quadBezier(float64(from.X), float64(ctrl.X), float64(to.X), t)
y := quadBezier(float64(from.Y), float64(ctrl.Y), float64(to.Y), t)
// Дрожание руки гаснет к концу движения.
jitter := int(math.Round((1-t)*4)) + 1
points = append(points, point{
X: int(math.Round(x)) + randx.Intn(jitter*2+1) - jitter,
Y: int(math.Round(y)) + randx.Intn(jitter*2+1) - jitter,
})
}
points[len(points)-1] = to
return points
}
func quadBezier(p0, p1, p2, t float64) float64 {
inv := 1 - t
return inv*inv*p0 + 2*t*inv*p1 + t*t*p2
}
func easeInOut(t float64) float64 {
if t < 0.5 {
return 2 * t * t
}
p := -2*t + 2
return 1 - p*p/2
}
// sampleConnection повторяет NetworkInformation: Chrome огрубляет rtt до кратных
// 25 мс и downlink до шага 0.05 с потолком 10 Мбит/с, а между тиками оценка
// меняется редко.
@@ -12,24 +12,13 @@ func testPersona(plat browserprofile.Platform) browserprofile.Profile {
return browserprofile.For(plat, browserprofile.Identity{Seed: "telemetry-test"})
}
func testGesture(p browserprofile.Profile) gesture {
l := layoutFor(p)
target := l.sliderTarget(3, 8)
return gesture{
from: approachFrom(p, l, target),
to: target,
move: 1500 * time.Millisecond,
settle: 400 * time.Millisecond,
}
}
// Виджет всегда сериализует все семь ключей аккумулятора: пустой массив - валидный
// ответ браузера, отсутствующий ключ - невозможное состояние.
func TestAnalyticsAlwaysSendsAllKeys(t *testing.T) {
want := []string{"accelerometer", "gyroscope", "motion", "cursor", "taps", "connectionRtt", "connectionDownlink"}
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
p := testPersona(plat)
fields := buildAnalytics(p, defaultSensorConfig(), testGesture(p), 4*time.Second).fields()
fields := buildAnalytics(p, defaultSensorConfig(), 4*time.Second).fields()
if len(fields) != len(want) {
t.Fatalf("%s: %d fields, want %d", plat, len(fields), len(want))
}
@@ -47,16 +36,15 @@ func TestAnalyticsAlwaysSendsAllKeys(t *testing.T) {
// Телеметрия не может содержать данных, которых персона физически не соберёт.
func TestAnalyticsRespectsPersonaCapabilities(t *testing.T) {
cases := []struct {
plat browserprofile.Platform
wantAccel bool
wantPointer string
plat browserprofile.Platform
wantAccel bool
}{
{browserprofile.Desktop, false, "cursor"},
{browserprofile.Mobile, true, "taps"},
{browserprofile.Desktop, false},
{browserprofile.Mobile, true},
}
for _, tc := range cases {
p := testPersona(tc.plat)
data := buildAnalytics(p, defaultSensorConfig(), testGesture(p), 4*time.Second)
data := buildAnalytics(p, defaultSensorConfig(), 4*time.Second)
if len(data.connRtt) == 0 {
t.Fatalf("%s: NetworkInformation отсутствует", tc.plat)
@@ -70,37 +58,34 @@ func TestAnalyticsRespectsPersonaCapabilities(t *testing.T) {
if len(data.gyroscope) != 0 || len(data.motion) != 0 {
t.Fatalf("%s: web-персона не может дать gyroscope/motion", tc.plat)
}
if tc.wantPointer == "cursor" && (len(data.cursor) == 0 || len(data.taps) != 0) {
t.Fatalf("%s: мышь обязана писать cursor и не писать taps", tc.plat)
}
if tc.wantPointer == "taps" && (len(data.taps) == 0 || len(data.cursor) != 0) {
t.Fatalf("%s: тач обязан писать taps и не писать cursor", tc.plat)
}
}
// Указатель пишется только через externalCoords, а их заполняет хост-страница или
// нативный бридж. При прямой навигации виджет живого браузера оставляет cursor и
// taps пустыми - и мы обязаны делать то же.
func TestAnalyticsLeavesPointerEmpty(t *testing.T) {
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
p := testPersona(plat)
data := buildAnalytics(p, defaultSensorConfig(), 10*time.Second)
if len(data.cursor) != 0 || len(data.taps) != 0 {
t.Fatalf("%s: cursor %d, taps %d, want 0", plat, len(data.cursor), len(data.taps))
}
}
}
// Число сэмплов задаёт таймер виджета: сколько тиков уместилось в реальное время
// сессии, столько и точек. Мышь видна с первого тика, палец - только с касания.
// сессии, столько и точек.
func TestAnalyticsSampleCountFollowsElapsed(t *testing.T) {
cfg := sensorConfig{delay: 100 * time.Millisecond, cursor: true, taps: true, accelerometer: true}
elapsed := 5 * time.Second
ticks := int(elapsed / cfg.delay)
desktop := testPersona(browserprofile.Desktop)
data := buildAnalytics(desktop, cfg, testGesture(desktop), elapsed)
if len(data.cursor) != ticks || len(data.connRtt) != ticks {
t.Fatalf("desktop: cursor %d, conn %d, want %d", len(data.cursor), len(data.connRtt), ticks)
}
mobile := testPersona(browserprofile.Mobile)
g := testGesture(mobile)
data = buildAnalytics(mobile, cfg, g, elapsed)
gestureTicks := int((g.move + g.settle) / cfg.delay)
if len(data.taps) > gestureTicks+1 || len(data.taps) == 0 {
t.Fatalf("mobile: taps %d, want <= %d (только время касания)", len(data.taps), gestureTicks+1)
}
if len(data.connRtt) != ticks {
t.Fatalf("mobile: conn %d, want %d", len(data.connRtt), ticks)
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
data := buildAnalytics(testPersona(plat), cfg, elapsed)
if len(data.connRtt) != ticks {
t.Fatalf("%s: conn %d, want %d", plat, len(data.connRtt), ticks)
}
}
}
@@ -116,7 +101,7 @@ func TestAnalyticsHonoursSensorList(t *testing.T) {
}
p := testPersona(browserprofile.Mobile)
data := buildAnalytics(p, cfg, testGesture(p), 3*time.Second)
data := buildAnalytics(p, cfg, 3*time.Second)
if len(data.taps) != 0 || len(data.accelerometer) != 0 {
t.Fatalf("taps %d, accelerometer %d: сервер их не слушает", len(data.taps), len(data.accelerometer))
}
@@ -126,7 +111,7 @@ func TestAnalyticsHonoursSensorList(t *testing.T) {
func TestAnalyticsWithoutTicksIsEmpty(t *testing.T) {
p := testPersona(browserprofile.Mobile)
cfg := sensorConfig{delay: 2 * time.Second, cursor: true, taps: true, accelerometer: true}
data := buildAnalytics(p, cfg, testGesture(p), 500*time.Millisecond)
data := buildAnalytics(p, cfg, 500*time.Millisecond)
for _, kv := range data.fields() {
if kv[1] != "[]" {
t.Fatalf("field %q = %s, want []", kv[0], kv[1])
@@ -145,19 +130,6 @@ func TestParseSensorConfigFallsBackToDefaults(t *testing.T) {
}
}
// Координаты указателя обязаны лежать внутри окна, заявленного в componentDone.
func TestPointerStaysInsideViewport(t *testing.T) {
for _, plat := range []browserprofile.Platform{browserprofile.Desktop, browserprofile.Mobile} {
p := testPersona(plat)
data := buildAnalytics(p, defaultSensorConfig(), testGesture(p), 4*time.Second)
for _, pt := range append(append([]point{}, data.cursor...), data.taps...) {
if pt.X < 0 || pt.Y < 0 || pt.X > p.Viewport.W || pt.Y > p.Viewport.H {
t.Fatalf("%s: точка %+v вне окна %+v", plat, pt, p.Viewport)
}
}
}
}
// Chrome огрубляет NetworkInformation: rtt кратен 25 мс, downlink - шагу 0.05 и
// не превышает 10 Мбит/с.
func TestConnectionSamplesQuantized(t *testing.T) {
@@ -0,0 +1,35 @@
package personanet
import (
neturl "net/url"
"time"
fhttp "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
)
// cookieJar чинит удаление кук в tls-client: его фильтр смотрит только на
// Max-Age (jar.go, проверка Expires закомментирована), а VK гасит куки прошедшим
// Expires. Без этого remixir=DELETED уезжает в каждом запросе - живой браузер
// такую куку удаляет и не шлёт никогда.
type cookieJar struct {
inner tlsclient.CookieJar
}
func NewCookieJar() tlsclient.CookieJar {
return &cookieJar{inner: tlsclient.NewCookieJar()}
}
func (j *cookieJar) SetCookies(u *neturl.URL, cookies []*fhttp.Cookie) {
now := time.Now()
for _, c := range cookies {
if c.MaxAge == 0 && !c.Expires.IsZero() && !c.Expires.After(now) {
c.MaxAge = -1
}
}
j.inner.SetCookies(u, cookies)
}
func (j *cookieJar) Cookies(u *neturl.URL) []*fhttp.Cookie { return j.inner.Cookies(u) }
func (j *cookieJar) GetAllCookies() map[string][]*fhttp.Cookie { return j.inner.GetAllCookies() }
@@ -10,7 +10,7 @@ import (
tlsclient "github.com/bogdanfinn/tls-client"
)
func (c *Client) defaultAutoSolve(
func (*Client) defaultAutoSolve(
ctx context.Context,
captchaErr *captcha.Error,
streamID int,
@@ -27,9 +27,7 @@ func (c *Client) defaultAutoSolve(
return "", fmt.Errorf("no redirect_uri for auto-solve")
}
successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, log, func() {
c.burnPersona(streamID)
})
successToken, err := captcha.Solve(ctx, captchaErr, streamID, client, profile, log)
if err != nil {
return "", 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/personanet"
"github.com/samosvalishe/free-turn-proxy/internal/randx"
tlsclient "github.com/bogdanfinn/tls-client"
@@ -268,7 +269,7 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string,
var lastErr error
burns := 0
jar := tlsclient.NewCookieJar()
jar := personanet.NewCookieJar()
for i := 0; i < len(c.credentials); {
creds := c.credentials[i]
c.log.Infof("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID)
@@ -285,7 +286,7 @@ func (c *Client) fetch(ctx context.Context, link string, streamID int) (string,
// куками, пока не кончатся режимы решения captcha.
if errors.Is(err, ErrPersonaBurned) && burns < maxPersonaBurns {
burns++
jar = tlsclient.NewCookieJar()
jar = personanet.NewCookieJar()
continue
}
i++