Files
leaderboard-scanner/main.go

431 lines
12 KiB
Go

package main
import (
"bytes"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strconv"
"syscall"
"time"
"github.com/goccy/go-json"
"github.com/joho/godotenv"
"github.com/robfig/cron/v3"
"github.com/valyala/fastjson"
)
var (
yearArg = flag.String("year", "2023", "the year to scan")
leaderboardArg = flag.String("leaderboard", "", "the leaderboard code to check")
sessionArg = flag.String("session", "", "session cookie to use to request the leaderboard")
webhookURLArg = flag.String("webhookURL", "", "webhook to post updates to")
daemonizeArg = flag.Bool("d", false, "daemonizes the application to run and scan every 15 minutes")
)
var (
webhook = ""
webhookURL *url.URL
ChicagoTimeZone, _ = time.LoadLocation("America/Chicago")
ordinals = []string{"th", "st", "nd", "rd"}
)
type completionPartData struct {
GotStarAt int64 `json:"get_star_ts"`
StarIndex int64 `json:"star_index"`
}
type completionDayData struct {
Part1 *completionPartData
Part2 *completionPartData
}
type memberData struct {
Name string `json:"name"`
CompletionDayLevel []completionDayData `json:"-"`
ID int `json:"id"`
LocalScore int `json:"local_score"`
GlobalScore int `json:"global_score"`
Stars int `json:"stars"`
LastStarTimestamp int `json:"last_star_ts"`
}
type leaderboardData struct {
Event string `json:"event"`
Members []memberData `json:"-"`
OwnerID int `json:"owner_id"`
}
func main() {
flag.Parse()
fmt.Println("Started AOC leaderboard scanner.")
dotenvErr := godotenv.Load()
if dotenvErr != nil && !errors.Is(dotenvErr, os.ErrNotExist) {
log.Fatalln("Error loading .env file:", dotenvErr)
}
session := *sessionArg
if len(session) == 0 {
session = os.Getenv("AOC_SESSION")
}
if len(session) == 0 {
log.Fatalln("No session code provided. You must specify your session code as an argument or as an AOC_SESSION environment variable in either .env or defined in your environment to pull leaderboard info.")
}
leaderboardID := *leaderboardArg
if len(leaderboardID) == 0 {
leaderboardID = os.Getenv("AOC_LEADERBOARD")
}
if len(leaderboardID) == 0 {
log.Fatalln("No leaderboard ID provided.")
}
webhook = *webhookURLArg
if len(webhook) == 0 {
webhook = os.Getenv("AOC_WEBHOOK")
}
if len(webhook) == 0 {
log.Fatalln("No webhook URL provided.")
}
var webhookErr error
webhookURL, webhookErr = url.Parse(webhook)
if webhookErr != nil {
log.Fatalln("Unable to parse given webhook", webhook, "to a URL:", webhookErr)
}
var p fastjson.Parser
var lastRead int64
var lastBody []byte
cache, cacheErr := os.ReadFile(".cache.json")
if cacheErr != nil {
if !errors.Is(cacheErr, os.ErrNotExist) {
log.Println("Error reading cached data, will pull fresh copy:", cacheErr)
}
} else {
cacheObj, parseErr := p.ParseBytes(cache)
if parseErr == nil {
lastRead = cacheObj.GetInt64("last_read")
lastBody = cacheObj.GetStringBytes("last_body")
}
}
refresh := func() {
fmt.Println("Scanning for new leaderboard data...")
// the website requests no more than every 15mins, but this gives us a little slop for cron jobs
if time.Since(time.Unix(lastRead, 0)) < time.Minute*14 {
log.Println("Too soon since the last request; doing nothing")
return
}
currBody, downloadErr := downloadLeaderboardData(*yearArg, leaderboardID, session)
if downloadErr != nil {
log.Println("Error downloading leaderboard data:", downloadErr)
return
}
defer func() { lastBody = currBody }()
lastRead = time.Now().Unix()
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)
}
}
if len(lastBody) == 0 {
return
}
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 {
s := func(part *completionPartData, partNum int) {
// in case we get two updates at once, this prevents us from saying the same number of total stars for both parts.
// it's never possible to have part2 completed before part 1 for a day, so this is all we need to check.
skipPart2OfDay := -1
if partNum == 1 {
skipPart2OfDay = dayIdx
}
totalStars := getTotalStars(&member, skipPart2OfDay)
totalStarsPlural := "s"
if totalStars == 1 {
totalStarsPlural = ""
}
completionTime := time.Unix(part.GotStarAt, 0).In(ChicagoTimeZone).Format("3:04:05pm")
rank := getCompletionRank(&leaderboard, &member, dayIdx, partNum) + 1
ordinal := getOrdinal(rank)
overallRank := getLeaderboardRank(&leaderboard, &member) + 1
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, and now has %d star%s on the year, good for %d%s place overall. :tada:",
member.Name,
dayIdx+1,
partNum,
rank,
ordinal,
*yearArg,
leaderboardID,
completionTime,
totalStars,
totalStarsPlural,
overallRank,
getOrdinal(overallRank),
))
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)
}
}
}
}
if !*daemonizeArg {
refresh()
return
}
c := cron.New()
c.AddFunc("*/15 * * * *", refresh)
c.Start()
quit := make(chan os.Signal, 2)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
fmt.Println("Shutting down.")
}
func getTotalStars(member *memberData, skipPart2OfDay int) int {
total := 0
for dayIdx, day := range member.CompletionDayLevel {
if day.Part1 != nil {
total++
}
if day.Part2 != nil && skipPart2OfDay != dayIdx {
total++
}
}
return total
}
func getCompletionRank(leaderboard *leaderboardData, inMember *memberData, dayIdx int, partNum int) int {
targetTime := inMember.CompletionDayLevel[dayIdx].Part1.GotStarAt
if partNum != 1 {
targetTime = inMember.CompletionDayLevel[dayIdx].Part2.GotStarAt
}
numAhead := 0
for _, member := range leaderboard.Members {
if member.ID == inMember.ID {
continue
}
part := member.CompletionDayLevel[dayIdx].Part1
if partNum != 1 {
part = member.CompletionDayLevel[dayIdx].Part2
}
if part == nil {
continue
}
if part.GotStarAt < targetTime {
numAhead++
}
}
return numAhead
}
func getLeaderboardRank(leaderboard *leaderboardData, inMember *memberData) int {
ranking := make([]memberData, 0, len(leaderboard.Members))
ranking = append(ranking, leaderboard.Members...)
sort.Slice(ranking, func(i, j int) bool {
if ranking[i].LocalScore > ranking[j].LocalScore {
return true
}
if ranking[i].LocalScore < ranking[j].LocalScore {
return false
}
return ranking[i].ID < ranking[j].ID
})
for i := range ranking {
if ranking[i].ID == inMember.ID {
return i
}
}
return -1
}
func getOrdinal(n int) string {
v := n % 100
if v >= 20 && len(ordinals) > (v-20)%10 {
return ordinals[(v-20)%10]
}
if len(ordinals) > v {
return ordinals[v]
}
return ordinals[0]
}
func downloadLeaderboardData(year, leaderboardID, sessionID string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://adventofcode.com/%s/leaderboard/private/view/%s.json", year, leaderboardID), nil)
if err != nil {
return nil, fmt.Errorf("error creating request for leaderboard: %w", err)
}
req.AddCookie(&http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
Domain: ".adventofcode.com",
Secure: true,
HttpOnly: true,
})
client := http.DefaultClient
resp, reqErr := client.Do(req)
if reqErr != nil {
return nil, fmt.Errorf("error attempting to download leaderboard: %w", reqErr)
}
read, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("error reading response body: %w", readErr)
}
return read, nil
}
func buildLeaderboard(body []byte) (leaderboardData, error) {
var leaderboard leaderboardData
marshalErr := json.Unmarshal(body, &leaderboard)
if marshalErr != nil {
return leaderboard, fmt.Errorf("error unmarshaling string `%s` into leaderboardData: %w", string(body), marshalErr)
}
jsonObj, parseErr := fastjson.ParseBytes(body)
if parseErr != nil {
return leaderboard, fmt.Errorf("error parsing string into json: %w", parseErr)
}
members := jsonObj.GetObject("members")
members.Visit(func(key []byte, memberVal *fastjson.Value) {
var member memberData
json.Unmarshal([]byte(memberVal.String()), &member)
member.CompletionDayLevel = make([]completionDayData, 25)
completionObj := memberVal.GetObject("completion_day_level")
completionObj.Visit(func(completionKey []byte, completionDay *fastjson.Value) {
memberCompletionObj := completionDayData{}
completionDayObj, _ := completionDay.Object()
completionDayObj.Visit(func(completionPartKey []byte, completionPartVal *fastjson.Value) {
var completionPart completionPartData
json.Unmarshal([]byte(completionPartVal.String()), &completionPart)
if string(completionPartKey) == "1" {
memberCompletionObj.Part1 = &completionPart
} else {
memberCompletionObj.Part2 = &completionPart
}
})
completionDayNum, _ := strconv.Atoi(string(completionKey))
member.CompletionDayLevel[completionDayNum-1] = memberCompletionObj
})
leaderboard.Members = append(leaderboard.Members, member)
})
return leaderboard, nil
}
func sendNotification(content string) error {
b, _ := json.Marshal(struct {
Text string `json:"text"`
}{
Text: content,
})
fmt.Println("Sending notification:", content)
resp, err := http.DefaultClient.Post(webhookURL.String(), "application/json", bytes.NewReader(b))
if err != nil {
return fmt.Errorf("error POSTing to webhook: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code %d", resp.StatusCode)
}
return nil
}
func arrayContains[T any](array []T, pred func(val T) bool) bool {
for _, v := range array {
if pred(v) {
return true
}
}
return false
}
func arrayFind[T any](array []T, pred func(val T) bool) *T {
for _, v := range array {
if pred(v) {
return &v
}
}
return nil
}