fix(yeeke): browser UA, captcha _t param and classified login failures (#336)

Align the yeeke client with the working reference demo/yeeke_demo.py and
the HAR: send a desktop Chrome User-Agent on every request and a _t
timestamp on randomImage. Business failures now surface as APIError with
yeeke's own short message; LoginWithOCR retries only captcha rejections
and stops at once on any other refusal (e.g. wrong password) instead of
burning attempts, and the final error reports how many captchas were
rejected or unreadable. Expired-login detection keeps mapping to
ErrSessionInvalid. Errors never include credentials, captcha text or token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-23 15:48:49 +08:00
co-authored by Claude Opus 5.5
parent e845651933
commit faac8bb155
2 changed files with 172 additions and 8 deletions
+59 -8
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
)
@@ -103,6 +104,9 @@ func (c *Client) do(ctx context.Context, method, path string, body any, query ur
return nil, e
}
req.Header.Set("Accept", "application/json")
// yeeke is driven through its web front end; a Go default user agent is
// treated differently from the browser (see demo/yeeke_demo.py and the HAR).
req.Header.Set("User-Agent", browserUserAgent)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
@@ -136,15 +140,48 @@ func (c *Client) do(ctx context.Context, method, path string, body any, query ur
return nil, e
}
if !env.Success {
if env.Code == 401 || strings.Contains(env.Message, "登录") || strings.Contains(env.Message, "token") {
return nil, ErrSessionInvalid
}
return nil, fmt.Errorf("yeeke request failed code=%d", env.Code)
return nil, &APIError{Code: env.Code, Message: env.Message}
}
return env.Result, nil
}
// browserUserAgent mirrors the desktop Chrome the yeeke web client runs in.
const browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
// APIError is a yeeke business failure (success=false). Message is yeeke's own
// short text (e.g. "验证码错误"); it never contains our credentials or token.
type APIError struct {
Code int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("yeeke request failed code=%d: %s", e.Code, safeMessage(e.Message))
}
// Is keeps the historical contract: an expired or missing login is reported as
// ErrSessionInvalid so callers can re-login.
func (e *APIError) Is(target error) bool {
if target != ErrSessionInvalid {
return false
}
return e.Code == 401 || strings.Contains(e.Message, "token") || strings.Contains(e.Message, "登录已过期") || strings.Contains(e.Message, "未登录") || strings.Contains(e.Message, "重新登录")
}
// isCaptchaRejected reports a wrong captcha, the only login failure worth
// retrying with a fresh image.
func (e *APIError) isCaptchaRejected() bool { return strings.Contains(e.Message, "验证码") }
func safeMessage(m string) string {
r := []rune(strings.TrimSpace(m))
if len(r) > 60 {
r = r[:60]
}
return string(r)
}
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, nil)
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, q)
if e != nil {
return nil, e
}
@@ -195,10 +232,11 @@ func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password s
if maxAttempts <= 0 || maxAttempts > 5 {
maxAttempts = 3
}
emptyOCR, captchaRejected := 0, 0
for i := 0; i < maxAttempts; i++ {
cap, e := c.FetchCaptcha(ctx)
if e != nil {
return nil, e
return nil, fmt.Errorf("yeeke 获取验证码失败: %w", e)
}
code, e := ocr.Recognize(ctx, cap.Image)
if e != nil {
@@ -206,13 +244,26 @@ func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password s
}
code = strings.TrimSpace(code)
if code == "" {
emptyOCR++
continue
}
if out, e := c.Login(ctx, username, password, code, cap.CheckKey); e == nil {
out, e := c.Login(ctx, username, password, code, cap.CheckKey)
if e == nil {
return out, nil
}
var apiErr *APIError
if errors.As(e, &apiErr) && apiErr.isCaptchaRejected() {
captchaRejected++
continue
}
// Anything else (wrong account/password, locked account, network) will
// not be fixed by another captcha; stop instead of burning attempts.
if errors.As(e, &apiErr) {
return nil, fmt.Errorf("yeeke 登录被拒绝: %s", safeMessage(apiErr.Message))
}
return nil, fmt.Errorf("yeeke 登录请求失败: %w", e)
}
return nil, fmt.Errorf("yeeke login failed after limited captcha attempts")
return nil, fmt.Errorf("yeeke login failed after limited captcha attempts (attempts=%d, captcha_rejected=%d, ocr_empty=%d)", maxAttempts, captchaRejected, emptyOCR)
}
func (c *Client) Login(ctx context.Context, username, password, captcha, checkKey string) (*LoginResult, error) {
@@ -3,8 +3,10 @@ package yeekeclient
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -97,3 +99,114 @@ func keysOnly(m map[string]any) []string {
}
return out
}
// Mirrors demo/yeeke_demo.py and the HAR: browser user agent on every call and
// a _t cache-buster on randomImage.
func TestRequestsCarryBrowserUserAgentAndCaptchaTimestamp(t *testing.T) {
var captchaQuery, captchaUA, loginUA string
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
captchaQuery, captchaUA = r.URL.RawQuery, r.UserAgent()
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
loginUA = r.UserAgent()
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}})
}
}))
defer s.Close()
c, _ := New(s.URL)
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(captchaQuery, "_t=") {
t.Fatalf("randomImage query = %q, want _t timestamp", captchaQuery)
}
for _, ua := range []string{captchaUA, loginUA} {
if !strings.Contains(ua, "Chrome/") {
t.Fatalf("user agent = %q, want browser UA", ua)
}
}
}
// A wrong captcha is retried with a fresh image; a wrong password is not.
func TestLoginWithOCRRetriesOnlyCaptchaRejections(t *testing.T) {
cases := []struct {
name string
responses []map[string]any
wantOK bool
wantLogins int
wantErrSubstr string
}{
{
name: "captcha wrong then ok",
responses: []map[string]any{
{"success": false, "code": 500, "message": "验证码错误"},
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
},
wantOK: true, wantLogins: 2,
},
{
name: "wrong password stops immediately",
responses: []map[string]any{
{"success": false, "code": 500, "message": "用户名或密码错误"},
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
},
wantLogins: 1, wantErrSubstr: "用户名或密码错误",
},
{
name: "captcha always wrong",
responses: []map[string]any{
{"success": false, "code": 500, "message": "验证码错误"},
{"success": false, "code": 500, "message": "验证码错误"},
{"success": false, "code": 500, "message": "验证码错误"},
},
wantLogins: 3, wantErrSubstr: "captcha_rejected=3",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
logins := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
resp := tc.responses[logins]
logins++
json.NewEncoder(w).Encode(resp)
}
}))
defer s.Close()
c, _ := New(s.URL)
_, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "S3cr3t-Do-Not-Leak", 3)
if (e == nil) != tc.wantOK {
t.Fatalf("err = %v, wantOK = %v", e, tc.wantOK)
}
if logins != tc.wantLogins {
t.Fatalf("login calls = %d, want %d", logins, tc.wantLogins)
}
if e != nil {
if !strings.Contains(e.Error(), tc.wantErrSubstr) {
t.Fatalf("err = %q, want it to contain %q", e, tc.wantErrSubstr)
}
if strings.Contains(e.Error(), "S3cr3t-Do-Not-Leak") || strings.Contains(e.Error(), "abcd") {
t.Fatalf("error leaks credentials or captcha text: %q", e)
}
}
})
}
}
func TestAPIErrorMapsExpiredLoginToSessionInvalid(t *testing.T) {
for msg, want := range map[string]bool{"token失效,请重新登录": true, "未登录": true, "验证码错误": false, "用户名或密码错误": false} {
if got := errors.Is(&APIError{Code: 500, Message: msg}, ErrSessionInvalid); got != want {
t.Fatalf("%q: errors.Is(ErrSessionInvalid) = %v, want %v", msg, got, want)
}
}
if !errors.Is(&APIError{Code: 401}, ErrSessionInvalid) {
t.Fatal("code 401 must map to ErrSessionInvalid")
}
}