diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3a628a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +badminton +badminton.exe +.gocache diff --git a/README.md b/README.md index c82ae07..398060e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,272 @@ # 北理工羽毛球抢票 -## 快速使用 +一个用于北理工体育馆羽毛球场地预约的命令行小工具。程序会根据你输入的场馆、日期、场地和时间段持续检查目标时段,并在可下单时尝试提交订单;检测到未支付订单后,可选发送钉钉机器人通知。 -1. 使用抓包工具抓取 http://gym.dazuiwl.cn 下的任意请求(触发方式为使用微信打开北理工体育馆公众号的预约功能,可以随便操作一些),找到请求参数中的`token`、`openid`填入/config/config.go的对应位置,这里的openid对每一个微信号唯一(微信分发的),确保不要泄露。 -2. 运行`go build main.go`编译 -3. 无运行时参数,运行后先输入25-良乡,51-中关村,54-中关村早晚场中的一个来确定场馆;然后输入YYYY-MM-DD格式的日期选择预定日期,例如2026-12-13;之后控制台会打印对应日期对应场馆的预定情况,接下来选择场地和时间:如要选择主馆4,09:00~10:00,就依次输入“主馆4”,“9”。 -4. 程序在07:00~23:30以内的时间会尝试立刻下单;在此阶段以外的时间会等待至第二天早七点立刻下单 -5. 理论上适用于其他类型的场地,可以自行在运行时输入不同的场馆id来测试。 \ No newline at end of file +## 使用提醒 + +- 本项目仅建议本人账号自用。 +- 不要泄露 `token`、`openid`、钉钉 `Webhook` 配置,这些信息可以代表你的账号或通知机器人发起请求。 +- 不要把填写了真实配置的 `config/config.go` 上传到公开仓库。 +- 程序不能保证一定抢到场地,最终结果以体育馆预约系统实际订单为准。 + +## 准备工作 + +建议优先使用以下环境: + +- Windows +- WSL2 Debian + +项目只在 Windows 和 WSL2 Debian 上做过实际测试。Go 官方提供 Windows、macOS、Linux 的安装包,所以 Linux/macOS 理论上也可以运行;主要差别通常只是编译输出文件名是否带 `.exe`。 + +你需要准备: + +- Go 环境:[Go 官方下载页](https://go.dev/dl/) +- 抓包工具,例如 Reqable +- 微信,登录你自己的账号 +- 钉钉自定义机器人,可选,用于抢到后通知,防止抢到后错过 15 分钟支付有效期 + +## 抓取 token 与 openid + +下面以 Reqable 为例。 + +1. 打开 Reqable,开始抓包。 +2. 使用微信打开“北理工体育馆”公众号的预约功能。 +3. 在预约页面随便进行一些操作,例如查看场馆、日期、时段、提交预约的订单信息。 +4. 在 Reqable 中筛选: + +```text +http://gym.dazuiwl.cn +``` + +5. 找到请求里的 `token` 和 `openid`: + - `token`:通常大多数接口的请求头里都能看到,可以找一找。 + - `openid`:通常在点击“提交订单-提交并支付订单”后相关接口(例如 `/api/order/submit`)的请求体里可以找到,不需要真支付。 + +目前公众号预约接口是 HTTP 请求,通常可以直接查看内容,不需要安装 CA 证书,只有抓 HTTPS 请求时,才涉及安装 CA 证书和信任证书。 + +## 安装 Go 环境 + +安装 Go 可参考 Go 官方下载页,安装完成后在终端验证: + +```powershell +go version +``` + +`go.mod` 中写的是: + +```text +go 1.23 +``` + +使用 Go 1.23 或更新版本均可尝试,本地测试使用 Go 1.26.3 版本也能运行。 + +## 获取项目源码 + +方式一:使用 Git 克隆。 + +```powershell +git clone <仓库地址> +cd BitBMTGrab +``` + +方式二:下载 ZIP,解压后进入项目目录。 + +```powershell +cd <解压后的项目目录> +``` + +## 配置账号信息 + +打开 [config/config.go](config/config.go),填写: + +```go +const ApiToken = "替换为你的token" +const OpenId = "替换为你的openid" +``` + +示例: + +```go +const ApiToken = "xxxxxxxx" +const OpenId = "xxxxxxxx" +``` + +这些配置不要泄露。 + +## 配置钉钉通知(可选) + +钉钉自定义机器人可以通过 Webhook 向群里发送消息。参考:[钉钉开发者百科:创建自定义机器人](https://open.dingtalk.com/document/dingstart/custom-bot-creation-and-installation)。 + +配置步骤: + +1. 在钉钉群里添加“自定义机器人”。 +2. 安全设置选择“自定义关键词”。 +3. 关键词建议设置为: + +```text +抢票通知 +``` + +4. 复制机器人 Webhook URL,填写到 [config/config.go](config/config.go): + +```go +const DingTalkWebhook = "https://oapi.dingtalk.com/robot/send?access_token=xxx" +``` + +如果不需要钉钉通知,保持空字符串即可: + +```go +const DingTalkWebhook = "" +``` + +测试钉钉通知: + +```powershell +go run . --testdingtalk +``` + +如果配置正确,钉钉群会收到一条测试消息。默认通知标题包含“抢票通知”,用于匹配钉钉机器人关键词策略。 + +## 运行程序 + +直接运行: + +```powershell +go run . +``` + +编译后运行。 + +Windows: + +```powershell +go build -o badminton.exe . +.\badminton.exe +``` + +WSL2 Debian / Linux / macOS: + +```bash +go build -o badminton . +./badminton +``` + +输出文件名可以自己改,Windows 通常使用 `.exe` 后缀,Linux/macOS/WSL 通常不需要后缀。 + +## 输入预约参数 + +程序启动后会依次要求输入: + +1. 场馆 ID +2. 预约日期 +3. 场地名称 +4. 预约开始小时 + +场馆 ID: + +```text +25 - 良乡 +51 - 中关村 +54 - 中关村早晚场 +``` + +示例输入: + +```text +请输入场馆ID(25-良乡,51-中关村,54-中关村早晚场): 25 +请输入预订日期(格式:YYYY-MM-DD): 2026-05-11 +请输入场地名称: 场4 +请输入预订时间(小时,例如9表示09:00开始的时段): 17 +``` + +其中: + +- 日期必须是 `YYYY-MM-DD` 格式。 +- 场地名称必须和程序打印出来的场地名称一致。 +- 时间只输入开始小时,例如 `17` 表示 `17:00` 开始的时段。 + +## 运行状态说明 + +程序会先打印目标日期的场地预订情况,然后进入检查和下单流程。 + +常见输出含义: + +```text +检查订单失败: 订单检查失败: 场地该时间段预约中,5秒后继续检查... +``` + +目标时段当前不可下单,但属于可等待状态。程序会每 5 秒继续检查。 + +```text +检查通过,准备进入下单流程... +``` + +`CheckSportSchedule` 检查通过,程序准备尝试提交订单。 + +```text +提交订单失败: xxx,5秒后回到检查阶段... +``` + +提交接口失败,程序不会继续硬提交,而是等待 5 秒后重新回到检查阶段。 + +```text +订单提交成功,开始确认订单... +``` + +提交接口返回成功,程序开始查询未支付订单列表。 + +```text +已检测到订单 123,正在等待支付... +``` + +程序检测到未支付订单,说明已经抢到待支付订单,此时如果配置了钉钉 Webhook,会发送钉钉通知。 + +建议每次使用时先观察一轮检查输出,因为程序目前只对“预约中”状态持续等待;如果接口返回的是其他状态,例如: + +```text +场地该时间段上课中 +场地该时间段包场中 +``` + +程序会认为这不是可恢复的临时状态,并直接退出。 + +程序在 `07:00:00` 到 `23:30:00` 之间会尝试立刻下单;如果不在这个时间范围内,会等待到下一个早上 7 点再继续。 + +## 常见问题 + +### `go` 命令找不到 + +说明 Go 没安装好,或者环境变量没有配置好。重新安装 Go 后,打开一个新的终端执行: + +```powershell +go version +``` + +### `token` 或 `openid` 失效 + +重新抓包并更新 [config/config.go](config/config.go) 中的 `ApiToken` 和 `OpenId`。 + +### 场地名称不匹配 + +程序按场地名称精确匹配。请使用程序打印出来的场地名称,不要自己猜名称。 + +### 钉钉通知不触发 + +检查: + +- `DingTalkWebhook` 是否为空。 +- Webhook URL 是否完整。 +- 钉钉机器人是否启用了关键词安全策略。 +- 钉钉机器人配置的关键词是否能匹配通知标题里的“抢票通知”。 + +可以先运行: + +```powershell +go run . --testdingtalk +``` + +如果仍然失败,根据控制台打印的钉钉错误信息继续排查,也可以把错误信息发给 AI 辅助分析。 + +### 程序一直没有抢到 + +可能是目标时段一直没有释放、网络较慢、接口限制、账号状态异常或场地不可预约,程序只能自动执行检查和提交,不能绕过预约系统规则。 diff --git a/badminton.go b/badminton.go index da4b63c..637abd8 100755 --- a/badminton.go +++ b/badminton.go @@ -4,7 +4,9 @@ import ( "badminton/api" "badminton/models" "fmt" + "os" "sort" + "strings" "time" ) @@ -124,6 +126,15 @@ func waitUntilNextAvailableTime() { } func main() { + if len(os.Args) > 1 && os.Args[1] == "--testdingtalk" { + if err := sendDingTalkTestNotice(); err != nil { + fmt.Printf("钉钉通知测试失败: %v\n", err) + return + } + fmt.Println("钉钉通知测试发送成功") + return + } + headers := map[string]string{ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", } @@ -190,16 +201,23 @@ func main() { scene := fmt.Sprintf("[{\"day\":\"%s\",\"fields\":{\"%s\":[%d]}}]", dateStr, venueFieldID, hourID) - check, err := api.CheckSportSchedule(venueID, scene, headers) - if err != nil { - fmt.Printf("检查订单失败: %v\n", err) - return - } + for { + check, err := api.CheckSportSchedule(venueID, scene, headers) + if err != nil { + if strings.Contains(err.Error(), "预约中") { + fmt.Printf("检查订单失败: %v,5秒后继续检查...\n", err) + time.Sleep(5 * time.Second) + continue + } - fmt.Println("\n等待可下单时间...") - waitUntilNextAvailableTime() + fmt.Printf("检查订单失败: %v\n", err) + return + } + + fmt.Println("\n检查通过,准备进入下单流程...") + fmt.Println("\n等待可下单时间...") + waitUntilNextAvailableTime() - for { orderList, err := api.GetOrderList("makeappointment", "created", "", 1, 20, headers) if err != nil { fmt.Printf("获取订单列表失败: %v\n", err) @@ -208,6 +226,10 @@ func main() { for _, order := range orderList.Data.List { if order.SportEventsID == venueID { + err = sendOrderNotice(venueID, venueName, dateStr, hour, order.ID, order.Amount) + if err != nil { + fmt.Printf("钉钉通知失败: %v\n", err) + } fmt.Printf("已检测到订单 %d,正在等待支付...\n", order.ID) return } @@ -215,9 +237,34 @@ func main() { _, err = api.SubmitOrder(venueID, check.Data.TotalAmount, scene, headers) if err != nil { - fmt.Printf("提交订单失败: %v\n", err) + fmt.Printf("提交订单失败: %v,5秒后回到检查阶段...\n", err) + time.Sleep(5 * time.Second) + continue + } + + fmt.Println("订单提交成功,开始确认订单...") + for { + orderList, err := api.GetOrderList("makeappointment", "created", "", 1, 20, headers) + if err != nil { + fmt.Printf("获取订单列表失败: %v,5秒后继续确认...\n", err) + time.Sleep(5 * time.Second) + continue + } + + for _, order := range orderList.Data.List { + if order.SportEventsID == venueID { + err = sendOrderNotice(venueID, venueName, dateStr, hour, order.ID, order.Amount) + if err != nil { + fmt.Printf("钉钉通知失败: %v\n", err) + } + fmt.Printf("已检测到订单 %d,正在等待支付...\n", order.ID) + return + } + } + + fmt.Println("暂未检测到未支付订单,5秒后继续确认...") + time.Sleep(5 * time.Second) } - time.Sleep(5 * time.Second) } } diff --git a/config/config.go b/config/config.go index 4232fef..6db6d77 100644 --- a/config/config.go +++ b/config/config.go @@ -3,3 +3,9 @@ package config const ApiHost = "http://gym.dazuiwl.cn" const ApiToken = "替换为你的token" const OpenId = "替换为你的openid" + +// DingTalkWebhook 留空表示禁用钉钉通知。 +// 启用时填写完整机器人 Webhook,例如:https://oapi.dingtalk.com/robot/send?access_token=xxx +// 钉钉机器人安全设置请选择“自定义关键词”,请确保给机器人配置的关键词包含在通知标题中; +// 默认通知标题包含“抢票通知”。 +const DingTalkWebhook = "" diff --git a/models/string.go b/models/string.go index 11a803f..4ee27ca 100644 --- a/models/string.go +++ b/models/string.go @@ -4,7 +4,7 @@ import "fmt" // String 为Response实现String方法 func (r Response[T]) String() string { - return fmt.Sprintf("Code: %s\nMessage: %s\nTime: %s\nData: %v", r.Code, r.Message, r.TimeString, r.Data) + return fmt.Sprintf("Code: %d\nMessage: %s\nTime: %s\nData: %v", r.Code, r.Message, r.TimeString, r.Data) } // String 为SportEventsHour实现String方法 @@ -16,4 +16,4 @@ func (s SportEventsHour) String() string { // String 为Venue实现String方法 func (v Venue) String() string { return fmt.Sprintf("场馆ID: %d\n场馆名称: %s\n最大人数: %d", v.ID, v.Name, v.MaxNums) -} \ No newline at end of file +} diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..5092bf0 --- /dev/null +++ b/notify.go @@ -0,0 +1,101 @@ +package main + +import ( + "badminton/config" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const dingTalkNoticeKeyword = "抢票通知" + +type dingTalkMarkdownRequest struct { + MsgType string `json:"msgtype"` + Markdown struct { + Title string `json:"title"` + Text string `json:"text"` + } `json:"markdown"` +} + +type dingTalkResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +func dingTalkWebhook() string { + return strings.TrimSpace(config.DingTalkWebhook) +} + +func sendDingTalkMarkdown(title, text string) error { + webhook := dingTalkWebhook() + if webhook == "" { + return nil + } + + var payload dingTalkMarkdownRequest + payload.MsgType = "markdown" + payload.Markdown.Title = title + payload.Markdown.Text = text + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("构造钉钉通知失败: %w", err) + } + + client := &http.Client{Timeout: 10 * time.Second} + res, err := client.Post(webhook, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("发送钉钉通知失败: %w", err) + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("读取钉钉通知响应失败: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("钉钉通知 HTTP 状态异常: %s, 响应: %s", res.Status, string(responseBody)) + } + + var response dingTalkResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return fmt.Errorf("解析钉钉通知响应失败: %w, 响应: %s", err, string(responseBody)) + } + if response.ErrCode != 0 { + return fmt.Errorf("钉钉通知返回错误: errcode=%d, errmsg=%s", response.ErrCode, response.ErrMsg) + } + + return nil +} + +func sendOrderNotice(venueID int, venueName string, dateStr string, hour int, orderID int, amount int) error { + title := dingTalkNoticeKeyword + " - 已检测到订单" + text := fmt.Sprintf( + "### 已检测到订单\n\n- 场馆ID: %d\n- 场地: %s\n- 日期: %s\n- 时间: %02d:00\n- 订单ID: %d\n- 金额: %d元\n\n请尽快前往支付。", + venueID, + venueName, + dateStr, + hour, + orderID, + amount, + ) + + return sendDingTalkMarkdown(title, text) +} + +func sendDingTalkTestNotice() error { + if dingTalkWebhook() == "" { + return errors.New("钉钉通知未启用:请先在 config.DingTalkWebhook 中填写完整机器人 Webhook") + } + + title := dingTalkNoticeKeyword + " - 测试消息" + text := "### 测试消息\n\n这是一条钉钉机器人测试消息,用于验证 Webhook 是否可用。" + + return sendDingTalkMarkdown(title, text) +}