package main import ( "bufio" "encoding/json" "flag" "fmt" "io/ioutil" "log" "net/http" "os" "os/signal" "strings" "text/template" "time" ) const ( statsURL = "https://covid19stats.alberta.ca" cacheTimeout = time.Hour * 8 ) var ( cache *Cache t *template.Template // HTML Templates used for web mode. Initialized as a null pointer has near zero overhead when cli mode is run ) // ABGovCovidData stores the Javascript object which is ripped from the statsURL. It's parsed as JSON encoded data. type ABGovCovidData [][]string // They screwed this up so much, the first index in the column, not the row. WTF alberta // [0] = case number alberta // [1] = date // [2] = geo zone // [3] = gender // [4] = age range // [5] = case status // [6] = confirmed/suspected // Cache keeps a local copy of the data in memory. The cache will only update if a web request comes in, not on a fixed timer. // This is to ensure reduced load on Alberta's servers and to ensure a quick response when possible, and err on the side of caution when not possible. type Cache struct { Data struct { ActiveCasesEdmonton int TotalCasesEdmonton int ActiveCasesAlberta int TotalCasesAlberta int } UpdatedDate time.Time } func main() { webMode := flag.Bool("web", false, "listen on port 8080 as web server") flag.Parse() if *webMode { // Setup a web server stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt) // Initial load of data go func() { for { var err error cache, err = getUpdatedData() if err != nil { log.Printf("cache is empty and failed to download data from ab website: %v", err) fmt.Println("sleeping for 2 hours") time.Sleep(time.Hour * 2) continue } break } }() // Web server routing go func() { http.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.Dir("public")))) http.HandleFunc("/", homePageHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }() log.Println("started covid19-edmonton server on 8080") // Parse all template files at startup var err error t, err = template.ParseGlob("./templates/*") if err != nil { log.Fatalf("couldn't parse HTML templates in templates directory: %v", err) } <-stop log.Println("shutting covid19-edmonton server down") } else { var err error cache, err = getUpdatedData() if err != nil { log.Fatalf("failed to get data from alberta government website: %v", err) } fmt.Printf("As of %s Mountain Time (the real time)\n\n", cache.UpdatedDate.Format("2006 Jan 02 15:04:05")) // CLI mode, print output to stdout fmt.Println("Edmonton Active: ", cache.Data.ActiveCasesEdmonton) fmt.Println("Edmonton Total: ", cache.Data.TotalCasesEdmonton) fmt.Println("Alberta Active: ", cache.Data.ActiveCasesAlberta) fmt.Println("Alberta Total: ", cache.Data.TotalCasesAlberta) log.Print("\n\nPress 'Enter' to continue...") bufio.NewReader(os.Stdin).ReadBytes('\n') } } // getUpdatedData reaches out to Alberta's website to get the most recent data and returns a pointer to a cache and an error func getUpdatedData() (*Cache, error) { log.Printf("getting up to date data from AB website") // Download the latest stats resp, err := http.Get(statsURL) if err != nil { return nil, fmt.Errorf("failed to download stats page '%s': %v", statsURL, err) } defer resp.Body.Close() bodyB, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read stats page response body: %v", err) } // Parse the HTML for the data // Find the beginning, trim off everything before split := strings.Split(string(bodyB), "\"data\":[[\"1\",") body := fmt.Sprintf("%s%s", "[[\"1\",", split[1]) // Find the end, trim off everything after split = strings.Split(body, "]]") body = fmt.Sprintf("%s%s", split[0], "]]") // Parse the json string into a struct data := ABGovCovidData{} err = json.Unmarshal([]byte(body), &data) if err != nil { return nil, fmt.Errorf("failed to parse data as json: %v", err) } cache := &Cache{UpdatedDate: time.Now()} // count the cases for i := range data[2] { if data[2][i] == "Edmonton Zone" { cache.Data.TotalCasesEdmonton++ if data[5][i] == "Active" { cache.Data.ActiveCasesEdmonton++ } } if data[5][i] == "Active" { cache.Data.ActiveCasesAlberta++ } cache.Data.TotalCasesAlberta++ } return cache, nil } // Web Mode only: HTTP GET / func homePageHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { if cache == nil { w.WriteHeader(http.StatusFailedDependency) log.Printf("unable to serve page due to no cached data, possibly due to application still starting up, or AB government site down, or changed formatting - may need to review how page is parsed") return } if time.Now().After(cache.UpdatedDate.Add(cacheTimeout)) { w.WriteHeader(http.StatusOK) tempCache, err := getUpdatedData() // Hold tempCache in case there's an error, we don't want to nullify our pointer to a working cache that has aged. We will proceed with aged data. if err != nil { log.Printf("failed to update cache: %v", err) } else { cache = tempCache // we retrieved it successfully, so overwrite our old data } } err := t.ExecuteTemplate(w, "index.html", cache) if err != nil { w.WriteHeader(http.StatusInternalServerError) log.Fatalf("failed to execute index.html template: %v", err) } } else { w.WriteHeader(http.StatusMethodNotAllowed) } }