diff options
-rw-r--r-- | flat.go | 19 | ||||
-rw-r--r-- | flat_test.go | 31 | ||||
-rw-r--r-- | flatbot.go | 79 | ||||
-rw-r--r-- | go.mod | 2 | ||||
-rw-r--r-- | parse.go | 84 | ||||
-rw-r--r-- | parse_test.go (renamed from flatbot_test.go) | 10 | ||||
-rw-r--r-- | sent.go | 52 | ||||
-rw-r--r-- | sent_test.go | 106 |
8 files changed, 304 insertions, 79 deletions
@@ -0,0 +1,19 @@ +package main + +import ( + "cmp" + "fmt" +) + +type flat struct { + ID int + Price string +} + +func (f *flat) URL() string { + return fmt.Sprintf("https://rightmove.co.uk/properties/%v", f.ID) +} + +func compareID(a, b flat) int { + return cmp.Compare(a.ID, b.ID) +} diff --git a/flat_test.go b/flat_test.go new file mode 100644 index 0000000..2ac3ddb --- /dev/null +++ b/flat_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" +) + +func TestURL(t *testing.T) { + f := flat{ID: 0, Price: "£1,000"} + got := f.URL() + want := "https://rightmove.co.uk/properties/0" + if got != want { + t.Errorf("URL call failed: got: %v, want: %v", got, want) + } +} + +func TestCompareID(t *testing.T) { + a := flat{ID: 0, Price: "£3,000"} + b := flat{ID: 1, Price: "£1,000"} + got := compareID(a, b) + if got != -1 { + t.Errorf("Wrong compare result: got: %v, want: %v", got, -1) + } + got = compareID(b, a) + if got != 1 { + t.Errorf("Wrong compare result: got: %v, want: %v", got, 1) + } + got = compareID(a, a) + if got != 0 { + t.Errorf("Wrong compare result: got: %v, want: %v", got, 0) + } +} @@ -1,15 +1,10 @@ package main import ( - "bytes" - "errors" "fmt" "io" "log" "net/http" - "strings" - - "golang.org/x/net/html" ) func main() { @@ -22,7 +17,13 @@ func main() { if err != nil { log.Fatal(err) } + sent, err := readSent("sent/sent.json") + if err != nil { + log.Fatal(err) + } + flats = removeAlreadySent(flats, sent) fmt.Println(flats) + writeSent(flats, "/tmp/sent.json") } func fetch(url string) ([]byte, error) { @@ -41,71 +42,3 @@ func fetch(url string) ([]byte, error) { } return body, nil } - -type flat struct { - URL string - Price string -} - -func parse(body []byte) ([]flat, error) { - doc, err := html.Parse(bytes.NewReader(body)) - if err != nil { - return make([]flat, 0), err - } - flats := make([]flat, 0) - for _, n := range findNodes(doc) { - flat, err := parseNode(n) - if err != nil { - continue - } - flats = append(flats, flat) - } - return flats, nil -} - -func findNodes(root *html.Node) []*html.Node { - flats := make([]*html.Node, 0) - for n := range root.Descendants() { - if n.Type != html.ElementNode { - continue - } - if n.Data != "a" { - continue - } - attr := matchAttr(n, "data-testid") - if attr == nil || attr.Val != "property-price" { - continue - } - flats = append(flats, n) - } - return flats -} - -func matchAttr(n *html.Node, key string) *html.Attribute { - for _, attr := range n.Attr { - if attr.Key == key { - return &attr - } - } - return nil -} - -func parseNode(root *html.Node) (flat, error) { - url := matchAttr(root, "href") - if url == nil { - return flat{}, errors.New("Couldn't find URL") - } - f := flat{URL: makeURL(url.Val), Price: ""} - for n := range root.Descendants() { - if price, found := strings.CutSuffix(n.Data, " pcm"); found { - f.Price = price - return f, nil - } - } - return flat{}, errors.New("Couldn't find price") -} - -func makeURL(path string) string { - prettySuffix, _ := strings.CutSuffix(path, "/?channel=RES_LET") - return fmt.Sprintf("https://rightmove.co.uk%v", prettySuffix) -} @@ -2,4 +2,4 @@ module ilvokhin.com/flatbot go 1.24.0 -require golang.org/x/net v0.35.0 // indirect +require golang.org/x/net v0.35.0 diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..6be416e --- /dev/null +++ b/parse.go @@ -0,0 +1,84 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/net/html" +) + +func parse(body []byte) ([]flat, error) { + doc, err := html.Parse(bytes.NewReader(body)) + if err != nil { + return make([]flat, 0), err + } + flats := make([]flat, 0) + for _, n := range findNodes(doc) { + flat, err := parseNode(n) + if err != nil { + continue + } + flats = append(flats, flat) + } + return flats, nil +} + +func findNodes(root *html.Node) []*html.Node { + flats := make([]*html.Node, 0) + for n := range root.Descendants() { + if n.Type != html.ElementNode { + continue + } + if n.Data != "a" { + continue + } + attr := matchAttr(n, "data-testid") + if attr == nil || attr.Val != "property-price" { + continue + } + flats = append(flats, n) + } + return flats +} + +func matchAttr(n *html.Node, key string) *html.Attribute { + for _, attr := range n.Attr { + if attr.Key == key { + return &attr + } + } + return nil +} + +func parseNode(root *html.Node) (flat, error) { + url := matchAttr(root, "href") + if url == nil { + return flat{}, errors.New("Couldn't find URL") + } + ID, err := parseID(url.Val) + if err != nil { + return flat{}, err + } + f := flat{ID, ""} + for n := range root.Descendants() { + if price, found := strings.CutSuffix(n.Data, " pcm"); found { + f.Price = price + return f, nil + } + } + return flat{}, errors.New("Couldn't find price") +} + +func parseID(path string) (int, error) { + s, _ := strings.CutPrefix(path, "/properties/") + maybeID, _ := strings.CutSuffix(s, "#/?channel=RES_LET") + ID, err := strconv.Atoi(maybeID) + if err != nil { + err := fmt.Errorf("Couldn't extract ID from %q", path) + return -1, err + } + return ID, err +} diff --git a/flatbot_test.go b/parse_test.go index 0308295..cb04638 100644 --- a/flatbot_test.go +++ b/parse_test.go @@ -14,22 +14,22 @@ func TestParse(t *testing.T) { } want := []flat{ flat{ - URL: "https://rightmove.co.uk/properties/156522206#", + ID: 156522206, Price: "£2,500", }, flat{ - URL: "https://rightmove.co.uk/properties/158462822#", + ID: 158462822, Price: "£3,000", }, flat{ - URL: "https://rightmove.co.uk/properties/157948184#", + ID: 157948184, Price: "£2,400", }} got, err := parse(data) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(want, got) { - t.Errorf("Parse failed: got: %v, want: %v", want, got) + if !reflect.DeepEqual(got, want) { + t.Errorf("Parse failed: got: %v, want: %v", got, want) } } @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "slices" +) + +func readSent(filename string) ([]flat, error) { + data, err := os.ReadFile(filename) + sent := make([]flat, 0) + if err != nil { + // This is fine, as we might just started and didn't dump + // anything into sent file yet. + if errors.Is(err, os.ErrNotExist) { + return sent, nil + } + return sent, err + } + err = json.Unmarshal(data, &sent) + if err != nil { + return sent, err + } + if !slices.IsSortedFunc(sent, compareID) { + return nil, errors.New("Invalid sent: not sorted") + } + return sent, nil +} + +func removeAlreadySent(fetched []flat, sent []flat) []flat { + if !slices.IsSortedFunc(sent, compareID) { + panic("Sent expected to be sorted") + } + recent := make([]flat, 0) + for _, f := range fetched { + _, found := slices.BinarySearchFunc(sent, f, compareID) + if found { + continue + } + recent = append(recent, f) + } + return recent +} + +func writeSent(sent []flat, filename string) error { + jsonData, err := json.Marshal(sent) + if err != nil { + return err + } + return os.WriteFile(filename, jsonData, 0644) +} diff --git a/sent_test.go b/sent_test.go new file mode 100644 index 0000000..64b13d6 --- /dev/null +++ b/sent_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestReadSentNew(t *testing.T) { + tmp := t.TempDir() + filename := filepath.Join(tmp, "does-not-exist.json") + got, err := readSent(filename) + if err != nil { + t.Fatal(err) + } + if len(got) > 0 { + t.Errorf("Expect empty slice, but got: %v", got) + } +} + +func TestReadSent(t *testing.T) { + tmp := t.TempDir() + filename := filepath.Join(tmp, "sent.json") + data := []byte(`[{"ID":156522206,"Price":"£2,500"}]`) + os.WriteFile(filename, data, 0644) + + got, err := readSent(filename) + if err != nil { + t.Fatal(err) + } + want := []flat{ + flat{ID: 156522206, Price: "£2,500"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("readSent failed: got: %v, want: %v", got, want) + } +} + +func TestRemoveAlreadySent(t *testing.T) { + flats := []flat{ + flat{ + ID: 156522206, + Price: "£2,500", + }, + flat{ + ID: 158462822, + Price: "£3,000", + }} + sent := []flat{ + flat{ + ID: 156522206, + Price: "£2,500", + }, + } + + got := removeAlreadySent(flats, sent) + want := []flat{flat{ID: 158462822, Price: "£3,000"}} + if !reflect.DeepEqual(got, want) { + t.Errorf("removeAlreadySent failed: got: %v, want: %v", + got, want) + } + +} + +func TestWriteSentNew(t *testing.T) { + tmp := t.TempDir() + filename := filepath.Join(tmp, "sent.json") + flats := []flat{flat{ID: 156522206, Price: "£2,500"}} + + err := writeSent(flats, filename) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + want := []byte(`[{"ID":156522206,"Price":"£2,500"}]`) + if !reflect.DeepEqual(got, want) { + t.Errorf("writeSent failed: got: %v, want: %v", got, want) + } +} + +func TestWriteSentOverride(t *testing.T) { + tmp := t.TempDir() + filename := filepath.Join(tmp, "sent.json") + _, err := os.Create(filename) + if err != nil { + t.Fatal(err) + } + flats := []flat{flat{ID: 156522206, Price: "£2,500"}} + err = writeSent(flats, filename) + + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + want := []byte(`[{"ID":156522206,"Price":"£2,500"}]`) + if !reflect.DeepEqual(got, want) { + t.Errorf("writeSent failed: got: %v, want: %v", got, want) + } +} |