5 Commits

Author SHA1 Message Date
samokrutka 0a6b0e0e85 Merge pull request #58 from samosvalishe/release-please--branches--master
chore(master): release 2.1.2
2026-08-14 14:00:54 +03:00
samokrutka 25451e8e25 chore(master): release 2.1.2 2026-08-14 13:57:05 +03:00
samosvalishe 5bc164f78d fix(deps): обновить Go 1.26.5 -> 1.26.6 2026-08-14 13:56:43 +03:00
samosvalishe 95803f481a fix(captcha): fmt 2026-08-14 13:48:05 +03:00
samosvalishe 235ab1a646 fix(captcha): выровнять авторешение по живому браузеру 2026-08-14 13:46:39 +03:00
14 changed files with 791 additions and 630 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: true
env:
GO_VERSION: "1.26.5"
GO_VERSION: "1.26.6"
GOLANGCI_VERSION: v2.12.2
GOVULN_VERSION: v1.6.0
GORELEASER_VERSION: v2.17.1
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
packages: write
env:
GO_VERSION: "1.26.5"
GO_VERSION: "1.26.6"
GORELEASER_VERSION: v2.17.1
GOMOBILE_VERSION: latest
TASK_VERSION: 3.52.0
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.1.1"
".": "2.1.2"
}
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## [2.1.2](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.1...v2.1.2) (2026-08-14)
### Bug Fixes
* **captcha:** fmt ([95803f4](https://github.com/samosvalishe/free-turn-proxy/commit/95803f481a683056651768225bc0c64e177ad468))
* **captcha:** выровнять авторешение по живому браузеру ([235ab1a](https://github.com/samosvalishe/free-turn-proxy/commit/235ab1a6463a9758223d38f0eacc4c630d80a4a5))
* **deps:** обновить Go 1.26.5 -> 1.26.6 ([5bc164f](https://github.com/samosvalishe/free-turn-proxy/commit/5bc164f78da2b54a2013c3e046f7d02799ceaf20))
## [2.1.1](https://github.com/samosvalishe/free-turn-proxy/compare/v2.1.0...v2.1.1) (2026-08-04)
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/samosvalishe/free-turn-proxy
go 1.26.5
go 1.26.6
require (
github.com/amnezia-vpn/amneziawg-go v0.2.19
@@ -47,31 +47,14 @@ type Profile struct {
SecChUaMobile string
SecChUaPlatform string
AcceptLanguage string
// Viewport - innerWidth/innerHeight персоны; из него же считаются координаты
// указателя в телеметрии captcha.
Viewport Size
// DeviceJSON - navigator/screen fingerprint для captcha componentDone,
// сериализованный из того же device, что дал Viewport.
// DeviceJSON - navigator/screen fingerprint для captcha componentDone.
DeviceJSON string
// VisitorID - идентификатор FingerprintJS этой личности.
VisitorID string
}
type Size struct {
W int
H int
}
func (p Profile) IsMobile() bool { return p.Platform == Mobile }
// Touch - ввод пальцем вместо мыши: определяет, какой массив телеметрии captcha
// (taps против cursor) вообще может быть непустым.
func (p Profile) Touch() bool { return p.IsMobile() }
// Accelerometer - даст ли window.Accelerometer (Generic Sensor API) реальные
// показания: на десктопе сенсора нет.
func (p Profile) Accelerometer() bool { return p.IsMobile() }
// device - то, что виджет captcha собирает из navigator/screen. Порядок полей
// повторяет порядок ключей в объекте виджета, отсутствующие в браузере поля
// выпадают из JSON так же, как undefined выпадает у JSON.stringify.
@@ -147,8 +130,8 @@ var mobileSpecs = []spec{
type Identity struct {
// Seed - стабильная строка установки: личность переживает перезапуск.
Seed string
// Gen растёт при сжигании персоны: отвергнутый captcha отпечаток не
// переиспользуется, но и не меняется в середине сессии.
// Gen растёт при сжигании персоны: отвергнутый отпечаток не переиспользуется
// до конца процесса, но между запусками возвращается.
Gen int
}
@@ -171,7 +154,7 @@ func For(p Platform, id Identity) Profile {
SecChUa: chromeSecChUa,
SecChUaMobile: "?0",
SecChUaPlatform: s.chPlatform,
AcceptLanguage: "ru-RU,ru;q=0.9",
AcceptLanguage: "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
UserAgent: s.userAgent,
}
if p == Mobile {
@@ -190,20 +173,16 @@ func newDevice(screenW, screenH, availH, innerW, innerH int, dpr float64, cores,
ScreenWidth: screenW, ScreenHeight: screenH,
ScreenAvailWidth: screenW, ScreenAvailHeight: availH,
InnerWidth: innerW, InnerHeight: innerH,
DevicePixelRatio: dpr,
Language: "ru-RU",
Languages: []string{"ru-RU"},
HardwareConcurrency: cores,
DeviceMemory: &memory,
// Сеть репортится как 4g и на Wi-Fi: NetworkInformation огрубляет тип до
// класса скорости.
DevicePixelRatio: dpr,
Language: "ru-RU",
Languages: []string{"ru-RU", "en-US"},
HardwareConcurrency: cores,
DeviceMemory: &memory,
ConnectionEffectiveType: "4g",
NotificationsPermission: "prompt",
NotificationsPermission: "denied",
}
}
// visitorID - слот FingerprintJS: у живого посетителя стабилен между визитами,
// поэтому выводится из личности целиком, а не генерится на попытку.
func visitorID(id Identity, p Profile) string {
sum := sha256.Sum256([]byte(id.String() + "|" + p.UserAgent + "|" + p.DeviceJSON))
return hex.EncodeToString(sum[:16])
@@ -214,7 +193,6 @@ func withDevice(p Profile, d device) Profile {
if err != nil {
return p
}
p.Viewport = Size{W: d.InnerWidth, H: d.InnerHeight}
p.DeviceJSON = string(data)
return p
}
@@ -141,31 +141,8 @@ func TestPlatformSet(t *testing.T) {
}
}
// Показания акселерометра даёт только мобильная персона: на десктопе сенсора нет.
func TestAccelerometerOnlyOnMobile(t *testing.T) {
if For(Desktop, Identity{Seed: testSeed}).Accelerometer() {
t.Fatal("desktop persona claims accelerometer")
}
if !For(Mobile, Identity{Seed: testSeed}).Accelerometer() {
t.Fatal("mobile persona lost accelerometer")
}
}
// Viewport и device считаются из одного источника: координаты телеметрии обязаны
// лежать внутри окна, которое персона заявила в componentDone.
func TestViewportMatchesDeviceJSON(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
dev := deviceOf(t, p)
if p.Viewport.W != dev.InnerWidth || p.Viewport.H != dev.InnerHeight {
t.Fatalf("%s viewport %+v != device %dx%d", plat, p.Viewport, dev.InnerWidth, dev.InnerHeight)
}
}
}
}
// Окно не бывает больше экрана, а рабочая область - больше самого экрана.
func TestViewportFitsScreen(t *testing.T) {
func TestWindowFitsScreen(t *testing.T) {
for _, plat := range []Platform{Desktop, Mobile} {
for _, p := range personas(t, plat) {
dev := deviceOf(t, p)
@@ -0,0 +1,156 @@
package captcha
import (
neturl "net/url"
"regexp"
"strings"
"sync"
"sync/atomic"
fhttp "github.com/bogdanfinn/fhttp"
)
const (
assetsMaxParallel = 6
assetsMaxCount = 40
)
var (
reAssetScript = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
reAssetLink = regexp.MustCompile(`<link[^>]+>`)
reAssetHref = regexp.MustCompile(`href="([^"]+)"`)
reAssetRel = regexp.MustCompile(`rel="([^"]+)"`)
reAssetAs = regexp.MustCompile(`as="([^"]+)"`)
reAssetImg = regexp.MustCompile(`<img[^>]+src="([^"]+)"`)
)
type asset struct {
URL string
Dest string
}
func parsePageAssets(html, skip string) []asset {
seen := map[string]struct{}{}
if skip != "" {
seen[skip] = struct{}{}
}
out := make([]asset, 0, assetsMaxCount)
add := func(raw, dest string) {
url := absoluteAssetURL(raw)
if url == "" || len(out) >= assetsMaxCount {
return
}
if _, dup := seen[url]; dup {
return
}
seen[url] = struct{}{}
out = append(out, asset{URL: url, Dest: dest})
}
for _, m := range reAssetScript.FindAllStringSubmatch(html, -1) {
add(m[1], "script")
}
for _, tag := range reAssetLink.FindAllString(html, -1) {
href := reAssetHref.FindStringSubmatch(tag)
if len(href) < 2 {
continue
}
add(href[1], linkDest(tag))
}
for _, m := range reAssetImg.FindAllStringSubmatch(html, -1) {
add(m[1], "image")
}
return out
}
func linkDest(tag string) string {
if as := reAssetAs.FindStringSubmatch(tag); len(as) > 1 {
return as[1]
}
rel := ""
if m := reAssetRel.FindStringSubmatch(tag); len(m) > 1 {
rel = m[1]
}
switch {
case strings.Contains(rel, "stylesheet"):
return "style"
case strings.Contains(rel, "icon"):
return "image"
default:
return "empty"
}
}
// Рекламные хосты сюда не входят: manual их блокирует и всё равно проходит.
var assetHostSuffixes = []string{".vk.com", ".vk.ru", ".userapi.com", ".okcdn.ru", ".mycdn.me"}
// Относительные пути пропускаются: статика раздаётся с CDN.
func absoluteAssetURL(raw string) string {
raw = strings.TrimSpace(raw)
switch {
case strings.HasPrefix(raw, "https://"):
case strings.HasPrefix(raw, "//"):
raw = "https:" + raw
default:
return ""
}
u, err := neturl.Parse(raw)
if err != nil {
return ""
}
host := u.Hostname()
for _, suffix := range assetHostSuffixes {
if strings.HasSuffix(host, suffix) || host == suffix[1:] {
return raw
}
}
return ""
}
func assetAccept(dest string) string {
switch dest {
case "style":
return "text/css,*/*;q=0.1"
case "image":
return "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
default:
return "*/*"
}
}
func (s *captchaSession) loadAssets(assets []asset) {
if len(assets) == 0 {
return
}
sem := make(chan struct{}, assetsMaxParallel)
var wg sync.WaitGroup
var loaded, failed atomic.Int32
for _, a := range assets {
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
// Шрифт браузер тянет в cors, остальное - no-cors.
mode := "no-cors"
if a.Dest == "font" {
mode = "cors"
}
_, err := s.doRaw(fhttp.MethodGet, a.URL, nil, map[string]string{
"Accept": assetAccept(a.Dest),
"Sec-Fetch-Dest": a.Dest,
"Sec-Fetch-Mode": mode,
"Sec-Fetch-Site": "same-site",
"Referer": s.pageOrigin + "/",
})
if err != nil {
failed.Add(1)
} else {
loaded.Add(1)
}
})
}
wg.Wait()
s.logger().Debugf("[Captcha] assets loaded=%d failed=%d", loaded.Load(), failed.Load())
}
@@ -0,0 +1,79 @@
package captcha
import (
"strings"
"testing"
)
const assetsPageHTML = `<html><head>
<link rel="stylesheet" href="https://static.vk.ru/vkid/1.1.1391/main.css">
<link rel="preload" as="font" href="https://static.vk.ru/fonts/vk.woff2">
<link rel="icon" href="//static.vk.ru/favicon.ico">
<script src="https://static.vk.ru/vkid/1.1.1391/not_robot_captcha.js"></script>
<script src="https://static.vk.ru/vkid/1.1.1391/chunk.js"></script>
<script src="/local/inline.js"></script>
</head><body>
<img src="https://static.vk.ru/img/logo.png">
<img src="https://static.vk.ru/img/logo.png">
</body></html>`
func TestParsePageAssetsClassifiesDest(t *testing.T) {
bundle := "https://static.vk.ru/vkid/1.1.1391/not_robot_captcha.js"
got := parsePageAssets(assetsPageHTML, bundle)
want := map[string]string{
"https://static.vk.ru/vkid/1.1.1391/main.css": "style",
"https://static.vk.ru/fonts/vk.woff2": "font",
"https://static.vk.ru/favicon.ico": "image",
"https://static.vk.ru/vkid/1.1.1391/chunk.js": "script",
"https://static.vk.ru/img/logo.png": "image",
}
if len(got) != len(want) {
t.Fatalf("assets = %d, want %d: %+v", len(got), len(want), got)
}
for _, a := range got {
if want[a.URL] != a.Dest {
t.Fatalf("%s dest = %q, want %q", a.URL, a.Dest, want[a.URL])
}
}
}
// Бандл тянется отдельно ради debug_info: второй запрос за ним живой браузер не
// делает, у него ресурс уже в кеше.
func TestParsePageAssetsSkipsBundle(t *testing.T) {
bundle := "https://static.vk.ru/vkid/1.1.1391/not_robot_captcha.js"
for _, a := range parsePageAssets(assetsPageHTML, bundle) {
if a.URL == bundle {
t.Fatal("bundle must not be refetched as an asset")
}
}
}
// Относительные пути виджета обслуживает не CDN, и абсолютного URL для них нет.
func TestParsePageAssetsDropsRelative(t *testing.T) {
for _, a := range parsePageAssets(assetsPageHTML, "") {
if a.URL == "/local/inline.js" {
t.Fatal("relative asset must be dropped")
}
}
}
// Рекламу manual блокирует и всё равно проходит - авто-путь не должен её тянуть.
func TestParsePageAssetsDropsForeignHosts(t *testing.T) {
html := `<script src="https://ad.mail.ru/static/sync-loader.js"></script>` +
`<script src="https://static.vk.ru/vkid/app.js"></script>`
got := parsePageAssets(html, "")
if len(got) != 1 || got[0].URL != "https://static.vk.ru/vkid/app.js" {
t.Fatalf("assets = %+v, want only the vk.ru script", got)
}
}
func TestParsePageAssetsCaps(t *testing.T) {
var html strings.Builder
for i := range assetsMaxCount + 20 {
html.WriteString(`<script src="https://static.vk.ru/` + string(rune('a'+i%26)) + string(rune('a'+i/26)) + `.js"></script>`)
}
if got := len(parsePageAssets(html.String(), "")); got > assetsMaxCount {
t.Fatalf("assets = %d, want <= %d", got, assetsMaxCount)
}
}
+268 -98
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"math"
neturl "net/url"
"regexp"
"sort"
@@ -33,18 +34,17 @@ func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
const (
captchaAPIVersion = "5.131"
captchaAPIOrigin = "https://id.vk.ru"
captchaAPIHost = "api.vk.ru"
captchaDomain = "vk.ru"
// captchaDebugInfoFallback - константа виджета из not_robot_captcha.js 1.1.1388.
// Ротируется вместе с версией бандла; используется, только если бандл не скачался
// (UUID тут был бы аномалией - VK всегда шлёт 64-hex).
captchaDebugInfoFallback = "0dd096fc7f8d86664f9e8789cf34a99977d39090c0477affead7437709c7990f"
)
var (
reCaptchaPowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
reCaptchaDifficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`)
// Префикс версии берётся со страницы: VK меняет его без предупреждения.
reCaptchaPowResult = regexp.MustCompile(`captchaPowResult\s*=\s*['"]([A-Za-z0-9._-]*)['"]\s*\+\s*btoa`)
reCaptchaScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
reCaptchaInitGlobal = regexp.MustCompile(`window\.init\s*=\s*\{`)
reCaptchaDebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
errCaptchaRateLimit = errors.New("captcha session rate limit reached")
@@ -52,8 +52,7 @@ var (
captchaMaxAttempts = 2
// debugInfoCache кэширует 64-hex debug_info по URL JS-бандла (константа виджета,
// одна на всех).
// debug_info - константа бандла, одна на все сессии; ключ кэша - URL бандла.
debugInfoCache sync.Map
)
@@ -66,7 +65,25 @@ type captchaInitSetting struct {
type captchaPage struct {
PowInput string
PowDifficulty int
PowPrefix string
ScriptURL string
Init captchaInitData
}
// captchaInitData - состояние из window.init: пока оно есть, виджет не ходит в
// initSession, и лишний запрос выдавал бы нас.
type captchaInitData struct {
Found bool
APIHost string
ShowType string
Content captchaContentRef
}
// powResult - конверт window.captchaPowResult: виджету уходит не голый хэш.
type powResult struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
DurationMs int64 `json:"duration_ms"`
}
type captchaCheck struct {
@@ -89,19 +106,28 @@ type captchaSession struct {
ctx context.Context
client tlsclient.HttpClient
profile browserprofile.Profile
layout widgetLayout
domain string
apiHost string
log logx.Logger
// browserFP - слот visitorId FingerprintJS: у живого посетителя стабилен
// между сессиями, поэтому выводится из seed, а не генерится заново.
// VK раздаёт страницу то с id.vk.ru, то с api.vk.ru, а от её совпадения с
// apiHost зависят Origin, Referer и Sec-Fetch-Site.
pageURL string
pageOrigin string
// checked - после check session_token потрачен, повтор сессии сам по себе аномалия.
checked bool
// browserFP - visitorId FingerprintJS: у живого посетителя стабилен между сессиями.
browserFP string
debugInfo string
powHash string
// downlink - у живого клиента одно значение на всю captcha, не отдельное на тик.
downlink float64
sensors sensorConfig
// sensorsStart - момент ответа settings: с него виджет начинает тикать
// таймером телеметрии.
// sensorsStart - ответ settings: с него виджет начинает тикать таймером телеметрии.
sensorsStart time.Time
}
@@ -131,10 +157,11 @@ func Solve(
ctx: ctx,
client: client,
profile: profile,
layout: layoutFor(profile),
domain: captchaDomain,
apiHost: captchaAPIHost,
log: l,
browserFP: profile.VisitorID,
downlink: sessionDownlink(),
sensors: defaultSensorConfig(),
}
@@ -144,7 +171,8 @@ func Solve(
return token, nil
}
l.Warnf("[STREAM %d] [Captcha] solve attempt %d failed: %v", streamID, attempt, solveErr)
if errors.Is(solveErr, errCaptchaRateLimit) || errors.Is(solveErr, errCaptchaBot) {
// Повторяем только то, что не дошло до check.
if s.checked || errors.Is(solveErr, errCaptchaRateLimit) || errors.Is(solveErr, errCaptchaBot) {
return "", solveErr
}
@@ -162,7 +190,8 @@ func Solve(
func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
s.domain = captchaDomainFromRedirectURI(captchaErr.RedirectURI)
s.logger().Debugf("[Captcha] using domain=%s", s.domain)
s.setPageURL(captchaErr.RedirectURI)
s.logger().Debugf("[Captcha] using domain=%s page_origin=%s", s.domain, s.pageOrigin)
html, err := s.fetchCaptchaHTML(captchaErr.RedirectURI)
if err != nil {
@@ -176,13 +205,58 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
return "", err
}
s.logger().Debugf("[Captcha] solving pow difficulty=%d", page.PowDifficulty)
s.powHash = solveCaptchaPoW(s.ctx, page.PowInput, page.PowDifficulty)
if s.powHash == "" {
// Браузер тянет подресурсы параллельно с исполнением скрипта, не после него.
assets := parsePageAssets(html, page.ScriptURL)
assetsDone := make(chan struct{})
go func() {
defer close(assetsDone)
s.loadAssets(assets)
}()
// Ранний выход не оставляет догрузку в фоне следующей попытке.
defer func() { <-assetsDone }()
s.logger().Debugf("[Captcha] solving pow difficulty=%d assets=%d", page.PowDifficulty, len(assets))
powHash, powNonce := solveCaptchaPoW(s.ctx, page.PowInput, page.PowDifficulty)
if powHash == "" {
return "", errors.New("captcha pow failed")
}
s.powHash, err = encodePowResult(page.PowPrefix, powResult{
Hash: powHash,
Nonce: powNonce,
DurationMs: powDurationMs(powNonce),
})
if err != nil {
return "", fmt.Errorf("captcha pow encode: %w", err)
}
s.debugInfo = s.resolveDebugInfo(page.ScriptURL)
s.debugInfo, err = s.resolveDebugInfo(page.ScriptURL)
if err != nil {
return "", err
}
<-assetsDone
if page.Init.APIHost != "" {
s.apiHost = page.Init.APIHost
}
var showType string
var sliderContent captchaContentRef
if page.Init.Found {
showType, sliderContent = page.Init.ShowType, page.Init.Content
s.logger().Debugf("[Captcha] challenge from window.init show_type=%q slider_len=%d", showType, len(sliderContent.Value))
} else {
initResp, initErr := s.captchaRequest("captchaNotRobot.initSession", [][2]string{
{"session_token", captchaErr.SessionToken},
{"domain", s.domain},
{"lang", "0"},
})
if initErr != nil {
return "", fmt.Errorf("captcha initSession failed: %w", initErr)
}
showType, sliderContent = parseCaptchaInitSession(initResp)
s.logger().Debugf("[Captcha] initSession show_type=%q slider_len=%d", showType, len(sliderContent.Value))
}
base := s.captchaBaseValues(captchaErr.SessionToken)
settingsResp, err := s.captchaRequest("captchaNotRobot.settings", base)
@@ -191,22 +265,10 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
}
s.sensors = parseSensorConfig(settingsResp)
s.sensorsStart = time.Now()
s.logger().Debugf("[Captcha] sensors delay=%s cursor=%t taps=%t accel=%t",
s.sensors.delay, s.sensors.cursor, s.sensors.taps, s.sensors.accelerometer)
initResp, err := s.captchaRequest("captchaNotRobot.initSession", [][2]string{
{"session_token", captchaErr.SessionToken},
{"domain", s.domain},
{"lang", "0"},
})
if err != nil {
return "", fmt.Errorf("captcha initSession failed: %w", err)
}
showType, sliderContent := parseCaptchaInitSession(initResp)
s.logger().Debugf("[Captcha] initSession show_type=%q slider_len=%d", showType, len(sliderContent.Value))
s.logger().Debugf("[Captcha] sensors delay=%s", s.sensors.delay)
// Отрисовка виджета и первая реакция человека.
if dwellErr := s.dwell(400, 700); dwellErr != nil {
if dwellErr := s.dwell(250, 400); dwellErr != nil {
return "", dwellErr
}
@@ -251,17 +313,19 @@ func (s *captchaSession) escalate(sessionToken string, initContent captchaConten
return "", cause
}
s.logger().Debugf("[Captcha] escalated to slider in-session (content source=%s)", content.Source)
// Перерисовка виджета плюс пауза на осознание.
if err := s.dwell(500, 1100); err != nil {
return "", err
}
return s.solveSliderCaptcha(sessionToken, content)
}
// dwell выдерживает паузу [minMs, maxMs): телеметрия виджета тикает по таймеру,
// поэтому длина её массивов обязана биться с реальным временем сессии.
// dwell - пауза [minMs, maxMs): массивы телеметрии обязаны биться с реальным
// временем сессии, поэтому её нельзя "нарисовать".
func (s *captchaSession) dwell(minMs, maxMs int) error {
d := time.Duration(minMs+randx.Intn(max(maxMs-minMs, 1))) * time.Millisecond
return s.sleepFor(time.Duration(minMs+randx.Intn(max(maxMs-minMs, 1))) * time.Millisecond)
}
func (s *captchaSession) sleepFor(d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
@@ -343,8 +407,7 @@ func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) {
// Переход по редиректу считается пользовательской активацией.
"Sec-Fetch-User": "?1",
"Sec-Fetch-Site": "cross-site",
// Referer кросс-сайтового перехода режется политикой до origin страницы,
// с которой ушли, а ей была страница звонка.
// Кросс-сайтовый Referer режется до origin страницы звонка, откуда ушли.
"Referer": "https://" + s.domain + "/",
})
if err != nil {
@@ -353,18 +416,18 @@ func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) {
return string(body), nil
}
func (s *captchaSession) resolveDebugInfo(scriptURL string) string {
if scriptURL != "" {
v, err := s.fetchDebugInfoJS(scriptURL)
if err == nil {
s.logger().Debugf("[Captcha] debug_info from JS len=%d", len(v))
return v
}
s.logger().Warnf("[Captcha] debug_info JS fetch failed: %v; using pinned fallback", err)
} else {
s.logger().Warnf("[Captcha] captcha script URL not in HTML; using pinned fallback")
// resolveDebugInfo берёт константу только из живого бандла: пин означал бы чужой
// debug_info после любой ротации версии виджета.
func (s *captchaSession) resolveDebugInfo(scriptURL string) (string, error) {
if scriptURL == "" {
return "", errors.New("captcha script URL not in HTML")
}
return captchaDebugInfoFallback
v, err := s.fetchDebugInfoJS(scriptURL)
if err != nil {
return "", fmt.Errorf("captcha debug_info: %w", err)
}
s.logger().Debugf("[Captcha] debug_info from JS len=%d", len(v))
return v, nil
}
func (s *captchaSession) fetchDebugInfoJS(scriptURL string) (string, error) {
@@ -379,7 +442,7 @@ func (s *captchaSession) fetchDebugInfoJS(scriptURL string) (string, error) {
"Sec-Fetch-Dest": "script",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "same-site",
"Referer": captchaAPIOrigin + "/",
"Referer": s.pageOrigin + "/",
})
if err != nil {
return "", err
@@ -389,11 +452,6 @@ func (s *captchaSession) fetchDebugInfoJS(scriptURL string) (string, error) {
return "", errors.New("debug_info constant not found in JS")
}
v := string(m[1])
if v != captchaDebugInfoFallback {
// Константа ротируется вместе с версией бандла: сигнал, что пора
// пересматривать пин и сверять протокол виджета.
s.logger().Warnf("[Captcha] debug_info drifted from pinned value; bundle=%s", captchaSafeURL(scriptURL))
}
debugInfoCache.Store(scriptURL, v)
return v, nil
}
@@ -432,15 +490,115 @@ func parseCaptchaPage(html string) (*captchaPage, error) {
return nil, fmt.Errorf("invalid captcha difficulty %q", dm[1])
}
page.PowDifficulty = difficulty
pm := reCaptchaPowResult.FindStringSubmatch(html)
if len(pm) < 2 {
return nil, errors.New("captcha pow result envelope not recognized")
}
page.PowPrefix = pm[1]
page.Init = parseCaptchaInitGlobal(html)
return page, nil
}
// parseCaptchaInitGlobal вынимает window.init - серверный дамп, из которого
// виджет берёт хосты и challenge.
func parseCaptchaInitGlobal(html string) captchaInitData {
m := reCaptchaInitGlobal.FindStringIndex(html)
if m == nil {
return captchaInitData{}
}
raw := balancedJSONObject(html[m[1]-1:])
if raw == "" {
return captchaInitData{}
}
var parsed struct {
Hosts struct {
API string `json:"api"`
} `json:"hosts"`
Data struct {
ShowCaptchaType string `json:"show_captcha_type"`
CaptchaSettings []captchaInitSetting `json:"captcha_settings"`
} `json:"data"`
}
if json.Unmarshal([]byte(raw), &parsed) != nil {
return captchaInitData{}
}
out := captchaInitData{
Found: parsed.Data.ShowCaptchaType != "",
APIHost: parsed.Hosts.API,
ShowType: parsed.Data.ShowCaptchaType,
}
for _, setting := range parsed.Data.CaptchaSettings {
if setting.Type == "slider" {
out.Content = setting.contentRef()
}
}
return out
}
// balancedJSONObject возвращает первый сбалансированный {...} с учётом строк.
func balancedJSONObject(s string) string {
depth, inStr, esc := 0, false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inStr && esc:
esc = false
case inStr && c == '\\':
esc = true
case inStr && c == '"':
inStr = false
case inStr:
case c == '"':
inStr = true
case c == '{':
depth++
case c == '}':
depth--
if depth == 0 {
return s[:i+1]
}
}
}
return ""
}
// setPageURL: битый redirect_uri откатывает на дефолтный origin виджета.
func (s *captchaSession) setPageURL(raw string) {
s.pageURL = captchaAPIOrigin + "/"
s.pageOrigin = captchaAPIOrigin
u, err := neturl.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return
}
s.pageURL = raw
s.pageOrigin = u.Scheme + "://" + u.Host
}
// pageIsAPIOrigin решает, что уйдёт в Referer: полный URL страницы (same-origin)
// или один origin (strict-origin-when-cross-origin).
func (s *captchaSession) pageIsAPIOrigin() bool {
return s.pageOrigin == "https://"+s.apiHost
}
func (s *captchaSession) apiRequestHeaders() map[string]string {
if s.pageIsAPIOrigin() {
return map[string]string{
"Origin": s.pageOrigin,
"Referer": s.pageURL,
"Sec-Fetch-Site": "same-origin",
}
}
return map[string]string{
"Origin": s.pageOrigin,
"Referer": s.pageOrigin + "/",
"Sec-Fetch-Site": "same-site",
}
}
func (s *captchaSession) captchaRequest(method string, form [][2]string) (map[string]any, error) {
endpoint := "https://api.vk.ru/method/" + method + "?v=" + captchaAPIVersion
body, err := s.doRaw(fhttp.MethodPost, endpoint, form, map[string]string{
"Origin": captchaAPIOrigin,
"Referer": captchaAPIOrigin + "/",
})
endpoint := "https://" + s.apiHost + "/method/" + method + "?v=" + captchaAPIVersion
body, err := s.doRaw(fhttp.MethodPost, endpoint, form, s.apiRequestHeaders())
if err != nil {
return nil, err
}
@@ -456,7 +614,8 @@ func (s *captchaSession) performCaptchaCheck(
sessionToken string,
answerJSON string,
) (*captchaCheck, error) {
data := buildAnalytics(s.profile, s.sensors, time.Since(s.sensorsStart))
s.checked = true
data := buildAnalytics(s.sensors, s.downlink, time.Since(s.sensorsStart))
values := make([][2]string, 0, 15)
values = append(values,
[2]string{"session_token", sessionToken},
@@ -475,8 +634,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 conn_samples=%d accel_samples=%d",
len(answerJSON), len(data.connRtt), len(data.accelerometer))
s.logger().Debugf("[Captcha] check payload answer_bytes=%d downlink_samples=%d",
len(answerJSON), len(data.connDownlink))
check, err := parseCaptchaCheck(resp)
if err != nil {
return nil, err
@@ -521,33 +680,12 @@ func (s *captchaSession) sendComponentDone(sessionToken string) error {
return nil
}
// performGesture проживает жест в реальном времени: массивы телеметрии считаются
// от фактически прошедшего времени, поэтому жест нельзя "нарисовать" мгновенно.
func (s *captchaSession) performGesture(g gesture) error {
total := g.move + g.settle
timer := time.NewTimer(total)
defer timer.Stop()
select {
case <-s.ctx.Done():
return s.ctx.Err()
case <-timer.C:
return nil
}
}
func (s *captchaSession) solveCheckboxCaptcha(sessionToken string) (string, error) {
if err := s.sendComponentDone(sessionToken); err != nil {
return "", err
}
target := s.layout.checkbox
g := gesture{
from: approachFrom(s.profile, s.layout, target),
to: target,
move: time.Duration(500+randx.Intn(500)) * time.Millisecond,
settle: time.Duration(250+randx.Intn(350)) * time.Millisecond,
}
if err := s.performGesture(g); err != nil {
if err := s.dwell(350, 550); err != nil {
return "", err
}
@@ -573,18 +711,18 @@ func (s *captchaSession) solveCheckboxCaptcha(sessionToken string) (string, erro
return check.SuccessToken, nil
}
func solveCaptchaPoW(ctx context.Context, input string, difficulty int) string {
func solveCaptchaPoW(ctx context.Context, input string, difficulty int) (string, int) {
if input == "" || difficulty <= 0 {
return ""
return "", 0
}
target := strings.Repeat("0", difficulty)
buf := make([]byte, 0, len(input)+20)
buf = append(buf, input...)
for nonce := 1; nonce <= 10_000_000; nonce++ {
for nonce := 0; nonce <= 10_000_000; nonce++ {
if nonce&1023 == 0 {
select {
case <-ctx.Done():
return ""
return "", 0
default:
}
}
@@ -592,10 +730,22 @@ func solveCaptchaPoW(ctx context.Context, input string, difficulty int) string {
sum := sha256.Sum256(buf)
hashHex := hex.EncodeToString(sum[:])
if strings.HasPrefix(hashHex, target) {
return hashHex
return hashHex, nonce
}
}
return ""
return "", 0
}
func powDurationMs(nonce int) int64 {
return max(1, int64(math.Round(float64(nonce+1)*0.015)))
}
func encodePowResult(prefix string, r powResult) (string, error) {
data, err := json.Marshal(r)
if err != nil {
return "", err
}
return prefix + base64.StdEncoding.EncodeToString(data), nil
}
func (s *captchaSession) doRaw(
@@ -616,19 +766,17 @@ 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("Referer", captchaAPIOrigin+"/")
req.Header.Set("Referer", s.pageOrigin+"/")
if form != nil {
// Origin ставится только на CORS-запросы виджета; GET навигации и тега
// <script> его не несут.
req.Header.Set("Origin", captchaAPIOrigin)
req.Header.Set("Origin", s.pageOrigin)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
// Последним: персона снимает заголовки, которых у неё быть не может, и
// задаёт порядок отправки.
browserprofile.ApplyFhttp(req, s.profile)
s.dumpExchange(req, body)
start := time.Now()
resp, err := s.client.Do(req)
@@ -646,6 +794,28 @@ func (s *captchaSession) doRaw(
return data, readErr
}
func (s *captchaSession) dumpExchange(req *fhttp.Request, body []byte) {
if !strings.Contains(req.URL.Path, "captchaNotRobot.check") &&
!strings.Contains(req.URL.Path, "captchaNotRobot.componentDone") {
return
}
s.logger().Debugf("[Captcha] we sent %s data: %s", req.URL.Path, string(body))
for _, name := range req.Header[fhttp.HeaderOrderKey] {
if v := req.Header.Get(name); v != "" {
s.logger().Debugf("[Captcha] header (%s): %s = %s", req.URL.Path, name, v)
}
}
if cookies := s.client.GetCookies(req.URL); len(cookies) > 0 {
pairs := make([]string, 0, len(cookies))
for _, c := range cookies {
pairs = append(pairs, c.Name+"="+c.Value)
}
s.logger().Debugf("[Captcha] header (%s): cookie = %s", req.URL.Path, strings.Join(pairs, "; "))
} else {
s.logger().Debugf("[Captcha] header (%s): cookie = <none>", req.URL.Path)
}
}
func captchaAPIResponseSummary(raw map[string]any) string {
if errData, ok := raw["error"].(map[string]any); ok {
return fmt.Sprintf("error code=%s msg=%q keys=%s", captchaStringifyAny(errData["error_code"]), captchaStringifyAny(errData["error_msg"]), captchaMapKeys(errData))
@@ -2,6 +2,10 @@ package captcha
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strconv"
"strings"
"testing"
)
@@ -33,6 +37,7 @@ func TestCaptchaInitSettingContentRefLegacySettings(t *testing.T) {
func TestParseCaptchaPageSPA(t *testing.T) {
html := `<html><head><script>
window.captchaPowResult = 'v2.' + btoa(JSON.stringify({ hash: hash, nonce: nonce }));
const powInput = "Pihj7tyAHFxdwm4t";
const difficulty = 2;
</script>
@@ -46,6 +51,9 @@ const difficulty = 2;
if page.PowInput != "Pihj7tyAHFxdwm4t" || page.PowDifficulty != 2 {
t.Fatalf("pow parse = %q/%d", page.PowInput, page.PowDifficulty)
}
if page.PowPrefix != "v2." {
t.Fatalf("pow prefix = %q, want v2.", page.PowPrefix)
}
if page.ScriptURL != "https://static.vk.ru/vkid/1.1.1384/not_robot_captcha.js" {
t.Fatalf("script url = %q", page.ScriptURL)
}
@@ -57,16 +65,52 @@ func TestParseCaptchaPageMissingPoW(t *testing.T) {
}
}
// Формат конверта задаёт страница: незнакомая обёртка должна ронять авторешение,
// а не уезжать в check заведомо неверным hash.
func TestParseCaptchaPageMissingPowEnvelope(t *testing.T) {
html := `<html><head><script>
const powInput = "Pihj7tyAHFxdwm4t";
const difficulty = 2;
</script></head><body></body></html>`
if _, err := parseCaptchaPage(html); err == nil {
t.Fatal("expected error when captchaPowResult envelope absent")
}
}
func TestSolveCaptchaPoWRawHex(t *testing.T) {
got := solveCaptchaPoW(context.Background(), "input", 1)
got, nonce := solveCaptchaPoW(context.Background(), "input", 1)
if len(got) != 64 {
t.Fatalf("pow = %q, want 64-hex", got)
}
if !strings.HasPrefix(got, "0") {
t.Fatalf("pow = %q, want leading zero for difficulty 1", got)
}
if again := solveCaptchaPoW(context.Background(), "input", 1); again != got {
t.Fatalf("pow not deterministic: %q vs %q", got, again)
again, againNonce := solveCaptchaPoW(context.Background(), "input", 1)
if again != got || againNonce != nonce {
t.Fatalf("pow not deterministic: %q/%d vs %q/%d", got, nonce, again, againNonce)
}
sum := sha256.Sum256([]byte("input" + strconv.Itoa(nonce)))
if hex.EncodeToString(sum[:]) != got {
t.Fatalf("pow hash does not match sha256(input+nonce)")
}
}
func TestEncodePowResult(t *testing.T) {
got, err := encodePowResult("v2.", powResult{Hash: "00ab", Nonce: 7, DurationMs: 42})
if err != nil {
t.Fatal(err)
}
payload, ok := strings.CutPrefix(got, "v2.")
if !ok {
t.Fatalf("pow result = %q, want v2. prefix", got)
}
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatal(err)
}
// Порядок ключей копирует JSON.stringify страницы.
if string(raw) != `{"hash":"00ab","nonce":7,"duration_ms":42}` {
t.Fatalf("pow payload = %s", raw)
}
}
@@ -183,3 +227,82 @@ func TestPickSliderAttempts(t *testing.T) {
})
}
}
// VK раздаёт страницу то с id.vk.ru, то с api.vk.ru: Origin от одного хоста при
// странице на другом браузер выдать не может.
func TestAPIRequestHeadersFollowPageOrigin(t *testing.T) {
const pageQuery = "/not_robot_captcha?domain=vk.com&session_token=x&variant=popup"
cases := []struct {
name string
page string
apiHost string
wantSite string
wantRef string
wantOrig string
}{
{
name: "cross-origin page",
page: "https://id.vk.ru" + pageQuery,
apiHost: "api.vk.ru",
wantSite: "same-site",
wantRef: "https://id.vk.ru/",
wantOrig: "https://id.vk.ru",
},
{
name: "same-origin page keeps full url in referer",
page: "https://api.vk.ru" + pageQuery,
apiHost: "api.vk.ru",
wantSite: "same-origin",
wantRef: "https://api.vk.ru" + pageQuery,
wantOrig: "https://api.vk.ru",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &captchaSession{apiHost: tc.apiHost}
s.setPageURL(tc.page)
got := s.apiRequestHeaders()
if got["Sec-Fetch-Site"] != tc.wantSite {
t.Fatalf("Sec-Fetch-Site = %q, want %q", got["Sec-Fetch-Site"], tc.wantSite)
}
if got["Referer"] != tc.wantRef {
t.Fatalf("Referer = %q, want %q", got["Referer"], tc.wantRef)
}
if got["Origin"] != tc.wantOrig {
t.Fatalf("Origin = %q, want %q", got["Origin"], tc.wantOrig)
}
})
}
}
// Пока challenge есть в window.init, виджет не ходит в initSession - и мы тоже.
func TestParseCaptchaInitGlobal(t *testing.T) {
html := `<script>window.init = {"hosts":{"api":"api.vk.ru"},` +
`"data":{"show_captcha_type":"slider","captcha_settings":[{"type":"slider","settings_key":"a2V5"}]},` +
`"tail":{"brace":"}"}};</script>`
got := parseCaptchaInitGlobal(html)
if !got.Found || got.APIHost != "api.vk.ru" || got.ShowType != "slider" {
t.Fatalf("init = %+v", got)
}
if got.Content.Value != "a2V5" || got.Content.Source != "settings_key" {
t.Fatalf("content = %+v", got.Content)
}
}
// Без window.init виджет запрашивает initSession сам - и Found обязан быть false.
func TestParseCaptchaInitGlobalAbsent(t *testing.T) {
if got := parseCaptchaInitGlobal(`<script>var x = 1;</script>`); got.Found || got.APIHost != "" {
t.Fatalf("init = %+v, want empty", got)
}
}
// Битый redirect_uri не должен оставлять Origin пустым.
func TestSetPageURLFallsBackToWidgetOrigin(t *testing.T) {
s := &captchaSession{apiHost: "api.vk.ru"}
s.setPageURL("not-a-url")
if s.pageOrigin != captchaAPIOrigin || s.pageURL != captchaAPIOrigin+"/" {
t.Fatalf("page = %q / %q", s.pageOrigin, s.pageURL)
}
}
+42 -157
View File
@@ -10,15 +10,11 @@ import (
"image/color"
_ "image/jpeg" // регистрация JPEG-декодера для image.Decode
"math"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/randx"
)
type sliderPuzzle struct {
@@ -69,10 +65,7 @@ func (s *captchaSession) solveSliderCaptcha(
return "", err
}
limit := puzzle.Attempts
if limit > len(guesses) {
limit = len(guesses)
}
limit := min(puzzle.Attempts, len(guesses))
if limit <= 0 {
return "", errors.New("slider has no attempts available")
}
@@ -83,12 +76,6 @@ func (s *captchaSession) solveSliderCaptcha(
return "", err
}
// Ручка стоит в начале дорожки; каждая следующая попытка тянет её от места,
// где остановилась предыдущая.
handle := point{X: s.layout.sliderLeft, Y: s.layout.sliderY}
if !s.profile.Touch() {
handle = approachFrom(s.profile, s.layout, handle)
}
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 {
@@ -97,17 +84,11 @@ func (s *captchaSession) solveSliderCaptcha(
if err != nil {
return "", err
}
target := s.layout.sliderTarget(guess.Index, len(guesses))
g := gesture{
from: handle,
to: target,
move: sliderDragTime(handle, target),
settle: time.Duration(200+randx.Intn(400)) * time.Millisecond,
// Протяжка ручки: координаты никуда не уходят, но время сессии задаёт
// длину массивов телеметрии.
if dragErr := s.dwell(650, 1750); dragErr != nil {
return "", dragErr
}
if gestureErr := s.performGesture(g); gestureErr != nil {
return "", gestureErr
}
handle = target
check, err := s.performCaptchaCheck(sessionToken, string(answerData))
if err != nil {
@@ -132,14 +113,6 @@ func (s *captchaSession) solveSliderCaptcha(
return "", errors.New("slider guesses exhausted")
}
// sliderDragTime - сколько занимает протяжка ручки: закон Фиттса, огрублённый до
// линейной зависимости от дистанции.
func sliderDragTime(from, to point) time.Duration {
dist := math.Abs(float64(to.X - from.X))
ms := 450 + dist*2.2 + float64(randx.Intn(350))
return time.Duration(ms) * time.Millisecond
}
func parseSliderPuzzle(raw map[string]any) (*sliderPuzzle, error) {
resp, ok := raw["response"].(map[string]any)
if !ok {
@@ -245,69 +218,25 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
lumaRank[g.Index] = rank
}
stage2Count := candidateCount
stage2Set := make(map[int]struct{}, stage2Count)
for i := 0; i < stage2Count; i++ {
stage2Set[lumaOrder[i].Index] = struct{}{}
}
type stage2Result struct {
index int
rgb int64
text float64
err error
}
jobs := make([]int, 0, stage2Count)
for idx := range stage2Set {
jobs = append(jobs, idx)
}
jobCh := make(chan int, len(jobs))
resCh := make(chan stage2Result, len(jobs))
workers := runtime.NumCPU()
if workers < 1 {
workers = 1
}
if workers > len(jobs) {
workers = len(jobs)
}
// Второй проход тяжелее первого - считаем параллельно, каждый в свою ячейку.
errs := make([]error, candidateCount)
var wg sync.WaitGroup
for range workers {
for i := range guesses {
wg.Go(func() {
for index := range jobCh {
mapping, err := applySliderSwaps(gridSize, guesses[index-1].Swaps)
if err != nil {
resCh <- stage2Result{index: index, err: err}
continue
}
rgb, text := seamScoreRGBText(img, gridSize, mapping)
resCh <- stage2Result{index: index, rgb: rgb, text: text}
mapping, err := applySliderSwaps(gridSize, guesses[i].Swaps)
if err != nil {
errs[i] = err
return
}
guesses[i].ScoreRGB, guesses[i].ScoreText = seamScoreRGBText(img, gridSize, mapping)
})
}
for _, idx := range jobs {
jobCh <- idx
}
close(jobCh)
wg.Wait()
close(resCh)
for r := range resCh {
if r.err != nil {
return nil, r.err
}
g := &guesses[r.index-1]
g.ScoreRGB = r.rgb
g.ScoreText = r.text
if err := errors.Join(errs...); err != nil {
return nil, err
}
stage2 := make([]sliderGuess, 0, stage2Count)
for _, g := range guesses {
if _, ok := stage2Set[g.Index]; ok {
stage2 = append(stage2, g)
}
}
rgbOrder := append([]sliderGuess(nil), stage2...)
rgbOrder := append([]sliderGuess(nil), guesses...)
sort.SliceStable(rgbOrder, func(i, j int) bool {
if rgbOrder[i].ScoreRGB == rgbOrder[j].ScoreRGB {
return rgbOrder[i].Index < rgbOrder[j].Index
@@ -319,7 +248,7 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
rgbRank[g.Index] = rank
}
textOrder := append([]sliderGuess(nil), stage2...)
textOrder := append([]sliderGuess(nil), guesses...)
sort.SliceStable(textOrder, func(i, j int) bool {
if textOrder[i].ScoreText == textOrder[j].ScoreText {
return textOrder[i].Index < textOrder[j].Index
@@ -333,12 +262,7 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
for i := range guesses {
g := &guesses[i]
g.ConsensusRank = lumaRank[g.Index]
if _, ok := stage2Set[g.Index]; ok {
g.ConsensusRank += rgbRank[g.Index] + textRank[g.Index]
} else {
g.ConsensusRank += candidateCount
}
g.ConsensusRank = lumaRank[g.Index] + rgbRank[g.Index] + textRank[g.Index]
g.Score = int64(g.ConsensusRank)
}
@@ -354,9 +278,8 @@ func rankSliderGuesses(img image.Image, gridSize int, swaps []int) ([]sliderGues
return guesses, nil
}
// pickSliderAttempts разносит попытки по дорожке: соседние кандидаты отличаются
// одним свапом и получают почти равный скор, поэтому вторая попытка рядом с
// первой промахивается вместе с ней.
// pickSliderAttempts разносит попытки по номеру кандидата: соседние отличаются
// одним свапом и промахиваются вместе.
func pickSliderAttempts(guesses []sliderGuess, limit int) []sliderGuess {
const minGap = 3
out := make([]sliderGuess, 0, limit)
@@ -382,10 +305,7 @@ func activeSwapsForIndex(swaps []int, index int) []int {
if index <= 0 {
return []int{}
}
end := index * 2
if end > len(swaps) {
end = len(swaps)
}
end := min(index*2, len(swaps))
return append([]int(nil), swaps[:end]...)
}
@@ -415,19 +335,15 @@ func applySliderSwaps(gridSize int, swaps []int) ([]int, error) {
func seamScoreLuma(img image.Image, gridSize int, mapping []int) int64 {
bounds := img.Bounds()
var score int64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
for row := range gridSize {
for col := range gridSize - 1 {
leftIdx := row*gridSize + col
rightIdx := leftIdx + 1
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
h := leftDst.Dy()
if rightDst.Dy() < h {
h = rightDst.Dy()
}
for y := 0; y < h; y++ {
for y := range min(leftDst.Dy(), rightDst.Dy()) {
yy := leftDst.Min.Y + y
a := sampleLumaMapped(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
b := sampleLumaMapped(img, rightDst, rightSrc, rightDst.Min.X, yy)
@@ -435,19 +351,15 @@ func seamScoreLuma(img image.Image, gridSize int, mapping []int) int64 {
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
for row := range gridSize - 1 {
for col := range gridSize {
topIdx := row*gridSize + col
bottomIdx := (row+1)*gridSize + col
topDst := sliderTileRect(bounds, gridSize, topIdx)
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
w := topDst.Dx()
if bottomDst.Dx() < w {
w = bottomDst.Dx()
}
for x := 0; x < w; x++ {
for x := range min(topDst.Dx(), bottomDst.Dx()) {
xx := topDst.Min.X + x
a := sampleLumaMapped(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleLumaMapped(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
@@ -472,31 +384,24 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
}
weight := func(y int) float64 {
yf := float64(y)
best := absFloat(yf - textCenters[0])
for i := 1; i < len(textCenters); i++ {
d := absFloat(yf - textCenters[i])
if d < best {
best = d
}
best := math.Abs(yf - textCenters[0])
for _, center := range textCenters[1:] {
best = min(best, math.Abs(yf-center))
}
return 1 + 3*math.Exp(-(best*best)/(2*sigma*sigma))
}
var rgbScore int64
var textScore float64
for row := 0; row < gridSize; row++ {
for col := 0; col < gridSize-1; col++ {
for row := range gridSize {
for col := range gridSize - 1 {
leftIdx := row*gridSize + col
rightIdx := leftIdx + 1
leftDst := sliderTileRect(bounds, gridSize, leftIdx)
rightDst := sliderTileRect(bounds, gridSize, rightIdx)
leftSrc := sliderTileRect(bounds, gridSize, mapping[leftIdx])
rightSrc := sliderTileRect(bounds, gridSize, mapping[rightIdx])
h := leftDst.Dy()
if rightDst.Dy() < h {
h = rightDst.Dy()
}
for y := 0; y < h; y++ {
for y := range min(leftDst.Dy(), rightDst.Dy()) {
yy := leftDst.Min.Y + y
l := sampleColorMapped(img, leftDst, leftSrc, leftDst.Max.X-1, yy)
r := sampleColorMapped(img, rightDst, rightSrc, rightDst.Min.X, yy)
@@ -507,19 +412,15 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
}
}
}
for row := 0; row < gridSize-1; row++ {
for col := 0; col < gridSize; col++ {
for row := range gridSize - 1 {
for col := range gridSize {
topIdx := row*gridSize + col
bottomIdx := (row+1)*gridSize + col
topDst := sliderTileRect(bounds, gridSize, topIdx)
bottomDst := sliderTileRect(bounds, gridSize, bottomIdx)
topSrc := sliderTileRect(bounds, gridSize, mapping[topIdx])
bottomSrc := sliderTileRect(bounds, gridSize, mapping[bottomIdx])
w := topDst.Dx()
if bottomDst.Dx() < w {
w = bottomDst.Dx()
}
for x := 0; x < w; x++ {
for x := range min(topDst.Dx(), bottomDst.Dx()) {
xx := topDst.Min.X + x
t := sampleColorMapped(img, topDst, topSrc, xx, topDst.Max.Y-1)
b := sampleColorMapped(img, bottomDst, bottomSrc, xx, bottomDst.Min.Y)
@@ -533,37 +434,21 @@ func seamScoreRGBText(img image.Image, gridSize int, mapping []int) (int64, floa
return rgbScore, textScore
}
func sampleColorMapped(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) color.Color {
dx := dstRect.Dx()
if dx < 1 {
dx = 1
}
dy := dstRect.Dy()
if dy < 1 {
dy = 1
}
func sampleColorMapped(img image.Image, dstRect, srcRect image.Rectangle, dstX, dstY int) color.Color {
dx := max(dstRect.Dx(), 1)
dy := max(dstRect.Dy(), 1)
sx := srcRect.Min.X + (dstX-dstRect.Min.X)*srcRect.Dx()/dx
sy := srcRect.Min.Y + (dstY-dstRect.Min.Y)*srcRect.Dy()/dy
return img.At(sx, sy)
}
func sampleLumaMapped(img image.Image, dstRect image.Rectangle, srcRect image.Rectangle, dstX int, dstY int) uint8 {
func sampleLumaMapped(img image.Image, dstRect, srcRect image.Rectangle, dstX, dstY int) uint8 {
c := sampleColorMapped(img, dstRect, srcRect, dstX, dstY)
r, g, b, _ := c.RGBA()
y := (299*(r>>8) + 587*(g>>8) + 114*(b>>8)) / 1000
if y > 255 {
y = 255
}
y := min((299*(r>>8)+587*(g>>8)+114*(b>>8))/1000, 255)
return uint8(y) //nolint:gosec // bounded above by 255
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func absInt(v int) int {
if v < 0 {
return -v
@@ -571,7 +456,7 @@ func absInt(v int) int {
return v
}
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
func sliderTileRect(bounds image.Rectangle, gridSize, index int) image.Rectangle {
row := index / gridSize
col := index % gridSize
x0 := bounds.Min.X + col*bounds.Dx()/gridSize
@@ -1,26 +1,9 @@
package captcha
// Телеметрия captcha.
//
// Виджет заводит setInterval(sensors_delay) и на каждом тике кладёт в аккумулятор
// navigator.connection.rtt/downlink. Акселерометр пишется отдельным потоком
// событий Generic Sensor API с частотой 1000/sensors_delay.
//
// Указатель (cursor - мышь, taps - палец) пишется только через externalCoords, а
// их заполняет либо нативный бридж мобильного SDK, либо хост-страница, если
// виджет открыт в iframe. Мы ходим на captcha прямой навигацией, без хоста, -
// в этом режиме виджет живого браузера оставляет оба массива пустыми.
//
// В captchaNotRobot.check уходят все семь массивов, даже пустые: значения,
// которых браузер не дал, виджет просто не кладёт в массив, но сам ключ
// отправляет всегда. Отсутствие ключа - то, чего живой браузер сделать не может.
import (
"encoding/json"
"math"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
"github.com/samosvalishe/free-turn-proxy/internal/randx"
)
@@ -28,27 +11,19 @@ const (
sensorDelayDefault = 100 * time.Millisecond
sensorDelayFloor = 20 * time.Millisecond
sensorDelayCeil = 2 * time.Second
// sensorTicksMax ограничивает размер выборки: виджет режет аккумулятор по
// 900 КБ, до такого объёма наши сессии не доживают.
// Виджет режет аккумулятор по 900 КБ, до такого объёма сессии не доживают.
sensorTicksMax = 600
emptyArray = "[]"
)
const (
sensorAccelerometer = "accelerometer"
sensorCursor = "cursor"
sensorTaps = "taps"
)
// sensorConfig - что и как часто слушать; приходит из captchaNotRobot.settings.
// sensorConfig - период таймера телеметрии; приходит из captchaNotRobot.settings.
type sensorConfig struct {
delay time.Duration
cursor bool
taps bool
accelerometer bool
delay time.Duration
}
func defaultSensorConfig() sensorConfig {
return sensorConfig{delay: sensorDelayDefault, cursor: true, taps: true, accelerometer: true}
return sensorConfig{delay: sensorDelayDefault}
}
func parseSensorConfig(raw map[string]any) sensorConfig {
@@ -60,93 +35,46 @@ func parseSensorConfig(raw map[string]any) sensorConfig {
if ms, hasDelay := resp["sensors_delay"].(float64); hasDelay && ms > 0 {
cfg.delay = min(max(time.Duration(ms)*time.Millisecond, sensorDelayFloor), sensorDelayCeil)
}
list, ok := resp["bridge_sensors_list"].([]any)
if !ok {
return cfg
}
cfg.cursor, cfg.taps, cfg.accelerometer = false, false, false
for _, item := range list {
switch name, _ := item.(string); name {
case sensorCursor:
cfg.cursor = true
case sensorTaps:
cfg.taps = true
case sensorAccelerometer:
cfg.accelerometer = true
}
}
return cfg
}
type point struct {
X int `json:"x"`
Y int `json:"y"`
}
type vec3 struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
// analytics - аккумулятор виджета целиком.
// analytics - аккумулятор виджета; наполняется только downlink.
type analytics struct {
accelerometer []vec3
gyroscope []vec3
motion []vec3
cursor []point
taps []point
connRtt []int
connDownlink []float64
connDownlink []float64
}
// fields отдаёт массивы в порядке ключей аккумулятора.
// fields сохраняет порядок ключей виджета; ненаполняемые массивы уходят пустыми.
func (a analytics) fields() [][2]string {
return [][2]string{
{sensorAccelerometer, jsonArray(a.accelerometer)},
{"gyroscope", jsonArray(a.gyroscope)},
{"motion", jsonArray(a.motion)},
{sensorCursor, jsonArray(a.cursor)},
{sensorTaps, jsonArray(a.taps)},
{"connectionRtt", jsonArray(a.connRtt)},
{"connectionDownlink", jsonArray(a.connDownlink)},
{"accelerometer", emptyArray},
{"gyroscope", emptyArray},
{"motion", emptyArray},
{"cursor", emptyArray},
{"taps", emptyArray},
{"connectionRtt", emptyArray},
{"connectionDownlink", downlinkArray(a.connDownlink)},
}
}
func jsonArray[T any](values []T) string {
func downlinkArray(values []float64) string {
if len(values) == 0 {
return "[]"
return emptyArray
}
data, err := json.Marshal(values)
if err != nil {
return "[]"
return emptyArray
}
return string(data)
}
// gesture - движение указателя, показанное виджету перед check.
type gesture struct {
from point
to point
move time.Duration // само перетаскивание/подведение
settle time.Duration // пауза в конечной точке до отправки check
}
// buildAnalytics собирает аккумулятор так, как его собрал бы живой браузер за
// elapsed с момента ответа settings: тиков ровно столько, сколько успел сделать
// таймер, а непокрытые персоной сенсоры остаются пустыми.
func buildAnalytics(p browserprofile.Profile, cfg sensorConfig, elapsed time.Duration) analytics {
// buildAnalytics: тиков столько, сколько таймер успел сделать за elapsed с
// момента ответа settings.
func buildAnalytics(cfg sensorConfig, downlink float64, elapsed time.Duration) analytics {
ticks := ticksIn(elapsed, cfg.delay)
if ticks == 0 {
// Таймер не успел тикнуть ни разу - живой виджет отправил бы пустые массивы.
return analytics{}
}
out := analytics{}
out.connRtt, out.connDownlink = sampleConnection(p.IsMobile(), ticks)
if p.Accelerometer() && cfg.accelerometer {
out.accelerometer = sampleAccelerometer(ticks)
}
return out
return analytics{connDownlink: sampleDownlink(downlink, ticks)}
}
func ticksIn(d time.Duration, delay time.Duration) int {
@@ -156,105 +84,27 @@ func ticksIn(d time.Duration, delay time.Duration) int {
return min(int(d/delay), sensorTicksMax)
}
// sampleConnection повторяет NetworkInformation: Chrome огрубляет rtt до кратных
// 25 мс и downlink до шага 0.05 с потолком 10 Мбит/с, а между тиками оценка
// меняется редко.
func sampleConnection(mobile bool, ticks int) ([]int, []float64) {
// Оценка Chrome квантована шагом 0.05, считаем в этих шагах.
const (
downlinkMinSteps = 160 // 8.0
downlinkMaxSteps = 200 // 10.0
)
// sessionDownlink: у живого виджета значение постоянно ([10,10,10]), но жёсткая
// константа была бы отпечатком.
func sessionDownlink() float64 {
steps := downlinkMinSteps + randx.Intn(downlinkMaxSteps-downlinkMinSteps+1)
// Делим, а не умножаем на 0.05: иначе выходит соседний double и JSON печатает 9.200000000000001.
return float64(steps*5) / 100
}
func sampleDownlink(value float64, ticks int) []float64 {
if ticks <= 0 {
return nil, nil
}
rtt := 25 * (2 + randx.Intn(5)) // 50..150
downlink := 5.0 + randx.Float64()*5.0
if mobile {
rtt = 25 * (2 + randx.Intn(3)) // 50..100
downlink = 1.4 + randx.Float64()*0.7
}
rttOut := make([]int, 0, ticks)
downlinkOut := make([]float64, 0, ticks)
for range ticks {
if randx.Intn(12) == 0 {
rtt = max(25, rtt+25*(randx.Intn(3)-1))
downlink = math.Max(0.05, downlink+(randx.Float64()-0.5))
}
rttOut = append(rttOut, rtt)
downlinkOut = append(downlinkOut, quantizeDownlink(downlink))
}
return rttOut, downlinkOut
}
func quantizeDownlink(v float64) float64 {
return math.Min(10, math.Round(v/0.05)*0.05)
}
// sampleAccelerometer генерирует показания покоящегося телефона: вектор длиной g
// в наклонённой системе координат плюс шум сенсора.
func sampleAccelerometer(count int) []vec3 {
if count <= 0 {
return nil
}
const g = 9.81
bx := -2.5 + randx.Float64()*5.0
by := 4.0 + randx.Float64()*4.0
bz := g
if rem := g*g - bx*bx - by*by; rem > 0 {
bz = math.Sqrt(rem)
}
jitter := func() float64 { return (randx.Float64() - 0.5) * 0.2 }
out := make([]vec3, 0, count)
for range count {
out = append(out, vec3{
X: round1(bx + jitter()),
Y: round1(by + jitter()),
Z: round1(bz + jitter()),
})
out := make([]float64, ticks)
for i := range out {
out[i] = value
}
return out
}
func round1(v float64) float64 { return math.Round(v*10) / 10 }
// widgetLayout - геометрия виджета в координатах страницы. Виджет центрируется в
// viewport персоны, поэтому координаты указателя обязаны лежать внутри него.
type widgetLayout struct {
center point
sliderY int
sliderLeft int
sliderRight int
checkbox point
}
func layoutFor(p browserprofile.Profile) widgetLayout {
w, h := p.Viewport.W, p.Viewport.H
center := point{X: w / 2, Y: h / 2}
track := min(280, w-80)
return widgetLayout{
center: center,
sliderY: center.Y + min(150, h/4),
sliderLeft: center.X - track/2,
sliderRight: center.X + track/2,
checkbox: point{X: center.X - min(110, w/4), Y: center.Y},
}
}
// sliderTarget - позиция ручки для кандидата index из count.
func (l widgetLayout) sliderTarget(index, count int) point {
if count < 2 {
count = 2
}
index = min(max(index, 1), count)
span := l.sliderRight - l.sliderLeft
x := l.sliderLeft + span*(index-1)/(count-1)
return point{X: x + randx.Intn(7) - 3, Y: l.sliderY + randx.Intn(7) - 3}
}
// approachFrom - откуда указатель приходит к цели: мышь появляется в стороне,
// палец касается почти вплотную.
func approachFrom(p browserprofile.Profile, l widgetLayout, target point) point {
if p.Touch() {
return point{X: l.sliderLeft + randx.Intn(24) - 12, Y: target.Y + randx.Intn(16) - 8}
}
return point{
X: min(max(l.center.X+randx.Intn(320)-160, 8), p.Viewport.W-8),
Y: min(max(l.center.Y+randx.Intn(240)-120, 8), p.Viewport.H-8),
}
}
@@ -4,72 +4,29 @@ import (
"encoding/json"
"testing"
"time"
"github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/browserprofile"
)
func testPersona(plat browserprofile.Platform) browserprofile.Profile {
return browserprofile.For(plat, browserprofile.Identity{Seed: "telemetry-test"})
}
// Виджет всегда сериализует все семь ключей аккумулятора: пустой массив - валидный
// ответ браузера, отсутствующий ключ - невозможное состояние.
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(), 4*time.Second).fields()
if len(fields) != len(want) {
t.Fatalf("%s: %d fields, want %d", plat, len(fields), len(want))
}
for i, kv := range fields {
if kv[0] != want[i] {
t.Fatalf("%s: field %d = %q, want %q", plat, i, kv[0], want[i])
}
if kv[1] == "" {
t.Fatalf("%s: field %q empty, want at least []", plat, kv[0])
}
}
// Эталон снят с успешного check живого браузера через manual-прокси: непустой
// там ровно один ключ.
func TestAnalyticsMatchesLiveBrowser(t *testing.T) {
want := [][2]string{
{"accelerometer", "[]"},
{"gyroscope", "[]"},
{"motion", "[]"},
{"cursor", "[]"},
{"taps", "[]"},
{"connectionRtt", "[]"},
{"connectionDownlink", "[10,10,10]"},
}
}
cfg := sensorConfig{delay: 200 * time.Millisecond}
got := buildAnalytics(cfg, 10, 600*time.Millisecond).fields()
// Телеметрия не может содержать данных, которых персона физически не соберёт.
func TestAnalyticsRespectsPersonaCapabilities(t *testing.T) {
cases := []struct {
plat browserprofile.Platform
wantAccel bool
}{
{browserprofile.Desktop, false},
{browserprofile.Mobile, true},
if len(got) != len(want) {
t.Fatalf("%d fields, want %d", len(got), len(want))
}
for _, tc := range cases {
p := testPersona(tc.plat)
data := buildAnalytics(p, defaultSensorConfig(), 4*time.Second)
if len(data.connRtt) == 0 {
t.Fatalf("%s: NetworkInformation отсутствует", tc.plat)
}
if len(data.connRtt) != len(data.connDownlink) {
t.Fatalf("%s: rtt %d != downlink %d", tc.plat, len(data.connRtt), len(data.connDownlink))
}
if got := len(data.accelerometer) > 0; got != tc.wantAccel {
t.Fatalf("%s: accelerometer present = %v", tc.plat, got)
}
if len(data.gyroscope) != 0 || len(data.motion) != 0 {
t.Fatalf("%s: web-персона не может дать gyroscope/motion", 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))
for i, kv := range got {
if kv != want[i] {
t.Fatalf("field %d = %v, want %v", i, kv, want[i])
}
}
}
@@ -77,43 +34,19 @@ func TestAnalyticsLeavesPointerEmpty(t *testing.T) {
// Число сэмплов задаёт таймер виджета: сколько тиков уместилось в реальное время
// сессии, столько и точек.
func TestAnalyticsSampleCountFollowsElapsed(t *testing.T) {
cfg := sensorConfig{delay: 100 * time.Millisecond, cursor: true, taps: true, accelerometer: true}
cfg := sensorConfig{delay: 100 * time.Millisecond}
elapsed := 5 * time.Second
ticks := int(elapsed / cfg.delay)
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)
}
}
}
// Сервер сам говорит, что слушает: неслушаемый сенсор остаётся пустым.
func TestAnalyticsHonoursSensorList(t *testing.T) {
raw := map[string]any{"response": map[string]any{
"sensors_delay": float64(250),
"bridge_sensors_list": []any{"cursor"},
}}
cfg := parseSensorConfig(raw)
if cfg.delay != 250*time.Millisecond || !cfg.cursor || cfg.taps || cfg.accelerometer {
t.Fatalf("sensor config = %+v", cfg)
}
p := testPersona(browserprofile.Mobile)
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))
if got := len(buildAnalytics(cfg, 10, elapsed).connDownlink); got != int(elapsed/cfg.delay) {
t.Fatalf("downlink = %d, want %d", got, int(elapsed/cfg.delay))
}
}
// Сессия короче одного тика: массивы пустые, но ключи на месте.
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, 500*time.Millisecond)
for _, kv := range data.fields() {
if kv[1] != "[]" {
cfg := sensorConfig{delay: 2 * time.Second}
for _, kv := range buildAnalytics(cfg, 10, 500*time.Millisecond).fields() {
if kv[1] != emptyArray {
t.Fatalf("field %q = %s, want []", kv[0], kv[1])
}
}
@@ -130,44 +63,45 @@ func TestParseSensorConfigFallsBackToDefaults(t *testing.T) {
}
}
// Chrome огрубляет NetworkInformation: rtt кратен 25 мс, downlink - шагу 0.05 и
// не превышает 10 Мбит/с.
func TestConnectionSamplesQuantized(t *testing.T) {
rtt, downlink := sampleConnection(false, 40)
for i := range rtt {
if rtt[i] <= 0 || rtt[i]%25 != 0 {
t.Fatalf("rtt[%d] = %d, want positive multiple of 25", i, rtt[i])
}
if downlink[i] <= 0 || downlink[i] > 10 {
t.Fatalf("downlink[%d] = %v, want (0, 10]", i, downlink[i])
}
if steps := downlink[i] / 0.05; steps-float64(int(steps+0.5)) > 1e-9 {
t.Fatalf("downlink[%d] = %v, want multiple of 0.05", i, downlink[i])
func TestParseSensorConfigReadsDelay(t *testing.T) {
cfg := parseSensorConfig(map[string]any{"response": map[string]any{"sensors_delay": float64(250)}})
if cfg.delay != 250*time.Millisecond {
t.Fatalf("delay = %s, want 250ms", cfg.delay)
}
}
// Оценка не меняется за время captcha: замеры дали [10,10,10] и [9.6,9.6,9.6].
func TestDownlinkIsConstantWithinSession(t *testing.T) {
for _, v := range sampleDownlink(9.6, 16) {
if v != 9.6 {
t.Fatalf("downlink = %v, want 9.6", v)
}
}
}
func TestAccelerometerSamplesAreGravity(t *testing.T) {
samples := sampleAccelerometer(12)
if len(samples) != 12 {
t.Fatalf("samples = %d, want 12", len(samples))
}
for _, s := range samples {
if mag := s.X*s.X + s.Y*s.Y + s.Z*s.Z; mag < 64 || mag > 144 {
t.Fatalf("magnitude^2 = %.1f, not gravity-like", mag)
// JSON.stringify печатает число кратчайшей записью; 9.200000000000001 в выдаче
// живого браузера невозможен и сам был бы маркером.
func TestSessionDownlinkSerializesShort(t *testing.T) {
for range 500 {
v := sessionDownlink()
if v < 8 || v > 10 {
t.Fatalf("downlink = %v, want [8, 10]", v)
}
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
if len(raw) > 4 {
t.Fatalf("downlink serialized as %s, want at most 4 chars", raw)
}
}
}
func TestJSONArrayEmptyIsBrackets(t *testing.T) {
if got := jsonArray[point](nil); got != "[]" {
t.Fatalf("jsonArray(nil) = %q, want []", got)
func TestDownlinkArray(t *testing.T) {
if got := downlinkArray(nil); got != emptyArray {
t.Fatalf("downlinkArray(nil) = %q, want []", got)
}
var pts []point
if err := json.Unmarshal([]byte(jsonArray([]point{{X: 1, Y: 2}})), &pts); err != nil {
t.Fatal(err)
}
if len(pts) != 1 || pts[0].X != 1 || pts[0].Y != 2 {
t.Fatalf("round-trip = %+v", pts)
if got := downlinkArray([]float64{10, 9.6}); got != "[10,9.6]" {
t.Fatalf("downlinkArray = %s, want [10,9.6]", got)
}
}