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

704 lines
18 KiB
Go

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"
)
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
}
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))
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) {
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, nil)
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(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
}
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, prize)
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
}
}
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:
retryAt := now.Add(cfg.ErrorBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
case CategoryFailed:
retryAt := now.Add(cfg.ErrorBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
}
return result
}
func (r *Runner) refreshAfterSuccess(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
}
go func() {
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),
}
for _, account := range accounts {
state.accounts[account.UID] = make([]targetState, targetCount)
}
return state
}
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
}
func normalizeTargets(targets []Target) []Target {
out := make([]Target, 0, len(targets))
seen := map[string]bool{}
for _, target := range targets {
target.Name = strings.TrimSpace(target.Name)
if target.PID <= 0 && target.Name == "" && !target.Auto {
continue
}
key := targetKey(target)
if seen[key] {
continue
}
seen[key] = 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
}
func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string {
if err != nil {
return CategoryError
}
if resp == nil {
return CategoryFailed
}
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
}
}
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
}