- 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)并重新生成前端绑定
760 lines
20 KiB
Go
760 lines
20 KiB
Go
package gui
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||
|
||
"huya-go/internal/huya"
|
||
"huya-go/internal/sniper"
|
||
)
|
||
|
||
const (
|
||
eventLog = "gui:log"
|
||
eventMonitorResult = "gui:monitor-result"
|
||
eventMonitorState = "gui:monitor-state"
|
||
maxLogEntries = 500
|
||
logQueueSize = 2048
|
||
)
|
||
|
||
type App struct {
|
||
ctx context.Context
|
||
|
||
logMu sync.Mutex
|
||
logs []LogEntry
|
||
|
||
logCh chan LogEntry
|
||
logStop chan struct{}
|
||
logWorkerOnce sync.Once
|
||
logStopOnce sync.Once
|
||
droppedLogs atomic.Int64
|
||
|
||
runMu sync.Mutex
|
||
monitorCancel context.CancelFunc
|
||
monitorRunning bool
|
||
}
|
||
|
||
type Config struct {
|
||
Transport string `json:"transport"`
|
||
WSSHost string `json:"wssHost"`
|
||
SID int32 `json:"sid"`
|
||
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"`
|
||
DelayMS int `json:"delayMs"`
|
||
Concurrency int `json:"concurrency"`
|
||
StopAfterSuccess bool `json:"stopAfterSuccess"`
|
||
StopAccountAfterSuccess bool `json:"stopAccountAfterSuccess"`
|
||
Prewarm bool `json:"prewarm"`
|
||
DirectExchange bool `json:"directExchange"`
|
||
SharePrizeList bool `json:"sharePrizeList"`
|
||
AutoSelect bool `json:"autoSelect"`
|
||
RefreshAfterSuccess bool `json:"refreshAfterSuccess"`
|
||
LeadMS int `json:"leadMs"`
|
||
FrequentBackoffMS int `json:"frequentBackoffMs"`
|
||
ErrorBackoffMS int `json:"errorBackoffMs"`
|
||
TimeoutSec int `json:"timeoutSec"`
|
||
StartAt string `json:"startAt"`
|
||
}
|
||
|
||
type AccountPreview struct {
|
||
Line int `json:"line"`
|
||
UID int64 `json:"uid"`
|
||
Valid bool `json:"valid"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type LogEntry struct {
|
||
Time string `json:"time"`
|
||
Level string `json:"level"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
type ScoreResult struct {
|
||
UID int64 `json:"uid"`
|
||
Score int64 `json:"score"`
|
||
Resp *huya.GetUserScoreResp `json:"resp,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type RecordResult struct {
|
||
UID int64 `json:"uid"`
|
||
Records []huya.UserPrizeInfo `json:"records"`
|
||
Resp *huya.GetUserPrizeRecordsResp `json:"resp,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
type ExchangeResult struct {
|
||
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 {
|
||
Running bool `json:"running"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
type cookieLine struct {
|
||
lineNo int
|
||
text string
|
||
}
|
||
|
||
func NewApp() *App {
|
||
return &App{
|
||
logCh: make(chan LogEntry, logQueueSize),
|
||
logStop: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
func (a *App) Startup(ctx context.Context) {
|
||
a.ctx = ctx
|
||
a.startLogWorker()
|
||
a.addLog("info", "GUI 已启动")
|
||
}
|
||
|
||
func (a *App) Shutdown(ctx context.Context) {
|
||
a.StopMonitor()
|
||
a.stopLogWorker()
|
||
}
|
||
|
||
func (a *App) DefaultConfig() Config {
|
||
return Config{
|
||
Transport: "http",
|
||
WSSHost: huya.DefaultWSSHost,
|
||
SID: 2203,
|
||
Loops: 20,
|
||
DelayMS: 120,
|
||
Concurrency: 5,
|
||
StopAfterSuccess: false,
|
||
StopAccountAfterSuccess: true,
|
||
Prewarm: true,
|
||
DirectExchange: true,
|
||
SharePrizeList: true,
|
||
AutoSelect: false,
|
||
RefreshAfterSuccess: true,
|
||
LeadMS: 2500,
|
||
FrequentBackoffMS: 300,
|
||
ErrorBackoffMS: 300,
|
||
TimeoutSec: 15,
|
||
}
|
||
}
|
||
|
||
func (a *App) PreviewAccounts(cookieText string) []AccountPreview {
|
||
previews := make([]AccountPreview, 0)
|
||
for _, line := range splitCookieLines(cookieText) {
|
||
uid := huya.ResolveUID(line.text)
|
||
item := AccountPreview{Line: line.lineNo, UID: uid, Valid: uid > 0}
|
||
if uid <= 0 {
|
||
item.Error = "无法解析 yyuid/udb_uid"
|
||
}
|
||
previews = append(previews, item)
|
||
}
|
||
return previews
|
||
}
|
||
|
||
func (a *App) GetLogs() []LogEntry {
|
||
a.logMu.Lock()
|
||
defer a.logMu.Unlock()
|
||
return append([]LogEntry(nil), a.logs...)
|
||
}
|
||
|
||
func (a *App) IsRunning() bool {
|
||
a.runMu.Lock()
|
||
defer a.runMu.Unlock()
|
||
return a.monitorRunning
|
||
}
|
||
|
||
func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) {
|
||
cfg = normalizeConfig(cfg)
|
||
accounts, err := parseAccounts(cfg)
|
||
if err != nil {
|
||
return PrizeListResult{}, err
|
||
}
|
||
if cfg.SID <= 0 {
|
||
return PrizeListResult{}, errors.New("SID 不能为空")
|
||
}
|
||
client, closeClient, err := a.newClient(cfg)
|
||
if err != nil {
|
||
return PrizeListResult{}, err
|
||
}
|
||
defer closeClient()
|
||
|
||
ctx, cancel := requestContext(cfg.TimeoutSec)
|
||
defer cancel()
|
||
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID)
|
||
if err != nil {
|
||
a.addLog("error", "查询奖品失败: %v", err)
|
||
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)
|
||
return PrizeListResult{
|
||
UID: accounts[0].UID,
|
||
Status: resp.Status,
|
||
Msg: resp.Msg,
|
||
Prizes: resp.Prizes,
|
||
Raw: resp,
|
||
}, nil
|
||
}
|
||
|
||
func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
|
||
cfg = normalizeConfig(cfg)
|
||
accounts, err := parseAccounts(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if cfg.SID <= 0 {
|
||
return nil, errors.New("SID 不能为空")
|
||
}
|
||
client, closeClient, err := a.newClient(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer closeClient()
|
||
|
||
results := make([]ScoreResult, len(accounts))
|
||
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
|
||
ctx, cancel := requestContext(cfg.TimeoutSec)
|
||
defer cancel()
|
||
resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
|
||
item := ScoreResult{UID: account.UID, Resp: resp, Error: errorString(err)}
|
||
if resp != nil {
|
||
item.Score = resp.AvailableScore()
|
||
}
|
||
results[i] = item
|
||
})
|
||
a.addLog("info", "查询积分完成: accounts=%d", len(accounts))
|
||
return results, nil
|
||
}
|
||
|
||
func (a *App) GetRecords(cfg Config) ([]RecordResult, error) {
|
||
cfg = normalizeConfig(cfg)
|
||
accounts, err := parseAccounts(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if cfg.SID <= 0 {
|
||
return nil, errors.New("SID 不能为空")
|
||
}
|
||
client, closeClient, err := a.newClient(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer closeClient()
|
||
|
||
results := make([]RecordResult, len(accounts))
|
||
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
|
||
ctx, cancel := requestContext(cfg.TimeoutSec)
|
||
defer cancel()
|
||
resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
|
||
item := RecordResult{UID: account.UID, Resp: resp, Error: errorString(err)}
|
||
if resp != nil {
|
||
item.Records = resp.Records
|
||
}
|
||
results[i] = item
|
||
})
|
||
a.addLog("info", "查询兑换记录完成: accounts=%d", len(accounts))
|
||
return results, nil
|
||
}
|
||
|
||
func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
|
||
cfg = normalizeConfig(cfg)
|
||
accounts, err := parseAccounts(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if cfg.SID <= 0 || cfg.PID <= 0 {
|
||
return nil, errors.New("SID 和 PID 都不能为空")
|
||
}
|
||
client, closeClient, err := a.newClient(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer closeClient()
|
||
|
||
results := make([]ExchangeResult, len(accounts))
|
||
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
|
||
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,
|
||
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
|
||
}
|
||
|
||
func (a *App) StartMonitor(cfg Config) error {
|
||
cfg = normalizeConfig(cfg)
|
||
accounts, err := parseAccounts(cfg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if cfg.SID <= 0 {
|
||
return errors.New("SID 不能为空")
|
||
}
|
||
targets := parseTargets(cfg)
|
||
if len(targets) == 0 {
|
||
return errors.New("PID 和名称至少填写一个")
|
||
}
|
||
startAt, err := parseStartTime(cfg.StartAt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
a.runMu.Lock()
|
||
if a.monitorRunning {
|
||
a.runMu.Unlock()
|
||
return errors.New("监控已在运行")
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
a.monitorCancel = cancel
|
||
a.monitorRunning = true
|
||
a.runMu.Unlock()
|
||
|
||
client, closeClient, err := a.newClient(cfg)
|
||
if err != nil {
|
||
cancel()
|
||
a.finishMonitor("启动失败")
|
||
return err
|
||
}
|
||
|
||
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)
|
||
|
||
go func() {
|
||
defer closeClient()
|
||
defer a.finishMonitor("已停止")
|
||
|
||
runner := sniper.Runner{Client: client}
|
||
emit := func(result sniper.Result) {
|
||
a.emit(eventMonitorResult, result)
|
||
a.addLog("info", "监控结果: loop=%d uid=%d action=%s category=%s msg=%s error=%s", result.Loop, result.UID, result.Action, result.Category, result.Message, result.Error)
|
||
}
|
||
runnerCfg := sniper.Config{
|
||
SID: cfg.SID,
|
||
Loops: cfg.Loops,
|
||
Delay: time.Duration(cfg.DelayMS) * time.Millisecond,
|
||
Concurrency: cfg.Concurrency,
|
||
StopAfterSuccess: cfg.StopAfterSuccess,
|
||
StopAccountAfterSuccess: cfg.StopAccountAfterSuccess,
|
||
Prewarm: false,
|
||
DirectExchange: cfg.DirectExchange,
|
||
SharePrizeList: cfg.SharePrizeList,
|
||
RefreshAfterSuccess: cfg.RefreshAfterSuccess,
|
||
TooFrequentBackoff: time.Duration(cfg.FrequentBackoffMS) * time.Millisecond,
|
||
ErrorBackoff: time.Duration(cfg.ErrorBackoffMS) * time.Millisecond,
|
||
}
|
||
if cfg.Prewarm {
|
||
a.addLog("info", "开始预热连接: accounts=%d", len(accounts))
|
||
if err := runner.Prewarm(ctx, accounts, runnerCfg, emit); err != nil {
|
||
a.addLog("error", "连接预热异常: %v", err)
|
||
return
|
||
}
|
||
}
|
||
if !startAt.IsZero() {
|
||
when := startAt.Add(-time.Duration(cfg.LeadMS) * time.Millisecond)
|
||
a.addLog("info", "等待定时开始: %s 提前=%dms", when.Format("2006-01-02 15:04:05.000"), cfg.LeadMS)
|
||
if err := waitUntil(ctx, when); err != nil {
|
||
a.addLog("info", "定时等待已取消")
|
||
return
|
||
}
|
||
}
|
||
|
||
err := runner.RunTargets(ctx, accounts, targets, runnerCfg, emit)
|
||
if err != nil && !errors.Is(err, context.Canceled) {
|
||
a.addLog("error", "监控结束异常: %v", err)
|
||
}
|
||
}()
|
||
return nil
|
||
}
|
||
|
||
func (a *App) StopMonitor() bool {
|
||
a.runMu.Lock()
|
||
cancel := a.monitorCancel
|
||
a.runMu.Unlock()
|
||
if cancel == nil {
|
||
return false
|
||
}
|
||
cancel()
|
||
return true
|
||
}
|
||
|
||
func (a *App) newClient(cfg Config) (huya.ActivityClient, func(), error) {
|
||
return huya.NewActivityClient(cfg.Transport, cfg.WSSHost, time.Duration(cfg.TimeoutSec)*time.Second, func(msg string) {
|
||
a.addLog("debug", "%s", msg)
|
||
})
|
||
}
|
||
|
||
func (a *App) runConcurrent(accounts []sniper.Account, concurrency int, fn func(i int, account sniper.Account)) {
|
||
if concurrency <= 0 {
|
||
concurrency = 1
|
||
}
|
||
sem := make(chan struct{}, concurrency)
|
||
var wg sync.WaitGroup
|
||
for i, account := range accounts {
|
||
i, account := i, account
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
sem <- struct{}{}
|
||
defer func() { <-sem }()
|
||
fn(i, account)
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
func (a *App) finishMonitor(message string) {
|
||
a.runMu.Lock()
|
||
a.monitorCancel = nil
|
||
a.monitorRunning = false
|
||
a.runMu.Unlock()
|
||
a.emitState(false, message)
|
||
}
|
||
|
||
func (a *App) addLog(level, format string, args ...any) {
|
||
entry := LogEntry{
|
||
Time: time.Now().Format("15:04:05.000"),
|
||
Level: level,
|
||
Message: fmt.Sprintf(format, args...),
|
||
}
|
||
if a.logCh == nil {
|
||
a.writeLogEntry(entry)
|
||
return
|
||
}
|
||
select {
|
||
case a.logCh <- entry:
|
||
default:
|
||
a.droppedLogs.Add(1)
|
||
}
|
||
}
|
||
|
||
func (a *App) startLogWorker() {
|
||
if a.logCh == nil {
|
||
a.logCh = make(chan LogEntry, logQueueSize)
|
||
}
|
||
if a.logStop == nil {
|
||
a.logStop = make(chan struct{})
|
||
}
|
||
a.logWorkerOnce.Do(func() {
|
||
go a.logWorker()
|
||
})
|
||
}
|
||
|
||
func (a *App) stopLogWorker() {
|
||
if a.logStop == nil {
|
||
return
|
||
}
|
||
a.logStopOnce.Do(func() {
|
||
close(a.logStop)
|
||
})
|
||
}
|
||
|
||
func (a *App) logWorker() {
|
||
for {
|
||
select {
|
||
case entry := <-a.logCh:
|
||
a.flushDroppedLogs()
|
||
a.writeLogEntry(entry)
|
||
case <-a.logStop:
|
||
a.drainLogQueue()
|
||
a.flushDroppedLogs()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *App) drainLogQueue() {
|
||
for {
|
||
select {
|
||
case entry := <-a.logCh:
|
||
a.flushDroppedLogs()
|
||
a.writeLogEntry(entry)
|
||
default:
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *App) flushDroppedLogs() {
|
||
dropped := a.droppedLogs.Swap(0)
|
||
if dropped <= 0 {
|
||
return
|
||
}
|
||
a.writeLogEntry(LogEntry{
|
||
Time: time.Now().Format("15:04:05.000"),
|
||
Level: "warn",
|
||
Message: fmt.Sprintf("日志队列繁忙,已异步丢弃 %d 条日志", dropped),
|
||
})
|
||
}
|
||
|
||
func (a *App) writeLogEntry(entry LogEntry) {
|
||
a.logMu.Lock()
|
||
a.logs = append(a.logs, entry)
|
||
if len(a.logs) > maxLogEntries {
|
||
a.logs = append([]LogEntry(nil), a.logs[len(a.logs)-maxLogEntries:]...)
|
||
}
|
||
a.logMu.Unlock()
|
||
a.emit(eventLog, entry)
|
||
}
|
||
|
||
func (a *App) emitState(running bool, message string) {
|
||
a.emit(eventMonitorState, MonitorState{Running: running, Message: message})
|
||
}
|
||
|
||
func (a *App) emit(event string, data any) {
|
||
if a.ctx == nil {
|
||
return
|
||
}
|
||
runtime.EventsEmit(a.ctx, event, data)
|
||
}
|
||
|
||
func normalizeConfig(cfg Config) Config {
|
||
if strings.TrimSpace(cfg.Transport) == "" {
|
||
cfg.Transport = "http"
|
||
}
|
||
if strings.TrimSpace(cfg.WSSHost) == "" {
|
||
cfg.WSSHost = huya.DefaultWSSHost
|
||
}
|
||
if cfg.Loops <= 0 {
|
||
cfg.Loops = 1
|
||
}
|
||
if cfg.DelayMS <= 0 {
|
||
cfg.DelayMS = 150
|
||
}
|
||
if cfg.Concurrency <= 0 {
|
||
cfg.Concurrency = 1
|
||
}
|
||
if cfg.LeadMS < 0 {
|
||
cfg.LeadMS = 0
|
||
}
|
||
if cfg.FrequentBackoffMS <= 0 {
|
||
cfg.FrequentBackoffMS = 300
|
||
}
|
||
if cfg.ErrorBackoffMS <= 0 {
|
||
cfg.ErrorBackoffMS = 300
|
||
}
|
||
if cfg.TimeoutSec <= 0 {
|
||
cfg.TimeoutSec = 15
|
||
}
|
||
cfg.Name = strings.TrimSpace(cfg.Name)
|
||
cfg.TargetText = strings.TrimSpace(cfg.TargetText)
|
||
cfg.CookieText = strings.TrimSpace(cfg.CookieText)
|
||
cfg.StartAt = strings.TrimSpace(cfg.StartAt)
|
||
return cfg
|
||
}
|
||
|
||
func parseTargets(cfg Config) []sniper.Target {
|
||
targets := make([]sniper.Target, 0)
|
||
if cfg.PID > 0 || strings.TrimSpace(cfg.Name) != "" {
|
||
targets = append(targets, sniper.Target{PID: cfg.PID, Name: strings.TrimSpace(cfg.Name)})
|
||
}
|
||
if cfg.AutoSelect {
|
||
targets = append(targets, sniper.Target{Auto: true})
|
||
}
|
||
for _, item := range splitTargetText(cfg.TargetText) {
|
||
if item == "auto" || item == "自动" || item == "自动选择" {
|
||
targets = append(targets, sniper.Target{Auto: true})
|
||
continue
|
||
}
|
||
if pid, err := strconv.ParseInt(item, 10, 32); err == nil && pid > 0 {
|
||
targets = append(targets, sniper.Target{PID: int32(pid)})
|
||
continue
|
||
}
|
||
targets = append(targets, sniper.Target{Name: item})
|
||
}
|
||
// 价格/库存过滤对所有目标统一生效,避免逐处重复赋值。
|
||
for i := range targets {
|
||
targets[i].MaxPrice = cfg.MaxPrice
|
||
targets[i].MinLeft = cfg.MinLeft
|
||
}
|
||
return targets
|
||
}
|
||
|
||
func splitTargetText(text string) []string {
|
||
replacer := strings.NewReplacer(",", ",", "\n", ",", "\t", ",")
|
||
parts := strings.Split(replacer.Replace(text), ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
item := strings.TrimSpace(part)
|
||
if item != "" {
|
||
out = append(out, item)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseAccounts(cfg Config) ([]sniper.Account, error) {
|
||
lines := splitCookieLines(cfg.CookieText)
|
||
if len(lines) == 0 {
|
||
return nil, errors.New("Cookie 不能为空")
|
||
}
|
||
selected := selectedLineSet(cfg.SelectedAccountLines)
|
||
accounts := make([]sniper.Account, 0, len(lines))
|
||
for _, cookie := range lines {
|
||
if len(selected) > 0 && !selected[cookie.lineNo] {
|
||
continue
|
||
}
|
||
uid := huya.ResolveUID(cookie.text)
|
||
if uid <= 0 {
|
||
return nil, fmt.Errorf("第 %d 行 Cookie 无法解析 yyuid/udb_uid", cookie.lineNo)
|
||
}
|
||
accounts = append(accounts, sniper.Account{UID: uid, Cookie: cookie.text})
|
||
}
|
||
if len(accounts) == 0 {
|
||
return nil, errors.New("未选择有效账号")
|
||
}
|
||
return accounts, nil
|
||
}
|
||
|
||
func selectedLineSet(lines []int) map[int]bool {
|
||
out := make(map[int]bool, len(lines))
|
||
for _, line := range lines {
|
||
if line > 0 {
|
||
out[line] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func splitCookieLines(cookieText string) []cookieLine {
|
||
lines := strings.Split(normalizeCookieText(cookieText), "\n")
|
||
out := make([]cookieLine, 0, len(lines))
|
||
for i, line := range lines {
|
||
item := strings.TrimSpace(line)
|
||
if item == "" || strings.HasPrefix(item, "#") {
|
||
continue
|
||
}
|
||
out = append(out, cookieLine{lineNo: i + 1, text: item})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeCookieText(cookieText string) string {
|
||
normalized := strings.NewReplacer(
|
||
"\r\n", "\n",
|
||
"\r", "\n",
|
||
`\r\n`, "\n",
|
||
`\r`, "\n",
|
||
`\n`, "\n",
|
||
).Replace(cookieText)
|
||
if strings.Contains(normalized, "\n") ||
|
||
(!strings.Contains(normalized, "/r/n") && !strings.Contains(normalized, "/r")) {
|
||
return normalized
|
||
}
|
||
return strings.NewReplacer(
|
||
"/r/n", "\n",
|
||
"/r", "\n",
|
||
"/n", "\n",
|
||
).Replace(normalized)
|
||
}
|
||
|
||
func requestContext(timeoutSec int) (context.Context, context.CancelFunc) {
|
||
if timeoutSec <= 0 {
|
||
timeoutSec = 15
|
||
}
|
||
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||
}
|
||
|
||
func parseStartTime(text string) (time.Time, error) {
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return time.Time{}, nil
|
||
}
|
||
if ts, err := strconv.ParseInt(text, 10, 64); err == nil {
|
||
return time.Unix(ts, 0), nil
|
||
}
|
||
for _, layout := range []string{"15:04:05", "15:04"} {
|
||
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
|
||
return nextClockTime(time.Now(), t.Hour(), t.Minute(), t.Second()), nil
|
||
}
|
||
}
|
||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04"} {
|
||
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
|
||
return t, nil
|
||
}
|
||
}
|
||
return time.Time{}, fmt.Errorf("无法解析开始时间: %s", text)
|
||
}
|
||
|
||
func nextClockTime(now time.Time, hour, minute, second int) time.Time {
|
||
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, second, 0, now.Location())
|
||
if !target.After(now) {
|
||
target = target.Add(24 * time.Hour)
|
||
}
|
||
return target
|
||
}
|
||
|
||
func waitUntil(ctx context.Context, when time.Time) error {
|
||
for {
|
||
remaining := time.Until(when)
|
||
if remaining <= 0 {
|
||
return nil
|
||
}
|
||
timer := time.NewTimer(minDuration(remaining, 200*time.Millisecond))
|
||
select {
|
||
case <-timer.C:
|
||
case <-ctx.Done():
|
||
timer.Stop()
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
}
|
||
|
||
func minDuration(a, b time.Duration) time.Duration {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func errorString(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
func prizeInventorySummary(prizes []huya.PrizeInfo) (left, total int64, leftKnown, totalKnown int) {
|
||
for _, prize := range prizes {
|
||
if prize.Num > 0 {
|
||
total += prize.Num
|
||
totalKnown++
|
||
}
|
||
if itemLeft, ok := prize.InventoryLeft(); ok {
|
||
left += itemLeft
|
||
leftKnown++
|
||
}
|
||
}
|
||
return left, total, leftKnown, totalKnown
|
||
}
|