优化 CK 导入和监控配置
This commit is contained in:
+2
-1
@@ -3,4 +3,5 @@
|
||||
/bin/
|
||||
/frontend/node_modules/
|
||||
*.test
|
||||
build/
|
||||
build/
|
||||
frontend/dist/
|
||||
+97
-11
@@ -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
File diff suppressed because it is too large
Load Diff
+357
-107
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user