A basic COVID-19 dashboard for Edmonton https://edmonton.deadbeef.codes
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

185 lines
5.3 KiB

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)
}
// 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().Add(-time.Hour * 6)}
// 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().Add(-time.Hour * 6).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)
}
}