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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user