Files
huya-go/internal/sniper/sniper.go
T

851 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sniper
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"huya-go/internal/huya"
)
const (
CategoryReady = "ready"
CategorySuccess = "success"
CategoryNotFound = "not_found"
CategoryNotOpen = "not_open"
CategoryTooFrequent = "too_frequent"
CategoryNotEnough = "not_enough"
CategoryAlreadyDone = "already_done"
CategoryOffShelf = "off_shelf"
CategorySoldOut = "sold_out"
CategoryFailed = "failed"
CategoryError = "error"
// CategoryCondition 表示服务端返回了 precondition 门禁(条件未达成),
// 没有可识别文案,按可重试失败处理但单独分类便于观察。
CategoryCondition = "condition"
)
// LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。
func LogLevelForCategory(category string) string {
switch category {
case CategorySuccess:
return "success"
case CategoryError:
return "error"
case CategoryFailed, CategoryTooFrequent, CategoryCondition:
return "warn"
default:
return "info"
}
}
type Account struct {
UID int64 `json:"uid"`
Cookie string `json:"-"`
}
type Target struct {
PID int32 `json:"pid"`
Name string `json:"name"`
Auto bool `json:"auto,omitempty"`
MaxPrice int64 `json:"max_price,omitempty"`
MinLeft int64 `json:"min_left,omitempty"`
}
type Config struct {
SID int32
Loops int
Delay time.Duration
Concurrency int
StopAfterSuccess bool
StopAccountAfterSuccess bool
Prewarm bool
DirectExchange bool
ListBeforeExchange bool
SharePrizeList bool
RefreshAfterSuccess bool
StopOnNotEnough bool
ContinueOnNotEnough bool
StopOnSoldOut bool
ContinueOnSoldOut bool
TooFrequentBackoff time.Duration
ErrorBackoff time.Duration
}
type Result struct {
Loop int `json:"loop"`
UID int64 `json:"uid"`
TargetIndex int `json:"target_index,omitempty"`
Target Target `json:"target"`
Action string `json:"action"`
Category string `json:"category,omitempty"`
Message string `json:"message,omitempty"`
Prize *huya.PrizeInfo `json:"prize,omitempty"`
Exchange *huya.ScoreExchangePrizeResp `json:"exchange,omitempty"`
Score *huya.GetUserScoreResp `json:"score,omitempty"`
Records *huya.GetUserPrizeRecordsResp `json:"records,omitempty"`
Done bool `json:"done,omitempty"`
RetryAfter int64 `json:"retry_after,omitempty"`
Error string `json:"error,omitempty"`
}
type Runner struct {
Client huya.ActivityClient
}
type ActivityPrewarmer interface {
PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error
}
type runState struct {
mu sync.Mutex
accounts map[int64][]targetState
targetDone []bool
// exchanged 记录每个账号已成功兑换的奖品,避免多个 target 命中同一奖品时重复兑换。
exchanged map[int64]map[int64]bool
// refresh 用于等待成功后的异步刷新,确保 RunTargets 返回前不会提前释放连接。
refresh sync.WaitGroup
}
type targetState struct {
Done bool
RetryAfter time.Time
}
type listSnapshot struct {
resp *huya.GetActPrizeListResp
err error
}
func (r *Runner) Run(ctx context.Context, accounts []Account, target Target, cfg Config, emit func(Result)) error {
return r.RunTargets(ctx, accounts, []Target{target}, cfg, emit)
}
func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []Target, cfg Config, emit func(Result)) error {
cfg = normalizeConfig(cfg)
targets = normalizeTargets(targets)
if len(accounts) == 0 {
return errors.New("账号不能为空")
}
if len(targets) == 0 {
return errors.New("目标奖品不能为空")
}
client := r.client()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if cfg.Prewarm {
if err := r.Prewarm(ctx, accounts, cfg, emit); err != nil {
return err
}
}
state := newRunState(accounts, len(targets))
// 成功后的刷新是异步的,等它们结束再返回,避免调用方提前关闭连接。
defer state.refresh.Wait()
for loop := 1; loop <= cfg.Loops; loop++ {
if ctx.Err() != nil {
return ctx.Err()
}
if state.allDone() {
return nil
}
sharedList := r.sharedList(ctx, client, accounts, targets, cfg, state)
sem := make(chan struct{}, cfg.Concurrency)
var wg sync.WaitGroup
for _, account := range accounts {
account := account
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
r.runAccountLoop(ctx, client, state, account, targets, cfg, loop, sharedList, emit, cancel)
}()
}
wg.Wait()
if ctx.Err() != nil {
return ctx.Err()
}
if state.allDone() {
return nil
}
if loop < cfg.Loops {
if err := sleepContext(ctx, cfg.Delay); err != nil {
return err
}
}
}
return nil
}
func (r *Runner) Prewarm(ctx context.Context, accounts []Account, cfg Config, emit func(Result)) error {
cfg = normalizeConfig(cfg)
if len(accounts) == 0 {
return errors.New("账号不能为空")
}
client := r.client()
prewarmer, ok := client.(ActivityPrewarmer)
if !ok {
return nil
}
sem := make(chan struct{}, cfg.Concurrency)
var wg sync.WaitGroup
for _, account := range accounts {
account := account
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
err := prewarmer.PrewarmActivity(ctx, account.UID, account.Cookie, cfg.SID)
result := Result{
UID: account.UID,
Action: "prewarm",
Category: CategoryReady,
Message: "连接已预热",
}
if err != nil {
result.Category = CategoryError
result.Error = err.Error()
result.Message = "连接预热失败"
}
if emit != nil {
emit(result)
}
}()
}
wg.Wait()
return ctx.Err()
}
func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, loop int, sharedList *listSnapshot, emit func(Result), cancel context.CancelFunc) {
active := state.activeTargets(account.UID, len(targets), time.Now())
if len(active) == 0 {
return
}
listTargets := make([]int, 0, len(active))
for _, index := range active {
target := targets[index]
if shouldDirectExchange(target, cfg) {
// 直兑路径没有奖品列表,用 target.PID 判断是否已经兑换过同一奖品。
if state.prizeExchanged(account.UID, int64(target.PID)) {
emitSkippedDuplicate(emit, loop, account.UID, index, target)
continue
}
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, nil)
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if result.Category == CategorySuccess {
state.markPrizeExchanged(account.UID, int64(target.PID))
}
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
continue
}
listTargets = append(listTargets, index)
}
if len(listTargets) == 0 || ctx.Err() != nil {
return
}
list := sharedList
if list == nil {
resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID)
list = &listSnapshot{resp: resp, err: err}
}
if list.err != nil || list.resp == nil {
errText := "奖品列表响应为空"
if list.err != nil {
errText = list.err.Error()
}
for _, index := range listTargets {
retryAt := time.Now().Add(cfg.ErrorBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: targets[index],
Action: "list_error",
Category: CategoryError,
Message: "查询奖品列表失败",
RetryAfter: retryAt.UnixMilli(),
Error: errText,
}
if emit != nil {
emit(result)
}
}
return
}
for _, index := range listTargets {
target := targets[index]
prize := matchPrize(list.resp.Prizes, target)
if prize == nil {
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "not_found",
Category: CategoryNotFound,
Message: "未查询到所需物品,继续监控",
}
if emit != nil {
emit(result)
}
continue
}
// 多个 target 可能命中同一个奖品(例如按不同名称匹配),已成功兑换过就跳过。
if state.prizeExchanged(account.UID, prize.ID) {
emitSkippedDuplicate(emit, loop, account.UID, index, target)
continue
}
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, prize)
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if result.Category == CategorySuccess {
state.markPrizeExchanged(account.UID, prize.ID)
}
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
}
}
// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。
func emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, target Target) {
if emit == nil {
return
}
emit(Result{
Loop: loop,
UID: uid,
TargetIndex: index,
Target: target,
Action: "skip_duplicate",
Category: CategoryAlreadyDone,
Message: "该奖品本账号已兑换成功,跳过重复兑换",
})
}
func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo) Result {
pid := target.PID
if prize != nil {
pid = int32(prize.ID)
}
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "exchange",
Prize: prize,
}
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid)
result.Exchange = exchange
result.Category = ClassifyExchange(exchange, err)
result.Message = ExchangeMessage(exchange)
if err != nil {
result.Action = "exchange_error"
result.Error = err.Error()
if result.Message == "" {
result.Message = "兑换请求失败"
}
}
return result
}
func (r *Runner) applyExchangeDecision(state *runState, account Account, index int, cfg Config, result Result, cancel context.CancelFunc) Result {
now := time.Now()
switch result.Category {
case CategorySuccess:
if cfg.StopAccountAfterSuccess {
state.markDone(account.UID, index)
result.Done = true
}
if cfg.StopAfterSuccess {
cancel()
}
case CategoryNotEnough, CategoryAlreadyDone:
if cfg.StopOnNotEnough {
state.markDone(account.UID, index)
result.Done = true
}
case CategoryOffShelf, CategorySoldOut:
if cfg.StopOnSoldOut {
state.markTargetDone(index)
result.Done = true
} else {
retryAt := now.Add(cfg.ErrorBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
}
case CategoryTooFrequent:
retryAt := now.Add(cfg.TooFrequentBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
case CategoryError, CategoryFailed, CategoryCondition:
retryAt := now.Add(cfg.ErrorBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
}
return result
}
func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) {
if !cfg.RefreshAfterSuccess || emit == nil || result.Category != CategorySuccess {
return
}
state.refresh.Add(1)
go func() {
defer state.refresh.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
score, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
scoreResult := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "refresh_score",
Category: CategoryReady,
Message: "刷新积分完成",
Score: score,
}
if err != nil {
scoreResult.Category = CategoryError
scoreResult.Message = "刷新积分失败"
scoreResult.Error = err.Error()
} else if score != nil {
scoreResult.Message = fmt.Sprintf("剩余积分: %d", score.AvailableScore())
}
emit(scoreResult)
records, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
recordResult := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "refresh_records",
Category: CategoryReady,
Message: "刷新兑换记录完成",
Records: records,
}
if err != nil {
recordResult.Category = CategoryError
recordResult.Message = "刷新兑换记录失败"
recordResult.Error = err.Error()
}
emit(recordResult)
}()
}
func (r *Runner) sharedList(ctx context.Context, client huya.ActivityClient, accounts []Account, targets []Target, cfg Config, state *runState) *listSnapshot {
if !cfg.SharePrizeList || len(accounts) == 0 {
return nil
}
needsList := false
for _, account := range accounts {
for _, index := range state.activeTargets(account.UID, len(targets), time.Now()) {
if !shouldDirectExchange(targets[index], cfg) {
needsList = true
break
}
}
if needsList {
break
}
}
if !needsList {
return nil
}
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID)
return &listSnapshot{resp: resp, err: err}
}
func (r *Runner) client() huya.ActivityClient {
if r.Client != nil {
return r.Client
}
return huya.NewHTTPClient(nil)
}
func newRunState(accounts []Account, targetCount int) *runState {
state := &runState{
accounts: make(map[int64][]targetState, len(accounts)),
targetDone: make([]bool, targetCount),
exchanged: make(map[int64]map[int64]bool, len(accounts)),
}
for _, account := range accounts {
state.accounts[account.UID] = make([]targetState, targetCount)
}
return state
}
// prizeExchanged 判断该账号是否已经成功兑换过这个奖品。
func (s *runState) prizeExchanged(uid, prizeID int64) bool {
if prizeID <= 0 {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.exchanged[uid][prizeID]
}
func (s *runState) markPrizeExchanged(uid, prizeID int64) {
if prizeID <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.exchanged[uid] == nil {
s.exchanged[uid] = map[int64]bool{}
}
s.exchanged[uid][prizeID] = true
}
func (s *runState) activeTargets(uid int64, targetCount int, now time.Time) []int {
s.mu.Lock()
defer s.mu.Unlock()
items := s.accounts[uid]
active := make([]int, 0, targetCount)
for i := 0; i < targetCount; i++ {
if i < len(s.targetDone) && s.targetDone[i] {
continue
}
if i >= len(items) || items[i].Done {
continue
}
if !items[i].RetryAfter.IsZero() && items[i].RetryAfter.After(now) {
continue
}
active = append(active, i)
}
return active
}
func (s *runState) markDone(uid int64, index int) {
s.mu.Lock()
defer s.mu.Unlock()
if items := s.accounts[uid]; index >= 0 && index < len(items) {
items[index].Done = true
items[index].RetryAfter = time.Time{}
s.accounts[uid] = items
}
}
func (s *runState) markTargetDone(index int) {
s.mu.Lock()
defer s.mu.Unlock()
if index >= 0 && index < len(s.targetDone) {
s.targetDone[index] = true
}
}
func (s *runState) setRetryAfter(uid int64, index int, when time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if items := s.accounts[uid]; index >= 0 && index < len(items) {
items[index].RetryAfter = when
s.accounts[uid] = items
}
}
func (s *runState) allDone() bool {
s.mu.Lock()
defer s.mu.Unlock()
for index, globallyDone := range s.targetDone {
if globallyDone {
continue
}
for _, items := range s.accounts {
if index < len(items) && !items[index].Done {
return false
}
}
}
return true
}
func normalizeConfig(cfg Config) Config {
if cfg.Loops <= 0 {
cfg.Loops = 1
}
if cfg.Delay <= 0 {
cfg.Delay = 150 * time.Millisecond
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 1
}
if cfg.TooFrequentBackoff <= 0 {
cfg.TooFrequentBackoff = maxDuration(cfg.Delay*2, 300*time.Millisecond)
}
if cfg.ErrorBackoff <= 0 {
cfg.ErrorBackoff = maxDuration(cfg.Delay, 300*time.Millisecond)
}
if !cfg.ContinueOnNotEnough {
cfg.StopOnNotEnough = true
}
if !cfg.ContinueOnSoldOut {
cfg.StopOnSoldOut = true
}
if !cfg.ListBeforeExchange {
cfg.DirectExchange = true
}
return cfg
}
// normalizeTargets 去掉空目标与重复目标。
//
// 除了完全相同的 target,还按 pid / 名称去重:界面选品会同时提交 pid、名称,
// 以及 targetText 里的同一个 pid,不去重就会对同一奖品重复兑换(实测踩过)。
func normalizeTargets(targets []Target) []Target {
out := make([]Target, 0, len(targets))
seenKey := map[string]bool{}
seenPID := map[int32]bool{}
seenName := map[string]bool{}
for _, target := range targets {
target.Name = strings.TrimSpace(target.Name)
if target.PID <= 0 && target.Name == "" && !target.Auto {
continue
}
if target.PID > 0 && seenPID[target.PID] {
continue
}
if target.Name != "" && seenName[target.Name] {
continue
}
key := targetKey(target)
if seenKey[key] {
continue
}
seenKey[key] = true
if target.PID > 0 {
seenPID[target.PID] = true
}
if target.Name != "" {
seenName[target.Name] = true
}
out = append(out, target)
}
return out
}
func shouldDirectExchange(target Target, cfg Config) bool {
return cfg.DirectExchange && !cfg.ListBeforeExchange && target.PID > 0 && !target.Auto
}
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 只依据文案判定失败原因,命中时返回分类。
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
}
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")
}
func int32String(value int32) string {
return strconv.FormatInt(int64(value), 10)
}
func int64String(value int64) string {
return strconv.FormatInt(value, 10)
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}