295 lines
7.2 KiB
Go
295 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
)
|
|
|
|
var (
|
|
postcode = flag.String("postcode", "", "Postcode to look for")
|
|
pushoverToken = flag.String("pushover_token", "", "Pushover application token")
|
|
pushoverUser = flag.String("pushover_user", "", "Pushover user token")
|
|
|
|
checkIntervalError = flag.Duration("interval_error", 5*time.Minute, "Interval between loop reruns in the event of an error")
|
|
checkInterval = flag.Duration("interval", 30*time.Minute, "Interval between checks")
|
|
)
|
|
|
|
type Position struct {
|
|
Lat float64 `xml:"lat,attr"`
|
|
Lon float64 `xml:"lng,attr"`
|
|
}
|
|
|
|
func (p1 Position) Equal(p2 Position) bool {
|
|
return p1.Lat == p2.Lat && p1.Lon == p2.Lon
|
|
}
|
|
|
|
type Locations struct {
|
|
XMLName xml.Name `xml:"Locations"`
|
|
|
|
Postcode string `xml:"postcode,attr"`
|
|
Position
|
|
|
|
StreetWorks []StreetWork `xml:"StreetWorks"`
|
|
}
|
|
|
|
// sortIfUnsorted returns the input slice if it's sorted, otherwise it creates a *new* slice that _is_ sorted.
|
|
func sortIfUnsorted(sw []StreetWork) []StreetWork {
|
|
less := func(i, j int) bool {
|
|
a, b := sw[i], sw[j]
|
|
switch {
|
|
case a.StartDate.Before(b.StartDate):
|
|
return true
|
|
case a.StartDate.After(b.StartDate):
|
|
return false
|
|
case a.Street < b.Street:
|
|
return true
|
|
case a.Street > b.Street:
|
|
return false
|
|
case a.Location < b.Location:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
if sort.SliceIsSorted(sw, less) {
|
|
return sw
|
|
}
|
|
|
|
// Make a brand new slice...
|
|
sw = append([]StreetWork(nil), sw...)
|
|
sort.Slice(sw, less)
|
|
return sw
|
|
}
|
|
|
|
func (l1 Locations) Equal(l2 Locations) bool {
|
|
if l1.Postcode != l2.Postcode {
|
|
return false
|
|
}
|
|
|
|
// Ignore Position, we don't care.
|
|
|
|
if len(l1.StreetWorks) != len(l2.StreetWorks) {
|
|
return false
|
|
}
|
|
|
|
l1s := sortIfUnsorted(l1.StreetWorks)
|
|
l2s := sortIfUnsorted(l2.StreetWorks)
|
|
|
|
for n := range l1s {
|
|
a, b := l1s[n], l2s[n]
|
|
if !a.Equal(b) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type CamdenTime struct {
|
|
time.Time
|
|
}
|
|
|
|
var CamdenTZ = func() *time.Location {
|
|
l, err := time.LoadLocation("Europe/London")
|
|
if err != nil {
|
|
panic(fmt.Errorf("loading Europe/London timezone: %v", err))
|
|
}
|
|
return l
|
|
}()
|
|
|
|
func (t *CamdenTime) UnmarshalText(data []byte) error {
|
|
rt, err := time.ParseInLocation("2006-01-02T15:04:05", string(data), CamdenTZ)
|
|
if err == nil {
|
|
t.Time = rt
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (t CamdenTime) Equal(t2 CamdenTime) bool { return t.Time.Equal(t2.Time) }
|
|
func (t CamdenTime) Before(t2 CamdenTime) bool { return t.Time.Before(t2.Time) }
|
|
func (t CamdenTime) After(t2 CamdenTime) bool { return t.Time.After(t2.Time) }
|
|
|
|
type StreetWork struct {
|
|
Location string `xml:"Location,attr"`
|
|
StartDate CamdenTime `xml:"StartDate,attr"` // RFC3339
|
|
EndDate CamdenTime `xml:"EndDate,attr"` // RFC3339
|
|
LocalAuthorityRef string `xml:"LAref,attr"`
|
|
Organisation string `xml:"Organisation,attr"`
|
|
ExternalRef string `xml:"externalref,attr"`
|
|
Telephone string `xml:"Telephone,attr"`
|
|
Position
|
|
Description string `xml:"Description,attr"`
|
|
Street string `xml:"Street,attr"`
|
|
Type string `xml:"Type,attr"`
|
|
OutOfHoursTelephone string `xml:"OutofHours,attr"`
|
|
}
|
|
|
|
func (sw1 StreetWork) Equal(sw2 StreetWork) bool {
|
|
for _, b := range []bool{
|
|
sw1.Location == sw2.Location,
|
|
sw1.StartDate.Equal(sw2.StartDate),
|
|
sw1.EndDate.Equal(sw2.EndDate),
|
|
sw1.LocalAuthorityRef == sw2.LocalAuthorityRef,
|
|
sw1.Organisation == sw2.Organisation,
|
|
sw1.ExternalRef == sw2.ExternalRef,
|
|
sw1.Telephone == sw2.Telephone,
|
|
sw1.Position.Equal(sw2.Position),
|
|
sw1.Description == sw2.Description,
|
|
sw1.Street == sw2.Street,
|
|
sw1.Type == sw2.Type,
|
|
sw1.OutOfHoursTelephone == sw2.OutOfHoursTelephone,
|
|
} {
|
|
if !b {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func getStreetWorks(ctx context.Context, postcode string) (*Locations, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://find.camden.gov.uk/StreetWorksrest.aspx", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http.NewRequest: %w", err)
|
|
}
|
|
v := url.Values{}
|
|
v.Set("area", postcode)
|
|
req.URL.RawQuery = v.Encode()
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("making HTTP request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("bad HTTP response code %v (%v)", resp.StatusCode, resp.Status)
|
|
}
|
|
|
|
l := new(Locations)
|
|
if err := xml.NewDecoder(resp.Body).Decode(l); err != nil {
|
|
return nil, fmt.Errorf("decoding XML response: %w", err)
|
|
}
|
|
if bs, err := io.ReadAll(resp.Body); err != nil {
|
|
return nil, fmt.Errorf("checking for trailing data: %w", err)
|
|
} else if len(bs) > 0 {
|
|
return nil, fmt.Errorf("found trailing data (%d bytes; %q)", len(bs), string(bs))
|
|
}
|
|
|
|
return l, nil
|
|
}
|
|
|
|
type Notification struct {
|
|
Message string
|
|
Title string
|
|
Priority int
|
|
Sound string
|
|
}
|
|
|
|
func (n Notification) EncodeValues() url.Values {
|
|
v := url.Values{}
|
|
v.Set("message", n.Message)
|
|
if n.Title != "" {
|
|
v.Set("title", n.Title)
|
|
}
|
|
if n.Priority != 0 {
|
|
v.Set("priority", strconv.Itoa(n.Priority))
|
|
}
|
|
if n.Sound != "" {
|
|
v.Set("sound", n.Sound)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func sendPushoverNotification(ctx context.Context, token, user string, notification Notification) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
|
|
v := notification.EncodeValues()
|
|
v.Set("token", token)
|
|
v.Set("user", user)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.pushover.net/1/messages.json", strings.NewReader(v.Encode()))
|
|
if err != nil {
|
|
return fmt.Errorf("creating new HTTP request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("doing HTTP request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("bad HTTP response: %v (%q)", resp.StatusCode, resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
if *postcode == "" {
|
|
log.Fatalf("-postcode must be provided.")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var lastWorks *Locations
|
|
|
|
for {
|
|
l, err := getStreetWorks(ctx, *postcode)
|
|
if err != nil {
|
|
log.Printf("getStreetWorks failed: %v; waiting 5 minutes", err)
|
|
time.Sleep(*checkIntervalError)
|
|
continue
|
|
}
|
|
log.Println("refreshed data...")
|
|
|
|
if lastWorks != nil && !lastWorks.Equal(*l) {
|
|
// OMG!!!
|
|
lj, err := json.MarshalIndent(l, "", " ")
|
|
if err != nil {
|
|
log.Fatalf("json.Marshal streetworks: %v", err)
|
|
}
|
|
|
|
log.Println("it's changed!")
|
|
log.Println(string(lj))
|
|
|
|
diff := cmp.Diff(lastWorks.StreetWorks, l.StreetWorks)
|
|
if diff != "" {
|
|
log.Println(diff)
|
|
if *pushoverToken != "" && *pushoverUser != "" {
|
|
if err := sendPushoverNotification(ctx, *pushoverToken, *pushoverUser, Notification{
|
|
Title: fmt.Sprintf("Camden StreetWorks Diff for %v", *postcode),
|
|
Message: diff,
|
|
Priority: -1,
|
|
}); err != nil {
|
|
log.Printf("sendPushoverNotification: %v; waiting 5 minutes", err)
|
|
time.Sleep(*checkIntervalError)
|
|
continue
|
|
// We'll see the diff again next time.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
lastWorks = l
|
|
|
|
time.Sleep(*checkInterval)
|
|
}
|
|
}
|