mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a6b0e0e85 | |||
| 25451e8e25 | |||
| 5bc164f78d | |||
| 95803f481a | |||
| 235ab1a646 |
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "2.1.1"
|
||||
".": "2.1.2"
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user