diff --git a/README.md b/README.md index f669468..849ece8 100644 --- a/README.md +++ b/README.md @@ -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`:轮询次数、轮询间隔、并发账号数。 diff --git a/WSS_NOTES.md b/WSS_NOTES.md index c16a8f6..5cf4e69 100644 --- a/WSS_NOTES.md +++ b/WSS_NOTES.md @@ -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:///?baseinfo=` - 手动 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:///?baseinfo=`。 -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 编解码单测。 diff --git a/internal/huya/baseinfo.go b/internal/huya/baseinfo.go index 63604a5..1dd432b 100644 --- a/internal/huya/baseinfo.go +++ b/internal/huya/baseinfo.go @@ -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 +} diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index 88aca92..b18064b 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -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 diff --git a/internal/huya/wss_client_test.go b/internal/huya/wss_client_test.go new file mode 100644 index 0000000..c86d9ba --- /dev/null +++ b/internal/huya/wss_client_test.go @@ -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) + } +} diff --git a/internal/huya/wss_message.go b/internal/huya/wss_message.go index c2deadc..883151e 100644 --- a/internal/huya/wss_message.go +++ b/internal/huya/wss_message.go @@ -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 diff --git a/purchase_logic_analysis.md b/purchase_logic_analysis.md new file mode 100644 index 0000000..b7deb16 --- /dev/null +++ b/purchase_logic_analysis.md @@ -0,0 +1,1354 @@ +# 抢购/抢兑逻辑静态分析记录 + +## 分析范围 + +- 项目目录:`/Users/yml/Downloads/多功能计算器2.7.unpacked.exe_export_for_ai` +- 样本形态:Windows EXE 的逆向导出结果,不是源码仓库。 +- 主要依据: + - `strings.txt` + - `memory/00586000--005CD000.txt` + - `pointers.txt` + - `function_index.txt` + - `decompile/*.c` + - `disassembly/42FC3C.asm` +- 追加依据: + - `imgs/1.png` + - `imgs/2.png` + - `imgs/3.png` + - `/Users/yml/codes/douyu_login_py/core/huya/http_client.py` + - `/Users/yml/codes/douyu_login_py/core/huya/wss_client.py` + - `/Users/yml/codes/douyu_login_py/core/huya/wup_protocol.py` + - `/Users/yml/codes/douyu_login_py/core/huya/taf_protocol.py` + - `/Users/yml/codes/douyu_login_py/core/huya/activity_structs.py` +- 本记录为静态分析,没有运行程序,也没有打开浏览器。 + +## 总体结论 + +当前样本里确实存在“抢购/抢兑/自动兑换”相关逻辑。 + +这套逻辑不是普通计算器功能,而是一组虎牙活动自动监控与积分兑换流程: + +1. 登录/连接后维护 WUP/WebSocketCommand 通道。 +2. 周期性查询活动奖品列表。 +3. 根据界面配置的目标奖品或自定义 pid 匹配奖品。 +4. 命中后构造 `scoreExchangePrize(cookie, pid, sid)` 兑换请求。 +5. 通过通用 HTTP 封装发送编码后的 WUP/WebSocketCommand 数据。 +6. 根据返回的成功、频控、下架等状态继续重试、记录或重启监控。 + +## 核心接口 + +嵌入 JS/WUP 协议代码在 `strings.txt` 中,关键函数如下: + +- `scoreExchangePrize(cookie, pid, sid)` + - 位置:`strings.txt:617` + - servant/func:`webActUI` / `scoreExchangePrize` + - 请求体:`ExchangePrizeReq` + - 关键字段: + - `pid` + - `sid` + - `userId.lUid` + - `userId.sCookie` + - `userId.sHuYaUA = "web&1.0&huya"` + +- `getActPrizeList(cookie, sid)` + - 位置:`strings.txt:622` + - servant/func:`webActUI` / `getActPrizeList` + - 用于查询活动奖品列表。 + +- `getUserPrizeRecords(cookie, sid)` + - 位置:`strings.txt:622` + - servant/func:`webActUI` / `getUserPrizeRecords` + - 用于查询用户兑换/中奖记录。 + +- `getUserScore(cookie, sid)` + - 位置:`strings.txt:617` + - 用于查询剩余积分。 + +- `checkUserBindGameAccount(cookie, outerActId)` + - 位置:`strings.txt:625` + - 用于检查绑定角色/区服信息。 + +## WUP 封装逻辑 + +`_sendWup` 在 JS 中构造 `w.Wup`: + +- 设置 servant:`setServant(t)` +- 设置 func:`setFunc(e)` +- 设置 request id:`setRequestId(r)` +- 写入请求结构:`writeStruct("tReq", i)` +- 外层再包成 `E.WebSocketCommand` +- `iCmdType = EWSCmd_WupReq` +- 最后编码成 hex/base64 样式字符串返回给 C 层发送。 + +响应解码通过 `decodeTaf`: + +- 先解 `WebSocketCommand` +- 如果是 `EWSCmd_WupRsp`,再解 WUP +- 根据 `sFuncName` 查 `WupMapping` +- 映射包括: + - `scoreExchangePrize -> ExchangePrizeResp` + - `getActPrizeList -> GetActPrizeListResp` + - `getUserScore -> GetUserScoreResp` + - `checkUserBindGameAccount -> CheckUserBindGameAccountResp` + - `getUserPrizeRecords -> GetUserPrizeRecordsResp` + +## 自动监控入口 + +### `sub_43597D` + +位置:`decompile/43597D.c` + +这是自动监控的外层调度器: + +- 初始化账号/任务列表相关容器。 +- 等待 `dword_69EE3C` 连接对象可用: + - `decompile/43597D.c:133` +- 打印/记录线程创建成功: + - `decompile/43597D.c:152` +- 设置自动监控标志: + - `dword_69EE38 = 1` + - `decompile/43597D.c:156` +- 遍历账号和活动/任务组合。 +- 通过连接对象的 `+24` 方法投递任务: + - `decompile/43597D.c:221` +- 投递入口为: + - `sub_436737 -> sub_4363A4` +- 循环间隔走 `sub_4276C9`: + - `decompile/43597D.c:243` +- 结束时清理并关闭监控: + - `dword_69EE38 = 0` + - `decompile/43597D.c:248` + +### `sub_436756` + +位置:`decompile/436756.c` + +这是继续监控线程入口: + +```c +int sub_436756() +{ + return sub_43597D(); +} +``` + +汇编中可见在未命中奖品后会投递该入口继续监控: + +- `disassembly/42FC3C.asm:2872` + +## 请求构造函数对应关系 + +这些函数本质上都是从 ScriptControl/JScript 调用指定函数名,拿到编码后的 WUP/WebSocketCommand 数据,再转成二进制/缓冲区。 + +从 `pointers.txt` 可确认: + +- `sub_435001` + - 字符串指针:`getActPrizeList` + - `pointers.txt:2376` + - 用于构造奖品列表查询请求。 + +- `sub_436610` + - 字符串指针:`scoreExchangePrize` + - `pointers.txt:2479` + - 用于构造兑换请求。 + +- `sub_4362B2` + - 字符串指针:`getUserPrizeRecords` + - `pointers.txt:2463` + - 用于构造兑换记录查询请求。 + +- `sub_42FA78` + - 多个账号/槽位回调中使用。 + - 具体函数名需结合 `sub_4968A7(5)` 的运行时字符串表确认。 + +- `sub_42FB54` + - 多个账号/槽位回调中使用。 + - 具体函数名需结合 `sub_4968A7(5)` 的运行时字符串表确认。 + +## 核心响应处理 + +### `sub_43E81F` + +位置:`decompile/43E81F.c` + +这是一个槽位回调模板,其他如 `436B5A.c`、`4394F1.c`、`43BE88.c`、`443B4D.c`、`458805.c`、`4604CA.c` 等逻辑高度同构,只是开头取不同账号/任务槽位。 + +关键解析字段: + +- `status` + - `decompile/43E81F.c:213` +- `msg` + - 实际变量名由反编译器表现为 `off_58DDE9` + - `decompile/43E81F.c:221` +- `iRequestId` + - `decompile/43E81F.c:230` +- `score` + - `decompile/43E81F.c:239` +- `roleName` + - `decompile/43E81F.c:248` +- `areaName` + - `decompile/43E81F.c:260` +- `platName` + - `decompile/43E81F.c:272` + +## iRequestId 分支 + +已确认的分支含义: + +- `iRequestId == "1"` + - 奖品列表/兑换响应主分支。 + - C 侧在该分支解析 `prizeList.value`,也处理 `scoreExchangePrize` 返回。 + - 位置:`decompile/43E81F.c:330` + +- `iRequestId == "2"` + - `getUserScore` 积分分支。 + - 位置:`decompile/43E81F.c:288` + +- `iRequestId == "6"` + - `getUserPrizeRecords` 兑换记录分支。 + - 位置:`decompile/43E81F.c:286` 和 `decompile/43E81F.c:673` + +- `iRequestId == "17"` + - `checkUserBindGameAccount` 绑定信息分支。 + - 位置:`decompile/43E81F.c:307` + +- `iRequestId == "-1"` + - `doLaunch/wsLaunch`,读取 `sGuid`。 + - 位置:`decompile/43E81F.c:766` + +## 奖品列表解析 + +`iRequestId == "1"` 时会解析: + +- `prizeList.value` + - `decompile/43E81F.c:337` +- `prizeList.value[i].id` + - 汇编确认:`disassembly/42FC3C.asm:2051` +- `prizeList.value[i].name` + - 汇编确认:`disassembly/42FC3C.asm:2106` +- `prizeList.value[i].num` + - 汇编确认:`disassembly/42FC3C.asm:2161` +- `prizeList.value[i].percent` + - 汇编确认:`disassembly/42FC3C.asm:2226` +- `prizeList.value[i].newScore` + - 汇编确认:`disassembly/42FC3C.asm:2298` + +同时有 `num * percent / 100` 一类计算,用于展示或估算剩余可兑数量: + +- `disassembly/42FC3C.asm:2276` +- `disassembly/42FC3C.asm:2350` + +## 命中策略 + +命中逻辑有两类信号: + +1. 自定义 pid + - 常量:`自定义pid` + - 地址:`0x5A479F` + - 汇编引用:`disassembly/42FC3C.asm:1950` + +2. 自动选择奖品 + - 常量:`自动选择奖品:` + - 地址:`0x5A47EB` + - 汇编引用:`disassembly/42FC3C.asm:2775` + +当没有在 `prizeList.value` 中查到目标时: + +- 输出:`未查询到所需物品,继续监控` +- 地址:`0x5A47FD` +- 汇编引用:`disassembly/42FC3C.asm:2886` +- 会等待约 100ms: + - `sub_4276C9(100, 1, 0, 0)` + - `disassembly/42FC3C.asm:2847` +- 再投递 `sub_436756` 继续监控: + - `disassembly/42FC3C.asm:2872` + +## 兑换请求发送 + +兑换请求由 `scoreExchangePrize(cookie, pid, sid)` 生成 WUP/WebSocketCommand 数据。 + +C 层最终通过 `sub_428C41` 发送: + +- 第一处发送: + - `decompile/43E81F.c:512` + - 汇编:`disassembly/42FC3C.asm:1376` +- 第二处发送: + - `decompile/43E81F.c:620` + - 汇编:`disassembly/42FC3C.asm:1749` + +这两处发送均使用: + +- URL/目标地址:`unk_69EE18` +- 方法参数:`a2 = 1` +- 在 `sub_428C41` 中 `a2 = 1` 对应第 2 个方法,即 `POST` + - `decompile/428C41.c:159` + +## HTTP 封装函数 + +### `sub_428C41` + +位置:`decompile/428C41.c` + +这是通用 HTTP 请求封装,不是业务函数。 + +功能特征: + +- 支持多种 HTTP 方法: + - GET + - POST + - HEAD + - PUT + - OPTIONS + - DELETE + - TRACE + - CONNECT +- 方法分支位置: + - `decompile/428C41.c:159` +- 检测 `https://`: + - `decompile/428C41.c:212` +- 默认 User-Agent: + - `Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)` + - `decompile/428C41.c:225` +- 默认超时约 5000ms: + - `decompile/428C41.c:524` + +## 响应处理 + +### 请求成功/兑换成功 + +相关常量: + +- `请求成功` + - 地址:`0x5A4756` + - 在 `decompile/43E81F.c:453` 比较。 +- `:兑换成功` + - 地址:`0x5A475F` + - 汇编引用:`disassembly/42FC3C.asm:1092` + +逻辑含义: + +- 当 `status == "200"` 且 `msg == "请求成功"` 时,会进入成功处理路径。 +- 成功输出中会把当前奖品名与 `:兑换成功` 拼接。 + +注意: + +- `byte_5A476B` 不是“兑换成功”,而是“操作频繁,请稍后再试”。 +- `:兑换成功` 实际从 `0x5A475F` 开始。 + +### 操作频繁 + +相关常量: + +- `操作频繁,请稍后再试` + - 地址:`0x5A476B` + - 比较位置:`decompile/43E81F.c:569` + +- `操作频繁数据#` + - 地址:`0x5A477F` + - 汇编引用:`disassembly/42FC3C.asm:1561` + +逻辑含义: + +- 如果返回频控提示,会记录频控数据。 +- 之后仍会进入后续请求/重试相关路径。 + +### 奖品已下架 + +相关常量: + +- `奖品已下架` + - 地址:`0x5A4827` + - 汇编引用:`disassembly/42FC3C.asm:3888` + +关联逻辑: + +- 检测到下架后,会调用连接对象停止/关闭方法: + - `disassembly/42FC3C.asm:3908` +- 打印 `线程销毁` + - 地址:`0x5A4832` + - 汇编引用:`disassembly/42FC3C.asm:3914` +- 清除监控标志: + - `dword_69EE38 = 0` + - `disassembly/42FC3C.asm:3933` +- 短暂等待后打印 `执行自动监控` + - 地址:`0x5A483B` + - 汇编引用:`disassembly/42FC3C.asm:3943` +- 调用 `sub_4349E2` 尝试重启/恢复监控: + - `disassembly/42FC3C.asm:3954` + +## 已解码关键中文/字符串常量 + +从 `memory/00586000--005CD000.txt` GBK 解码: + +| 地址 | 内容 | +| --- | --- | +| `0x5A3C72` | `1` | +| `0x5A471F` | `2` | +| `0x5A472E` | `17` | +| `0x5A4756` | `请求成功` | +| `0x5A475F` | `:兑换成功` | +| `0x5A476B` | `操作频繁,请稍后再试` | +| `0x5A477F` | `操作频繁数据#` | +| `0x5A478F` | `prizeList.value` | +| `0x5A479F` | `自定义pid` | +| `0x5A47A9` | `].id` | +| `0x5A47AE` | `prizeList.value[` | +| `0x5A47BF` | `].num` | +| `0x5A47C5` | `].percent` | +| `0x5A47D7` | `].newScore` | +| `0x5A47E2` | `开始监控` | +| `0x5A47EB` | `自动选择奖品:` | +| `0x5A47FD` | `未查询到所需物品,继续监控` | +| `0x5A4818` | `-1` | +| `0x5A481B` | `sGuid` | +| `0x5A4821` | `guid=` | +| `0x5A4827` | `奖品已下架` | +| `0x5A4832` | `线程销毁` | +| `0x5A483B` | `执行自动监控` | +| `0x5A4C14` | `线程创建成功...` | +| `0x5A4C24` | `getUserPrizeRecords` | +| `0x5A4C38` | `scoreExchangePrize` | +| `0x5A4C5F` | `prize:` | + +## 同构槽位回调 + +下面这些文件中的主体逻辑基本相同,差异主要是开头从 `dword_69EDD8 + 4 * slot` 读取不同账号/任务槽位: + +- `decompile/436B5A.c` +- `decompile/4394F1.c` +- `decompile/43BE88.c` +- `decompile/43E81F.c` +- `decompile/4411B6.c` +- `decompile/443B4D.c` +- `decompile/4464E4.c` +- `decompile/448E7B.c` +- `decompile/44B812.c` +- `decompile/44E1A9.c` +- `decompile/4534D7.c` +- `decompile/455E6E.c` +- `decompile/458805.c` +- `decompile/45B19C.c` +- `decompile/45DB33.c` +- `decompile/4604CA.c` +- `decompile/462E61.c` +- `decompile/4657F8.c` + +这些函数均引用同一批常量: + +- `byte_5A3C72` +- `unk_5A471F` +- `unk_5A472E` +- `byte_5A4756` +- `byte_5A476B` +- `byte_5A4818` +- `unk_5A4C5F` + +可见它们是一套多账号/多槽位并发模板。 + +## 流程伪代码 + +```text +启动自动监控: + 初始化账号列表和活动/任务列表 + 等待连接对象 dword_69EE3C 可用 + dword_69EE38 = 1 + + while 未停止 且 未超过循环次数: + for 每个账号: + for 每个活动/任务: + 投递请求任务 sub_436737 -> sub_4363A4 + sleep(配置间隔) + +收到 WUP 响应: + json = decodeTaf(response) + status = json.status + msg = json.msg + requestId = json.iRequestId + + if requestId == 2: + 读取 score 并输出剩余积分 + + if requestId == 17: + 读取 roleName/areaName/platName + 输出绑定信息或未获取到绑定信息 + + if requestId == 1: + if 响应里无目标 pid/目标奖品: + 输出 未查询到所需物品,继续监控 + sleep(约 100ms) + 投递 sub_436756 继续监控 + else: + 遍历 prizeList.value + 读取 id/name/num/percent/newScore + 记录/展示奖品信息 + 构造 scoreExchangePrize(cookie, pid, sid) + 通过 sub_428C41 POST 发出 + + if status == 200 且 msg == 请求成功: + 输出 当前奖品 + :兑换成功 + + if msg == 操作频繁,请稍后再试: + 记录操作频繁数据 + 进入后续重试/刷新路径 + + if 奖品已下架: + 停止连接/销毁线程 + dword_69EE38 = 0 + 再次执行自动监控 + + if requestId == 6: + 遍历 userPrizeList.value + 输出/记录兑换记录 + + if requestId == -1: + 读取 sGuid +``` + +## 待继续确认点 + +1. `unk_69EE18` 的运行时 URL 来源还未完全定位。 + - 静态字符串里没有直接看到固定初始化。 + - 更像是连接对象、配置或运行时脚本中保存的地址。 + +2. `sub_4968A7(index)` 的运行时字符串表需要进一步命名。 + - 目前已通过 `pointers.txt` 和上下文确认一部分业务函数。 + - 仍有若干 `sub_4968A7(5/6/7/9)` 取出的值需要结合更多调用点还原。 + +3. GUI 控件字段可以继续命名。 + - 已知存在自定义 pid、自动选择奖品、循环/间隔相关配置。 + - 但每个控件 ID 到语义字段的映射还可以继续细分。 + +4. `getActPrizeList` 的 request id 在 JS 中传入 `null`,但 C 侧奖品列表解析落在 `iRequestId == "1"` 分支。 + - 可能是 WUP/脚本层默认值或反编译导出表现导致。 + - 这一点如需完全确认,需要继续追 `setRequestId(null)` 的编码结果或运行时响应样本。 + +## 一句话总结 + +程序的抢兑链路是: + +```text +查活动奖品列表 -> 匹配自定义 pid/目标奖品 -> 选中奖品 -> 构造 scoreExchangePrize(cookie,pid,sid) +-> POST 发送 WUP/WebSocketCommand -> 根据成功/频控/下架结果继续记录、重试或重启监控 +``` + +## 2026-07-06 追加:截图与参考项目对照 + +### 截图里的运行态信息 + +`imgs/1.png`、`imgs/2.png`、`imgs/3.png` 能把 GUI 字段和逆向逻辑对齐: + +- 主页面按钮: + - `查询` + - `积分查询` + - `绑定查询` + - `兑换记录` + - `兑换1次` + - `停止兑换` + - `取消定时` +- 设置页面参数: + - `线程 = 5` + - `次数 = 300` + - `延时 = 150` + - `Cookie` + - `网页登录` + - 定时时间选择器 +- 已登录账号页面: + - 多账号表列:`num / cookie / name / 状态 / 首个兑换` + - 每个账号可勾选。 + - 下方共用同一份奖品表。 +- 奖品表列: + - `num` + - `name` + - `剩余` + - `总量` + - `价格` +- 日志示例: + - `登录成功` + - `你选择了:套装-朝萌小黄鸭` + +与反编译对应关系: + +- `次数` 基本对应 `sub_43597D` 中的 `v51`,循环条件为 `v46 < v51`: + - `decompile/43597D.c:73-95` + - `decompile/43597D.c:243-245` +- `延时` 基本对应 `sub_43597D` 中的 `v50`,外层循环 sleep: + - `decompile/43597D.c:86-90` + - `decompile/43597D.c:243` +- `线程` 与连接对象/worker 数量相关,`sub_43597D` 会通过连接对象方法读取可用数量并投递任务: + - `decompile/43597D.c:160` + - `decompile/43597D.c:221` + - 具体 UI 控件到线程池大小的赋值点还未完全命名。 +- 多账号、多目标组合会形成笛卡尔积投递: + - 外层账号列表:`v53` + - 内层目标/活动列表:`v52` + - 投递入口:`sub_436737 -> sub_4363A4` + - `decompile/43597D.c:163-227` + +### JS 请求构造的精确字段 + +从 `strings.txt` 里抽出的 JS 可以确认: + +- `_sendWup(servant, func, req, requestId, ..., options)` + - 构造 `w.Wup` + - `setServant(servant)` + - `setFunc(func)` + - `setRequestId(requestId)` + - `writeStruct("tReq", req)` + - 再包进 `E.WebSocketCommand` + - `iCmdType = EWSCmd_WupReq` + - `traceId = hex16:hex16:0:0` + - 返回 base64 编码后的 `WebSocketCommand` 字节。 + +- `scoreExchangePrize(cookie, pid, sid)` + - `ExchangePrizeReq.pid = pid` + - `ExchangePrizeReq.sid = sid` + - `userId.iTokenType = 0` + - `userId.lUid = parseInt(cookie["yyuid"])` + - `userId.sCookie = cookie` + - `userId.sHuYaUA = "web&1.0&huya"` + - `requestId = 1` + +- `getActPrizeList(cookie, sid)` + - `GetActPrizeListReq.userId.lUid = yyuid` + - `GetActPrizeListReq.userId.sCookie = cookie` + - `GetActPrizeListReq.userId.sHuYaUA = "web&1.0&huya"` + - `GetActPrizeListReq.sid = sid` + - JS 里 `requestId = null` + - 但 C 侧奖品列表和兑换响应都落在 `iRequestId == "1"` 分支,需要按实际响应确认。 + +- `getUserPrizeRecords(cookie, sid)` + - `GetUserPrizeRecordsReq.sid = sid` + - `userId.lUid = yyuid` + - `userId.sGuid = guid` + - `userId.sCookie = cookie` + - `userId.sHuYaUA = "webh5&1.0.0&huya"` + - `requestId = 6` + +- `getUserScore(cookie, sid)` + - `requestId = 2` + - `userId.sHuYaUA = "web&1.0&huya"` + +- `checkUserBindGameAccount(cookie, outerActId)` + - `requestId = 17` + - `userId.sHuYaUA = "pc&1.0.0&huya"` + - `gid = "3203"` + +- `doLaunch(cookie)` + - servant/func:`launch` / `wsLaunch` + - `requestId = -1` + - `sUA = "webh5&0.0.1&activity&other"` + - `sAppSrc = "HUYA&ZH&2052"` + +### 奖品结构字段 + +样本内 JS 定义的 `TActPrizeInfo` 字段: + +| tag | 字段 | 类型/含义 | +| --- | --- | --- | +| 0 | `id` | 奖品 pid | +| 1 | `sid` | 活动 sid | +| 2 | `name` | 奖品名 | +| 3 | `score` | 旧积分价格 | +| 4 | `type` | 类型 | +| 5 | `img` | 图片 | +| 6 | `sort` | 排序 | +| 7 | `frequency` | 频次 | +| 8 | `frequencyLimit` | 频次限制 | +| 9 | `createTime` | 创建时间 | +| 10 | `num` | 总量 | +| 11 | `status` | 状态 | +| 12 | `percent` | 剩余百分比文本 | +| 13 | `newScore` | 新积分价格 | +| 14 | `tags` | 标签/分类 | +| 15 | `leftNum` | 剩余数量字段 | +| 16 | `isShowNum` | 是否展示数量 | + +当前 C 回调实际重点读取: + +- `id` +- `name` +- `num` +- `percent` +- `newScore` + +截图中的展示大概率为: + +- `总量 = num` +- `价格 = newScore` +- `剩余 = floor(num * percent / 100)` 或近似取整 + +例子:截图里 `套装-朝萌小黄鸭` 行显示 `剩余 6000 / 总量 24001 / 价格 988`,和 `num * percent / 100` 这类计算路径吻合: + +- `decompile/43E81F.c:401-419` +- `disassembly/42FC3C.asm:2276` +- `disassembly/42FC3C.asm:2350` + +## 参考项目可复用结论 + +参考项目:`/Users/yml/codes/douyu_login_py` + +### HTTP RPC 通道 + +文件:`core/huya/http_client.py` + +该项目已经实现了一条可直接迁移到 Go 的 HTTP POST RPC 链路: + +- URL: + - `https://cdnws.api.huya.com/?baseinfo=` +- Body: + - 完整 WUP 包,带 4 字节大端长度前缀。 +- Header: + - `Content-Type: application/octet-stream` + - `Origin: https://zt.huya.com` + - `Referer: https://zt.huya.com/` + - PC Chrome UA +- `WSConnectParaInfo` baseinfo 字段: + +| tag | 字段 | 说明 | +| --- | --- | --- | +| 0 | `lUid` | yyuid | +| 1 | `sGuid` | guid,可为空 | +| 2 | `sUA` | `webh5&0.0.1&websocket&&diypc_52775` | +| 3 | `sAppSrc` | `HUYA&ZH&2052` | +| 4 | `sMid` | 空 | +| 5 | `sExp` | 空 | +| 6 | `iTokenType` | 0 | +| 7 | `sToken` | 空 | +| 8 | `sCookie` | 完整 Cookie | +| 9 | `sTraceId` | `hex16:hex16:0:0` | +| 10 | `mCustomHeaders` | 空 Map | + +便捷方法: + +- `query_user_score` +- `get_act_prize_list` +- `get_user_prize_records` +- `score_exchange_prize` +- `check_user_bind_game_account` + +注意: + +- 参考项目的 HTTP `call_rpc` 统一 `setRequestId(1)`。 +- 这和样本 JS 的 `getUserScore=2`、`getUserPrizeRecords=6`、`check=17` 不完全一致。 +- 对 HTTP cdnws 通道来说,服务端主要靠 servant/func 和 `tReq` 解析,requestId 更像客户端响应关联字段。 +- 如果目标是“尽量复刻样本”,Go 版应允许每个接口显式指定 requestId。 + +### TAF/WUP 编解码 + +文件: + +- `core/huya/taf_protocol.py` +- `core/huya/wup_protocol.py` + +可直接翻译为 Go: + +- TAF type: + - `INT8=0` + - `INT16=1` + - `INT32=2` + - `INT64=3` + - `FLOAT=4` + - `DOUBLE=5` + - `STRING1=6` + - `STRING4=7` + - `MAP=8` + - `LIST=9` + - `STRUCT_BEGIN=10` + - `STRUCT_END=11` + - `ZERO=12` + - `SIMPLE_LIST=13` +- 头部: + - `tag < 15`:单字节 `(tag << 4) | type` + - `tag >= 15`:两字节 `0xF0 | type, tag` +- 整数写入要做压缩: + - 0 写 `ZERO` + - int8 范围写 `INT8` + - int16 范围写 `INT16` + - int32 范围写 `INT32` + - 否则写 `INT64` +- WUP v3: + - tag1 `iVersion` + - tag2 `cPacketType` + - tag3 `iMessageType` + - tag4 `iRequestId` + - tag5 `sServantName` + - tag6 `sFuncName` + - tag7 `sBuffer` + - tag8 `iTimeout` + - tag9 `context` + - tag10 `status` + - `sBuffer = Map{"tReq": encodedStruct}` + - 最外层加 4 字节大端总长度。 + +### 活动结构体 + +文件:`core/huya/activity_structs.py` + +Go 版最小需要先实现: + +- `ActivityUserId` + - tag0 `lUid` + - tag1 `sGuid` + - tag2 `sToken` + - tag3 `sHuYaUA` + - tag4 `sCookie` + - tag5 `iTokenType` + - tag6 `sDeviceInfo` + +- `GetActPrizeListReq` + - tag0 `userId` + - tag1 `sid` + +- `GetActPrizeListResp` + - tag0 `status` + - tag1 `msg` + - tag2 `prizeList` + - tag4 `tagList` + +- `ScoreExchangePrizeReq` + - tag0 `userId` + - tag1 `sid` + - tag2 `pid` + - tag3 `ip` + - tag4 `clientEnv` + - tag5 `source` + - tag6 `isRole` + +- `ScoreExchangePrizeResp` + - tag0 `status` + - tag1 `msg` + - tag3 `orderId` + - tag4 `exchangeInfo` + - tag5 `actPreCondition` + +- `GetUserPrizeRecordsReq/Resp` +- `GetUserScoreReq/Resp` + +### WSS 参考项目和当前样本的差异 + +文件:`core/huya/wss_client.py` + +参考项目的 WSS 是商城通道: + +- host:`77bc035c-ws.va.huya.com` +- frame header:`>BBI` + - 1B version + - 1B command + - 4B sequence +- 初始化: + - heartbeat + - `launch.wsLaunch` + - `mobileui.getConfig` + - register + - confirm + - business RPC +- 响应匹配: + - 服务端响应 seq 不一定等于请求 seq + - 项目用 FIFO 队列匹配。 + +当前样本 JS 的业务外层是 `E.WebSocketCommand` TAF 结构: + +- tag0 `iCmdType` +- tag1 `vData` +- tag2 `lRequestId` +- tag3 `traceId` +- tag4 `iEncryptType` +- tag5 `lTime` +- tag6 `sMD5` + +样本里明文 WSS host: + +- `wss://65cecb21-ws.va.huya.com/` +- 位置: + - `strings.txt:702` + - `decompile/46DEE1.c:147` + +因此 Go 复刻时应把通道拆成两个可替换实现: + +1. `cdnws HTTP POST WUP` + - 参考项目已有完整实现。 + - 最适合先做最小可用版本。 +2. `va.huya WSS + WebSocketCommand` + - 更贴近样本 JS 的外层 `WebSocketCommand`。 + - 需要继续确认握手、verifyCookie、registerGroup、heartbeat 与业务发送封装。 + +## Go 复刻建议蓝图 + +### 包结构 + +```text +huya-go/ + taf/ + type.go + writer.go + reader.go + wup/ + request.go + response.go + huya/ + cookie.go + baseinfo.go + activity/ + structs.go + client.go + transport/ + http_rpc.go + wss_rpc.go + sniper/ + config.go + scheduler.go + matcher.go + result.go +``` + +### 最小可用链路 + +先做 HTTP cdnws 版本: + +```text +读取账号 Cookie + -> 解析 yyuid/guid + -> 构造 ActivityUserId + -> 构造 GetActPrizeListReq + -> WUP encode + -> POST cdnws + -> WUP decode GetActPrizeListResp + -> 匹配目标 pid/name + -> 构造 ScoreExchangePrizeReq + -> POST cdnws + -> WUP decode ScoreExchangePrizeResp + -> 记录 status/msg/orderId +``` + +### 抢购调度逻辑 + +建议按样本行为复刻,但加上明确的停止条件和退避: + +```text +config: + accounts: []Account + targets: []Target + sid: int + maxLoops: int # 截图里的 次数 + delayMs: int # 截图里的 延时 + concurrency: int # 截图里的 线程 + scheduledAt: time + stopAfterFirstSuccess: bool + +run: + 等到 scheduledAt + for loop in 1..maxLoops: + 并发遍历勾选账号 + 查询奖品列表 + 过滤目标: + 自定义 pid 优先 + 否则按 name/选中行匹配 + 如果未命中: + 记录 未查询到所需物品,继续监控 + return + 如果命中: + 如果剩余估算 <= 0: + 可选择跳过或继续尝试 + 调 scoreExchangePrize + status == 200 且 msg == 请求成功: + 记录兑换成功 + 如果 stopAfterFirstSuccess: 标记账号停止 + msg == 操作频繁: + 记录频控,按账号退避 + msg == 奖品已下架: + 刷新列表/重启监控 + sleep(delayMs) +``` + +### Go 实现时的关键细节 + +- Cookie: + - 去重保留最后一次。 + - 从 `yyuid` 或 `udb_uid` 解析 uid。 + - `guid` 可从 Cookie 取;HTTP cdnws 可为空,但 WSS/launch 分支需要处理返回 `sGuid`。 +- UA: + - 样本活动兑换/列表:`web&1.0&huya` + - 样本兑换记录:`webh5&1.0.0&huya` + - 样本绑定查询:`pc&1.0.0&huya` + - 参考 HTTP 客户端:`webh5&0.0.1&websocket&&diypc_52775` + - 建议 Go 结构允许按接口覆盖,不要写死一个全局值。 +- requestId: + - 样本兑换:`1` + - 样本积分:`2` + - 样本记录:`6` + - 样本绑定:`17` + - 样本 launch:`-1` + - HTTP cdnws 可先统一 `1`,但保留参数。 +- 奖品剩余: + - 样本 C 侧使用 `num`、`percent`、`newScore` 展示。 + - 如果响应有 `leftNum`,Go 版可以同时记录直接字段和估算字段,避免误判。 +- 频控: + - 样本识别 `操作频繁,请稍后再试` 后仍会继续后续重试路径。 + - Go 版建议记录频控并按账号退避,避免无意义高频请求。 + +## 新增待确认点 + +1. `unk_69EE18` 仍未定位到静态写入点。 + - 目前只看到它作为 `sub_428C41` 的 URL 参数被读取: + - `decompile/43E81F.c:513` + - `decompile/43E81F.c:621` + - 明文有 `wss://65cecb21-ws.va.huya.com/`: + - `strings.txt:702` + - `decompile/46DEE1.c:147` + - 但参考项目 HTTP 版使用 `https://cdnws.api.huya.com/?baseinfo=...`。 + - 需要实际抓一次样本运行请求,或继续追连接对象初始化,才能最终确认业务发送 URL。 + +2. `线程` 控件的赋值点还未完全映射。 + - 样本有 18 个同构槽位回调。 + - 截图中设置为 5。 + - `sub_43597D` 通过连接对象方法读取可用数量和投递任务,但 UI 控件到连接池容量的路径还需继续找。 + +3. `getActPrizeList` 的 requestId。 + - JS 传 `null`。 + - `Wup.prototype.setRequestId=function(t){this.iRequestId=t||++this.iRequestId}`。 + - 所以 `null` 会触发自增,初始值为 0 时会变成 1。 + - 这解释了 C 侧奖品列表落入 `iRequestId == "1"` 分支。 + +4. HTTP cdnws 与样本 WSS 外层差异。 + - 用 Go 先做 HTTP 版本可更快验证结构体和兑换接口。 + - 如果要完全复刻样本,需要再补 `WebSocketCommand` 外层、verifyCookie、doLaunch、registerGroup、heartbeat。 + +## 2026-07-06 运行日志与 HAR 新证据 + +### 输入文件 + +- `/Users/yml/Desktop/抓包/hy/抢购/7.6-软件抓包.har` +- `/Users/yml/Desktop/抓包/hy/抢购/软件日志.txt` +- `/Users/yml/Desktop/抓包/hy/抢购/软件日志2.txt` + +注意:HAR 中包含真实账号 Cookie、uid、guid、token 类字段。下面只记录结构化结论,不保存明文敏感值。 + +### HAR 总览 + +- HAR 共 702 条请求。 +- 其中 661 条是 `HEAD http://www.baidu.com/`,应为软件或代理的联网检测/保活噪声。 +- 真正与本软件相关的主要请求: + - `api3.1wxyun.com`:5 条软件授权/登录相关请求,`type=1/11/8/24/21`。 + - `129.204.148.26:7381/huya/heping.html`:1 条远端配置/公告页。 + - `i.huya.com`:3 条账号页面加载。 + - `65cecb21-ws.va.huya.com`:3 条 WebSocket 握手,状态码 `101`。 + - `diy-assets.msstatic.com/cqhfolder/NewDefault.png`:3 条默认头像/资源图加载。 + - `82.156.166.142:1822/upload`:3 条兑换成功后的远端上报。 + +HAR 只保存了 WebSocket 握手,没有保存 WebSocket 帧内容,所以无法直接从这份 HAR 还原 `scoreExchangePrize` 的二进制 WUP 请求体。它能确认真实运行使用 WSS 通道,但请求体仍需要依赖反编译和参考项目里的 WUP/TAF 编码继续复刻。 + +### 远端配置页 + +`http://129.204.148.26:7381/huya/heping.html` 返回了软件配置: + +- 版本号:`2.7` +- 软件开关:`1` +- 兑换代码:`2203` +- 子奖品代码 A:`10329` +- 子奖品代码 B:`10329` +- livelink 活动 id:`17096` +- 上传地址: + - `http://129.204.148.26:1822/upload` + - `http://115.159.215.151:1822/upload` + - `http://82.156.166.142:1822/upload` +- 登录开关、cookie 开关、切换活动开关、任务开关均为 `1`。 + +这说明软件并不只依赖本地静态常量,还会启动时拉取作者远端配置。对 Go 复刻来说,`兑换代码=2203`、`子奖品代码=10329`、`活动 id=17096` 是这次“套装-潮萌小黄鸭”的直接运行参数;远端开关/上传地址不需要复刻。 + +### WSS 连接时序 + +HAR 时间换算成本地时间后: + +- `16:53:58.071`:3 条 `GET https://i.huya.com/` +- `16:53:58.109` 到 `16:53:58.114`:3 条 `GET wss://65cecb21-ws.va.huya.com/` 握手成功 +- `16:53:59`:日志显示 3 个账号登录成功 +- `16:59:57`:日志显示线程创建成功并开始兑换 + +这和前面的反编译结论可以对上:软件在登录阶段为每个账号建立或准备连接,抢购触发时复用已有连接,而不是到点后再创建 WSS。Go 版如果要贴近样本,应改成“账号初始化 -> 建立持久 WSS -> launch/register/heartbeat -> 到点并发发送兑换 WUP”。 + +WebSocket 握手头特征: + +- Host:`65cecb21-ws.va.huya.com` +- User-Agent:`WebSocket++/0.8.2` +- Upgrade:`websocket` +- 未看到浏览器常见的 `Origin`。 + +### 成功上报行为 + +HAR 中 3 条 `POST http://82.156.166.142:1822/upload` 与 `软件日志.txt` 的 3 个成功账号对应: + +- 本地时间约 `17:00:01.946`、`17:00:04.751`、`17:00:05.508` 上报。 +- 上报体使用 GBK 文本拼接,字段以 `#` 分隔。 +- 上报内容包括账号昵称、成功时间、奖品名、完整 Cookie、活动/软件标识、程序名 `多功能计算器2.7.vmp.exe` 等。 + +这是明显的数据外传逻辑。Go 复刻不应实现这个上传接口;如果保留日志,只应写本地脱敏日志。 + +### `软件日志.txt` 统计 + +该日志对应 3 个账号,和 HAR 中的 3 条 `i.huya.com`、3 条 WSS、3 条上传最匹配。 + +- 登录账号数:3 +- 选择奖品:`套装-潮萌小黄鸭` +- 兑换动作日志:270 条 +- 成功:3 条 +- 操作频繁:4 条 +- 兑换货币不足:263 条 +- 成功时间:全部在 `16:59:57` +- 成功后查询到剩余积分:3 个账号均为 `2` + +首批结果: + +| 时间 | 账号结果 | +| --- | --- | +| 16:59:57 | 堂上不仁也:兑换成功 | +| 16:59:57 | 里村路老:兑换成功 | +| 16:59:57 | 颁赏胡同兰博:先操作频繁,随后同秒兑换成功 | + +后续从 `17:00:01` 起继续大量重试,但返回基本变成“兑换货币不足”。这说明样本成功后没有立刻按账号停止兑换循环,或停止标记只影响了部分路径;复刻时应加“账号成功后停止该奖品重试”,否则会制造无意义请求并触发频控。 + +### `软件日志2.txt` 统计 + +该日志对应 10 个账号,HAR 未捕获到对应的 10 条 WSS/上传,因此它应是另一轮运行日志或抓包缺失了这部分流量。 + +- 登录账号数:10 +- 选择奖品:`套装-潮萌小黄鸭` +- 兑换动作日志:1190 条 +- 奖品暂未开放兑换:70 条 +- 成功:10 条 +- 操作频繁:6 条 +- 兑换货币不足:1104 条 +- 成功时间:全部在 `17:00:00` +- 成功后查询到剩余积分:10 个账号均为 `2` + +关键节奏: + +| 秒级时间 | 请求结果分布 | +| --- | --- | +| 16:59:57 | 13 条,全部“奖品暂未开放兑换” | +| 16:59:58 | 37 条,全部“奖品暂未开放兑换” | +| 16:59:59 | 20 条,全部“奖品暂未开放兑换” | +| 17:00:00 | 10 条,全部“兑换成功” | +| 17:00:02 起 | 大量“兑换货币不足”,夹杂少量“操作频繁” | + +这说明软件会在开放前约 3 秒开始高频探测,不等官方开放时间才发第一波。开放瞬间,每个账号至少成功提交一次;成功之后仍继续跑循环,造成 1100+ 条无效的“兑换货币不足”。 + +### 对抢购逻辑的更新判断 + +1. 远端配置确认了这次奖品参数: + - 活动 id:`17096` + - 兑换代码/父奖品或活动奖品标识:`2203` + - 子奖品 id:`10329` + +2. 实际样本运行确认使用 WSS: + - 静态字符串里的 `wss://65cecb21-ws.va.huya.com/` 不是备用字符串。 + - 登录阶段按账号建立 WSS,抢购阶段复用连接发请求。 + +3. HAR 未保存 WSS 帧: + - 不能从当前 HAR 直接提取 `WebSocketCommand`、`iRequestId` 或兑换请求 WUP body。 + - 仍需从反编译、JS/WUP 定义、参考 Python 项目或重新抓 WebSocket frames 来补齐。 + +4. 样本抢购策略: + - 提前约 3 秒开始轮询/尝试。 + - 多账号并发。 + - 成功后继续重试,直到后续查询/循环结束。 + - 频控文案不是致命错误,样本会继续尝试。 + +5. Go 复刻建议: + - 账号维度维护持久 WSS 连接,而不是每次兑换新建 HTTP 请求。 + - 抢购开始时间可设置为官方时间前 2-3 秒,允许“暂未开放”继续重试。 + - 每个账号成功一次后立即停止该账号对同一奖品的兑换重试。 + - “操作频繁”做短退避;“兑换货币不足”可视为该账号已成功或积分不足,停止重试。 + - 禁止实现样本的远端 `upload` 上报;只保留本地脱敏日志。 + +### 仍需补证 + +- 重新抓包时需要确认工具能保存 WebSocket binary frames;当前 HAR 只有 WSS handshake。 +- 如果抓不到帧,可以在 Go 版用已实现的 WUP 编码对照参考项目,先构造: + - `webActUI.getActPrizeList` + - `webActUI.getUserScore` + - `webActUI.scoreExchangePrize` +- 然后补样本 WSS 外层: + - `WebSocketCommand` + - `verifyCookie` + - `doLaunch` + - `registerGroup` + - heartbeat + +## 2026-07-06 浏览器手动抢购小黄鸭 HAR 新证据 + +### 输入文件 + +- `/Users/yml/Desktop/抓包/hy/抢购/7.6-手动抢购小黄鸭` + +该文件是 Charles Proxy 5.2 导出的 HAR,文件无扩展名,实际是单行 JSON。HAR 内含真实 Cookie、uid、guid、token、账号名等敏感字段;这里只记录脱敏后的协议结论。 + +### HAR 总览 + +- 条目数:228 +- 主要 host: + - `cdnws.api.huya.com`:12 条 HTTP WUP 请求。 + - `77bc035c-ws.va.huya.com`:3 条 WebSocket 握手,状态码 `101`。 + - `zt.huya.com`:活动页 `b02faae1/pc/index.html`。 + - `dataservice.cdn.huya.com`、`l.huya.com`:配置/登录态检查。 + - `metric.huya.com`、`ylog.huya.com`、`statwup.huya.com`:埋点/统计噪声。 +- 活动页 URL: + - `https://zt.huya.com/b02faae1/pc/index.html?sourceId=yellowcarlist&pid=1199650619883&anchorUid=1199650619883&roomid=30596253` + +这份 HAR 比软件 HAR 更完整:Charles 保存了 WebSocket binary frames,能还原手动浏览器实际发送的 WUP 请求。 + +### Charles HAR 编码特征 + +Charles 在 HAR 中不是直接保存二进制 body,而是保存 base64 文本: + +1. `cdnws.api.huya.com` 的 `request.postData.text` 和 `response.content.text` 是 WUP 二进制包的 base64。 +2. `wss://77bc035c-ws.va.huya.com/` 的 `request.postData.text` / `response.content.text` 是原始 WebSocket wire bytes 的 base64。 +3. WebSocket client -> server 帧: + - `opcode=2` + - `masked=1` + - `RSV1=1` + - 启用了 `permessage-deflate` +4. WebSocket 解码顺序: + - base64 decode + - 解析 WebSocket frame + - client 帧按 mask key XOR 解 mask + - 若 `RSV1=1`,使用 raw deflate 解压;Python 可用 `zlib.decompressobj(wbits=-15)`,并在 payload 后补 `00 00 ff ff` + - 解压后 payload 中从 offset `6` 或 `7` 开始是 WUP packet + - WUP packet 后还有尾部追踪字段:client 侧约 44 bytes,server 侧约 25 bytes + +Go 复刻 WSS 时需要支持: + +- WebSocket binary frame +- permessage-deflate +- client mask 由 WebSocket 库自动处理 +- 从 `WebSocketCommand` 外层取出/填入内部 WUP packet + +### HTTP cdnws 初始化请求 + +`cdnws.api.huya.com` 中可见的主要 WUP: + +| 时间 | servant.func | 作用 | +| --- | --- | --- | +| 16:59:45.467 | `launch.wsLaunch` | HTTP 版启动/获取连接信息 | +| 16:59:45.467 | `launch.pingSignal` | 信令 ping | +| 16:59:45.518 | `webActUI.checkUserBindGameAccount` | 查询游戏账号绑定 | +| 16:59:45.518 | `webActUI.getLiveLinkParam` | 获取活动参数 | +| 16:59:45.519 | `huyauserui.getUserProfileBatch` | 查询用户资料 | +| 16:59:45.551 / 16:59:45.553 | `shopMiddleUI.getGoodsInfoV5` | 黄车商品信息 | +| 16:59:45.670/672/679 | `launch.queryHttpDns` | 查询可用 WSS host | +| 16:59:46.064 | `webLiveUI.getWebUserBaseInfo` | 直播用户信息 | +| 16:59:47.079 | `launch.pingSignal` | 后续 ping | + +`webActUI.getLiveLinkParam` 回包中确认: + +- `actId=17096` +- `gameIdList=cjm` +- `livePlatId=huya` +- `code`、`sig` 为动态签名字段,不在文档中保存明文。 + +`launch.queryHttpDns` 返回多个可用 WSS host,其中本次实际连接的是: + +- `77bc035c-ws.va.huya.com` + +这说明 `65cecb21-ws.va.huya.com` 不是唯一固定地址;Go 版不要写死单个 WSS host,应该先走 `launch.queryHttpDns`,或至少允许配置/轮换 host。 + +### 手动 WSS 抢购函数顺序 + +主要业务发生在 `77bc035c-ws.va.huya.com` 的第 2 条 WSS 连接中。解 mask + inflate 后,client -> server 函数顺序如下: + +| frame | requestId | servant.func | 关键参数 | +| --- | ---: | --- | --- | +| 6 | 7 | `webActUI.getActPrizeList` | `sid=2203` | +| 7 | 8 | `webActUI.getActInfo` | 活动信息 | +| 8 | 9 | `webActUI.getActTaskDetail` | 任务列表 | +| 9 | 10 | `webActUI.getActUserTaskDetail` | 用户任务状态 | +| 10 | 11 | `webActUI.getUserScore` | `sid=2203` | +| 12 | 12 | `webActUI.getActPrizeDetail` | `sid=2203` | +| 13 | 13 | `webActUI.getActPrizeDetail` | `sid=2203` | +| 14 | 14 | `webActUI.scoreExchangePrize` | `sid=2203`, `pid=12889`, `ip=""`, `clientEnv={}`, `source=""`, `isRole=1` | +| 15 | 15 | `webActUI.getActPrizeList` | `sid=2203` | +| 16 | 16 | `webActUI.getUserScore` | `sid=2203` | + +这条链路和之前逆向出的 `webActUI.scoreExchangePrize(cookie,pid,sid)` 完全对上。这里可以把 `sid=2203` 理解为活动/兑换场次 id,把 `pid=12889` 理解为本次“套装-潮萌小黄鸭”的奖品 id。 + +### 手动兑换成功回包 + +`scoreExchangePrize` 的 server -> client 回包: + +- `requestId=14` +- `servant=webActUI` +- `func=scoreExchangePrize` +- `status=200` +- `msg=请求成功` +- `orderId=sOrderId_2203_12889__1783328404_957` +- `exchangeInfo.pid=12889` +- `exchangeInfo.exchangeScore=988` +- `exchangeInfo.desc=兑换奖品:套装-潮萌小黄鸭` + +`1783328404` 转成本地时间为 `2026-07-06 17:00:04`,说明这次浏览器手动兑换成功发生在 17:00:04 左右。 + +该回包进一步确认: + +- 本次手动小黄鸭兑换参数是 `sid=2203`、`pid=12889`。 +- 小黄鸭套装消耗积分是 `988`。 +- 兑换成功后页面会继续请求 `getActPrizeList` 和 `getUserScore` 刷新列表与积分。 + +### 奖品列表信息 + +`getActPrizeList` 回包包含“小黄鸭”系列多项奖品,例如: + +- `套装-潮萌小黄鸭` +- `潮萌小黄鸭头盔` +- `潮萌小黄鸭背包` +- `潮萌小黄鸭降落伞` +- `平底锅-潮萌小黄鸭` +- `破片手榴弹-潮萌小黄鸭` +- `背包挂件-潮萌小黄鸭` + +`getActPrizeDetail` 在兑换前后各请求了一次,回包都指向 `套装-潮萌小黄鸭`;这解释了页面手动点击前会先刷详情,再发兑换。 + +### 与软件 HAR 的对比 + +1. 软件 HAR: + - 只保存 WSS handshake,没有保存 frames。 + - host 是 `65cecb21-ws.va.huya.com`。 + - 3 个账号登录阶段各建一条 WSS。 + - 成功后会向作者服务器 `82.156.166.142:1822/upload` 外传 Cookie 和结果。 + +2. 浏览器手动 HAR: + - 保存了完整 WebSocket frames。 + - host 是 `77bc035c-ws.va.huya.com`,由 `launch.queryHttpDns` 返回。 + - 真实兑换函数和参数已经确认:`webActUI.scoreExchangePrize(sid=2203,pid=12889)`。 + - 没有软件作者的 `upload` 外传行为。 + +3. 共同点: + - 都使用 `*-ws.va.huya.com` WSS 通道。 + - 都依赖 WUP/TAF。 + - 都围绕 `webActUI` 的活动接口:列表、积分、详情、兑换。 + +### 对 Go 复刻的更新建议 + +1. 参数修正: + - 当前“套装-潮萌小黄鸭”手动兑换的 `pid` 应使用 `12889`。 + - 远端配置里的 `子奖品代码=10329` 可能是软件作者配置中的另一层子奖品/旧字段;从浏览器成功回包看,实际 `scoreExchangePrize` 使用的是 `pid=12889`。 + - `sid=2203` 明确用于 `getActPrizeList`、`getUserScore`、`getActPrizeDetail`、`scoreExchangePrize`。 + +2. 传输层: + - HTTP `cdnws.api.huya.com` 可用于 launch、queryHttpDns、部分初始化请求。 + - 真正贴近浏览器/软件的抢购应走 WSS。 + - WSS 要支持 `permessage-deflate`;建议 Go 版使用成熟 WebSocket 库,并确认是否能开启压缩。 + +3. WSS 外层: + - 每个 WebSocket message 不是裸 WUP,前面有约 6/7 字节外层,后面有追踪尾部。 + - 内部 WUP 包可以按 4 字节大端长度切出。 + - Go 版需要补 `WebSocketCommand` 外层编码/解码,至少要能构造出和浏览器一致的 client frame payload。 + +4. 请求顺序: + - 初始化:`wsLaunch`、`getConfig`、`getLiveLinkParam`、`queryHttpDns` + - 抢购前:`getActPrizeList`、`getUserScore`、`getActPrizeDetail` + - 抢购:`scoreExchangePrize` + - 成功后:`getActPrizeList`、`getUserScore` + +5. UA/环境: + - WUP 元数据中常见 `webh5&0.0.1&websocket&&diypc_52775`。 + - Cookie 中可见 `huya_ua=webh5&0.1.0&websocket`。 + - Go 版不要写死单一 UA,应按接口/阶段可配置。