From d21b06a311cfbffb455cbf465f73adb6b57b7a20 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 18:23:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=95=86=E5=93=81=E7=B1=BB?= =?UTF-8?q?=E7=9B=AE=EF=BC=88=E9=99=90=E9=87=8F=E8=BF=94=E5=9C=BA/?= =?UTF-8?q?=E7=B2=BE=E8=8B=B1=E4=B8=93=E4=BA=AB=EF=BC=89=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8F=B7=200.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10.15 抓包确认:积分中心商城 PlayMallUI.queryAllocation 的商品与 webActUI.getActPrizeList(sid=2277) pid 完全一致,属于同一批商品,因此列表无需接商城链路;真号实测 sid=2277 返回 30 项(限量返场 99 共 12 项 + 精英专享 101 共 18 项),sid=2203 已返回空。\n\n- huya:解析商品 tag23/25/26/27/28(发布时间、上架/下架时间、每日时段),新增 PrizeInfo.CategoryIDs/InCategory/StartsAt/NotYetOnSale 与响应级 CategoryStats/CategorySummary/FilterByCategory,列表日志带类目统计\n- sniper:Config.Category 限定自动选品范围,未命中提示带类目\n- CLI:新增 -category / -category-only\n- GUI:Config.Category、ListPrizes 回传类目字典与统计;界面商品列表按类目分页签、每行标注类目、未上架显示上架时间与倒计时,所选类目随配置提交给监控\n- 测试:新增 internal/huya/category_test.go 与 sniper 类目用例;README/WSS_NOTES 补记结论 --- README.md | 28 ++++ WSS_NOTES.md | 49 +++++++ cmd/huya-go/main.go | 15 ++ frontend/src/App.tsx | 236 +++++++++++++++++++++++++------ frontend/src/styles.css | 20 +++ frontend/wailsjs/go/models.ts | 36 +++++ internal/gui/app.go | 41 +++--- internal/huya/activity.go | 197 +++++++++++++++++++++++++- internal/huya/category_test.go | 247 +++++++++++++++++++++++++++++++++ internal/huya/http_client.go | 2 +- internal/huya/wss_client.go | 2 +- internal/sniper/sniper.go | 29 +++- internal/sniper/sniper_test.go | 71 ++++++++++ internal/version/version.go | 6 +- wails.json | 2 +- 15 files changed, 913 insertions(+), 68 deletions(-) create mode 100644 internal/huya/category_test.go diff --git a/README.md b/README.md index 9bea1e8..e89316f 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签) - `-pid`:奖品 PID。`exchange` 必填,`monitor` 可用 `-pid` 或 `-name`。 - `-max-price`:目标奖品最高积分,`0` 表示不限制(CLI 与 GUI 设置都支持)。 - `-min-left`:目标奖品最少剩余库存,`0` 表示不限制(CLI 与 GUI 设置都支持)。 +- `-category`:类目过滤,支持类目名(如 `限量返场`、`精英专享`)或类目 id(如 `99`);留空表示不限。 +- `-category-only`:`list` 只输出该类目的商品(配合 `-category` 使用)。 - `-cookie`:单账号 Cookie。 - `-cookie-file`:多账号 Cookie 文件。 - `-transport`:`wss` 或 `http`,默认 `wss`。 @@ -145,3 +147,29 @@ GUI 与 CLI 共用同一套结构化日志(分类 + 等级 + 账号标签) - 订单号格式:`sOrderId_2277_13409__<时间戳>_<随机>` 该活动的列表/积分/兑换接口与 `sid=2203` 走同一套 `webActUI`,可直接用 `-cmd list -sid 2277` 验证;同一次抓包里「开通精英宝典 / 购买精英币」属于商城付费链路(`shopMiddleUI.*` + 支付宝),本项目未实现,详见 `WSS_NOTES.md`。 + +## 商品类目:限量返场 / 精英专享(2026-10-15 抓包) + +`webActUI.getActPrizeList` 的响应除商品外还下发类目字典(`tRsp.4`),每件商品用 `tags`(TAF tag14) +标注归属:`101=精英专享`、`99=限量返场`。2026-10-15 真号实测 `sid=2277`: + +- 共 30 件商品:限量返场(99) 12 件(怪诞乔克服装礼包 8000 积分、天网终结者大礼包 4800 积分、潮萌小黄鸭系列…)+ 精英专享(101) 18 件(幸运币/荣耀勋章礼包、套装…)。 +- `sid=2203`(上一期活动)已停止下发商品,列表为空,需要把 SID 切到 `2277`。 +- 限量返场是「预告上架」类商品:`start_time`(TAF tag25,实测 2026-10-16 17:00:00)与 `end_time`(tag26)决定 + 可兑换时间窗,页面上的「xx:xx:xx 后上架」倒计时就来自它;`daily_start/daily_end`(tag27/28)是每日可兑时段。 + +用法: + +```bash +# 全量列表(日志里会打出「类目=精英专享(101)=18项/限量返场(99)=12项」) +./huya-go -cmd list -sid 2277 -cookie-file cookies.txt + +# 只看限量返场(也支持类目 id 或模糊名,如 -category 99 / -category 返场) +./huya-go -cmd list -sid 2277 -cookie-file cookies.txt -category 限量返场 -category-only + +# 抢购时限定只在该类目里选品(配合 -auto) +./huya-go -cmd monitor -sid 2277 -cookie-file cookies.txt -category 限量返场 -auto +``` + +界面「商品列表」同样按类目分了页签(全部 / 限量返场 / 精英专享,带商品数),每行标注类目, +未上架的商品显示上架时间与倒计时;当前选中的类目会一起提交给监控,用作自动选品的范围。 diff --git a/WSS_NOTES.md b/WSS_NOTES.md index e8d490a..05a0182 100644 --- a/WSS_NOTES.md +++ b/WSS_NOTES.md @@ -185,8 +185,57 @@ getGoodsInfoV5 → getSupplierInfoV5 → listPayChannelV5 → checkHyProtocolV5 注意函数名带版本后缀(V5/V6)。`webActUI` 在 WSS 上同样可用(实测 `checkUserBindGameAccount`、`getUserPrizeRecords`)。 +### 积分中心商城的商品列表(`PlayMallUI.queryAllocation`) + +2026-10-15 HAR(`m-shop.huya.com/points.html?pagecode=heping_baodian_credit_center_page`)确认: + +```text +tReq.0 user {uid, guid, token, ua=web&1.0.0&huya, cookie, tokenType, deviceInfo} +tReq.1 {0:"huya", 1:1001, 3:"", 5:7, 6:"yellowcarlist", 7:""} +tReq.2 pageId string # "heping_baodian_credit_center_page" +tReq.3 "" tReq.4 0 tReq.5 1 tReq.6 24 +tReq.7 extraJson string # 切换类目时是 {"jumpTabId":433} +tReq.11 list # [6, 19] +tRsp.0 {0:200(status), 1:"成功"} +tRsp.1 {0:"和平精英宝典积分中心", 1:pageId} +tRsp.2 list{0:tabId(343/345), 4:int, 5:bytes} # 5 是内嵌 TAF 负载 +tRsp.3 list # CDN 域名 +``` + +内嵌负载解码后是页面区块(tabId 433=限量返场、431=精英专享),每个区块的 `6: list<商品>` 里带 +`tag9=2277(sid)`、`tag10="13433"(pid)`、价格是活动积分的 10 倍。 + +结论:**商城 grid 与 `webActUI.getActPrizeList(sid=2277)` 是同一批商品**(pid 完全一致), +本项目不需要走商城链路取列表;10.13 该负载只有 431(精英专享) 一个区块,10.15 新增 433(限量返场)。 + +## 商品类目(限量返场 / 精英专享) + +`webActUI.getActPrizeList` 响应除商品外还下发类目字典,商品侧用 `tag14` 标注归属: + +```text +tRsp.3 moduleId int32 +tRsp.4 list{0:id, 1:name, 2:weight} # 2026-10-15 实测:101=精英专享、99=限量返场 +PrizeInfo.14 tags string # 类目 id,多个用逗号分隔 +``` + +2026-10-15 实测 `sid=2277`:30 项商品 = 限量返场(99) 12 项(怪诞乔克服装礼包 8000 积分等, +页面显示「xx:xx:xx 后上架」)+ 精英专享(101) 18 项。`sid=2203` 的旧活动已停止返回内容。 +Go 版:`GetActPrizeListResp.CategorySummary/CategoryStats/FilterByCategory` 与 +`PrizeInfo.CategoryIDs/InCategory/StartsAt/NotYetOnSale`,CLI `-category` / `-category-only`, +界面商品列表按类目筛选并标注类目。 + +商品侧还有一组时间字段(此前未解析): + +```text +PrizeInfo.23 publish_time int64 # 商品发布时间(实测 2026-10-15 11:04:57) +PrizeInfo.25 start_time int64 # 上架时间,页面倒计时来源(实测 2026-10-16 17:00:00) +PrizeInfo.26 end_time int64 # 下架时间(实测 2026-10-23 23:59:59) +PrizeInfo.27/28 daily_start/daily_end string # 每日可兑时段 "00:00:00"/"23:59:59" +``` + ## 待完善 - 使用真实 Cookie 联调 `list/score/exchange`,确认服务端不再超时。 - WSS 侧对 `launch.pingSignal` 的实际可用性需真号验证(失败已降级为日志)。 - 补更多基于真实抓包摘要的 WSS 编解码单测。 +- 限量返场商品是「预告上架」,兑换时的服务端文案(如「未开放」)与开抢瞬间的表现需真号验证。 diff --git a/cmd/huya-go/main.go b/cmd/huya-go/main.go index 70c2024..e272292 100644 --- a/cmd/huya-go/main.go +++ b/cmd/huya-go/main.go @@ -34,6 +34,8 @@ func main() { cookie = flag.String("cookie", "", "虎牙 Cookie") cookieFile = flag.String("cookie-file", "", "Cookie 文件,一行一个账号") sid = flag.Int("sid", 0, "活动 SID") + category = flag.String("category", "", "类目过滤:类目名(如 限量返场)或类目 id,留空表示不限") + categoryOnly = flag.Bool("category-only", false, "list 只输出指定类目的商品") pid = flag.Int("pid", 0, "奖品 PID") pids = flag.String("pids", "", "多个奖品 PID,逗号分隔") name = flag.String("name", "", "按奖品名包含匹配") @@ -113,6 +115,18 @@ func main() { if err != nil { exitErr(err) } + logger.LogEvent(categoryCLI, logger.LevelInfo, "sid=%d 共%d项 类目=%s", *sid, len(resp.Prizes), resp.CategorySummary()) + key := strings.TrimSpace(*category) + if *categoryOnly { + if key == "" { + exitErr(errors.New("-category-only 需要同时提供 -category")) + } + filtered := *resp + filtered.Prizes = resp.FilterByCategory(key) + logger.LogEvent(categoryCLI, logger.LevelInfo, "类目[%s]筛选后 %d/%d 项", key, len(filtered.Prizes), len(resp.Prizes)) + printJSON(filtered) + return + } printJSON(resp) case "score": waitForCommand(ctx, startAt) @@ -161,6 +175,7 @@ func main() { } cfg := sniper.Config{ SID: int32(*sid), + Category: strings.TrimSpace(*category), Loops: *loops, Delay: time.Duration(*delayMS) * time.Millisecond, Concurrency: *concurrency, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d807777..758e581 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -90,6 +90,8 @@ type UiConfig = { sid: number pid: number name: string + // category 是类目筛选(类目 id 字符串,空串=全部),只影响监控时的自动选品范围。 + category: string targetText: string maxPrice: number minLeft: number @@ -165,10 +167,33 @@ type PrizeInfo = { percent?: string is_show_num?: number img?: string + // tags 是类目 id(逗号分隔):99=限量返场、101=精英专享(以服务端下发的类目字典为准)。 + tags?: string + publish_time?: number + start_time?: number + end_time?: number + daily_start?: string + daily_end?: string +} + +type PrizeTag = { + id: number + name: string + weight?: number +} + +type CategoryStat = { + id: number + name: string + count?: number + left?: number + total?: number } type PrizeCache = { prizes: PrizeInfo[] + categories?: PrizeTag[] + categoryStats?: CategoryStat[] updatedAt: number sid?: number } @@ -294,6 +319,49 @@ function priceOf(row?: PrizeInfo): number { return row.new_score || row.score || 0 } +// categoryIDsOf 解析奖品 tag14 里的类目 id(服务端可能下发多个,逗号分隔)。 +function categoryIDsOf(row?: PrizeInfo): number[] { + return String(row?.tags || '') + .split(',') + .map((item) => Number(item.trim())) + .filter((id) => Number.isFinite(id) && id > 0) +} + +// categoryNamesOf 把类目 id 翻译成类目名;字典里没有时用「类目」兜底。 +function categoryNamesOf(row: PrizeInfo | undefined, categories: PrizeTag[]): string[] { + return categoryIDsOf(row).map((id) => categories.find((tag) => Number(tag.id) === id)?.name || `类目${id}`) +} + +function formatDateTime(seconds?: number): string { + if (!seconds || seconds <= 0) return '' + const date = new Date(seconds * 1000) + const pad = (value: number) => String(value).padStart(2, '0') + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function formatCountdown(ms: number): string { + if (ms <= 0) return '' + const total = Math.floor(ms / 1000) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const seconds = total % 60 + const pad = (value: number) => String(value).padStart(2, '0') + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` +} + +// saleStateOf 描述商品的上下架状态:限量返场是「预告上架」,页面的倒计时来自 start_time。 +function saleStateOf(row: PrizeInfo | undefined, now: Date): { onSale: boolean; label: string; countdown: string } { + const start = Number(row?.start_time || 0) + if (!start || start <= 0) { + return { onSale: true, label: '', countdown: '' } + } + const remains = start * 1000 - now.getTime() + if (remains > 0) { + return { onSale: false, label: `${formatDateTime(start)} 上架`, countdown: formatCountdown(remains) } + } + return { onSale: true, label: '', countdown: '' } +} + type InventoryInfo = { left?: number total?: number @@ -444,19 +512,28 @@ function readSavedPrizeCache(): PrizeCache { : [] return { prizes: prizes as PrizeInfo[], + categories: Array.isArray(cache.categories) ? (cache.categories as PrizeTag[]) : [], + categoryStats: Array.isArray(cache.categoryStats) ? (cache.categoryStats as CategoryStat[]) : [], updatedAt: Number(cache.updatedAt || 0), sid: Number(cache.sid || 0) || undefined, } } catch { - return { prizes: [], updatedAt: 0 } + return { prizes: [], categories: [], categoryStats: [], updatedAt: 0 } } } -function writeSavedPrizeCache(prizes: PrizeInfo[], sid: number) { +function writeSavedPrizeCache( + prizes: PrizeInfo[], + sid: number, + categories: PrizeTag[] = [], + categoryStats: CategoryStat[] = [], +) { localStorage.setItem( scopedKey(prizeCacheStorageBase), JSON.stringify({ prizes, + categories, + categoryStats, sid, updatedAt: Date.now(), }), @@ -498,6 +575,7 @@ function pickConfig(values: Partial): UiConfig { sid: Number(values.sid || 0), pid: Number(values.pid || 0), name: values.name || '', + category: values.category || '', targetText: values.targetText || '', maxPrice: Number(values.maxPrice || 0), minLeft: Number(values.minLeft || 0), @@ -552,6 +630,10 @@ export default function Dashboard() { const [accounts, setAccounts] = useState([]) const [selectedAccountLines, setSelectedAccountLines] = useState([]) const [prizes, setPrizes] = useState([]) + const [prizeCategories, setPrizeCategories] = useState([]) + const [categoryStats, setCategoryStats] = useState([]) + // activeCategory 是当前选中的类目(空串=全部);监控时的自动选品也按它过滤。 + const [activeCategory, setActiveCategory] = useState('') const [selectedPrizeKeys, setSelectedPrizeKeys] = useState([]) const [productSearch, setProductSearch] = useState('') const [scores, setScores] = useState([]) @@ -639,13 +721,17 @@ export default function Dashboard() { const savedCookies = readSavedCookies() const savedPrizeCache = readSavedPrizeCache() + const savedConfig = readSavedConfig() form.setFieldsValue({ ...(defaults as UiConfig), - ...readSavedConfig(), + ...savedConfig, cookieText: cookieTextFromEntries(savedCookies), }) setCookieEntries(savedCookies) + setActiveCategory(String(savedConfig.category || '')) setPrizes(savedPrizeCache.prizes) + setPrizeCategories(savedPrizeCache.categories || []) + setCategoryStats(savedPrizeCache.categoryStats || []) setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt) setLogs((prev) => mergeLogs(prev, (logItems || []) as LogEntry[])) setLogPath(String(savedLogPath || instance?.logFile || '')) @@ -785,16 +871,30 @@ export default function Dashboard() { const filteredPrizes = useMemo(() => { const keyword = productSearch.trim().toLowerCase() - if (!keyword) return prizes - return prizes.filter( - (item) => - item.name.toLowerCase().includes(keyword) || - String(item.id).includes(keyword) || - String(priceOf(item)).includes(keyword), - ) - }, [prizes, productSearch]) + const categoryID = Number(activeCategory || 0) + return prizes.filter((item) => { + if (categoryID > 0 && !categoryIDsOf(item).includes(categoryID)) return false + if (!keyword) return true + const text = [item.name, String(item.id), String(priceOf(item)), ...categoryNamesOf(item, prizeCategories)] + .join(' ') + .toLowerCase() + return text.includes(keyword) + }) + }, [prizes, productSearch, activeCategory, prizeCategories]) const inventorySummary = useMemo(() => summarizeInventory(filteredPrizes), [filteredPrizes]) + // 类目页签与服务端下发的类目字典同序(限量返场/精英专享),数量优先用服务端统计。 + const categoryItems = useMemo(() => { + const items = [{ key: '', label: `全部 ${prizes.length}` }] + prizeCategories.forEach((tag) => { + const id = Number(tag.id) + const stat = categoryStats.find((item) => Number(item.id) === id) + const count = stat?.count ?? prizes.filter((item) => categoryIDsOf(item).includes(id)).length + items.push({ key: String(id), label: `${tag.name} ${count}` }) + }) + return items + }, [prizeCategories, categoryStats, prizes]) + // 日志筛选只在界面上生效;后端的 debug 开关由「调试日志」按钮控制。 const visibleLogs = useMemo( () => (logFilter === 'all' ? logs : logs.filter((item) => item.level === logFilter)), @@ -803,6 +903,7 @@ export default function Dashboard() { const config = () => ({ ...pickConfig(form.getFieldsValue(true)), + category: activeCategory, selectedAccountLines: selectedAccountLines.map(Number).filter((line) => line > 0), }) @@ -954,20 +1055,34 @@ export default function Dashboard() { setLoading('list') ListPrizes(config()) .then((data) => { - const result = data as { prizes?: PrizeInfo[] } + const result = data as { + prizes?: PrizeInfo[] + categories?: PrizeTag[] + categoryStats?: CategoryStat[] + } const nextPrizes = result?.prizes || [] if (nextPrizes.length === 0) { if (!quiet) message.warning('当前 CK 未返回可选商品') return } + const nextCategories = result?.categories || [] + const nextStats = result?.categoryStats || [] const updatedAt = Date.now() setPrizes(nextPrizes) + setPrizeCategories(nextCategories) + setCategoryStats(nextStats) + setActiveCategory((prev) => + prev && nextCategories.some((tag) => String(tag.id) === prev) ? prev : '', + ) setPrizeCacheUpdatedAt(updatedAt) - writeSavedPrizeCache(nextPrizes, Number(form.getFieldValue('sid') || 0)) + writeSavedPrizeCache(nextPrizes, Number(form.getFieldValue('sid') || 0), nextCategories, nextStats) setSelectedPrizeKeys((prev) => prev.filter((key) => nextPrizes.some((item) => item.id === Number(key))), ) - if (!quiet) message.success('商品已刷新') + if (!quiet) { + const summary = nextStats.map((item) => `${item.name} ${item.count ?? 0}`).join(' / ') + message.success(summary ? `商品已刷新:${summary}` : '商品已刷新') + } }) .catch((err: unknown) => { if (!quiet) message.error(String(err)) @@ -991,6 +1106,9 @@ export default function Dashboard() { onOk: () => { localStorage.removeItem(scopedKey(prizeCacheStorageBase)) setPrizes([]) + setPrizeCategories([]) + setCategoryStats([]) + setActiveCategory('') setSelectedPrizeKeys([]) setPrizeCacheUpdatedAt(0) message.success('商品缓存已清空') @@ -1006,6 +1124,7 @@ export default function Dashboard() { sid: values.sid, pid: values.pid, name: values.name, + category: values.category, targetText: values.targetText, maxPrice: values.maxPrice, minLeft: values.minLeft, @@ -1470,7 +1589,17 @@ export default function Dashboard() { onSearch={setProductSearch} /> - + {categoryItems.length > 1 ? ( + setActiveCategory(String(key))} + items={categoryItems} + /> + ) : null} + +
商品缓存 {filteredPrizes.length} 个 @@ -1485,29 +1614,44 @@ export default function Dashboard() { 导入有效 CK 后刷新最新商品
) : ( - filteredPrizes.map((item) => ( - - )) +
+ {item.name} +
+ {priceOf(item)} 积分 + {categoryNamesOf(item, prizeCategories).map((name) => ( + + {name} + + ))} + {sale.onSale ? ( + + ) : ( + {sale.label} + )} +
+ {sale.onSale ? null : ( + {sale.countdown} 后上架 + )} +
+ + ) + }) )} @@ -1870,8 +2014,17 @@ function InventoryTags({ prize, compact = false }: { prize?: PrizeInfo; compact? ) } -function CurrentProduct({ prize }: { prize?: PrizeInfo }) { +function CurrentProduct({ + prize, + now, + categories, +}: { + prize?: PrizeInfo + now: Date + categories: PrizeTag[] +}) { const name = prize?.name || '未从 CK 列表选择' + const sale = saleStateOf(prize, now) return (
当前商品 @@ -1881,7 +2034,12 @@ function CurrentProduct({ prize }: { prize?: PrizeInfo }) { <> {priceOf(prize)} 积分 PID {prize.id} - + {categoryNamesOf(prize, categories).map((item) => ( + + {item} + + ))} + {sale.onSale ? : {sale.label}} ) : ( CK 商品源 diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 85f5e62..d9f36ed 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -185,6 +185,26 @@ body { white-space: nowrap; } +.category-tabs { + margin: -4px 0 0; +} + +.category-tabs .ant-tabs-nav { + margin: 0; +} + +.category-tabs .ant-tabs-tab { + padding: 6px 0; + font-size: 12px; +} + +.product-hint { + display: block; + margin-top: 4px; + color: #d46b08; + font-size: 12px; +} + .desk { display: grid; min-width: 0; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index cd0af0a..28aac8d 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -23,6 +23,7 @@ export namespace gui { wssHost: string; sid: number; pid: number; + category: string; name: string; targetText: string; maxPrice: number; @@ -55,6 +56,7 @@ export namespace gui { this.wssHost = source["wssHost"]; this.sid = source["sid"]; this.pid = source["pid"]; + this.category = source["category"]; this.name = source["name"]; this.targetText = source["targetText"]; this.maxPrice = source["maxPrice"]; @@ -161,6 +163,8 @@ export namespace gui { status: number; msg: string; prizes: huya.PrizeInfo[]; + categories: huya.PrizeTag[]; + categoryStats: huya.CategoryStat[]; raw?: huya.GetActPrizeListResp; static createFrom(source: any = {}) { @@ -173,6 +177,8 @@ export namespace gui { this.status = source["status"]; this.msg = source["msg"]; this.prizes = this.convertValues(source["prizes"], huya.PrizeInfo); + this.categories = this.convertValues(source["categories"], huya.PrizeTag); + this.categoryStats = this.convertValues(source["categoryStats"], huya.CategoryStat); this.raw = this.convertValues(source["raw"], huya.GetActPrizeListResp); } @@ -291,6 +297,26 @@ export namespace huya { this.button_desc = source["button_desc"]; } } + export class CategoryStat { + id: number; + name: string; + count: number; + left: number; + total: number; + + static createFrom(source: any = {}) { + return new CategoryStat(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.count = source["count"]; + this.left = source["left"]; + this.total = source["total"]; + } + } export class ExchangeInfo { pid: number; exchange_score: number; @@ -341,6 +367,11 @@ export namespace huya { tags?: string; left_num: number; is_show_num: number; + publish_time?: number; + start_time?: number; + end_time?: number; + daily_start?: string; + daily_end?: string; static createFrom(source: any = {}) { return new PrizeInfo(source); @@ -365,6 +396,11 @@ export namespace huya { this.tags = source["tags"]; this.left_num = source["left_num"]; this.is_show_num = source["is_show_num"]; + this.publish_time = source["publish_time"]; + this.start_time = source["start_time"]; + this.end_time = source["end_time"]; + this.daily_start = source["daily_start"]; + this.daily_end = source["daily_end"]; } } export class GetActPrizeListResp { diff --git a/internal/gui/app.go b/internal/gui/app.go index f155942..4c4098e 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -54,10 +54,12 @@ type App struct { } type Config struct { - Transport string `json:"transport"` - WSSHost string `json:"wssHost"` - SID int32 `json:"sid"` - PID int32 `json:"pid"` + Transport string `json:"transport"` + WSSHost string `json:"wssHost"` + SID int32 `json:"sid"` + PID int32 `json:"pid"` + // Category 是类目(限量返场/精英专享,或类目 id):只影响监控时的自动选品范围。 + Category string `json:"category"` Name string `json:"name"` TargetText string `json:"targetText"` MaxPrice int64 `json:"maxPrice"` @@ -107,11 +109,14 @@ type InstanceInfo struct { } type PrizeListResult struct { - UID int64 `json:"uid"` - Status int32 `json:"status"` - Msg string `json:"msg"` - Prizes []huya.PrizeInfo `json:"prizes"` - Raw *huya.GetActPrizeListResp `json:"raw,omitempty"` + UID int64 `json:"uid"` + Status int32 `json:"status"` + Msg string `json:"msg"` + Prizes []huya.PrizeInfo `json:"prizes"` + // Categories 是服务端下发的类目字典(限量返场/精英专享),CategoryStats 是对应的商品数统计。 + Categories []huya.PrizeTag `json:"categories"` + CategoryStats []huya.CategoryStat `json:"categoryStats"` + Raw *huya.GetActPrizeListResp `json:"raw,omitempty"` } type ScoreResult struct { @@ -351,13 +356,15 @@ func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) { return PrizeListResult{}, err } left, total, leftKnown, totalKnown := prizeInventorySummary(resp.Prizes) - a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg) + a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 类目=%s msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), resp.Msg) return PrizeListResult{ - UID: accounts[0].UID, - Status: resp.Status, - Msg: resp.Msg, - Prizes: resp.Prizes, - Raw: resp, + UID: accounts[0].UID, + Status: resp.Status, + Msg: resp.Msg, + Prizes: resp.Prizes, + Categories: resp.Tags, + CategoryStats: resp.CategoryStats(), + Raw: resp, }, nil } @@ -489,7 +496,7 @@ func (a *App) StartMonitor(cfg Config) error { } a.emitState(true, "运行中") - a.addLog("info", "启动监控: sid=%d targets=%d accounts=%d prewarm=%v direct=%v", cfg.SID, len(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange) + a.addLog("info", "启动监控: sid=%d 类目=%s targets=%d accounts=%d prewarm=%v direct=%v", cfg.SID, sniper.CategoryText(cfg.Category), len(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange) go func() { defer closeClient() @@ -503,6 +510,7 @@ func (a *App) StartMonitor(cfg Config) error { } runnerCfg := sniper.Config{ SID: cfg.SID, + Category: cfg.Category, Loops: cfg.Loops, Delay: time.Duration(cfg.DelayMS) * time.Millisecond, Concurrency: cfg.Concurrency, @@ -637,6 +645,7 @@ func normalizeConfig(cfg Config) Config { cfg.TimeoutSec = 15 } cfg.Name = strings.TrimSpace(cfg.Name) + cfg.Category = strings.TrimSpace(cfg.Category) cfg.TargetText = strings.TrimSpace(cfg.TargetText) cfg.CookieText = strings.TrimSpace(cfg.CookieText) cfg.StartAt = strings.TrimSpace(cfg.StartAt) diff --git a/internal/huya/activity.go b/internal/huya/activity.go index 5c672b9..a68b425 100644 --- a/internal/huya/activity.go +++ b/internal/huya/activity.go @@ -1,9 +1,11 @@ package huya import ( + "fmt" "math" "strconv" "strings" + "time" "huya-go/internal/taf" ) @@ -192,9 +194,18 @@ type PrizeInfo struct { Status int32 `json:"status"` Percent string `json:"percent"` NewScore int64 `json:"new_score"` - Tags string `json:"tags,omitempty"` - LeftNum int64 `json:"left_num"` - IsShowNum int32 `json:"is_show_num"` + // Tags 是奖品归属的类目 id(tag14,多个用逗号分隔)。 + // 2026-10-15 实测:99=限量返场、101=精英专享,与响应 tag4 的字典一一对应。 + Tags string `json:"tags,omitempty"` + LeftNum int64 `json:"left_num"` + IsShowNum int32 `json:"is_show_num"` + // 以下为 2026-10-15 抓包新确认的时间字段:限量返场商品是「预告上架」, + // 页面的「xx:xx:xx 后上架」倒计时就来自 StartTime。 + PublishTime int64 `json:"publish_time,omitempty"` // tag23 商品发布时间 + StartTime int64 `json:"start_time,omitempty"` // tag25 上架时间(秒) + EndTime int64 `json:"end_time,omitempty"` // tag26 下架时间(秒) + DailyStart string `json:"daily_start,omitempty"` // tag27 每日可兑时段开始 + DailyEnd string `json:"daily_end,omitempty"` // tag28 每日可兑时段结束 } func (p *PrizeInfo) WriteTo(w *taf.Writer) error { return nil } @@ -249,10 +260,75 @@ func (p *PrizeInfo) ReadFrom(r *taf.Reader) error { if p.LeftNum, err = r.ReadInt64(15, false, p.LeftNum); err != nil { return err } - p.IsShowNum, err = r.ReadInt32(16, false, p.IsShowNum) + if p.IsShowNum, err = r.ReadInt32(16, false, p.IsShowNum); err != nil { + return err + } + if p.PublishTime, err = r.ReadInt64(23, false, p.PublishTime); err != nil { + return err + } + if p.StartTime, err = r.ReadInt64(25, false, p.StartTime); err != nil { + return err + } + if p.EndTime, err = r.ReadInt64(26, false, p.EndTime); err != nil { + return err + } + if p.DailyStart, err = r.ReadString(27, false, p.DailyStart); err != nil { + return err + } + p.DailyEnd, err = r.ReadString(28, false, p.DailyEnd) return err } +// CategoryIDs 返回奖品归属的类目 id(tag14,逗号分隔,可能是空串)。 +func (p PrizeInfo) CategoryIDs() []int64 { + text := strings.TrimSpace(p.Tags) + if text == "" { + return nil + } + parts := strings.Split(text, ",") + ids := make([]int64, 0, len(parts)) + for _, part := range parts { + id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil { + continue + } + ids = append(ids, id) + } + return ids +} + +// InCategory 判断奖品是否属于指定类目 id。 +func (p PrizeInfo) InCategory(id int64) bool { + for _, item := range p.CategoryIDs() { + if item == id { + return true + } + } + return false +} + +// StartsAt 返回上架时间;未下发时返回零值时间。 +func (p PrizeInfo) StartsAt() time.Time { + if p.StartTime <= 0 { + return time.Time{} + } + return time.Unix(p.StartTime, 0) +} + +// EndsAt 返回下架时间;未下发时返回零值时间。 +func (p PrizeInfo) EndsAt() time.Time { + if p.EndTime <= 0 { + return time.Time{} + } + return time.Unix(p.EndTime, 0) +} + +// NotYetOnSale 表示商品只是「预告上架」,还没到可兑换时间。 +func (p PrizeInfo) NotYetOnSale(now time.Time) bool { + start := p.StartsAt() + return !start.IsZero() && now.Before(start) +} + func (p PrizeInfo) Price() int64 { if p.NewScore != 0 { return p.NewScore @@ -347,6 +423,119 @@ func (r *GetActPrizeListResp) ReadFrom(rd *taf.Reader) error { return nil } +// CategoryStat 汇总一个类目下的商品数量与库存。 +type CategoryStat struct { + ID int64 `json:"id"` + Name string `json:"name"` + Count int `json:"count"` + Left int64 `json:"left"` + Total int64 `json:"total"` +} + +// CategoryName 按类目 id 查名字;字典里没有时返回空串。 +func (r GetActPrizeListResp) CategoryName(id int64) string { + for _, tag := range r.Tags { + if tag.ID == id { + return tag.Name + } + } + return "" +} + +// CategoriesOf 返回某个奖品所属的类目(字典里找不到的 id 用 "类目" 兜底)。 +func (r GetActPrizeListResp) CategoriesOf(p PrizeInfo) []PrizeTag { + ids := p.CategoryIDs() + items := make([]PrizeTag, 0, len(ids)) + for _, id := range ids { + if name := r.CategoryName(id); name != "" { + items = append(items, PrizeTag{ID: id, Name: name}) + continue + } + items = append(items, PrizeTag{ID: id, Name: fmt.Sprintf("类目%d", id)}) + } + return items +} + +// CategoryStats 按响应里的类目字典统计商品数、剩余量与总量。 +// +// 统计顺序与响应下发的类目顺序一致(页面 tab 顺序),便于日志与界面直接复用。 +func (r GetActPrizeListResp) CategoryStats() []CategoryStat { + stats := make([]CategoryStat, 0, len(r.Tags)) + for _, tag := range r.Tags { + prizes := make([]PrizeInfo, 0, len(r.Prizes)) + for _, prize := range r.Prizes { + if prize.InCategory(tag.ID) { + prizes = append(prizes, prize) + } + } + left, total, _, _ := prizeListInventorySummary(prizes) + stats = append(stats, CategoryStat{ID: tag.ID, Name: tag.Name, Count: len(prizes), Left: left, Total: total}) + } + return stats +} + +// CategorySummary 生成「限量返场(99)=12项/精英专享(101)=18项」这样的单行摘要,供日志使用。 +func (r GetActPrizeListResp) CategorySummary() string { + stats := r.CategoryStats() + if len(stats) == 0 { + return fmt.Sprintf("未下发类目(共%d项)", len(r.Prizes)) + } + parts := make([]string, 0, len(stats)) + for _, stat := range stats { + parts = append(parts, fmt.Sprintf("%s(%d)=%d项", stat.Name, stat.ID, stat.Count)) + } + return strings.Join(parts, "/") +} + +// FilterByCategory 按类目过滤商品。 +// +// category 支持:空串(不过滤)、类目 id(如 "99")、类目名(如 "限量返场", +// 先精确匹配再模糊匹配)。类目字典里没有匹配项时,退回用原始 tag14 文本匹配, +// 这样服务端新增类目但没下发字典时也不会把商品全部过滤掉。 +func (r GetActPrizeListResp) FilterByCategory(category string) []PrizeInfo { + key := strings.TrimSpace(category) + if key == "" { + return r.Prizes + } + ids := map[int64]bool{} + if id, err := strconv.ParseInt(key, 10, 64); err == nil { + ids[id] = true + } else { + for _, tag := range r.Tags { + if strings.EqualFold(strings.TrimSpace(tag.Name), key) { + ids[tag.ID] = true + } + } + if len(ids) == 0 { + for _, tag := range r.Tags { + if strings.Contains(tag.Name, key) { + ids[tag.ID] = true + } + } + } + } + out := make([]PrizeInfo, 0, len(r.Prizes)) + for _, prize := range r.Prizes { + if len(ids) > 0 { + matched := false + for id := range ids { + if prize.InCategory(id) { + matched = true + break + } + } + if matched { + out = append(out, prize) + } + continue + } + if strings.Contains(prize.Tags, key) { + out = append(out, prize) + } + } + return out +} + type GetUserPrizeRecordsReq struct { User ActivityUserID SID int32 diff --git a/internal/huya/category_test.go b/internal/huya/category_test.go new file mode 100644 index 0000000..01e9f2f --- /dev/null +++ b/internal/huya/category_test.go @@ -0,0 +1,247 @@ +package huya + +import ( + "testing" + "time" + + "huya-go/internal/taf" +) + +// testPrize 只写入断言用到的字段,字段顺序必须与真实响应一致(tag 递增)。 +type testPrize struct { + id int64 + name string + score int64 + num int64 + percent string + tags string + startTime int64 + endTime int64 +} + +func (p testPrize) WriteTo(w *taf.Writer) error { + if err := w.WriteInt64(0, p.id); err != nil { + return err + } + if err := w.WriteString(2, p.name); err != nil { + return err + } + if err := w.WriteInt64(3, p.score); err != nil { + return err + } + if err := w.WriteInt64(10, p.num); err != nil { + return err + } + if err := w.WriteInt32(11, 1); err != nil { + return err + } + if err := w.WriteString(12, p.percent); err != nil { + return err + } + if err := w.WriteInt64(13, p.score); err != nil { + return err + } + if err := w.WriteString(14, p.tags); err != nil { + return err + } + if err := w.WriteInt64(25, p.startTime); err != nil { + return err + } + if err := w.WriteInt64(26, p.endTime); err != nil { + return err + } + if err := w.WriteString(27, "00:00:00"); err != nil { + return err + } + return w.WriteString(28, "23:59:59") +} + +type testTag struct { + id int64 + name string + weight int32 +} + +func (t testTag) WriteTo(w *taf.Writer) error { + if err := w.WriteInt64(0, t.id); err != nil { + return err + } + if err := w.WriteString(1, t.name); err != nil { + return err + } + return w.WriteInt32(2, int64(t.weight)) +} + +// decodePrizeList 拼一个和线上同构的 getActPrizeList 响应再解码回来。 +func decodePrizeList(t *testing.T, tags, prizes []taf.Marshaler) GetActPrizeListResp { + t.Helper() + w := taf.NewWriter() + if err := w.WriteStructBegin(0); err != nil { + t.Fatalf("写入结构体失败: %v", err) + } + if err := w.WriteInt32(0, 200); err != nil { + t.Fatalf("写入 status 失败: %v", err) + } + if err := w.WriteString(1, "请求成功"); err != nil { + t.Fatalf("写入 msg 失败: %v", err) + } + if err := w.WriteListStruct(2, prizes); err != nil { + t.Fatalf("写入奖品列表失败: %v", err) + } + if err := w.WriteInt32(3, 20575); err != nil { + t.Fatalf("写入 moduleId 失败: %v", err) + } + if err := w.WriteListStruct(4, tags); err != nil { + t.Fatalf("写入类目字典失败: %v", err) + } + if err := w.WriteStructEnd(); err != nil { + t.Fatalf("写入结构体结尾失败: %v", err) + } + + reader := taf.NewReader(w.Bytes()) + if _, typ, err := reader.ReadHead(); err != nil || typ != taf.TypeStructBegin { + t.Fatalf("响应头解析失败: typ=%v err=%v", typ, err) + } + var resp GetActPrizeListResp + if err := resp.ReadFrom(reader); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + return resp +} + +func samplePrizeList(t *testing.T) GetActPrizeListResp { + t.Helper() + // 时间取 2026-10-16 17:00:00(限量返场实际开抢时间)与 2026-10-23 23:59:59。 + return decodePrizeList(t, + []taf.Marshaler{ + testTag{id: 101, name: "精英专享", weight: 2}, + testTag{id: 99, name: "限量返场", weight: 1}, + }, + []taf.Marshaler{ + testPrize{id: 13415, name: "套装-雪山精英", score: 600, num: 50000, percent: "93", tags: "101"}, + testPrize{id: 13433, name: "怪诞乔克服装礼包", score: 8000, num: 6001, percent: "100", tags: "99", startTime: 1792141200, endTime: 1792771200}, + }) +} + +func TestPrizeListCategoryHelpers(t *testing.T) { + resp := samplePrizeList(t) + if len(resp.Prizes) != 2 { + t.Fatalf("奖品数 = %d, 期望 2", len(resp.Prizes)) + } + + limited := resp.Prizes[1] + if got := limited.CategoryIDs(); len(got) != 1 || got[0] != 99 { + t.Fatalf("CategoryIDs = %v, 期望 [99]", got) + } + if !limited.InCategory(99) || limited.InCategory(101) { + t.Fatalf("InCategory 判定异常: tags=%s", limited.Tags) + } + + start := limited.StartsAt() + if start.IsZero() || start.Unix() != 1792141200 { + t.Fatalf("StartsAt = %v, 期望 epoch 1792141200", start) + } + if end := limited.EndsAt(); end.Unix() != 1792771200 { + t.Fatalf("EndsAt = %d, 期望 epoch 1792771200", end.Unix()) + } + if !limited.NotYetOnSale(start.Add(-time.Minute)) { + t.Fatal("上架前应判定为未开售") + } + if limited.NotYetOnSale(start.Add(time.Minute)) { + t.Fatal("上架后不应判定为未开售") + } + if resp.Prizes[0].NotYetOnSale(time.Now().Add(-100 * time.Hour)) { + t.Fatal("未下发上架时间的商品不应判定为未开售") + } + + if name := resp.CategoryName(99); name != "限量返场" { + t.Fatalf("CategoryName(99) = %q", name) + } + cats := resp.CategoriesOf(limited) + if len(cats) != 1 || cats[0].Name != "限量返场" { + t.Fatalf("CategoriesOf = %v", cats) + } +} + +func TestPrizeListCategoryStatsAndSummary(t *testing.T) { + resp := samplePrizeList(t) + stats := resp.CategoryStats() + if len(stats) != 2 { + t.Fatalf("类目数 = %d, 期望 2", len(stats)) + } + if stats[0].Name != "精英专享" || stats[0].Count != 1 || stats[0].ID != 101 { + t.Fatalf("精英专享统计异常: %+v", stats[0]) + } + if stats[1].Name != "限量返场" || stats[1].Count != 1 || stats[1].ID != 99 { + t.Fatalf("限量返场统计异常: %+v", stats[1]) + } + // 剩余库存按 percent*num 估算:50000*93% = 46500、6001*100% = 6001。 + if stats[0].Left != 46500 { + t.Fatalf("精英专享剩余 = %d, 期望 46500", stats[0].Left) + } + if stats[1].Left != 6001 { + t.Fatalf("限量返场剩余 = %d, 期望 6001", stats[1].Left) + } + + want := "精英专享(101)=1项/限量返场(99)=1项" + if got := resp.CategorySummary(); got != want { + t.Fatalf("CategorySummary = %q, 期望 %q", got, want) + } +} + +func TestFilterByCategory(t *testing.T) { + resp := samplePrizeList(t) + cases := []struct { + name string + category string + wantIDs []int64 + }{ + {name: "不限类目", category: "", wantIDs: []int64{13415, 13433}}, + {name: "按类目名精确匹配", category: "限量返场", wantIDs: []int64{13433}}, + {name: "按类目名带空格", category: " 精英专享 ", wantIDs: []int64{13415}}, + {name: "按类目 id", category: "99", wantIDs: []int64{13433}}, + {name: "按类目名模糊匹配", category: "返场", wantIDs: []int64{13433}}, + {name: "字典里没有的类目", category: "不存在的类目", wantIDs: nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + filtered := resp.FilterByCategory(tc.category) + if len(filtered) != len(tc.wantIDs) { + t.Fatalf("命中 %d 项, 期望 %d 项", len(filtered), len(tc.wantIDs)) + } + for i, want := range tc.wantIDs { + if filtered[i].ID != want { + t.Fatalf("第 %d 项 pid = %d, 期望 %d", i, filtered[i].ID, want) + } + } + }) + } +} + +func TestFilterByCategoryFallsBackToRawTags(t *testing.T) { + // 服务端新增类目但没下发字典时,用原始 tag14 文本兜底,避免把商品全过滤掉。 + resp := decodePrizeList(t, + nil, + []taf.Marshaler{testPrize{id: 1, name: "新类目商品", tags: "777"}}, + ) + if got := resp.FilterByCategory("777"); len(got) != 1 { + t.Fatalf("兜底匹配命中 %d 项, 期望 1 项", len(got)) + } + if stats := resp.CategoryStats(); len(stats) != 0 { + t.Fatalf("无字典时不应产出类目统计, 实际 %v", stats) + } + if summary := resp.CategorySummary(); summary != "未下发类目(共1项)" { + t.Fatalf("CategorySummary = %q", summary) + } +} + +func TestCategoryIDsIgnoresInvalidValues(t *testing.T) { + prize := PrizeInfo{Tags: "99, 101,abc,"} + ids := prize.CategoryIDs() + if len(ids) != 2 || ids[0] != 99 || ids[1] != 101 { + t.Fatalf("CategoryIDs = %v", ids) + } + if empty := (PrizeInfo{}).CategoryIDs(); empty != nil { + t.Fatalf("空 tags 应返回 nil, 实际 %v", empty) + } +} diff --git a/internal/huya/http_client.go b/internal/huya/http_client.go index e1471b7..b83eb5e 100644 --- a/internal/huya/http_client.go +++ b/internal/huya/http_client.go @@ -192,7 +192,7 @@ func (c *HTTPClient) GetActPrizeList(ctx context.Context, uid int64, cookie stri _, err := c.CallRPC(ctx, ServantWebActUI, FuncPrizeList, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 1}) if err == nil { left, total, leftKnown, totalKnown := prizeListInventorySummary(resp.Prizes) - c.logf("[HTTP] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg) + c.logf("[HTTP] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 类目=%s msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), resp.Msg) } return &resp, err } diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index e212622..c972879 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -723,7 +723,7 @@ func (c *WSSClient) GetActPrizeList(ctx context.Context, uid int64, cookie strin _, err = session.callRPC(ctx, ServantWebActUI, FuncPrizeList, req, &resp) if err == nil { left, total, leftKnown, totalKnown := prizeListInventorySummary(resp.Prizes) - c.logf("[WSS] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg) + c.logf("[WSS] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 类目=%s msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.CategorySummary(), resp.Msg) } return &resp, err } diff --git a/internal/sniper/sniper.go b/internal/sniper/sniper.go index 3a31d80..e1b87d6 100644 --- a/internal/sniper/sniper.go +++ b/internal/sniper/sniper.go @@ -29,6 +29,15 @@ const ( CategoryCondition = "condition" ) +// CategoryText 把类目限制渲染成日志文案(空串表示不限类目)。 +func CategoryText(category string) string { + key := strings.TrimSpace(category) + if key == "" { + return "不限" + } + return key +} + // LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。 func LogLevelForCategory(category string) string { switch category { @@ -57,7 +66,10 @@ type Target struct { } type Config struct { - SID int32 + SID int32 + // Category 限定只在某个类目(限量返场/精英专享,或类目 id)里挑选可兑奖品, + // 留空表示不限制。见 huya.GetActPrizeListResp.FilterByCategory。 + Category string Loops int Delay time.Duration Concurrency int @@ -299,9 +311,11 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, return } + // 只按目标类目匹配:类目为空表示不限制(限量返场/精英专享都参与)。 + prizes := list.resp.FilterByCategory(cfg.Category) for _, index := range listTargets { target := targets[index] - prize := matchPrize(list.resp.Prizes, target) + prize := matchPrize(prizes, target) if prize == nil { result := Result{ Loop: loop, @@ -310,7 +324,7 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, Target: target, Action: "not_found", Category: CategoryNotFound, - Message: "未查询到所需物品,继续监控", + Message: notFoundMessage(cfg.Category), } if emit != nil { emit(result) @@ -595,6 +609,7 @@ func (s *runState) allDone() bool { } func normalizeConfig(cfg Config) Config { + cfg.Category = strings.TrimSpace(cfg.Category) if cfg.Loops <= 0 { cfg.Loops = 1 } @@ -662,6 +677,14 @@ func shouldDirectExchange(target Target, cfg Config) bool { return cfg.DirectExchange && !cfg.ListBeforeExchange && target.PID > 0 && !target.Auto } +// notFoundMessage 在未命中时把类目限制写进提示,便于区分「被类目过滤掉」与「列表里确实没有」。 +func notFoundMessage(category string) string { + if strings.TrimSpace(category) == "" { + return "未查询到所需物品,继续监控" + } + return fmt.Sprintf("类目[%s]内未查询到所需物品,继续监控", strings.TrimSpace(category)) +} + func matchPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo { if target.Auto { return autoPrize(prizes, target) diff --git a/internal/sniper/sniper_test.go b/internal/sniper/sniper_test.go index a34a827..1a8e9a5 100644 --- a/internal/sniper/sniper_test.go +++ b/internal/sniper/sniper_test.go @@ -261,6 +261,77 @@ func TestRunTargetsAutoSelectPrefersPrizeWithLeft(t *testing.T) { } } +// TestRunTargetsCategoryFilter 验证按类目限定后只会在该类的商品里成交。 +func TestRunTargetsCategoryFilter(t *testing.T) { + var gotPID int32 + client := &fakeActivityClient{ + listResp: &huya.GetActPrizeListResp{ + Status: 200, + Msg: "请求成功", + Tags: []huya.PrizeTag{ + {ID: 101, Name: "精英专享", Weight: 2}, + {ID: 99, Name: "限量返场", Weight: 1}, + }, + Prizes: []huya.PrizeInfo{ + {ID: 13415, Name: "套装-雪山精英", NewScore: 600, LeftNum: 100, Tags: "101"}, + {ID: 13433, Name: "怪诞乔克服装礼包", NewScore: 8000, LeftNum: 50, Tags: "99"}, + }, + }, + exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) { + gotPID = pid + return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil + }, + } + runner := Runner{Client: client} + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{ + SID: 2277, + Category: "限量返场", + Loops: 1, + StopAccountAfterSuccess: true, + }, nil) + if err != nil { + t.Fatal(err) + } + if gotPID != 13433 { + t.Fatalf("gotPID=%d, want 13433(应只在限量返场里选品)", gotPID) + } +} + +// TestRunTargetsCategoryFilterMisses 验证类目里没有目标时给出带类目的未命中提示。 +func TestRunTargetsCategoryFilterMisses(t *testing.T) { + client := &fakeActivityClient{ + listResp: &huya.GetActPrizeListResp{ + Status: 200, + Msg: "请求成功", + Tags: []huya.PrizeTag{{ID: 99, Name: "限量返场", Weight: 1}}, + Prizes: []huya.PrizeInfo{{ID: 13415, Name: "套装-雪山精英", NewScore: 600, Tags: "101"}}, + }, + } + results := make([]Result, 0, 4) + runner := Runner{Client: client} + err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Name: "雪山"}}, Config{ + SID: 2277, + Category: "限量返场", + Loops: 1, + ListBeforeExchange: true, + }, func(result Result) { + results = append(results, result) + }) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Category != CategoryNotFound { + t.Fatalf("results=%+v, want 单条 not_found", results) + } + if results[0].Message != "类目[限量返场]内未查询到所需物品,继续监控" { + t.Fatalf("Message=%q", results[0].Message) + } + _, exchangeCount, _ := client.counts() + if exchangeCount != 0 { + t.Fatalf("exchangeCount=%d, want 0", exchangeCount) + } +} + func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) { client := &fakeActivityClient{} runner := Runner{Client: client} diff --git a/internal/version/version.go b/internal/version/version.go index 4fcbf34..56c8dd4 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,10 +5,10 @@ package version // // 构建时可用 ldflags 覆盖(构建脚本就是这么做的): // -// go build -ldflags "-X huya-go/internal/version.Version=0.2.0" -var Version = "0.2.0" +// go build -ldflags "-X huya-go/internal/version.Version=0.2.1" +var Version = "0.2.1" -// Display 返回带 v 前缀的展示版本号,例如 v0.2.0。 +// Display 返回带 v 前缀的展示版本号,例如 v0.2.1。 func Display() string { return "v" + Version } diff --git a/wails.json b/wails.json index e80aaa3..7306240 100644 --- a/wails.json +++ b/wails.json @@ -9,7 +9,7 @@ "info": { "companyName": "huya-go", "productName": "虎牙抢购助手", - "productVersion": "0.2.0", + "productVersion": "0.2.1", "copyright": "", "comments": "虎牙活动积分兑换/抢购工具" }