From f96fa7d85894e6fd92b026ddd4f8064602ac9030 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 13 Oct 2026 20:46:42 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=BD=9C=E6=88=98=E8=A1=A8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E5=85=91=E6=8D=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E3=80=8D=E5=88=97=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=B7=B2=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=E7=9A=84=E5=88=86=E6=9E=90=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 账号作战表新增「兑换结果」列:明确展示 已抢到(含奖品名与订单号)/ 未成功(含原因与尝试次数)/ 已兑换(来自服务端兑换记录兜底)/ 抢购中 / 待抢购 / 已跳过重复兑换 - 抢到奖品的账号整行标绿并加左侧色条,一眼可见 - 复盘表时间列改为可读时间(秒级时间戳格式化) - 按用户确认删除已废弃的 purchase_logic_analysis.md --- frontend/src/App.tsx | 141 +++- frontend/src/styles.css | 9 + purchase_logic_analysis.md | 1354 ------------------------------------ 3 files changed, 147 insertions(+), 1357 deletions(-) delete mode 100644 purchase_logic_analysis.md diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d3aa494..5b14272 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -210,6 +210,22 @@ type RecordRow = UserPrizeInfo & { uid: number } +// exchangeSummary 汇总某个账号本次运行里的兑换结果,供「兑换结果」列展示。 +type ExchangeSummary = { + success?: MonitorResult + failed?: MonitorResult + attempts: number + skipped: number +} + +function isExchangeAttempt(action?: string): boolean { + return action === 'exchange' || action === 'exchange_error' +} + +function exchangeOrderID(exchange?: ExchangeResp): string { + return exchange?.order_id || exchange?.orderId || '' +} + type SavedConfig = Omit type ScheduleCountdown = { @@ -303,6 +319,14 @@ function summarizeInventory(items: PrizeInfo[]): string { return `剩余 ${leftKnown ? left : '-'} / 总量 ${totalKnown ? total : '-'}` } +function formatUnixTime(value?: number): string { + const ts = Number(value || 0) + if (!ts) return '' + // 服务端返回秒级时间戳,兼容毫秒。 + const ms = ts > 1e12 ? ts : ts * 1000 + return new Date(ms).toLocaleString('zh-CN', { hour12: false }) +} + function statusTag(status?: number, error?: string): ReactNode { if (error) return 失败 if (status === 200) return 成功 @@ -615,6 +639,49 @@ export default function Dashboard() { return map }, [results]) + // 本次运行里各账号的兑换结果(results 为最新在前)。 + const exchangeByUID = useMemo(() => { + const map = new Map() + results.forEach((item) => { + if (item.action === 'skip_duplicate') { + const entry = map.get(item.uid) ?? { attempts: 0, skipped: 0 } + entry.skipped += 1 + map.set(item.uid, entry) + return + } + if (!isExchangeAttempt(item.action)) return + const entry = map.get(item.uid) ?? { attempts: 0, skipped: 0 } + entry.attempts += 1 + if (item.category === 'success') { + if (!entry.success) entry.success = item + } else if (!entry.failed) { + entry.failed = item + } + map.set(item.uid, entry) + }) + return map + }, [results]) + + // 用服务端兑换记录兜底,界面刷新后也能看出该账号是否已抢到当前奖品。 + const recordHitByUID = useMemo(() => { + const map = new Map() + const pid = Number(watchedPID || 0) + const name = String(watchedName || '').trim() + if (pid <= 0 && !name) return map + records.forEach((item) => { + ;(item.records || []).forEach((record) => { + const hitPID = pid > 0 && Number(record.prize_id || 0) === pid + const hitName = name !== '' && String(record.name || '').includes(name) + if (!hitPID && !hitName) return + const prev = map.get(item.uid) + if (!prev || Number(record.create_time || 0) > Number(prev.create_time || 0)) { + map.set(item.uid, record) + } + }) + }) + return map + }, [records, watchedPID, watchedName]) + const successCount = useMemo( () => new Set(results.filter((item) => item.category === 'success').map((item) => item.uid)).size, [results], @@ -983,6 +1050,63 @@ export default function Dashboard() { width: 84, render: (_, row) => scoreMap.get(row.uid)?.score ?? '-', }, + { + title: '兑换结果', + width: 190, + render: (_, row) => { + const exchange = exchangeByUID.get(row.uid) + if (exchange?.success) { + const orderID = exchangeOrderID(exchange.success.exchange) + return ( +
+ 已抢到 +
{exchange.success.prize?.name || currentPrize?.name || ''}
+ {orderID ? ( +
+ 单号 {orderID} +
+ ) : null} +
+ ) + } + if (exchange?.failed) { + return ( +
+ 未成功 +
+ {exchange.failed.error || + exchange.failed.message || + exchange.failed.exchange?.msg || + '兑换失败'} +
+ {exchange.attempts > 1 ? ( +
已尝试 {exchange.attempts} 次
+ ) : null} +
+ ) + } + const record = recordHitByUID.get(row.uid) + if (record) { + return ( +
+ 已兑换 +
{record.name || ''}
+ {record.order_id ? ( +
+ 单号 {record.order_id} +
+ ) : null} +
+ ) + } + return ( +
+ {running ? '抢购中' : '待抢购'} + {exchange?.skipped ?
已跳过重复兑换
: null} +
+ ) + }, + }, { title: '状态', width: 98, @@ -997,7 +1121,15 @@ export default function Dashboard() { }, }, ], - [currentPrize?.name, latestResultByUID, scoreMap, watchedName], + [ + currentPrize?.name, + exchangeByUID, + latestResultByUID, + recordHitByUID, + running, + scoreMap, + watchedName, + ], ) const cookieColumns: ColumnsType = useMemo( @@ -1086,7 +1218,7 @@ export default function Dashboard() { { title: '积分', width: 90, render: (_, row) => row.new_score || row.score || 0 }, { title: '状态', dataIndex: 'status', width: 84 }, { title: '订单号', dataIndex: 'order_id', minWidth: 200, ellipsis: true }, - { title: '时间', dataIndex: 'create_time', width: 130 }, + { title: '时间', width: 160, render: (_, row) => formatUnixTime(row.create_time) }, ], [], ) @@ -1400,7 +1532,10 @@ export default function Dashboard() { columns={accountColumns} dataSource={accounts} pagination={false} - scroll={{ x: 850, y: 560 }} + rowClassName={(row) => + exchangeByUID.get(row.uid)?.success ? 'account-row-success' : '' + } + scroll={{ x: 1040, y: 560 }} /> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f6673ac..85f5e62 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -369,6 +369,15 @@ body { margin: 0; } +/* 抢到奖品的账号整行标绿,方便一眼看出结果。 */ +.account-panel .ant-table-tbody > tr.account-row-success > td { + background: #f6ffed !important; +} + +.account-panel .ant-table-tbody > tr.account-row-success > td:first-child { + box-shadow: inset 3px 0 0 #52c41a; +} + .side-log-panel { padding: 0 12px 12px; } diff --git a/purchase_logic_analysis.md b/purchase_logic_analysis.md deleted file mode 100644 index b7deb16..0000000 --- a/purchase_logic_analysis.md +++ /dev/null @@ -1,1354 +0,0 @@ -# 抢购/抢兑逻辑静态分析记录 - -## 分析范围 - -- 项目目录:`/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,应按接口/阶段可配置。