go/streetworks: init, schedule on totoro
This commit is contained in:
parent
94470110ed
commit
0621fbfbf1
5 changed files with 338 additions and 0 deletions
|
@ -6,4 +6,5 @@ args: {
|
|||
twitterchiver = import ./twitterchiver args;
|
||||
twitternuke = import ./twitternuke args;
|
||||
minotarproxy = import ./minotarproxy args;
|
||||
streetworks = import ./streetworks args;
|
||||
}
|
||||
|
|
12
go/streetworks/default.nix
Normal file
12
go/streetworks/default.nix
Normal file
|
@ -0,0 +1,12 @@
|
|||
# SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
{ depot, ... }:
|
||||
depot.third_party.buildGo.program {
|
||||
name = "streetworks";
|
||||
srcs = [ ./streetworks.go ];
|
||||
deps = with depot.third_party; [
|
||||
gopkgs."github.com".google.go-cmp.cmp
|
||||
];
|
||||
}
|
295
go/streetworks/streetworks.go
Normal file
295
go/streetworks/streetworks.go
Normal file
|
@ -0,0 +1,295 @@
|
|||
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)
|
||||
}
|
||||
}
|
|
@ -471,6 +471,22 @@ in {
|
|||
};
|
||||
};
|
||||
|
||||
systemd.services.streetworks = {
|
||||
enable = true;
|
||||
after = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
ExecStart = "${depot.go.streetworks}/bin/streetworks -postcode='NW5 4HS' -pushover_token='${secrets.pushover.tokens.depot}' -pushover_user='${secrets.pushover.userKey}'";
|
||||
DynamicUser = true;
|
||||
MountAPIVFS = true;
|
||||
PrivateTmp = true;
|
||||
PrivateUsers = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
};
|
||||
};
|
||||
|
||||
my.prometheus.additionalExporterPorts.trains = 2112;
|
||||
|
||||
system.stateVersion = "20.03";
|
||||
|
|
14
third_party/gopkgs/github.com/google/go-cmp/default.nix
vendored
Normal file
14
third_party/gopkgs/github.com/google/go-cmp/default.nix
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
# SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
{ depot, ... }:
|
||||
depot.third_party.buildGo.external {
|
||||
path = "github.com/google/go-cmp";
|
||||
src = depot.third_party.nixpkgs.fetchFromGitHub {
|
||||
owner = "google";
|
||||
repo = "go-cmp";
|
||||
rev = "6faefd0594fae82639a62c23f0aed1451509dcc0";
|
||||
hash = "sha256:0w0nyaqqd29bdk919g0r3xr1hpn32sfwbf59ai6z6ngdhjp95nbv";
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue