- 抢购调度重构:参考 dygou_go 将 sniper 拆分为独立子模块(runner/run_control/retry/exchange/classify/config/summary/category/helpers);移除全局自适应降速与轮次上限,采用账号独立循环、统一账号级退避、单请求并发闸门与首发错峰 - 预热与建连优化:默认关闭预热(避免单账号超时连坐整机开抢),开启时转为后台非阻塞协程;WebSocket 握手超时从 10s 降至 1500ms - 双时间日志:logger 增加 SetServerOffset,校准后日志前缀自动渲染「本地时间(H虎牙估算时间)」,实现跨机器绝对毫秒对齐 - 默认配置调优:版本提升至 v0.5.0,默认线程调为 2(缓解多实例并发限流),前端自动平滑迁移 - 验证方式:go test ./... -count=1 全通,npm run build 构建正常,单测覆盖双时间与预热配置
192 lines
6.1 KiB
Go
192 lines
6.1 KiB
Go
package sniper
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"huya-go/internal/huya"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
for i := range prizes {
|
||
if target.PID > 0 && int32(prizes[i].ID) != target.PID {
|
||
continue
|
||
}
|
||
if target.PID == 0 && target.Name != "" && !strings.Contains(prizes[i].Name, target.Name) {
|
||
continue
|
||
}
|
||
if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice {
|
||
continue
|
||
}
|
||
if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft {
|
||
continue
|
||
}
|
||
return &prizes[i]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func autoPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
|
||
var fallback *huya.PrizeInfo
|
||
for i := range prizes {
|
||
if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice {
|
||
continue
|
||
}
|
||
if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft {
|
||
continue
|
||
}
|
||
if fallback == nil {
|
||
fallback = &prizes[i]
|
||
}
|
||
if prizes[i].EstimatedLeft() > 0 {
|
||
return &prizes[i]
|
||
}
|
||
}
|
||
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 只依据文案判定失败原因,命中时返回分类。
|
||
//
|
||
// 判定顺序固定:未开放 → 售罄 → 货币/次数 → 会话失效 → 下架 → 限流。
|
||
// 顺序很关键:限流关键词里有「稍后重试」「稍后」,如果排在前面会把更具体的原因吃掉。
|
||
// 2026-10-16 的日志里 244 条「领取频率限制,请稍后重试!!」和 917 条「活动太火爆了,
|
||
// 请稍后重试」过去全落到 failed:既看不出是被限流,也享受不到限流该有的退避语义。
|
||
func classifyExchangeText(text string) (string, bool) {
|
||
switch {
|
||
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, "账号未绑定", "未绑定账号", "未绑定手机", "登录态", "登录失效", "请先登录", "凭证失效", "token 失效"):
|
||
return CategorySessionLost, true
|
||
case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"):
|
||
return CategoryOffShelf, true
|
||
case containsAny(text, "操作频繁", "太频繁", "稍后再试", "稍后重试", "火爆", "频繁", "频率", "限流", "繁忙", "人多", "排队", "手慢", "点击太快", "请求过多", "行为异常"):
|
||
return CategoryTooFrequent, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string {
|
||
if err != nil {
|
||
// 协议层错误(缺少 tRsp/tResp、解码失败)单独归类:它不是业务失败,
|
||
// 通常是服务端在高并发下回了错误信封,需要单独统计与排查。
|
||
if errors.Is(err, huya.ErrProtocolResponse) {
|
||
return CategoryProtocol
|
||
}
|
||
return CategoryError
|
||
}
|
||
if resp == nil {
|
||
return CategoryFailed
|
||
}
|
||
if resp.Success() {
|
||
return CategorySuccess
|
||
}
|
||
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
|
||
}
|
||
|
||
// ExchangeMessage 返回响应里最有代表性的提示文案,供日志与 GUI 展示。
|
||
func ExchangeMessage(resp *huya.ScoreExchangePrizeResp) string {
|
||
if resp == nil {
|
||
return ""
|
||
}
|
||
for _, text := range []string{
|
||
resp.Msg,
|
||
resp.ActPreCondition.Tip,
|
||
resp.ActPreCondition.ButtonDesc,
|
||
resp.ExchangeInfo.Desc,
|
||
} {
|
||
if strings.TrimSpace(text) != "" {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func containsAny(text string, needles ...string) bool {
|
||
for _, needle := range needles {
|
||
if strings.Contains(text, needle) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func targetKey(target Target) string {
|
||
return strings.Join([]string{
|
||
strings.TrimSpace(target.Name),
|
||
int32String(target.PID),
|
||
strconv.FormatBool(target.Auto),
|
||
int64String(target.MaxPrice),
|
||
int64String(target.MinLeft),
|
||
}, "\x00")
|
||
}
|