继续补全 wss

This commit is contained in:
yml2213
2026-07-06 18:47:10 +08:00
parent 1d15fadfd9
commit 52015da857
7 changed files with 1566 additions and 26 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
虎牙活动积分兑换/抢购的 Go 复刻版。当前支持两种传输层:
- `http`:走 `https://cdnws.api.huya.com/`,兼容原有命令。
- `wss`:实验实现。浏览器手动抓包确认兑换链路会走 WSS,但当前 Go WSS 外层协议仍需按 TAF `WebSocketCommand` 重做。
- `wss`:实验实现。已按浏览器 HAR 改为 TAF `WebSocketCommand` 外层和 WSS 初始化流程,仍建议先用真实 Cookie 小流量验证。
## 构建
@@ -79,7 +79,7 @@ CLI 示例:
- `-pid`:奖品 PID。`exchange` 必填,`monitor` 可用 `-pid` 或 `-name`。
- `-cookie`:单账号 Cookie。
- `-cookie-file`:多账号 Cookie 文件。
- `-transport`:`http` 或 `wss`,默认 `http`;当前实际使用建议保持 `http`。
- `-transport`:`http` 或 `wss`,默认 `http`;WSS 仍为实验通道。
- `-wss-host`:WebSocket host。默认值来自 2026-07-06 手动抓包,失效时应从浏览器抓包或 `launch.queryHttpDns` 结果更新。
- `-timeout`:单次调用超时秒数。
- `-loops`、`-delay`、`-concurrency`:轮询次数、轮询间隔、并发账号数。
+16 -11
View File
@@ -10,13 +10,14 @@
- 浏览器同时建立了 3 条 `wss://77bc035c-ws.va.huya.com/` 连接。
- `scoreExchangePrize` 出现在 WSS 连接数据和静态 JS 中,没有出现在 `cdnws` POST 请求中。
- 当前 HTTP 版本可用,是因为 `webActUI` 的 WUP over HTTP 编码已经对齐。
- 当前 WSS 版本会超时,是因为 Go 里的 WSS 外层协议模型不符合浏览器 JS。
- Go 版 WSS 已改为浏览器同款 `WebSocketCommand` 外层,仍需真实 Cookie 联调确认服务端返回。
## 连接
- WebSocket URL:`wss://<host>/?baseinfo=<base64 taf>`
- 手动 HAR host:`77bc035c-ws.va.huya.com`
- host 来自 `launch.queryHttpDns` 或浏览器抓包,不应长期写死。
- 手动 HAR 中 `baseinfo.cookie` 与 `traceId` 为空,cookie 通过后续 `VerifyCookie` 命令发送。
- 握手启用 `permessage-deflate`。
- 浏览器请求扩展:`permessage-deflate; client_max_window_bits`
- 服务端返回扩展:`permessage-deflate;server_no_context_takeover;client_no_context_takeover`
@@ -65,6 +66,8 @@ ws.send(WebSocketCommand.writeTo(JceOutputStream))
- `6`:`EWSCmdS2C_HeartBeatAck`
- `10`:`EWSCmdC2S_VerifyCookieReq`
- `11`:`EWSCmdS2C_VerifyCookieRsp`
- `16`:`EWSCmdC2S_RegisterGroupReq`
- `17`:`EWSCmdS2C_RegisterGroupRsp`
- `20`:`EWSCmdC2S_HeartBeatReq`
- `21`:`EWSCmdS2C_HeartBeatRsp`
- `23`:`EWSCmdC2S_UpdateUserExpsReq`
@@ -86,11 +89,15 @@ ws.send(WebSocketCommand.writeTo(JceOutputStream))
浏览器 WSS session 的关键流程:
1. 连接 `wss://<host>/?baseinfo=<base64 taf>`。
2. 发送 `EWSCmdC2S_HeartBeatReq`。
3. 通过 WSS 调用 `launch.wsLaunch`。
4. `wsLaunch` 后触发 `sendWSUpdateUserInfoReq`。
5. `launch.queryHttpDns` 仍通过 HTTP `cdnws` 调用。
6. 后续 WUP 业务在连接可用时走 `signalSdk.sendWup`,连接不可用时可 fallback 到 `sendWupHttp`。
2. 发送 `EWSCmdC2S_RegisterGroupReq(16)`,默认 group 为 `live:0`。
3. 通过 WSS 调用 `launch.wsLaunch`,手动 HAR 中 requestId 为 `-1`。
4. 发送 `EWSCmdC2S_VerifyCookieReq(10)`,cookie 在这里进入 WSS 通道。
5. 通过 WSS 调用 `mobileui.getConfig`。
6. 发送 `EWSCmdC2S_UpdateUserExpsReq(23)`,请求体是空 map。
7. 再发送一次 `EWSCmdC2S_RegisterGroupReq(16)`。
8. 发送 `EWSCmdC2S_HeartBeatReq(20)`,后续维持心跳。
9. `launch.queryHttpDns` 仍通过 HTTP `cdnws` 调用。
10. 后续 WUP 业务在连接可用时走 `signalSdk.sendWup`,连接不可用时可 fallback 到 `sendWupHttp`。
响应应按 WUP `iRequestId` 匹配回调,不能只用 FIFO。
@@ -103,7 +110,7 @@ ws.send(WebSocketCommand.writeTo(JceOutputStream))
- `webActUI.getUserPrizeRecords`
- `webActUI.scoreExchangePrize`
WSS 版本仍是实验状态,需要按 `WebSocketCommand` 结构重写。
WSS 版本仍是实验状态,但外层 `WebSocketCommand`、初始化帧顺序和 requestId 匹配已按手动 HAR 修正。
抢购兑换请求结构:
@@ -130,8 +137,6 @@ tReq.isRole = 1
## 待完善
- 实现 TAF `WebSocketCommand` 编解码。
- WSS WUP response 按 `iRequestId` 匹配请求。
- 实现 `WSUpdateUserInfoReq`、心跳、verify cookie 等浏览器初始化步骤。
- 使用真实 Cookie 联调 `list/score/exchange`,确认服务端不再超时。
- 自动调用 `launch.queryHttpDns` 获取最新 WSS host。
- 增加基于真实抓包样本的 WSS 编解码单测。
- 补更多基于真实抓包摘要的 WSS 编解码单测。
+39
View File
@@ -99,3 +99,42 @@ func GenerateHTTPBaseinfo(uid int64, guid, cookie string) (string, error) {
}
return url.QueryEscape(base64.StdEncoding.EncodeToString(w.Bytes())), nil
}
// GenerateWSSBaseinfo 生成浏览器 WSS 握手里的 baseinfo。
//
// 手动抓包里 WSS 握手只带 uid/guid/ua/appSrc 等连接信息,cookie
// 通过后续 VerifyCookie 命令发送。
func GenerateWSSBaseinfo(uid int64, guid string) (string, error) {
w := taf.NewWriter()
if err := w.WriteInt64(0, uid); err != nil {
return "", err
}
if err := w.WriteString(1, guid); err != nil {
return "", err
}
if err := w.WriteString(2, HTTPHuyaUA); err != nil {
return "", err
}
if err := w.WriteString(3, "HUYA&ZH&2052"); err != nil {
return "", err
}
if err := w.WriteString(4, ""); err != nil {
return "", err
}
if err := w.WriteString(5, ""); err != nil {
return "", err
}
if err := w.WriteInt32(6, 0); err != nil {
return "", err
}
if err := w.WriteString(7, ""); err != nil {
return "", err
}
if err := w.WriteString(8, ""); err != nil {
return "", err
}
if err := w.WriteString(9, ""); err != nil {
return "", err
}
return url.QueryEscape(base64.StdEncoding.EncodeToString(w.Bytes())), nil
}
+64 -13
View File
@@ -22,8 +22,9 @@ const (
DefaultWSSOrigin = "https://zt.huya.com"
WSSCookieUA = "webh5&0.0.1&websocket&&diypc_52775"
WSSBizUA = "web&1.0.0&huya"
WSSInitUA = "webh5&0.0.1&websocket&&h5_/index.html"
WSSBizUA = WSSCookieUA
WSSInitUA = WSSCookieUA
WSSGroupID = "live:0"
)
type WSSClient struct {
@@ -136,7 +137,7 @@ func (c *WSSClient) dialSession(ctx context.Context, uid int64, cookie string) (
}
guid := CookieValue(cookie, "guid")
baseinfo, err := GenerateHTTPBaseinfo(uid, guid, NormalizeWSSCookie(cookie))
baseinfo, err := GenerateWSSBaseinfo(uid, guid)
if err != nil {
return nil, err
}
@@ -231,7 +232,7 @@ func (s *WSSSession) readLoop() {
s.resolveRequest(resp.RequestID, rpcResult{resp: &resp})
case WSSCmdHeartBeatAck, WSSCmdHeartBeatRsp:
// 心跳响应无需处理。
case WSSCmdVerifyCookieRsp, WSSCmdUpdateUserInfoRsp, WSSCmdUpdateUserExpsRsp:
case WSSCmdRegisterGroupRsp, WSSCmdVerifyCookieRsp, WSSCmdUpdateUserInfoRsp, WSSCmdUpdateUserExpsRsp:
s.client.logf("[WSS] 收到控制响应 cmd=%d data=%dB", cmd.CmdType, len(cmd.Data))
default:
s.client.logf("[WSS] 收到 cmd=%d data=%dB requestID=%d", cmd.CmdType, len(cmd.Data), cmd.RequestID)
@@ -303,23 +304,27 @@ func (s *WSSSession) writePayload(ctx context.Context, payload []byte) error {
}
func (s *WSSSession) initialize(ctx context.Context) error {
if err := s.sendHeartbeat(ctx); err != nil {
if err := s.sendRegisterGroup(ctx); err != nil {
return err
}
if err := s.callWSLaunch(ctx); err != nil {
return err
}
if s.launchGUID != "" {
if err := s.sendUpdateUserInfo(ctx, s.launchGUID); err != nil {
return err
}
}
if err := s.sendVerifyCookie(ctx); err != nil {
return err
}
if err := s.callGetConfig(ctx); err != nil {
s.client.logf("[WSS] getConfig 失败但继续: %v", err)
}
if err := s.sendUpdateUserExps(ctx); err != nil {
return err
}
if err := s.sendRegisterGroup(ctx); err != nil {
return err
}
if err := s.sendHeartbeat(ctx); err != nil {
return err
}
return nil
}
@@ -338,9 +343,18 @@ func (s *WSSSession) sendHeartbeat(ctx context.Context) error {
return s.writePayload(ctx, payload)
}
func (s *WSSSession) sendRegisterGroup(ctx context.Context) error {
req := &wsRegisterGroupReq{GroupIDs: []string{WSSGroupID}}
payload, err := encodeWSSStructCommand(WSSCmdRegisterGroupReq, req)
if err != nil {
return err
}
return s.writePayload(ctx, payload)
}
func (s *WSSSession) callWSLaunch(ctx context.Context) error {
req := &wsLaunchReq{UID: s.uid, GUID: s.guid, UA: WSSInitUA}
resp, err := s.callWUP(ctx, "launch", "wsLaunch", req)
resp, err := s.callWUPWithRequestID(ctx, "launch", "wsLaunch", req, -1)
if err != nil {
return err
}
@@ -373,6 +387,14 @@ func (s *WSSSession) sendUpdateUserInfo(ctx context.Context, launchGUID string)
return s.writePayload(ctx, payload)
}
func (s *WSSSession) sendUpdateUserExps(ctx context.Context) error {
payload, err := encodeWSSStructCommand(WSSCmdUpdateUserExpsReq, &wsUpdateUserExpsReq{})
if err != nil {
return err
}
return s.writePayload(ctx, payload)
}
func (s *WSSSession) sendVerifyCookie(ctx context.Context) error {
guid := s.launchGUID
if guid == "" {
@@ -402,7 +424,10 @@ func (s *WSSSession) nextRequestID() int32 {
}
func (s *WSSSession) callWUP(ctx context.Context, service, method string, reqStruct taf.Marshaler) (*wup.Response, error) {
requestID := s.nextRequestID()
return s.callWUPWithRequestID(ctx, service, method, reqStruct, s.nextRequestID())
}
func (s *WSSSession) callWUPWithRequestID(ctx context.Context, service, method string, reqStruct taf.Marshaler, requestID int32) (*wup.Response, error) {
wupReq := wup.NewRequest(service, method, requestID)
wupReq.Timeout = 0
if err := wupReq.WriteStruct("tReq", reqStruct); err != nil {
@@ -460,7 +485,7 @@ func (s *WSSSession) callRPC(ctx context.Context, service, method string, reqStr
}
func (c *WSSClient) buildUser(uid int64, cookie string) ActivityUserID {
user := NewActivityUserID(uid, NormalizeWSSCookie(cookie), WSSBizUA)
user := NewActivityUserID(uid, NormalizeCookie(cookie), WSSBizUA)
user.GUID = ""
user.Token = ""
user.DeviceInfo = ""
@@ -530,6 +555,26 @@ func NormalizeWSSCookie(cookie string) string {
return strings.Join(out, "; ")
}
type wsRegisterGroupReq struct {
GroupIDs []string
Token string
}
func (r *wsRegisterGroupReq) WriteTo(w *taf.Writer) error {
if err := w.WriteHead(0, taf.TypeList); err != nil {
return err
}
if err := w.WriteInt32(0, int64(len(r.GroupIDs))); err != nil {
return err
}
for _, groupID := range r.GroupIDs {
if err := w.WriteString(0, groupID); err != nil {
return err
}
}
return w.WriteString(1, r.Token)
}
type wsLaunchReq struct {
UID int64
GUID string
@@ -603,6 +648,12 @@ func (r *wsUpdateUserInfoReq) WriteTo(w *taf.Writer) error {
return w.WriteInt32(8, 0)
}
type wsUpdateUserExpsReq struct{}
func (r *wsUpdateUserExpsReq) WriteTo(w *taf.Writer) error {
return w.WriteMapStringString(0, nil)
}
type wsVerifyCookieReq struct {
UID int64
UA string
+83
View File
@@ -0,0 +1,83 @@
package huya
import (
"encoding/base64"
"encoding/hex"
"net/url"
"testing"
"huya-go/internal/taf"
)
func TestWSRegisterGroupReqEncoding(t *testing.T) {
w := taf.NewWriter()
req := &wsRegisterGroupReq{GroupIDs: []string{WSSGroupID}}
if err := req.WriteTo(w); err != nil {
t.Fatal(err)
}
got := hex.EncodeToString(w.Bytes())
want := "09000106066c6976653a301600"
if got != want {
t.Fatalf("register group bytes = %s, want %s", got, want)
}
}
func TestWSUpdateUserExpsReqEncoding(t *testing.T) {
w := taf.NewWriter()
if err := (&wsUpdateUserExpsReq{}).WriteTo(w); err != nil {
t.Fatal(err)
}
got := hex.EncodeToString(w.Bytes())
want := "080c"
if got != want {
t.Fatalf("update user exps bytes = %s, want %s", got, want)
}
}
func TestGenerateWSSBaseinfoMatchesBrowserShape(t *testing.T) {
encoded, err := GenerateWSSBaseinfo(123, "guid-value")
if err != nil {
t.Fatal(err)
}
unescaped, err := url.QueryUnescape(encoded)
if err != nil {
t.Fatal(err)
}
raw, err := base64.StdEncoding.DecodeString(unescaped)
if err != nil {
t.Fatal(err)
}
r := taf.NewReader(raw)
uid, err := r.ReadInt64(0, true, 0)
if err != nil {
t.Fatal(err)
}
guid, err := r.ReadString(1, true, "")
if err != nil {
t.Fatal(err)
}
ua, err := r.ReadString(2, true, "")
if err != nil {
t.Fatal(err)
}
appSrc, err := r.ReadString(3, true, "")
if err != nil {
t.Fatal(err)
}
cookie, err := r.ReadString(8, false, "not-empty")
if err != nil {
t.Fatal(err)
}
traceID, err := r.ReadString(9, false, "not-empty")
if err != nil {
t.Fatal(err)
}
if uid != 123 || guid != "guid-value" || ua != WSSCookieUA || appSrc != "HUYA&ZH&2052" {
t.Fatalf("unexpected baseinfo: uid=%d guid=%q ua=%q appSrc=%q", uid, guid, ua, appSrc)
}
if cookie != "" || traceID != "" {
t.Fatalf("wss baseinfo should not carry cookie/trace: cookie=%q trace=%q", cookie, traceID)
}
}
+8
View File
@@ -20,6 +20,14 @@ const (
WSSCmdDeregisterRsp int32 = 9
WSSCmdVerifyCookieReq int32 = 10
WSSCmdVerifyCookieRsp int32 = 11
WSSCmdVerifyHuyaTokenReq int32 = 12
WSSCmdVerifyHuyaTokenRsp int32 = 13
WSSCmdUnverifyReq int32 = 14
WSSCmdUnverifyRsp int32 = 15
WSSCmdRegisterGroupReq int32 = 16
WSSCmdRegisterGroupRsp int32 = 17
WSSCmdUnregisterGroupReq int32 = 18
WSSCmdUnregisterGroupRsp int32 = 19
WSSCmdHeartBeatReq int32 = 20
WSSCmdHeartBeatRsp int32 = 21
WSSCmdMsgPushReqV2 int32 = 22
File diff suppressed because it is too large Load Diff