Scan every 15 minutes, run until stopped

This commit is contained in:
2023-12-02 19:31:38 -06:00
parent 5d47e8d03c
commit 310cf44a88
3 changed files with 112 additions and 90 deletions

1
go.mod
View File

@ -5,5 +5,6 @@ go 1.21.4
require ( require (
github.com/goccy/go-json v0.10.2 github.com/goccy/go-json v0.10.2
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/robfig/cron/v3 v3.0.1
github.com/valyala/fastjson v1.6.4 github.com/valyala/fastjson v1.6.4
) )

2
go.sum
View File

@ -2,5 +2,7 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=

199
main.go
View File

@ -10,11 +10,14 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/signal"
"strconv" "strconv"
"syscall"
"time" "time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/robfig/cron/v3"
"github.com/valyala/fastjson" "github.com/valyala/fastjson"
) )
@ -34,8 +37,8 @@ var (
) )
type completionPartData struct { type completionPartData struct {
GetStarTimestamp int64 `json:"get_star_ts"` GotStarAt int64 `json:"get_star_ts"`
StarIndex int64 `json:"star_index"` StarIndex int64 `json:"star_index"`
} }
type completionDayData struct { type completionDayData struct {
@ -50,17 +53,20 @@ type memberData struct {
LocalScore int `json:"local_score"` LocalScore int `json:"local_score"`
GlobalScore int `json:"global_score"` GlobalScore int `json:"global_score"`
Stars int `json:"stars"` Stars int `json:"stars"`
LastStarTs int `json:"last_star_ts"` LastStarTimestamp int `json:"last_star_ts"`
} }
type leaderboardData struct { type leaderboardData struct {
Event string `json:"event"` Event string `json:"event"`
Members []memberData `json:"-"` Members []memberData `json:"-"`
OwnerId int `json:"owner_id"` OwnerID int `json:"owner_id"`
} }
func main() { func main() {
flag.Parse() flag.Parse()
fmt.Println("Started AOC leaderboard scanner.")
dotenvErr := godotenv.Load() dotenvErr := godotenv.Load()
if dotenvErr != nil { if dotenvErr != nil {
log.Fatal("Error loading .env file") log.Fatal("Error loading .env file")
@ -112,95 +118,105 @@ func main() {
} }
} }
if time.Since(time.Unix(lastRead, 0)) < time.Minute*15 { refresh := func() {
fmt.Println("Too soon since the last request; doing nothing") fmt.Println("Scanning for new leaderboard data...")
return
}
currBody := lastBody
currBody, downloadErr := downloadLeaderboardData(*yearArg, leaderboardID, session) // the website requests no more than every 15mins, but this gives us a little slop for cron jobs
if downloadErr != nil { if time.Since(time.Unix(lastRead, 0)) < time.Minute*14 {
log.Fatalln("Error downloading leaderboard data:", downloadErr) log.Println("Too soon since the last request; doing nothing")
} return
}
lastRead = time.Now().Unix() currBody, downloadErr := downloadLeaderboardData(*yearArg, leaderboardID, session)
jsonBytes, marshalErr := json.Marshal(map[string]any{"last_read": lastRead, "last_body": string(currBody)}) if downloadErr != nil {
if marshalErr != nil { log.Println("Error downloading leaderboard data:", downloadErr)
log.Println("Failed to marshal last-read data into json:", marshalErr) return
} else { }
writeErr := os.WriteFile(".cache.json", jsonBytes, 0644)
if writeErr != nil { lastRead = time.Now().Unix()
log.Println("Failed to save cached data:", writeErr) jsonBytes, marshalErr := json.Marshal(map[string]any{"last_read": lastRead, "last_body": string(currBody)})
if marshalErr != nil {
log.Println("Failed to marshal last-read data into json. Data:", string(jsonBytes), "- error:", marshalErr)
} else {
writeErr := os.WriteFile(".cache.json", jsonBytes, 0644)
if writeErr != nil {
log.Println("Failed to save cached data:", writeErr)
}
}
lastLeaderboard, lastLeaderboardErr := buildLeaderboard(lastBody)
if lastLeaderboardErr != nil {
log.Println("Error building leaderboard from cached body:", lastLeaderboardErr)
return
}
leaderboard, leaderboardErr := buildLeaderboard(currBody)
if leaderboardErr != nil {
log.Println("Error building leaderboard from downloaded body:", leaderboardErr)
return
}
for _, member := range leaderboard.Members {
lastMember := arrayFind(lastLeaderboard.Members, func(m memberData) bool { return m.ID == member.ID })
if lastMember == nil {
// todo: report if they've already got stars on the year
nErr := sendNotification(fmt.Sprintf(":tada: A new challenger has appeared! Welcome, %s, to [the leaderboard](https://adventofcode.com/%s/leaderboard/private/view/%s)! :tada:", member.Name, *yearArg, leaderboardID))
if nErr != nil {
log.Printf("Error sending new-challenger notification to the leaderboard for %s: %v\n", member.Name, nErr)
}
continue
}
for dayIdx, day := range member.CompletionDayLevel {
totalStars := getTotalStars(&member)
totalStarsPlural := "s"
if totalStars == 1 {
totalStarsPlural = ""
}
s := func(part *completionPartData, partNum int) {
completionTime := time.Unix(part.GotStarAt, 0).In(ChicagoTimeZone).Format("3:04:05pm")
rank := getCompletionRank(&leaderboard, &member, dayIdx, partNum) + 1
ordinal := getOrdinal(rank)
err := sendNotification(fmt.Sprintf(
":tada: %s completed day %d part %d %d%s on [the leaderboard](https://adventofcode.com/%s/leaderboard/private/view/%s) at %s! %s now has %d star%s on the year. :tada:",
member.Name,
dayIdx+1,
partNum,
rank,
ordinal,
*yearArg,
leaderboardID,
completionTime,
member.Name,
totalStars,
totalStarsPlural,
))
if err != nil {
log.Println("Error sending notification for", member, err)
}
}
// todo: probably want to batch these for delivery later so we can sort by completion rank/time
if day.Part1 != nil && lastMember.CompletionDayLevel[dayIdx].Part1 == nil {
s(day.Part1, 1)
}
if day.Part2 != nil && lastMember.CompletionDayLevel[dayIdx].Part2 == nil {
s(day.Part2, 2)
}
}
} }
} }
lastLeaderboard, lastLeaderboardErr := buildLeaderboard(lastBody) c := cron.New()
if lastLeaderboardErr != nil { c.AddFunc("*/15 * * * *", refresh)
log.Fatalln("Error building leaderboard from cached body:", lastLeaderboardErr)
}
leaderboard, leaderboardErr := buildLeaderboard(currBody)
if leaderboardErr != nil {
log.Fatalln("Error building leaderboard from downloaded body:", leaderboardErr)
}
for _, member := range leaderboard.Members { c.Start()
lastMember := arrayFind(lastLeaderboard.Members, func(m memberData) bool { return m.ID == member.ID }) quit := make(chan os.Signal, 2)
if lastMember == nil { signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
// todo: report if they've already got stars on the year <-quit
nErr := sendNotification(fmt.Sprintf("A new challenger has appeared! Welcome, %s, to [the leaderboard](https://adventofcode.com/%s/leaderboard/private/view/%s)!", member.Name, *yearArg, leaderboardID)) fmt.Println("Shutting down.")
if nErr != nil {
log.Printf("Error sending new-challenger notification to the leaderboard for %s: %v\n", member.Name, nErr)
}
continue
}
for idx, day := range member.CompletionDayLevel {
totalStars := getTotalStars(&member)
totalStarsPlural := "s"
if totalStars == 1 {
totalStarsPlural = ""
}
// todo: probably want to batch these for delivery later so we can sort by completion rank/time
if day.Part1 != nil && lastMember.CompletionDayLevel[idx].Part1 == nil {
completionTime := time.Unix(day.Part1.GetStarTimestamp, 0).In(ChicagoTimeZone).Format("3:04:05pm")
rank := getCompletionRank(&leaderboard, &member, idx, 1) + 1
ordinal := getOrdinal(rank)
sendNotification(fmt.Sprintf(
"%s completed day %d part 1 %d%s on [the leaderboard](https://adventofcode.com/%s/leaderboard/private/view/%s) at %s! %s now has %d star%s on the year.",
member.Name,
idx+1,
rank,
ordinal,
*yearArg,
leaderboardID,
completionTime,
member.Name,
totalStars,
totalStarsPlural,
))
}
if day.Part2 != nil && lastMember.CompletionDayLevel[idx].Part2 == nil {
completionTime := time.Unix(day.Part2.GetStarTimestamp, 0).In(ChicagoTimeZone).Format("3:04:05pm")
rank := getCompletionRank(&leaderboard, &member, idx, 2) + 1
ordinal := getOrdinal(rank)
sendNotification(fmt.Sprintf(
"%s completed day %d part 2 %d%s on [the leaderboard](https://adventofcode.com/%s/leaderboard/private/view/%s) at %s! %s now has %d star%s on the year.",
member.Name,
idx+1,
rank,
ordinal,
*yearArg,
leaderboardID,
completionTime,
member.Name,
totalStars,
totalStarsPlural,
))
}
}
}
} }
func getTotalStars(member *memberData) int { func getTotalStars(member *memberData) int {
@ -218,9 +234,9 @@ func getTotalStars(member *memberData) int {
} }
func getCompletionRank(leaderboard *leaderboardData, inMember *memberData, dayIdx int, partNum int) int { func getCompletionRank(leaderboard *leaderboardData, inMember *memberData, dayIdx int, partNum int) int {
targetTime := inMember.CompletionDayLevel[dayIdx].Part1.GetStarTimestamp targetTime := inMember.CompletionDayLevel[dayIdx].Part1.GotStarAt
if partNum != 1 { if partNum != 1 {
targetTime = inMember.CompletionDayLevel[dayIdx].Part2.GetStarTimestamp targetTime = inMember.CompletionDayLevel[dayIdx].Part2.GotStarAt
} }
numAhead := 0 numAhead := 0
@ -237,7 +253,7 @@ func getCompletionRank(leaderboard *leaderboardData, inMember *memberData, dayId
continue continue
} }
if part.GetStarTimestamp < targetTime { if part.GotStarAt < targetTime {
numAhead++ numAhead++
} }
} }
@ -334,6 +350,9 @@ func sendNotification(content string) error {
}{ }{
Text: content, Text: content,
}) })
fmt.Println("Sending notification:", content)
resp, err := http.DefaultClient.Post(webhookURL.String(), "application/json", bytes.NewReader(b)) resp, err := http.DefaultClient.Post(webhookURL.String(), "application/json", bytes.NewReader(b))
if err != nil { if err != nil {
return fmt.Errorf("error POSTing to webhook: %w", err) return fmt.Errorf("error POSTing to webhook: %w", err)