补全过滤入口与直接兑换,优化兑换分类和 WSS 建连
- GUI 接线 Exchange 绑定:新增「直接兑换」按钮,结果带 category/message 并进入结果面板 - 补 -max-price/-min-left(CLI 与 GUI 设置),Target 的价格与库存过滤不再悬空 - 删除死代码 sendUpdateUserInfo 及其请求结构体(浏览器流程不含该帧) - ClassifyExchange 改为多路判定:合并 msg/actPreCondition/exchangeInfo 文案,status=200 且有 orderId 视为成功,新增 condition 分类(precondition 门禁) - WSS 建连改为锁外 dial、锁内注册,同 uid 并发调用共享一次握手 - 默认传输层统一为 http(CLI/GUI/README),.gitignore 忽略 .DS_Store - 同步 Wails 依赖升级(2.12.0 -> 2.15.0)并重新生成前端绑定
This commit is contained in:
+2
-1
@@ -4,4 +4,5 @@
|
||||
/frontend/node_modules/
|
||||
*.test
|
||||
build/
|
||||
frontend/dist/
|
||||
frontend/dist/
|
||||
.DS_Store
|
||||
@@ -1,9 +1,9 @@
|
||||
# huya-go
|
||||
|
||||
虎牙活动积分兑换/抢购的 Go 复刻版。当前支持两种传输层:
|
||||
虎牙活动积分兑换/抢购的 Go 复刻版。支持两种传输层,CLI 与 GUI 默认都走 `http`:
|
||||
|
||||
- `http`:走 `https://cdnws.api.huya.com/`,兼容原有命令。
|
||||
- `wss`:实验实现。已按浏览器 HAR 改为 TAF `WebSocketCommand` 外层和 WSS 初始化流程,仍建议先用真实 Cookie 小流量验证。
|
||||
- `http`(默认):走 `https://cdnws.api.huya.com/`,兼容原有命令。
|
||||
- `wss`:实验实现。CLI 需要显式指定 `-transport wss`,GUI 在设置里切换;已按浏览器 HAR 改为 TAF `WebSocketCommand` 外层和 WSS 初始化流程,仍建议先用真实 Cookie 小流量验证。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -77,6 +77,8 @@ CLI 示例:
|
||||
- `-cmd`:`list`、`score`、`records`、`exchange`、`monitor`。
|
||||
- `-sid`:活动 SID,必填。
|
||||
- `-pid`:奖品 PID。`exchange` 必填,`monitor` 可用 `-pid` 或 `-name`。
|
||||
- `-max-price`:目标奖品最高积分,`0` 表示不限制(CLI 与 GUI 设置都支持)。
|
||||
- `-min-left`:目标奖品最少剩余库存,`0` 表示不限制(CLI 与 GUI 设置都支持)。
|
||||
- `-cookie`:单账号 Cookie。
|
||||
- `-cookie-file`:多账号 Cookie 文件。
|
||||
- `-transport`:`http` 或 `wss`,默认 `http`;WSS 仍为实验通道。
|
||||
|
||||
+9
-2
@@ -27,6 +27,8 @@ func main() {
|
||||
pids = flag.String("pids", "", "多个奖品 PID,逗号分隔")
|
||||
name = flag.String("name", "", "按奖品名包含匹配")
|
||||
names = flag.String("names", "", "多个奖品名,逗号分隔")
|
||||
maxPrice = flag.Int64("max-price", 0, "目标奖品最高积分,0 表示不限制")
|
||||
minLeft = flag.Int64("min-left", 0, "目标奖品最少剩余库存,0 表示不限制")
|
||||
autoSelect = flag.Bool("auto", false, "monitor 自动选择列表里的可兑奖品")
|
||||
loops = flag.Int("loops", 1, "monitor 循环次数")
|
||||
delayMS = flag.Int("delay", 150, "monitor 每轮间隔毫秒")
|
||||
@@ -126,7 +128,7 @@ func main() {
|
||||
waitForCommand(ctx, startAt)
|
||||
runExchange(ctx, client, accounts, int32(*sid), int32(*pid), *concurrency)
|
||||
case "monitor":
|
||||
targets := parseTargets(*pid, *pids, *name, *names, *autoSelect)
|
||||
targets := parseTargets(*pid, *pids, *name, *names, *autoSelect, *maxPrice, *minLeft)
|
||||
if len(targets) == 0 {
|
||||
exitErr(errors.New("monitor 必须提供 -pid 或 -name"))
|
||||
}
|
||||
@@ -237,7 +239,7 @@ func loadCookies(cookie, file string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseTargets(pid int, pids, name, names string, autoSelect bool) []sniper.Target {
|
||||
func parseTargets(pid int, pids, name, names string, autoSelect bool, maxPrice, minLeft int64) []sniper.Target {
|
||||
targets := make([]sniper.Target, 0)
|
||||
name = strings.TrimSpace(name)
|
||||
if pid > 0 || name != "" {
|
||||
@@ -259,6 +261,11 @@ func parseTargets(pid int, pids, name, names string, autoSelect bool) []sniper.T
|
||||
}
|
||||
targets = append(targets, sniper.Target{Name: text})
|
||||
}
|
||||
// 价格/库存过滤对所有目标统一生效。
|
||||
for i := range targets {
|
||||
targets[i].MaxPrice = maxPrice
|
||||
targets[i].MinLeft = minLeft
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
|
||||
+63
-3
@@ -31,9 +31,11 @@ import {
|
||||
Square,
|
||||
Settings,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DefaultConfig,
|
||||
Exchange,
|
||||
GetLogs,
|
||||
GetRecords,
|
||||
IsRunning,
|
||||
@@ -56,6 +58,8 @@ type UiConfig = {
|
||||
pid: number
|
||||
name: string
|
||||
targetText: string
|
||||
maxPrice: number
|
||||
minLeft: number
|
||||
cookieText: string
|
||||
selectedAccountLines: number[]
|
||||
loops: number
|
||||
@@ -167,6 +171,14 @@ type ExchangeResp = {
|
||||
actPreCondition?: ActPreCondition
|
||||
}
|
||||
|
||||
type ExchangeResult = {
|
||||
uid: number
|
||||
resp?: ExchangeResp
|
||||
category?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type MonitorResult = {
|
||||
key?: string
|
||||
loop?: number
|
||||
@@ -300,6 +312,7 @@ function categoryTag(category?: string): ReactNode {
|
||||
already_done: { color: 'purple', label: '已兑' },
|
||||
off_shelf: { color: 'error', label: '下架' },
|
||||
sold_out: { color: 'error', label: '抢完' },
|
||||
condition: { color: 'geekblue', label: '条件' },
|
||||
failed: { color: 'warning', label: '失败' },
|
||||
error: { color: 'error', label: '异常' },
|
||||
}
|
||||
@@ -399,12 +412,14 @@ function newCookieID(): string {
|
||||
|
||||
function pickConfig(values: Partial<UiConfig>): UiConfig {
|
||||
return {
|
||||
transport: values.transport || 'wss',
|
||||
transport: values.transport || 'http',
|
||||
wssHost: values.wssHost || '',
|
||||
sid: Number(values.sid || 0),
|
||||
pid: Number(values.pid || 0),
|
||||
name: values.name || '',
|
||||
targetText: values.targetText || '',
|
||||
maxPrice: Number(values.maxPrice || 0),
|
||||
minLeft: Number(values.minLeft || 0),
|
||||
cookieText: values.cookieText || '',
|
||||
selectedAccountLines: values.selectedAccountLines || [],
|
||||
loops: Number(values.loops || 1),
|
||||
@@ -596,7 +611,9 @@ export default function Dashboard() {
|
||||
const errorCount = useMemo(
|
||||
() =>
|
||||
results.filter((item) =>
|
||||
['error', 'failed', 'not_enough', 'off_shelf', 'sold_out'].includes(item.category || ''),
|
||||
['error', 'failed', 'not_enough', 'off_shelf', 'sold_out', 'condition'].includes(
|
||||
item.category || '',
|
||||
),
|
||||
).length,
|
||||
[results],
|
||||
)
|
||||
@@ -827,6 +844,8 @@ export default function Dashboard() {
|
||||
pid: values.pid,
|
||||
name: values.name,
|
||||
targetText: values.targetText,
|
||||
maxPrice: values.maxPrice,
|
||||
minLeft: values.minLeft,
|
||||
loops: values.loops,
|
||||
delayMs: values.delayMs,
|
||||
concurrency: values.concurrency,
|
||||
@@ -881,6 +900,33 @@ export default function Dashboard() {
|
||||
})
|
||||
}
|
||||
|
||||
const directExchange = () => {
|
||||
const values = config()
|
||||
if (Number(values.pid || 0) <= 0) {
|
||||
message.warning('请先在左侧商品列表中选择要兑换的商品')
|
||||
return undefined
|
||||
}
|
||||
return runAction(
|
||||
'exchange',
|
||||
() => Exchange(values),
|
||||
(data) => {
|
||||
const items = (data || []) as ExchangeResult[]
|
||||
setResults(
|
||||
items.map((item, index) => ({
|
||||
key: `exchange-${item.uid}-${index}-${Date.now()}`,
|
||||
uid: item.uid,
|
||||
action: 'exchange',
|
||||
category: item.category || (item.error ? 'error' : 'failed'),
|
||||
message: item.message || item.error || item.resp?.msg || '',
|
||||
exchange: item.resp,
|
||||
})),
|
||||
)
|
||||
const success = items.filter((item) => item.category === 'success').length
|
||||
message.info(`直接兑换:成功 ${success}/${items.length}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const selectAllAccounts = () => {
|
||||
setSelectedAccountLines(validAccounts.map((item) => item.line))
|
||||
}
|
||||
@@ -1180,7 +1226,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<CountdownBadge countdown={scheduleCountdown} now={now} running={running} />
|
||||
<Space size={12}>
|
||||
<Tag>{String(watchedTransport || 'wss').toUpperCase()}</Tag>
|
||||
<Tag>{String(watchedTransport || 'http').toUpperCase()}</Tag>
|
||||
<Button icon={<Settings size={16} />} onClick={() => setSettingsOpen(true)}>
|
||||
设置
|
||||
</Button>
|
||||
@@ -1225,6 +1271,14 @@ export default function Dashboard() {
|
||||
>
|
||||
开启定时
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Zap size={16} />}
|
||||
loading={loading === 'exchange'}
|
||||
disabled={running || validAccounts.length === 0}
|
||||
onClick={directExchange}
|
||||
>
|
||||
直接兑换
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ClipboardPaste size={16} />}
|
||||
loading={loading === 'clipboard'}
|
||||
@@ -1456,6 +1510,12 @@ export default function Dashboard() {
|
||||
<Form.Item name="pid" label="PID">
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxPrice" label="最高积分">
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="minLeft" label="最少库存">
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="loops" label="次数">
|
||||
<InputNumber min={1} max={9999} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -25,6 +25,8 @@ export namespace gui {
|
||||
pid: number;
|
||||
name: string;
|
||||
targetText: string;
|
||||
maxPrice: number;
|
||||
minLeft: number;
|
||||
cookieText: string;
|
||||
selectedAccountLines: number[];
|
||||
loops: number;
|
||||
@@ -55,6 +57,8 @@ export namespace gui {
|
||||
this.pid = source["pid"];
|
||||
this.name = source["name"];
|
||||
this.targetText = source["targetText"];
|
||||
this.maxPrice = source["maxPrice"];
|
||||
this.minLeft = source["minLeft"];
|
||||
this.cookieText = source["cookieText"];
|
||||
this.selectedAccountLines = source["selectedAccountLines"];
|
||||
this.loops = source["loops"];
|
||||
@@ -77,6 +81,8 @@ export namespace gui {
|
||||
export class ExchangeResult {
|
||||
uid: number;
|
||||
resp?: huya.ScoreExchangePrizeResp;
|
||||
category?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -87,6 +93,8 @@ export namespace gui {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.uid = source["uid"];
|
||||
this.resp = this.convertValues(source["resp"], huya.ScoreExchangePrizeResp);
|
||||
this.category = source["category"];
|
||||
this.message = source["message"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.26
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/wailsapp/wails/v2 v2.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,8 +31,8 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
)
|
||||
|
||||
@@ -47,8 +47,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -59,25 +59,25 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
github.com/wailsapp/wails/v2 v2.15.0 h1:u7cHK+UesZOlYxyJxfYLteaCPhws6UsZoDdqUejuX6Q=
|
||||
github.com/wailsapp/wails/v2 v2.15.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+21
-6
@@ -48,6 +48,8 @@ type Config struct {
|
||||
PID int32 `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
TargetText string `json:"targetText"`
|
||||
MaxPrice int64 `json:"maxPrice"`
|
||||
MinLeft int64 `json:"minLeft"`
|
||||
CookieText string `json:"cookieText"`
|
||||
SelectedAccountLines []int `json:"selectedAccountLines"`
|
||||
Loops int `json:"loops"`
|
||||
@@ -103,9 +105,11 @@ type RecordResult struct {
|
||||
}
|
||||
|
||||
type ExchangeResult struct {
|
||||
UID int64 `json:"uid"`
|
||||
Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
UID int64 `json:"uid"`
|
||||
Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MonitorState struct {
|
||||
@@ -138,7 +142,7 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
|
||||
func (a *App) DefaultConfig() Config {
|
||||
return Config{
|
||||
Transport: "wss",
|
||||
Transport: "http",
|
||||
WSSHost: huya.DefaultWSSHost,
|
||||
SID: 2203,
|
||||
Loops: 20,
|
||||
@@ -296,7 +300,13 @@ func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
|
||||
ctx, cancel := requestContext(cfg.TimeoutSec)
|
||||
defer cancel()
|
||||
resp, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, cfg.SID, cfg.PID)
|
||||
results[i] = ExchangeResult{UID: account.UID, Resp: resp, Error: errorString(err)}
|
||||
results[i] = ExchangeResult{
|
||||
UID: account.UID,
|
||||
Resp: resp,
|
||||
Category: sniper.ClassifyExchange(resp, err),
|
||||
Message: sniper.ExchangeMessage(resp),
|
||||
Error: errorString(err),
|
||||
}
|
||||
})
|
||||
a.addLog("info", "直接兑换完成: sid=%d pid=%d accounts=%d", cfg.SID, cfg.PID, len(accounts))
|
||||
return results, nil
|
||||
@@ -530,7 +540,7 @@ func (a *App) emit(event string, data any) {
|
||||
|
||||
func normalizeConfig(cfg Config) Config {
|
||||
if strings.TrimSpace(cfg.Transport) == "" {
|
||||
cfg.Transport = "wss"
|
||||
cfg.Transport = "http"
|
||||
}
|
||||
if strings.TrimSpace(cfg.WSSHost) == "" {
|
||||
cfg.WSSHost = huya.DefaultWSSHost
|
||||
@@ -582,6 +592,11 @@ func parseTargets(cfg Config) []sniper.Target {
|
||||
}
|
||||
targets = append(targets, sniper.Target{Name: item})
|
||||
}
|
||||
// 价格/库存过滤对所有目标统一生效,避免逐处重复赋值。
|
||||
for i := range targets {
|
||||
targets[i].MaxPrice = cfg.MaxPrice
|
||||
targets[i].MinLeft = cfg.MinLeft
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
|
||||
@@ -98,3 +98,35 @@ func TestAddLogDoesNotBlockWhenQueueIsFull(t *testing.T) {
|
||||
t.Fatalf("droppedLogs = %d, want 1", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetsAppliesPriceAndInventoryFilters(t *testing.T) {
|
||||
targets := parseTargets(Config{
|
||||
PID: 12889,
|
||||
Name: "小黄鸭",
|
||||
TargetText: "13001\nauto",
|
||||
AutoSelect: true,
|
||||
MaxPrice: 1000,
|
||||
MinLeft: 2,
|
||||
})
|
||||
if len(targets) != 4 {
|
||||
t.Fatalf("targets=%d, want 4", len(targets))
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.MaxPrice != 1000 {
|
||||
t.Fatalf("target %+v maxPrice=%d, want 1000", target, target.MaxPrice)
|
||||
}
|
||||
if target.MinLeft != 2 {
|
||||
t.Fatalf("target %+v minLeft=%d, want 2", target, target.MinLeft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetsWithoutFiltersKeepsZeroValues(t *testing.T) {
|
||||
targets := parseTargets(Config{PID: 12889})
|
||||
if len(targets) != 1 {
|
||||
t.Fatalf("targets=%d, want 1", len(targets))
|
||||
}
|
||||
if targets[0].MaxPrice != 0 || targets[0].MinLeft != 0 {
|
||||
t.Fatalf("unexpected filters: %+v", targets[0])
|
||||
}
|
||||
}
|
||||
|
||||
+49
-63
@@ -35,8 +35,13 @@ type WSSClient struct {
|
||||
Logger func(string)
|
||||
CallTimeout time.Duration
|
||||
|
||||
// dialFunc 仅用于测试注入,为 nil 时走 dialSession。
|
||||
dialFunc func(ctx context.Context, uid int64, cookie string) (*WSSSession, error)
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*WSSSession
|
||||
// dialing 记录正在建连的 uid,避免并发调用重复握手。
|
||||
dialing map[string]chan struct{}
|
||||
}
|
||||
|
||||
type WSSSession struct {
|
||||
@@ -73,6 +78,7 @@ func NewWSSClient(logger func(string)) *WSSClient {
|
||||
Logger: logger,
|
||||
CallTimeout: 10 * time.Second,
|
||||
sessions: map[string]*WSSSession{},
|
||||
dialing: map[string]chan struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,26 +111,54 @@ func (c *WSSClient) PrewarmActivity(ctx context.Context, uid int64, cookie strin
|
||||
return err
|
||||
}
|
||||
|
||||
// session 返回 uid 对应的连接,必要时新建。
|
||||
//
|
||||
// 握手与初始化在锁外完成,同一 uid 的并发调用共享同一次建连(single-flight),
|
||||
// 因此多账号预热/抢购时不会因为持有全局锁而串行化。
|
||||
func (c *WSSClient) session(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
|
||||
key := strconv.FormatInt(uid, 10)
|
||||
c.mu.Lock()
|
||||
if session := c.sessions[key]; session != nil && !session.isClosed() {
|
||||
c.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
for {
|
||||
c.mu.Lock()
|
||||
if session := c.sessions[key]; session != nil && !session.isClosed() {
|
||||
c.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
dialing := c.dialing[key]
|
||||
if dialing == nil {
|
||||
dialing = make(chan struct{})
|
||||
c.dialing[key] = dialing
|
||||
c.mu.Unlock()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if session := c.sessions[key]; session != nil && !session.isClosed() {
|
||||
return session, nil
|
||||
session, err := c.dial(ctx, uid, cookie)
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.dialing, key)
|
||||
if err == nil {
|
||||
c.sessions[key] = session
|
||||
}
|
||||
c.mu.Unlock()
|
||||
close(dialing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// 已有同 uid 的建连在途,等它结束后复用结果或自行重试。
|
||||
select {
|
||||
case <-dialing:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
session, err := c.dialSession(ctx, uid, cookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (c *WSSClient) dial(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
|
||||
if c.dialFunc != nil {
|
||||
return c.dialFunc(ctx, uid, cookie)
|
||||
}
|
||||
c.sessions[key] = session
|
||||
return session, nil
|
||||
return c.dialSession(ctx, uid, cookie)
|
||||
}
|
||||
|
||||
func (c *WSSClient) dialSession(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
|
||||
@@ -383,15 +417,6 @@ func (s *WSSSession) callGetConfig(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WSSSession) sendUpdateUserInfo(ctx context.Context, launchGUID string) error {
|
||||
req := &wsUpdateUserInfoReq{AppSrc: "HUYA&ZH&2052", GUID: launchGUID}
|
||||
payload, err := encodeWSSStructCommand(WSSCmdUpdateUserInfoReq, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writePayload(ctx, payload)
|
||||
}
|
||||
|
||||
func (s *WSSSession) sendUpdateUserExps(ctx context.Context) error {
|
||||
payload, err := encodeWSSStructCommand(WSSCmdUpdateUserExpsReq, &wsUpdateUserExpsReq{})
|
||||
if err != nil {
|
||||
@@ -631,45 +656,6 @@ func (r *wsLaunchReq) WriteTo(w *taf.Writer) error {
|
||||
return w.WriteStructEnd()
|
||||
}
|
||||
|
||||
type wsUpdateUserInfoReq struct {
|
||||
AppSrc string
|
||||
GUID string
|
||||
}
|
||||
|
||||
func (r *wsUpdateUserInfoReq) WriteTo(w *taf.Writer) error {
|
||||
if err := w.WriteString(0, r.AppSrc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteString(1, r.GUID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt32(2, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt32(3, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteStructBegin(6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteMapStringString(0, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteMapStringString(1, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteInt32(2, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteStructEnd(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteMapStringString(7, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.WriteInt32(8, 0)
|
||||
}
|
||||
|
||||
type wsUpdateUserExpsReq struct{}
|
||||
|
||||
func (r *wsUpdateUserExpsReq) WriteTo(w *taf.Writer) error {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package huya
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"huya-go/internal/taf"
|
||||
)
|
||||
@@ -96,3 +100,63 @@ func TestNewWSSCommandTraceMatchesBrowserShape(t *testing.T) {
|
||||
t.Fatalf("trace suffix = %q, want :0:0", trace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSharesSingleDialPerUID(t *testing.T) {
|
||||
client := NewWSSClient(nil)
|
||||
dialStarted := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
var dials atomic.Int32
|
||||
client.dialFunc = func(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
|
||||
dials.Add(1)
|
||||
select {
|
||||
case dialStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-release
|
||||
return &WSSSession{
|
||||
client: client,
|
||||
uid: uid,
|
||||
cookie: cookie,
|
||||
closed: make(chan struct{}),
|
||||
pending: map[int32]chan rpcResult{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
const callers = 4
|
||||
sessions := make([]*WSSSession, callers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < callers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
session, err := client.session(context.Background(), 42, "yyuid=42")
|
||||
if err != nil {
|
||||
t.Errorf("session: %v", err)
|
||||
return
|
||||
}
|
||||
sessions[i] = session
|
||||
}(i)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-dialStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dial was not started")
|
||||
}
|
||||
// 留出时间让其余调用进入等待分支。
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := dials.Load(); got != 1 {
|
||||
t.Fatalf("dials=%d, want 1", got)
|
||||
}
|
||||
for i, session := range sessions {
|
||||
if session == nil {
|
||||
t.Fatalf("caller %d got nil session", i)
|
||||
}
|
||||
if session != sessions[0] {
|
||||
t.Fatalf("caller %d got a different session", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
-25
@@ -24,6 +24,9 @@ const (
|
||||
CategorySoldOut = "sold_out"
|
||||
CategoryFailed = "failed"
|
||||
CategoryError = "error"
|
||||
// CategoryCondition 表示服务端返回了 precondition 门禁(条件未达成),
|
||||
// 没有可识别文案,按可重试失败处理但单独分类便于观察。
|
||||
CategoryCondition = "condition"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
@@ -314,7 +317,7 @@ func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient,
|
||||
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid)
|
||||
result.Exchange = exchange
|
||||
result.Category = ClassifyExchange(exchange, err)
|
||||
result.Message = exchangeMessage(exchange)
|
||||
result.Message = ExchangeMessage(exchange)
|
||||
if err != nil {
|
||||
result.Action = "exchange_error"
|
||||
result.Error = err.Error()
|
||||
@@ -354,11 +357,7 @@ func (r *Runner) applyExchangeDecision(state *runState, account Account, index i
|
||||
retryAt := now.Add(cfg.TooFrequentBackoff)
|
||||
state.setRetryAfter(account.UID, index, retryAt)
|
||||
result.RetryAfter = retryAt.UnixMilli()
|
||||
case CategoryError:
|
||||
retryAt := now.Add(cfg.ErrorBackoff)
|
||||
state.setRetryAfter(account.UID, index, retryAt)
|
||||
result.RetryAfter = retryAt.UnixMilli()
|
||||
case CategoryFailed:
|
||||
case CategoryError, CategoryFailed, CategoryCondition:
|
||||
retryAt := now.Add(cfg.ErrorBackoff)
|
||||
state.setRetryAfter(account.UID, index, retryAt)
|
||||
result.RetryAfter = retryAt.UnixMilli()
|
||||
@@ -609,6 +608,64 @@ func autoPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// exchangeSignals 汇总一次兑换响应里可用于判定的信号。
|
||||
//
|
||||
// 虎牙把失败原因分散在 msg / actPreCondition(Tip/ButtonDesc/ConditionParams)
|
||||
// 和 exchangeInfo.desc 里,status 除 200 外没有可依据的公开语义,
|
||||
// 所以判定顺序是:文案 -> 成功结构(status=200 且有 orderId)-> 结构门禁 -> 兜底失败。
|
||||
type exchangeSignals struct {
|
||||
text string
|
||||
status int32
|
||||
hasOrderID bool
|
||||
conditionType int32
|
||||
}
|
||||
|
||||
func buildExchangeSignals(resp *huya.ScoreExchangePrizeResp) exchangeSignals {
|
||||
if resp == nil {
|
||||
return exchangeSignals{}
|
||||
}
|
||||
parts := []string{
|
||||
resp.Msg,
|
||||
resp.ActPreCondition.Tip,
|
||||
resp.ActPreCondition.ButtonDesc,
|
||||
resp.ExchangeInfo.Desc,
|
||||
}
|
||||
for _, value := range resp.ActPreCondition.ConditionParams {
|
||||
parts = append(parts, value)
|
||||
}
|
||||
texts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if item := strings.TrimSpace(part); item != "" {
|
||||
texts = append(texts, item)
|
||||
}
|
||||
}
|
||||
return exchangeSignals{
|
||||
text: strings.Join(texts, " "),
|
||||
status: resp.Status,
|
||||
hasOrderID: strings.TrimSpace(resp.OrderID) != "",
|
||||
conditionType: resp.ActPreCondition.ConditionType,
|
||||
}
|
||||
}
|
||||
|
||||
// classifyExchangeText 只依据文案判定失败原因,命中时返回分类。
|
||||
func classifyExchangeText(text string) (string, bool) {
|
||||
switch {
|
||||
case containsAny(text, "操作频繁", "太频繁", "稍后再试"):
|
||||
return CategoryTooFrequent, true
|
||||
case containsAny(text, "暂未开放", "未开放", "未开始", "活动未开始"):
|
||||
return CategoryNotOpen, true
|
||||
case containsAny(text, "已抢完", "已兑完", "库存不足", "没有库存", "领完了"):
|
||||
return CategorySoldOut, true
|
||||
case containsAny(text, "货币不足", "积分不足", "余额不足", "点券不足", "不足"):
|
||||
return CategoryNotEnough, true
|
||||
case containsAny(text, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"):
|
||||
return CategoryAlreadyDone, true
|
||||
case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"):
|
||||
return CategoryOffShelf, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string {
|
||||
if err != nil {
|
||||
return CategoryError
|
||||
@@ -619,28 +676,23 @@ func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string {
|
||||
if resp.Success() {
|
||||
return CategorySuccess
|
||||
}
|
||||
msg := exchangeMessage(resp)
|
||||
switch {
|
||||
case containsAny(msg, "操作频繁", "太频繁", "稍后再试"):
|
||||
return CategoryTooFrequent
|
||||
case containsAny(msg, "暂未开放", "未开放", "未开始", "活动未开始"):
|
||||
return CategoryNotOpen
|
||||
case containsAny(msg, "已抢完", "已兑完", "库存不足", "没有库存", "领完了"):
|
||||
return CategorySoldOut
|
||||
case containsAny(msg, "货币不足", "积分不足", "余额不足", "点券不足", "不足"):
|
||||
return CategoryNotEnough
|
||||
case containsAny(msg, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"):
|
||||
return CategoryAlreadyDone
|
||||
case containsAny(msg, "奖品已下架", "已下架", "奖品不存在", "活动不存在"):
|
||||
return CategoryOffShelf
|
||||
case resp.Status != 0:
|
||||
return CategoryFailed
|
||||
default:
|
||||
return CategoryFailed
|
||||
signals := buildExchangeSignals(resp)
|
||||
if category, ok := classifyExchangeText(signals.text); ok {
|
||||
return category
|
||||
}
|
||||
// 成功回包也可能不带中文文案:status=200 且有 orderId 视为成功。
|
||||
if signals.status == 200 && signals.hasOrderID {
|
||||
return CategorySuccess
|
||||
}
|
||||
// 没有文案但服务端回了 precondition 门禁,归为条件未达成,按可重试失败处理。
|
||||
if signals.conditionType != 0 {
|
||||
return CategoryCondition
|
||||
}
|
||||
return CategoryFailed
|
||||
}
|
||||
|
||||
func exchangeMessage(resp *huya.ScoreExchangePrizeResp) string {
|
||||
// ExchangeMessage 返回响应里最有代表性的提示文案,供日志与 GUI 展示。
|
||||
func ExchangeMessage(resp *huya.ScoreExchangePrizeResp) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -83,6 +83,44 @@ func TestClassifyExchange(t *testing.T) {
|
||||
{name: "off shelf", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品已下架"}, want: CategoryOffShelf},
|
||||
{name: "sold out", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "库存不足"}, want: CategorySoldOut},
|
||||
{name: "error", err: errors.New("timeout"), want: CategoryError},
|
||||
{
|
||||
name: "success with order id",
|
||||
resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "sOrderId_2203_12889_1_1783328404_957"},
|
||||
want: CategorySuccess,
|
||||
},
|
||||
{
|
||||
name: "not enough in precondition tip",
|
||||
resp: &huya.ScoreExchangePrizeResp{
|
||||
Status: 500,
|
||||
Msg: "兑换失败",
|
||||
ActPreCondition: huya.ActPreCondition{Tip: "积分不足,无法兑换"},
|
||||
},
|
||||
want: CategoryNotEnough,
|
||||
},
|
||||
{
|
||||
name: "not enough in condition params",
|
||||
resp: &huya.ScoreExchangePrizeResp{
|
||||
Status: 500,
|
||||
Msg: "兑换失败",
|
||||
ActPreCondition: huya.ActPreCondition{ConditionParams: map[string]string{"tip": "余额不足"}},
|
||||
},
|
||||
want: CategoryNotEnough,
|
||||
},
|
||||
{
|
||||
name: "condition gate without text",
|
||||
resp: &huya.ScoreExchangePrizeResp{
|
||||
Status: 500,
|
||||
ActPreCondition: huya.ActPreCondition{ConditionType: 2},
|
||||
},
|
||||
want: CategoryCondition,
|
||||
},
|
||||
{name: "empty response", resp: &huya.ScoreExchangePrizeResp{Status: 500}, want: CategoryFailed},
|
||||
{name: "nil response", resp: nil, want: CategoryFailed},
|
||||
{
|
||||
name: "text wins over order id",
|
||||
resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "order", Msg: "库存不足"},
|
||||
want: CategorySoldOut,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -253,3 +291,29 @@ func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTargetsConditionGateKeepsRetrying(t *testing.T) {
|
||||
client := &fakeActivityClient{
|
||||
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
|
||||
return &huya.ScoreExchangePrizeResp{
|
||||
Status: 500,
|
||||
ActPreCondition: huya.ActPreCondition{ConditionType: 3},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
runner := Runner{Client: client}
|
||||
|
||||
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
|
||||
SID: 2203,
|
||||
Loops: 3,
|
||||
Delay: 10 * time.Millisecond,
|
||||
ErrorBackoff: time.Millisecond,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, exchangeCount, _ := client.counts()
|
||||
if exchangeCount != 3 {
|
||||
t.Fatalf("exchangeCount=%d, want 3", exchangeCount)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user