mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dcaa46659 | |||
| 7dc23ce3f0 | |||
| 1ca3451b76 | |||
| 59a8c84157 | |||
| 4174f29c63 | |||
| 4fa7810afa | |||
| 8eb3873d27 | |||
| ee1356308b | |||
| d9a0554489 | |||
| ea477a3bc2 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.1.0"
|
||||
".": "2.1.1"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user