5 Commits

Author SHA1 Message Date
Anton e009db0b9a Merge pull request #45 from samosvalishe/release-please--branches--master
chore(master): release 1.7.3
2026-07-12 19:15:19 +03:00
Anton 321a0995c3 chore(master): release 1.7.3 2026-07-12 19:13:46 +03:00
samosvalishe 6f934f10f4 chore: fmt autofix 2026-07-12 19:13:26 +03:00
samosvalishe ea410a044a fix(deps): обновить Go 1.26.4 -> 1.26.5 2026-07-12 19:10:27 +03:00
samosvalishe b4189f8f43 fix(captcha): адаптировать авторешение под SPA-капчу VK 2026-07-12 18:54:08 +03:00
7 changed files with 136 additions and 258 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: true
env:
GO_VERSION: "1.26.4"
GO_VERSION: "1.26.5"
GOLANGCI_VERSION: v2.12.2
GOVULN_VERSION: v1.1.4
GORELEASER_VERSION: v2.15.4
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
packages: write
env:
GO_VERSION: "1.26.4"
GO_VERSION: "1.26.5"
GORELEASER_VERSION: v2.15.4
GOMOBILE_VERSION: latest
TASK_VERSION: 3.51.1
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.7.2"
".": "1.7.3"
}
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## [1.7.3](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.2...v1.7.3) (2026-07-12)
### Bug Fixes
* **captcha:** адаптировать авторешение под SPA-капчу VK ([b4189f8](https://github.com/samosvalishe/free-turn-proxy/commit/b4189f8f4317b1c5c919ab8e0e183ebfb927d8be))
* **deps:** обновить Go 1.26.4 -> 1.26.5 ([ea410a0](https://github.com/samosvalishe/free-turn-proxy/commit/ea410a044ad1ebd3fbc14286f41213357a6732f4))
## [1.7.2](https://github.com/samosvalishe/free-turn-proxy/compare/v1.7.1...v1.7.2) (2026-07-07)
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/samosvalishe/free-turn-proxy
go 1.26.4
go 1.26.5
require (
github.com/bogdanfinn/fhttp v0.6.8
+71 -175
View File
@@ -17,7 +17,6 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
fhttp "github.com/bogdanfinn/fhttp"
@@ -36,7 +35,6 @@ func SetLogger(l logx.Logger) { Log = logx.OrNop(l) }
const (
captchaAPIVersion = "5.131"
captchaScriptVersion = "1.1.1374"
captchaAPIOrigin = "https://id.vk.ru"
captchaDomain = "vk.ru"
captchaDeviceInfo = `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1147,"innerHeight":945,"devicePixelRatio":1,"language":"ru-RU","languages":["ru-RU"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":16,"connectionEffectiveType":"4g","notificationsPermission":"denied"}`
@@ -46,19 +44,13 @@ const (
var (
reCaptchaPowInput = regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
reCaptchaDifficulty = regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`)
reCaptchaWindowInit = regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;`)
reCaptchaScriptSrc = regexp.MustCompile(`src="(https://[^"]+not_robot_captcha[^"]+)"`)
reCaptchaDebugInfo = regexp.MustCompile(`debug_info:(?:[^"]*\|\|)?"([a-fA-F0-9]{64})"`)
reCaptchaHex64 = regexp.MustCompile(`"([a-fA-F0-9]{64})"`)
reCaptchaVersion = regexp.MustCompile(`vkid/([0-9.]*)/not_robot_captcha\.js`)
reCaptchaDebugUUID = regexp.MustCompile(`[a-zA-Z0-9_]{6,}:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"`)
errCaptchaRateLimit = errors.New("captcha session rate limit reached")
errCaptchaBot = errors.New("captcha bot challenge")
captchaMaxAttempts = 2
captchaDebugCache sync.Map // scriptURL -> string
captchaVersionSeen sync.Map // script version -> struct{} (drift warned once)
captchaHeaderOrder = []string{
"host",
"content-length",
@@ -80,15 +72,6 @@ var (
captchaPHeaderOrder = []string{":method", ":path", ":authority", ":scheme"}
)
type captchaInit struct {
Data captchaInitData `json:"data"`
}
type captchaInitData struct {
ShowCaptchaType string `json:"show_captcha_type"`
CaptchaSettings []captchaInitSetting `json:"captcha_settings"`
}
type captchaInitSetting struct {
Type string `json:"type"`
Settings string `json:"settings"`
@@ -98,8 +81,7 @@ type captchaInitSetting struct {
type captchaPage struct {
PowInput string
PowDifficulty int
ScriptURL string
Init *captchaInit
DebugInfo string
}
type captchaCheck struct {
@@ -184,60 +166,46 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
page, err := parseCaptchaPage(html)
if err != nil {
s.dumpChunks("html", html)
return "", err
}
s.logCaptchaPage(page)
if page.PowInput == "" {
return "", errors.New("failed to find PoW settings")
}
sliderContent := captchaContentRef{}
if page.Init != nil {
for _, setting := range page.Init.Data.CaptchaSettings {
if setting.Type == "slider" {
sliderContent = setting.contentRef()
}
}
}
if page.Init != nil && page.Init.Data.ShowCaptchaType == "slider" && sliderContent.Value == "" {
return "", errors.New("failed to find slider captcha settings key")
}
s.logger().Debugf("[Captcha] solving pow difficulty=%d", page.PowDifficulty)
hash := solveCaptchaPoW(s.ctx, page.PowInput, page.PowDifficulty)
if hash == "" {
return "", errors.New("captcha pow failed")
}
s.logger().Debugf("[Captcha] pow solved hash_len=%d", len(hash))
base := s.captchaBaseValues(captchaErr.SessionToken)
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
debugInfo := page.DebugInfo
if debugInfo == "" {
debugInfo = newCaptchaUUID()
s.logger().Warnf("[Captcha] debug_info uuid not in HTML; generated fallback")
}
browserFP, err := captchaBrowserFP()
if err != nil {
return "", err
}
browserFPSource := "generated"
if s.savedProfile != nil && strings.TrimSpace(s.savedProfile.BrowserFp) != "" {
browserFP = s.savedProfile.BrowserFp
browserFPSource = "saved"
}
s.logger().Debugf("[Captcha] browser_fp source=%s len=%d", browserFPSource, len(browserFP))
s.warnVersionDrift(page.ScriptURL)
base := s.captchaBaseValues(captchaErr.SessionToken)
if _, settingsErr := s.captchaRequest("captchaNotRobot.settings", base); settingsErr != nil {
return "", fmt.Errorf("captcha settings failed: %w", settingsErr)
}
debugInfo, err := s.fetchDebugInfo(page.ScriptURL)
initResp, err := s.captchaRequest("captchaNotRobot.initSession", [][2]string{
{"session_token", captchaErr.SessionToken},
{"domain", s.domain},
{"lang", "0"},
})
if err != nil {
return "", fmt.Errorf("failed to fetch debug info: %w (script_version=%s)", err, captchaScriptVersion)
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))
showType := ""
if page.Init != nil {
showType = page.Init.Data.ShowCaptchaType
}
s.logger().Debugf("[Captcha] solving show_type=%s", showType)
var token string
switch showType {
case "slider":
@@ -257,6 +225,36 @@ func (s *captchaSession) solveOnce(captchaErr *Error) (string, error) {
return token, nil
}
func parseCaptchaInitSession(raw map[string]any) (string, captchaContentRef) {
resp, ok := raw["response"].(map[string]any)
if !ok {
return "", captchaContentRef{}
}
showType := captchaStringifyAny(resp["show_captcha_type"])
content := captchaContentRef{}
if data, err := json.Marshal(resp["content_settings"]); err == nil {
var settings []captchaInitSetting
if json.Unmarshal(data, &settings) == nil {
for _, setting := range settings {
if setting.Type == "slider" {
content = setting.contentRef()
}
}
}
}
return showType, content
}
func newCaptchaUUID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return ""
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// captchaContentRef несёт значение настроек слайдера.
type captchaContentRef struct {
Source string
@@ -315,101 +313,38 @@ func (s *captchaSession) fetchCaptchaHTML(redirectURI string) (string, error) {
return string(body), nil
}
func (s *captchaSession) fetchDebugInfo(scriptURL string) (string, error) {
if cached, ok := captchaDebugCache.Load(scriptURL); ok {
if cachedDebugInfo, ok := cached.(string); ok {
return cachedDebugInfo, nil
}
captchaDebugCache.Delete(scriptURL)
func (s *captchaSession) dumpChunks(label, body string) {
const chunk = 1800
total := (len(body) + chunk - 1) / chunk
l := s.logger()
l.Debugf("[Captcha][DUMP] %s bytes=%d chunks=%d", label, len(body), total)
for i := range total {
start := i * chunk
end := min(start+chunk, len(body))
l.Debugf("[Captcha][DUMP %s %d/%d] %s", label, i+1, total, body[start:end])
}
body, err := s.doRaw(fhttp.MethodGet, scriptURL, nil, map[string]string{
"Accept": "text/javascript,*/*",
"Referer": captchaAPIOrigin + "/",
})
if err != nil {
return "", err
}
v, usedFallback, err := extractDebugInfo(body)
if err != nil {
return "", err
}
if usedFallback {
s.logger().Warnf("[Captcha] debug_info primary pattern missed; used windowed fallback (script format drift)")
}
captchaDebugCache.Store(scriptURL, v)
s.logger().Debugf("[Captcha] debug_info fetched url=%s", scriptURL)
return v, nil
}
// warnVersionDrift предупреждает один раз на версию, если VK отдал скрипт новее
// оттестированного baseline: wire мог измениться, надо перепроверить против live.
func (s *captchaSession) warnVersionDrift(scriptURL string) {
m := reCaptchaVersion.FindStringSubmatch(scriptURL)
if len(m) < 2 || m[1] == "" || m[1] == captchaScriptVersion {
return
}
if _, seen := captchaVersionSeen.LoadOrStore(m[1], struct{}{}); seen {
return
}
s.logger().Warnf("[Captcha] script version %s differs from tested %s; wire unverified - re-check if BOT-rejections rise", m[1], captchaScriptVersion)
}
// extractDebugInfo достаёт fallback-константу debug_info из тела скрипта.
// VK-формат: `debug_info:(...window.vk.X)||"<64hex>"`; имя window.vk.X плавает
// от билда к билду, поэтому основной паттерн ловит только hash-литерал.
// usedFallback=true - основной паттерн промазал, взят первый 64-hex в окне после
// `debug_info` (страховка смены обёртки). Иначе - ошибка со снапшотом окна.
func extractDebugInfo(body []byte) (value string, usedFallback bool, err error) {
if m := reCaptchaDebugInfo.FindSubmatch(body); len(m) >= 2 {
return string(m[1]), false, nil
}
idx := bytes.Index(body, []byte("debug_info"))
if idx < 0 {
return "", false, errors.New("debug_info marker not found in script")
}
end := idx + 256
if end > len(body) {
end = len(body)
}
if m := reCaptchaHex64.FindSubmatch(body[idx:end]); len(m) >= 2 {
return string(m[1]), true, nil
}
return "", false, fmt.Errorf("debug_info match not found near: %q", body[idx:end])
}
func parseCaptchaPage(html string) (*captchaPage, error) {
page := &captchaPage{}
match := reCaptchaWindowInit.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha init json not found")
}
var init captchaInit
if err := json.Unmarshal([]byte(match[1]), &init); err != nil {
return nil, fmt.Errorf("captcha init json parse: %w", err)
}
page.Init = &init
match = reCaptchaScriptSrc.FindStringSubmatch(html)
if len(match) < 2 {
return nil, errors.New("captcha script url not found")
}
page.ScriptURL = match[1]
if m := reCaptchaPowInput.FindStringSubmatch(html); len(m) >= 2 {
page.PowInput = m[1]
}
if page.PowInput == "" {
return page, nil
if m := reCaptchaDebugUUID.FindStringSubmatch(html); len(m) >= 2 {
page.DebugInfo = m[1]
}
match = reCaptchaDifficulty.FindStringSubmatch(html)
if len(match) < 2 {
m := reCaptchaPowInput.FindStringSubmatch(html)
if len(m) < 2 {
return nil, errors.New("captcha powInput not found")
}
page.PowInput = m[1]
dm := reCaptchaDifficulty.FindStringSubmatch(html)
if len(dm) < 2 {
return nil, errors.New("captcha difficulty const not found")
}
difficulty, err := strconv.Atoi(match[1])
difficulty, err := strconv.Atoi(dm[1])
if err != nil || difficulty <= 0 {
return nil, fmt.Errorf("invalid captcha difficulty %q", match[1])
return nil, fmt.Errorf("invalid captcha difficulty %q", dm[1])
}
page.PowDifficulty = difficulty
return page, nil
@@ -572,26 +507,12 @@ func solveCaptchaPoW(ctx context.Context, input string, difficulty int) string {
sum := sha256.Sum256(buf)
hashHex := hex.EncodeToString(sum[:])
if strings.HasPrefix(hashHex, target) {
return encodeCaptchaPoW(hashHex, nonce)
return hashHex
}
}
return ""
}
func encodeCaptchaPoW(hash string, nonce int) string {
payload, err := json.Marshal(struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
}{
Hash: hash,
Nonce: nonce,
})
if err != nil {
return ""
}
return "v2." + base64.RawURLEncoding.EncodeToString(payload)
}
func (s *captchaSession) doRaw(
method string,
endpoint string,
@@ -638,31 +559,6 @@ func (s *captchaSession) doRaw(
return data, readErr
}
func (s *captchaSession) logCaptchaPage(page *captchaPage) {
showType := ""
settingsSummary := "none"
if page.Init != nil {
showType = page.Init.Data.ShowCaptchaType
parts := make([]string, 0, len(page.Init.Data.CaptchaSettings))
for _, setting := range page.Init.Data.CaptchaSettings {
ref := setting.contentRef()
valueLen := 0
if ref.Value != "" {
valueLen = len(ref.Value)
}
parts = append(parts, fmt.Sprintf("%s:%s:%d", setting.Type, ref.Source, valueLen))
}
if len(parts) > 0 {
settingsSummary = strings.Join(parts, ",")
}
}
scriptVersion := ""
if m := reCaptchaVersion.FindStringSubmatch(page.ScriptURL); len(m) > 1 {
scriptVersion = m[1]
}
s.logger().Debugf("[Captcha] page parsed show_type=%q settings=%s pow_difficulty=%d script_version=%q script_url=%s", showType, settingsSummary, page.PowDifficulty, scriptVersion, captchaSafeURL(page.ScriptURL))
}
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))
@@ -1,7 +1,7 @@
package captcha
import (
"encoding/base64"
"context"
"encoding/json"
"strings"
"testing"
@@ -34,99 +34,73 @@ func TestCaptchaInitSettingContentRefLegacySettings(t *testing.T) {
}
}
func TestParseCaptchaPageSettingsKey(t *testing.T) {
html := `<html><head><script src="https://static.vk.ru/vkid/1.1.1359/not_robot_captcha.js"></script></head><body>
<script>
window.init = {"data":{"show_captcha_type":"slider","captcha_settings":[{"type":"slider","settings_key":"abc123"}]}};
const powInput = "input";
func TestParseCaptchaPageSPA(t *testing.T) {
html := `<html><head><script>
const powInput = "Pihj7tyAHFxdwm4t";
const difficulty = 2;
</script>
</script></head><body><div id="spa_root"></div>
<script>window.vk = { statsMeta: {"hash":"39RzeEGG2Qq7t4YUwG"}, brlefapmjnpg: "1d958dbe-0663-42f2-95e5-0e2780ed2f93", id: 0 };</script>
</body></html>`
page, err := parseCaptchaPage(html)
if err != nil {
t.Fatal(err)
}
if page.Init == nil || len(page.Init.Data.CaptchaSettings) != 1 {
t.Fatalf("captcha settings missing: %+v", page.Init)
if page.PowInput != "Pihj7tyAHFxdwm4t" || page.PowDifficulty != 2 {
t.Fatalf("pow parse = %q/%d", page.PowInput, page.PowDifficulty)
}
got := page.Init.Data.CaptchaSettings[0].contentRef()
if got.Source != "settings_key" || got.Value != "abc123" {
t.Fatalf("contentRef = %+v, want settings_key/abc123", got)
if page.DebugInfo != "1d958dbe-0663-42f2-95e5-0e2780ed2f93" {
t.Fatalf("debug_info = %q, want brlefapmjnpg uuid", page.DebugInfo)
}
}
func TestEncodeCaptchaPoW(t *testing.T) {
got := encodeCaptchaPoW("00abcdef", 147)
if !strings.HasPrefix(got, "v2.") {
t.Fatalf("pow = %q, want v2 prefix", got)
}
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got, "v2."))
if err != nil {
t.Fatal(err)
}
var decoded struct {
Hash string `json:"hash"`
Nonce int `json:"nonce"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Hash != "00abcdef" || decoded.Nonce != 147 {
t.Fatalf("decoded = %+v, want hash/nonce", decoded)
func TestParseCaptchaPageMissingPoW(t *testing.T) {
if _, err := parseCaptchaPage(`<html><body><div id="spa_root"></div></body></html>`); err == nil {
t.Fatal("expected error when powInput/difficulty absent")
}
}
func TestExtractDebugInfo(t *testing.T) {
const hash = "59f60d917b13be6a22c076adb2c17df37302c5314d8353a27e72f9fbcc9b4838"
tests := []struct {
name string
body string
want string
wantFallback bool
fail bool
}{
{
name: "primary vk format",
body: `x,debug_info:(null===(r=window.vk)||void 0===r?void 0:r.brlefapmjnpg)||"` + hash + `"});var a`,
want: hash,
},
{
name: "windowed fallback no or-wrapper",
body: `debug_info: someRuntimeCall(),foo:"` + hash + `"`,
want: hash,
wantFallback: true,
},
{
name: "no hash near marker",
body: `debug_info:window.vk.x||"tooshort"`,
fail: true,
},
{
name: "no marker",
body: `nothing here`,
fail: true,
},
func TestSolveCaptchaPoWRawHex(t *testing.T) {
got := solveCaptchaPoW(context.Background(), "input", 1)
if len(got) != 64 {
t.Fatalf("pow = %q, want 64-hex", got)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, usedFallback, err := extractDebugInfo([]byte(tt.body))
if tt.fail {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("extractDebugInfo = %q, want %q", got, tt.want)
}
if usedFallback != tt.wantFallback {
t.Fatalf("usedFallback = %v, want %v", usedFallback, tt.wantFallback)
}
})
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)
}
}
func TestParseCaptchaInitSession(t *testing.T) {
raw := map[string]any{"response": map[string]any{
"show_captcha_type": "slider",
"captcha_id": "cid",
"content_settings": []any{
map[string]any{"type": "slider", "settings_key": "sliderkey"},
map[string]any{"type": "sound", "settings_key": "soundkey"},
},
}}
showType, content := parseCaptchaInitSession(raw)
if showType != "slider" {
t.Fatalf("show_type = %q, want slider", showType)
}
if content.Value != "sliderkey" || content.Source != "settings_key" {
t.Fatalf("content = %+v, want sliderkey/settings_key", content)
}
}
func TestParseCaptchaInitSessionCheckbox(t *testing.T) {
raw := map[string]any{"response": map[string]any{
"show_captcha_type": "checkbox",
"content_settings": []any{
map[string]any{"type": "slider", "settings_key": "k"},
},
}}
showType, _ := parseCaptchaInitSession(raw)
if showType != "checkbox" {
t.Fatalf("show_type = %q, want checkbox", showType)
}
}