优化 CK 导入和监控配置

This commit is contained in:
yml2213
2026-07-06 20:30:09 +08:00
parent f78c731f32
commit 6d8be28225
11 changed files with 2305 additions and 568 deletions
+2 -1
View File
@@ -3,4 +3,5 @@
/bin/
/frontend/node_modules/
*.test
build/
build/
frontend/dist/
+97 -11
View File
@@ -24,15 +24,27 @@ func main() {
cookieFile = flag.String("cookie-file", "", "Cookie 文件,一行一个账号")
sid = flag.Int("sid", 0, "活动 SID")
pid = flag.Int("pid", 0, "奖品 PID")
pids = flag.String("pids", "", "多个奖品 PID,逗号分隔")
name = flag.String("name", "", "按奖品名包含匹配")
names = flag.String("names", "", "多个奖品名,逗号分隔")
autoSelect = flag.Bool("auto", false, "monitor 自动选择列表里的可兑奖品")
loops = flag.Int("loops", 1, "monitor 循环次数")
delayMS = flag.Int("delay", 150, "monitor 每轮间隔毫秒")
concurrency = flag.Int("concurrency", 5, "monitor/exchange 并发账号数")
stopAfterSuccess = flag.Bool("stop-after-success", true, "monitor 成功后停止")
stopAfterSuccess = flag.Bool("stop-after-success", false, "monitor 任意账号成功后停止全部")
accountStop = flag.Bool("account-stop", true, "monitor 单账号成功后停止该账号该奖品")
at = flag.String("at", "", "定时开始时间,支持 RFC3339 或 2006-01-02 15:04:05")
leadMS = flag.Int("lead", 2500, "monitor 相对 -at 提前启动毫秒")
timeoutSec = flag.Int("timeout", 15, "单次请求超时秒数")
transport = flag.String("transport", "http", "传输层:http/wss")
wssHost = flag.String("wss-host", huya.DefaultWSSHost, "WSS host,来自 launch.queryHttpDns 或抓包")
prewarm = flag.Bool("prewarm", true, "monitor 开始前预热账号连接")
directExchange = flag.Bool("direct-exchange", true, "monitor 已知 PID 时跳过列表直接兑换")
listBefore = flag.Bool("list-before-exchange", false, "monitor 即使已知 PID 也先查询列表")
shareList = flag.Bool("share-list", true, "monitor 多账号共享同一轮奖品列表")
refreshSuccess = flag.Bool("refresh-after-success", true, "monitor 成功后异步刷新积分和记录")
freqBackoffMS = flag.Int("freq-backoff", 300, "操作频繁后的账号退避毫秒")
errorBackoffMS = flag.Int("error-backoff", 300, "请求异常后的账号退避毫秒")
verbose = flag.Bool("v", false, "输出调试日志")
)
flag.Parse()
@@ -65,22 +77,25 @@ func main() {
defer closeClient()
ctx := context.Background()
var startAt time.Time
if *at != "" {
when, err := parseTime(*at)
if err != nil {
exitErr(err)
}
waitUntil(ctx, when)
startAt = when
}
switch *cmd {
case "list":
waitForCommand(ctx, startAt)
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, int32(*sid))
if err != nil {
exitErr(err)
}
printJSON(resp)
case "score":
waitForCommand(ctx, startAt)
results := make([]map[string]any, 0, len(accounts))
for _, account := range accounts {
resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, int32(*sid))
@@ -93,6 +108,7 @@ func main() {
}
printJSON(results)
case "records":
waitForCommand(ctx, startAt)
results := make([]map[string]any, 0, len(accounts))
for _, account := range accounts {
resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, int32(*sid))
@@ -107,24 +123,42 @@ func main() {
if *pid <= 0 {
exitErr(errors.New("exchange 必须提供 -pid"))
}
waitForCommand(ctx, startAt)
runExchange(ctx, client, accounts, int32(*sid), int32(*pid), *concurrency)
case "monitor":
if *pid <= 0 && strings.TrimSpace(*name) == "" {
targets := parseTargets(*pid, *pids, *name, *names, *autoSelect)
if len(targets) == 0 {
exitErr(errors.New("monitor 必须提供 -pid 或 -name"))
}
runner := sniper.Runner{Client: client}
var outMu sync.Mutex
err := runner.Run(ctx, accounts, sniper.Target{PID: int32(*pid), Name: strings.TrimSpace(*name)}, sniper.Config{
SID: int32(*sid),
Loops: *loops,
Delay: time.Duration(*delayMS) * time.Millisecond,
Concurrency: *concurrency,
StopAfterSuccess: *stopAfterSuccess,
}, func(result sniper.Result) {
emit := func(result sniper.Result) {
outMu.Lock()
defer outMu.Unlock()
_ = json.NewEncoder(os.Stdout).Encode(result)
})
}
cfg := sniper.Config{
SID: int32(*sid),
Loops: *loops,
Delay: time.Duration(*delayMS) * time.Millisecond,
Concurrency: *concurrency,
StopAfterSuccess: *stopAfterSuccess,
StopAccountAfterSuccess: *accountStop,
Prewarm: false,
DirectExchange: *directExchange,
ListBeforeExchange: *listBefore,
SharePrizeList: *shareList,
RefreshAfterSuccess: *refreshSuccess,
TooFrequentBackoff: time.Duration(*freqBackoffMS) * time.Millisecond,
ErrorBackoff: time.Duration(*errorBackoffMS) * time.Millisecond,
}
if *prewarm {
if err := runner.Prewarm(ctx, accounts, cfg, emit); err != nil && !errors.Is(err, context.Canceled) {
exitErr(err)
}
}
waitForMonitor(ctx, startAt, time.Duration(*leadMS)*time.Millisecond)
err := runner.RunTargets(ctx, accounts, targets, cfg, emit)
if err != nil && !errors.Is(err, context.Canceled) {
exitErr(err)
}
@@ -133,6 +167,20 @@ func main() {
}
}
func waitForCommand(ctx context.Context, startAt time.Time) {
if !startAt.IsZero() {
waitUntil(ctx, startAt)
}
}
func waitForMonitor(ctx context.Context, startAt time.Time, lead time.Duration) {
if startAt.IsZero() {
return
}
when := startAt.Add(-lead)
waitUntil(ctx, when)
}
func runExchange(ctx context.Context, client huya.ActivityClient, accounts []sniper.Account, sid, pid int32, concurrency int) {
if concurrency <= 0 {
concurrency = 1
@@ -189,6 +237,44 @@ func loadCookies(cookie, file string) ([]string, error) {
return out, nil
}
func parseTargets(pid int, pids, name, names string, autoSelect bool) []sniper.Target {
targets := make([]sniper.Target, 0)
name = strings.TrimSpace(name)
if pid > 0 || name != "" {
targets = append(targets, sniper.Target{PID: int32(pid), Name: name})
}
if autoSelect {
targets = append(targets, sniper.Target{Auto: true})
}
for _, text := range splitList(pids) {
value, err := strconv.Atoi(text)
if err == nil && value > 0 {
targets = append(targets, sniper.Target{PID: int32(value)})
}
}
for _, text := range splitList(names) {
if text == "auto" || text == "自动" || text == "自动选择" {
targets = append(targets, sniper.Target{Auto: true})
continue
}
targets = append(targets, sniper.Target{Name: text})
}
return targets
}
func splitList(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 parseTime(text string) (time.Time, error) {
text = strings.TrimSpace(text)
if text == "" {
+731 -350
View File
File diff suppressed because it is too large Load Diff
+357 -107
View File
@@ -11,59 +11,368 @@ body,
}
body {
color: #1f2937;
background: #eef2f7;
color: #1f2933;
background: #edf2f7;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.app-shell {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 12px;
grid-template-columns: 300px minmax(0, 1fr);
width: 100%;
height: 100vh;
padding: 12px;
overflow: hidden;
background: #f5f8fb;
}
.side-panel,
.main-panel {
.product-sidebar {
display: flex;
min-height: 0;
border: 1px solid #d9e1ea;
flex-direction: column;
gap: 14px;
border-right: 1px solid #d8e0e8;
background: #ffffff;
padding: 18px 16px;
}
.sidebar-head,
.panel-head,
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.sidebar-head h4,
.panel-head h5 {
margin: 0 0 2px;
}
.product-search {
width: 100%;
}
.current-product {
display: grid;
gap: 8px;
border: 1px solid #bdd2ff;
border-radius: 8px;
background: #edf4ff;
padding: 14px;
}
.current-product > span,
.muted {
color: #697586;
font-size: 12px;
}
.current-product strong {
font-size: 16px;
}
.inventory-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #2f8f50;
font-size: 12px;
}
.product-list {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 8px;
overflow: auto;
padding-right: 2px;
}
.product-empty {
display: grid;
min-height: 120px;
place-content: center;
gap: 6px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #697586;
text-align: center;
}
.product-empty strong {
color: #344054;
}
.product-empty span {
font-size: 12px;
}
.product-row {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
gap: 10px;
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
padding: 8px;
text-align: left;
cursor: pointer;
}
.product-row:hover,
.product-row.selected {
border-color: #a8c5ff;
background: #eef5ff;
}
.product-thumb {
display: grid;
width: 42px;
height: 42px;
place-items: center;
overflow: hidden;
border: 1px solid #d5dde7;
border-radius: 8px;
background: #f8fafc;
color: #2563eb;
font-weight: 700;
}
.product-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-meta {
min-width: 0;
}
.product-meta strong {
display: block;
overflow: hidden;
margin-bottom: 5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.desk {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 64px 48px 72px minmax(0, 1fr) auto auto;
background: #f7fafc;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #d8e0e8;
background: #fff;
padding: 0 20px;
}
.tab-strip {
display: flex;
gap: 12px;
}
.transport-select {
width: 96px;
}
.statusbar {
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid #d8e0e8;
background: #fff;
padding: 0 20px;
}
.statusbar span {
color: #697586;
}
.statusbar strong {
font-size: 16px;
}
.green {
color: #16803d;
}
.red {
color: #d92d20;
}
.actionbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #d8e0e8;
background: #fff;
padding: 12px 20px;
}
.green-button {
background: #16803d;
border-color: #16803d;
}
.green-button:hover {
background: #117233 !important;
border-color: #117233 !important;
}
.target-time {
width: 190px;
}
.main-grid {
display: grid;
min-height: 0;
grid-template-columns: minmax(560px, 1.35fr) minmax(380px, 0.95fr);
gap: 14px;
padding: 14px 20px;
}
.account-panel,
.side-log-panel {
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: #fff;
}
.side-panel {
.account-panel {
display: flex;
flex-direction: column;
padding: 14px;
}
.panel-head {
min-height: 50px;
border-bottom: 1px solid #e6edf3;
padding: 0 14px;
}
.panel-head h5 {
margin: 0;
}
.side-log-panel {
padding: 0 12px 12px;
}
.panel-tools,
.panel-tools .ant-tabs,
.panel-tools .ant-tabs-content-holder,
.panel-tools .ant-tabs-content,
.panel-tools .ant-tabs-tabpane {
height: 100%;
min-height: 0;
}
.panel-tools .ant-tabs-nav {
margin-bottom: 8px;
}
.log-view {
height: calc(100% - 2px);
min-height: 300px;
overflow: auto;
border: 1px solid #e6edf3;
border-radius: 6px;
background: #ffffff;
padding: 10px;
}
.main-panel {
display: flex;
flex-direction: column;
.log-line {
display: grid;
grid-template-columns: 88px 54px minmax(0, 1fr);
align-items: start;
gap: 6px;
min-height: 23px;
color: #4b5565;
font-size: 12px;
line-height: 1.55;
}
.log-line code {
color: #1677ff;
white-space: pre-wrap;
word-break: break-word;
}
.log-line.level-error code {
color: #d92d20;
}
.log-line.level-debug code {
color: #667085;
}
.stats-pane {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 8px 2px;
}
.stats-pane .ant-statistic,
.bottom-config,
.advanced-config {
border: 1px solid #e6edf3;
border-radius: 8px;
background: #fff;
}
.stats-pane .ant-statistic {
padding: 14px;
overflow: hidden;
}
.brand-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
.bottom-config {
display: grid;
grid-template-columns: minmax(280px, 1fr) repeat(6, max-content);
gap: 10px;
margin-bottom: 14px;
align-items: end;
margin: 0 20px 10px;
padding: 10px 12px;
}
.brand-row h4 {
margin: 0 0 2px;
.bottom-config .ant-form-item,
.advanced-config .ant-form-item {
margin-bottom: 0;
}
.config-form .ant-form-item {
margin-bottom: 10px;
}
.full {
.target-select {
width: 100%;
margin-top: 6px;
}
.advanced-config {
display: grid;
grid-template-columns: 180px minmax(220px, 0.8fr) minmax(220px, 1fr) minmax(320px, 1.2fr);
gap: 10px;
align-items: end;
margin: 0 20px 14px;
padding: 10px 12px;
}
.switch-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.cookie-box textarea,
@@ -71,99 +380,40 @@ body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.action-bar {
display: flex;
margin-top: 2px;
.ant-table-wrapper {
min-height: 0;
}
.run-row {
width: 100%;
}
.run-row .ant-btn-primary {
flex: 1;
}
.account-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 30px;
padding-top: 12px;
}
.stats-row {
margin-bottom: 12px;
}
.stats-row .ant-statistic {
padding: 10px 12px;
border: 1px solid #e5ebf2;
border-radius: 6px;
background: #f8fafc;
}
.main-panel .ant-tabs {
.account-panel .ant-table-wrapper,
.account-panel .ant-spin-nested-loading,
.account-panel .ant-spin-container {
min-height: 0;
flex: 1;
}
.main-panel .ant-tabs-content,
.main-panel .ant-tabs-tabpane {
height: 100%;
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 280px minmax(0, 1fr);
}
.main-grid {
grid-template-columns: 1fr;
}
.bottom-config,
.advanced-config {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.log-view {
height: calc(100vh - 170px);
min-height: 360px;
overflow: auto;
border: 1px solid #e5ebf2;
border-radius: 6px;
background: #0f172a;
padding: 10px;
}
.log-line {
display: grid;
grid-template-columns: 92px 64px minmax(0, 1fr);
align-items: start;
gap: 8px;
min-height: 24px;
color: #cbd5e1;
font-size: 12px;
line-height: 1.6;
}
.log-line code {
white-space: pre-wrap;
word-break: break-word;
color: inherit;
}
.log-line.level-error {
color: #fecaca;
}
.log-line.level-debug {
color: #93c5fd;
}
.ant-table-wrapper {
height: 100%;
}
@media (max-width: 980px) {
@media (max-width: 880px) {
.app-shell {
grid-template-columns: 1fr;
overflow: auto;
}
.side-panel,
.main-panel {
overflow: visible;
}
.log-view {
height: 420px;
.product-sidebar,
.desk {
min-height: 720px;
}
}
+22
View File
@@ -24,11 +24,22 @@ export namespace gui {
sid: number;
pid: number;
name: string;
targetText: string;
cookieText: string;
selectedAccountLines: number[];
loops: number;
delayMs: number;
concurrency: number;
stopAfterSuccess: boolean;
stopAccountAfterSuccess: boolean;
prewarm: boolean;
directExchange: boolean;
sharePrizeList: boolean;
autoSelect: boolean;
refreshAfterSuccess: boolean;
leadMs: number;
frequentBackoffMs: number;
errorBackoffMs: number;
timeoutSec: number;
startAt: string;
@@ -43,11 +54,22 @@ export namespace gui {
this.sid = source["sid"];
this.pid = source["pid"];
this.name = source["name"];
this.targetText = source["targetText"];
this.cookieText = source["cookieText"];
this.selectedAccountLines = source["selectedAccountLines"];
this.loops = source["loops"];
this.delayMs = source["delayMs"];
this.concurrency = source["concurrency"];
this.stopAfterSuccess = source["stopAfterSuccess"];
this.stopAccountAfterSuccess = source["stopAccountAfterSuccess"];
this.prewarm = source["prewarm"];
this.directExchange = source["directExchange"];
this.sharePrizeList = source["sharePrizeList"];
this.autoSelect = source["autoSelect"];
this.refreshAfterSuccess = source["refreshAfterSuccess"];
this.leadMs = source["leadMs"];
this.frequentBackoffMs = source["frequentBackoffMs"];
this.errorBackoffMs = source["errorBackoffMs"];
this.timeoutSec = source["timeoutSec"];
this.startAt = source["startAt"];
}
+162 -48
View File
@@ -34,18 +34,29 @@ type App struct {
}
type Config struct {
Transport string `json:"transport"`
WSSHost string `json:"wssHost"`
SID int32 `json:"sid"`
PID int32 `json:"pid"`
Name string `json:"name"`
CookieText string `json:"cookieText"`
Loops int `json:"loops"`
DelayMS int `json:"delayMs"`
Concurrency int `json:"concurrency"`
StopAfterSuccess bool `json:"stopAfterSuccess"`
TimeoutSec int `json:"timeoutSec"`
StartAt string `json:"startAt"`
Transport string `json:"transport"`
WSSHost string `json:"wssHost"`
SID int32 `json:"sid"`
PID int32 `json:"pid"`
Name string `json:"name"`
TargetText string `json:"targetText"`
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 {
@@ -114,16 +125,23 @@ func (a *App) Shutdown(ctx context.Context) {
func (a *App) DefaultConfig() Config {
return Config{
Transport: "http",
WSSHost: huya.DefaultWSSHost,
SID: 2203,
PID: 12889,
Name: "小黄鸭",
Loops: 20,
DelayMS: 120,
Concurrency: 5,
StopAfterSuccess: true,
TimeoutSec: 15,
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,
}
}
@@ -154,7 +172,7 @@ func (a *App) IsRunning() bool {
func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
accounts, err := parseAccounts(cfg)
if err != nil {
return PrizeListResult{}, err
}
@@ -186,7 +204,7 @@ func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) {
func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
accounts, err := parseAccounts(cfg)
if err != nil {
return nil, err
}
@@ -216,7 +234,7 @@ func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
func (a *App) GetRecords(cfg Config) ([]RecordResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
accounts, err := parseAccounts(cfg)
if err != nil {
return nil, err
}
@@ -246,7 +264,7 @@ func (a *App) GetRecords(cfg Config) ([]RecordResult, error) {
func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
accounts, err := parseAccounts(cfg)
if err != nil {
return nil, err
}
@@ -272,14 +290,15 @@ func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
func (a *App) StartMonitor(cfg Config) error {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg.CookieText)
accounts, err := parseAccounts(cfg)
if err != nil {
return err
}
if cfg.SID <= 0 {
return errors.New("SID 不能为空")
}
if cfg.PID <= 0 && strings.TrimSpace(cfg.Name) == "" {
targets := parseTargets(cfg)
if len(targets) == 0 {
return errors.New("PID 和名称至少填写一个")
}
startAt, err := parseStartTime(cfg.StartAt)
@@ -305,34 +324,48 @@ func (a *App) StartMonitor(cfg Config) error {
}
a.emitState(true, "运行中")
a.addLog("info", "启动监控: sid=%d pid=%d name=%s accounts=%d", cfg.SID, cfg.PID, cfg.Name, len(accounts))
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() {
a.addLog("info", "等待定时开始: %s", startAt.Format("2006-01-02 15:04:05"))
if err := waitUntil(ctx, startAt); err != nil {
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
}
}
runner := sniper.Runner{Client: client}
err := runner.Run(ctx, accounts, sniper.Target{
PID: cfg.PID,
Name: strings.TrimSpace(cfg.Name),
}, sniper.Config{
SID: cfg.SID,
Loops: cfg.Loops,
Delay: time.Duration(cfg.DelayMS) * time.Millisecond,
Concurrency: cfg.Concurrency,
StopAfterSuccess: cfg.StopAfterSuccess,
}, func(result sniper.Result) {
a.emit(eventMonitorResult, result)
a.addLog("info", "监控结果: loop=%d uid=%d action=%s error=%s", result.Loop, result.UID, result.Action, result.Error)
})
err := runner.RunTargets(ctx, accounts, targets, runnerCfg, emit)
if err != nil && !errors.Is(err, context.Canceled) {
a.addLog("error", "监控结束异常: %v", err)
}
@@ -426,33 +459,95 @@ func normalizeConfig(cfg Config) Config {
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 parseAccounts(cookieText string) ([]sniper.Account, error) {
lines := splitCookieLines(cookieText)
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})
}
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(cookieText, "\n")
lines := strings.Split(normalizeCookieText(cookieText), "\n")
out := make([]cookieLine, 0, len(lines))
for i, line := range lines {
item := strings.TrimSpace(line)
@@ -464,6 +559,25 @@ func splitCookieLines(cookieText string) []cookieLine {
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
+46
View File
@@ -0,0 +1,46 @@
package gui
import "testing"
func TestSplitCookieLinesNormalizesLineBreaks(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{
name: "crlf_and_cr",
input: "ck-a\r\nck-b\rck-c",
want: []string{"ck-a", "ck-b", "ck-c"},
},
{
name: "escaped_line_breaks",
input: `ck-a\r\nck-b\rck-c\nck-d`,
want: []string{"ck-a", "ck-b", "ck-c", "ck-d"},
},
{
name: "slash_r_line_breaks",
input: "ck-a/r/nck-b/rck-c/nck-d",
want: []string{"ck-a", "ck-b", "ck-c", "ck-d"},
},
{
name: "slash_n_only_is_preserved",
input: "ck-a/nck-b",
want: []string{"ck-a/nck-b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lines := splitCookieLines(tt.input)
if len(lines) != len(tt.want) {
t.Fatalf("len(splitCookieLines()) = %d, want %d", len(lines), len(tt.want))
}
for i, line := range lines {
if line.text != tt.want[i] {
t.Fatalf("line[%d] = %q, want %q", i, line.text, tt.want[i])
}
}
})
}
}
+4
View File
@@ -45,6 +45,10 @@ func (c *HTTPClient) logf(format string, args ...any) {
}
}
func (c *HTTPClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error {
return nil
}
func (c *HTTPClient) CallRPC(ctx context.Context, service, method string, reqStruct taf.Marshaler, respStruct taf.Unmarshaler, opt CallOptions) (*wup.Response, error) {
if opt.RequestID == 0 {
opt.RequestID = 1
+5
View File
@@ -100,6 +100,11 @@ func (c *WSSClient) Close() error {
return firstErr
}
func (c *WSSClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error {
_, err := c.session(ctx, uid, cookie)
return err
}
func (c *WSSClient) session(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
key := strconv.FormatInt(uid, 10)
c.mu.Lock()
+624 -51
View File
@@ -2,6 +2,9 @@ package sniper
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
@@ -9,59 +12,128 @@ import (
"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"`
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
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"`
Action string `json:"action"`
Prize *huya.PrizeInfo `json:"prize,omitempty"`
Exchange *huya.ScoreExchangePrizeResp `json:"exchange,omitempty"`
Error string `json:"error,omitempty"`
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 {
if cfg.Loops <= 0 {
cfg.Loops = 1
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 cfg.Delay <= 0 {
cfg.Delay = 150 * time.Millisecond
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 1
}
var client huya.ActivityClient = r.Client
if client == nil {
client = huya.NewHTTPClient(nil)
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 {
@@ -75,56 +147,557 @@ func (r *Runner) Run(ctx context.Context, accounts []Account, target Target, cfg
case <-ctx.Done():
return
}
result := r.tryAccount(ctx, client, account, target, cfg.SID, loop)
if emit != nil {
emit(result)
}
if cfg.StopAfterSuccess && result.Exchange != nil && result.Exchange.Success() {
cancel()
}
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 {
timer := time.NewTimer(cfg.Delay)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return ctx.Err()
if err := sleepContext(ctx, cfg.Delay); err != nil {
return err
}
}
}
return nil
}
func (r *Runner) tryAccount(ctx context.Context, client huya.ActivityClient, account Account, target Target, sid int32, loop int) Result {
list, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, sid)
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 {
return Result{Loop: loop, UID: account.UID, Action: "list_error", Error: err.Error()}
result.Action = "exchange_error"
result.Error = err.Error()
if result.Message == "" {
result.Message = "兑换请求失败"
}
}
prize := matchPrize(list.Prizes, target)
if prize == nil {
return Result{Loop: loop, UID: account.UID, Action: "not_found"}
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()
}
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, int32(prize.ID))
if err != nil {
return Result{Loop: loop, UID: account.UID, Action: "exchange_error", Prize: prize, Error: err.Error()}
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
}
return Result{Loop: loop, UID: account.UID, Action: "exchange", Prize: prize, Exchange: exchange}
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 {
return &prizes[i]
if target.PID > 0 && int32(prizes[i].ID) != target.PID {
continue
}
if target.PID == 0 && target.Name != "" && strings.Contains(prizes[i].Name, target.Name) {
return &prizes[i]
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
}
+255
View File
@@ -0,0 +1,255 @@
package sniper
import (
"context"
"errors"
"sync"
"testing"
"time"
"huya-go/internal/huya"
)
type fakeActivityClient struct {
mu sync.Mutex
listCount int
exchangeCount int
prewarmCount int
scoreCount int
recordCount int
listResp *huya.GetActPrizeListResp
exchangeFn func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error)
}
func (f *fakeActivityClient) GetActPrizeList(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetActPrizeListResp, error) {
f.mu.Lock()
f.listCount++
f.mu.Unlock()
if f.listResp != nil {
return f.listResp, nil
}
return &huya.GetActPrizeListResp{}, nil
}
func (f *fakeActivityClient) QueryUserScore(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserScoreResp, error) {
f.mu.Lock()
f.scoreCount++
f.mu.Unlock()
return &huya.GetUserScoreResp{Status: 200, Msg: "请求成功"}, nil
}
func (f *fakeActivityClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserPrizeRecordsResp, error) {
f.mu.Lock()
f.recordCount++
f.mu.Unlock()
return &huya.GetUserPrizeRecordsResp{Status: 200, Msg: "请求成功"}, nil
}
func (f *fakeActivityClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
f.mu.Lock()
f.exchangeCount++
f.mu.Unlock()
if f.exchangeFn != nil {
return f.exchangeFn(uid, sid, pid)
}
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
}
func (f *fakeActivityClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error {
f.mu.Lock()
f.prewarmCount++
f.mu.Unlock()
return nil
}
func (f *fakeActivityClient) counts() (int, int, int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.listCount, f.exchangeCount, f.prewarmCount
}
func TestClassifyExchange(t *testing.T) {
tests := []struct {
name string
resp *huya.ScoreExchangePrizeResp
err error
want string
}{
{name: "success", resp: &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, want: CategorySuccess},
{name: "frequent", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "操作频繁,请稍后再试"}, want: CategoryTooFrequent},
{name: "not open", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品暂未开放兑换"}, want: CategoryNotOpen},
{name: "not enough", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "兑换货币不足"}, want: CategoryNotEnough},
{name: "already", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "已兑换该奖品"}, want: CategoryAlreadyDone},
{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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyExchange(tt.resp, tt.err); got != tt.want {
t.Fatalf("ClassifyExchange()=%s, want %s", got, tt.want)
}
})
}
}
func TestRunTargetsDirectExchangeSkipsListAndStopsPerAccount(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}}
err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 5,
Delay: time.Millisecond,
Concurrency: 2,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
listCount, exchangeCount, _ := client.counts()
if listCount != 0 {
t.Fatalf("listCount=%d, want 0", listCount)
}
if exchangeCount != len(accounts) {
t.Fatalf("exchangeCount=%d, want %d", exchangeCount, len(accounts))
}
}
func TestRunTargetsTerminalNotEnoughStopsAccountTarget(t *testing.T) {
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "兑换货币不足"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 4,
Delay: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1", exchangeCount)
}
}
func TestRunTargetsTooFrequentBackoffSkipsNextLoop(t *testing.T) {
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "操作频繁,请稍后再试"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 2,
Delay: time.Millisecond,
TooFrequentBackoff: 50 * time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1", exchangeCount)
}
}
func TestRunTargetsSharedListForNameTargets(t *testing.T) {
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Msg: "请求成功",
Prizes: []huya.PrizeInfo{{ID: 12889, SID: 2203, Name: "套装-潮萌小黄鸭", NewScore: 988, LeftNum: 10}},
},
}
runner := Runner{Client: client}
accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}}
err := runner.RunTargets(context.Background(), accounts, []Target{{Name: "小黄鸭"}}, Config{
SID: 2203,
Loops: 1,
Concurrency: 2,
StopAccountAfterSuccess: true,
ListBeforeExchange: true,
SharePrizeList: true,
}, nil)
if err != nil {
t.Fatal(err)
}
listCount, exchangeCount, _ := client.counts()
if listCount != 1 {
t.Fatalf("listCount=%d, want 1", listCount)
}
if exchangeCount != len(accounts) {
t.Fatalf("exchangeCount=%d, want %d", exchangeCount, len(accounts))
}
}
func TestRunTargetsAutoSelectPrefersPrizeWithLeft(t *testing.T) {
var gotPID int32
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Msg: "请求成功",
Prizes: []huya.PrizeInfo{
{ID: 1, SID: 2203, Name: "无剩余", NewScore: 10, LeftNum: 0},
{ID: 2, SID: 2203, Name: "有剩余", NewScore: 20, LeftNum: 5},
},
},
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
gotPID = pid
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{
SID: 2203,
Loops: 1,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
if gotPID != 2 {
t.Fatalf("gotPID=%d, want 2", gotPID)
}
}
func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
results := make(chan Result, 8)
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 1,
StopAccountAfterSuccess: true,
RefreshAfterSuccess: true,
}, func(result Result) {
results <- result
})
if err != nil {
t.Fatal(err)
}
seenScore := false
seenRecords := false
deadline := time.After(time.Second)
for !seenScore || !seenRecords {
select {
case result := <-results:
seenScore = seenScore || result.Action == "refresh_score"
seenRecords = seenRecords || result.Action == "refresh_records"
case <-deadline:
t.Fatalf("未收到刷新结果: score=%v records=%v", seenScore, seenRecords)
}
}
}