Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial setup with value command and estimate integrations #1

Merged
merged 1 commit into from
Feb 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@

# Dependency directories (remove the comment below to include it)
# vendor/

# IDE
.idea
9 changes: 9 additions & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package main

import (
"github.com/peppys/crib/internal/commands"
)

func main() {
commands.Execute()
}
24 changes: 24 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module github.com/peppys/crib

go 1.17

require (
github.com/leekchan/accounting v1.0.0
github.com/pterm/pterm v0.12.34
github.com/spf13/cobra v1.3.0
)

require (
github.com/atomicgo/cursor v0.0.1 // indirect
github.com/cockroachdb/apd v1.1.0 // indirect
github.com/gookit/color v1.4.2 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
)
802 changes: 802 additions & 0 deletions go.sum

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions internal/commands/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package commands

import (
"github.com/spf13/cobra"
"log"
)

var rootCmd = &cobra.Command{
Use: "cli",
Short: "cli is a CLI that manages your dream cli.",
Long: `A CLI that keeps track of the valuation of your cli, and other smart home automation.`,
Run: func(cmd *cobra.Command, args []string) {
if err := cmd.Help(); err != nil {
log.Fatal(err)
}
},
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}

func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
56 changes: 56 additions & 0 deletions internal/commands/value.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package commands

import (
"fmt"
"github.com/leekchan/accounting"
"github.com/peppys/crib/internal/services/property"
"github.com/peppys/crib/internal/services/property/estimators"
"github.com/peppys/crib/pkg/redfin"
"github.com/peppys/crib/pkg/zillow"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"log"
"net/http"
"strings"
)

var valueCmd = &cobra.Command{
Use: "value",
Short: "Checks the estimated valuation of your property",
Long: "Checks the estimated valuation of your property",
Run: func(cmd *cobra.Command, args []string) {
_ = pterm.DefaultBigText.WithLetters(pterm.NewLettersFromStringWithStyle("crib", pterm.NewStyle(pterm.FgLightMagenta))).Render()
introSpinner, _ := pterm.DefaultSpinner.WithShowTimer(true).WithRemoveWhenDone(true).Start(fmt.Sprintf("estimating valuation for crib '%s'...", address))
estimates, err := manager.Valuation(address)
introSpinner.Stop()
if err != nil {
pterm.Error.Println(err)
log.Fatal(err)
}
data := pterm.TableData{
{"Vendor", "Estimate"},
}
ac := accounting.Accounting{Symbol: "$", Precision: 2}
for _, estimate := range estimates {
data = append(data, []string{strings.ToLower(string(estimate.Vendor)), ac.FormatMoney(estimate.Value)})
}
pterm.DefaultTable.WithHasHeader().WithData(data).Render()
},
}

var manager *property.Manager
var address string

func init() {
valueCmd.Flags().StringVarP(&address, "address", "a", "", "Address of your cli")
valueCmd.MarkFlagRequired("address")
rootCmd.AddCommand(valueCmd)
manager = property.NewManager(
property.WithEstimator(
estimators.NewBulkEstimator(
estimators.NewZillowEstimator(zillow.NewClient(http.DefaultClient)),
estimators.NewRedfinEstimator(redfin.NewClient(http.DefaultClient)),
),
),
)
}
24 changes: 24 additions & 0 deletions internal/services/property/estimators/bulk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package estimators

import (
"fmt"
"github.com/peppys/crib/internal/services/property"
)

func NewBulkEstimator(estimators ...property.Estimator) func(address string) ([]property.Estimate, error) {
return func(address string) ([]property.Estimate, error) {
var estimates []property.Estimate

// TODO - make async
for _, estimator := range estimators {
result, err := estimator(address)
if err != nil {
return nil, fmt.Errorf("failed running one of the estimators: %w", err)
}

estimates = append(estimates, result...)
}

return estimates, nil
}
}
31 changes: 31 additions & 0 deletions internal/services/property/estimators/redfin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package estimators

import (
"fmt"
"github.com/peppys/crib/internal/services/property"
"github.com/peppys/crib/pkg/redfin"
"strings"
)

func NewRedfinEstimator(client *redfin.Client) func(address string) ([]property.Estimate, error) {
return func(address string) ([]property.Estimate, error) {
searchResponse, err := client.SearchProperties(address)
if err != nil {
return nil, fmt.Errorf("error searching redfin properties: %v", err)
}
if !strings.HasPrefix(searchResponse.Payload.ExactMatch.ID, "1_") {
return nil, fmt.Errorf("todo")
}
avmResponse, err := client.GetAutomatedValuationModel(strings.TrimPrefix(searchResponse.Payload.ExactMatch.ID, "1_"))
if err != nil {
return nil, fmt.Errorf("error getting redfin avm: %v", err)
}

return []property.Estimate{
{
Vendor: property.Redfin,
Value: avmResponse.Payload.Root.AVMInfo.PredictedValue,
},
}, nil
}
}
30 changes: 30 additions & 0 deletions internal/services/property/estimators/zillow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package estimators

import (
"fmt"
"github.com/peppys/crib/internal/services/property"
"github.com/peppys/crib/pkg/zillow"
)

func NewZillowEstimator(client *zillow.Client) func(address string) ([]property.Estimate, error) {
return func(address string) ([]property.Estimate, error) {
searchResponse, err := client.SearchProperties(address)
if err != nil {
return nil, fmt.Errorf("error looking up address on zillow: %w", err)
}
if len(searchResponse.Results) == 0 {
return nil, fmt.Errorf("could not find property on zillow")
}
propertyResponse, err := client.LookupProperty(searchResponse.Results[0].MetaData.Zpid)
if err != nil {
return nil, fmt.Errorf("error looking up property on zillow: %w", err)
}

return []property.Estimate{
{
Vendor: property.Zillow,
Value: float64(propertyResponse.LookupResults[0].Estimates.Zestimate),
},
}, nil
}
}
50 changes: 50 additions & 0 deletions internal/services/property/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package property

import (
"fmt"
)

type Manager struct {
estimate Estimator
}

type Option func(agent *Manager)

type Vendor string

const (
Zillow Vendor = "ZILLOW"
Redfin = "REDFIN"
)

type Estimate struct {
Vendor Vendor
Value float64
}

type Estimator func(string) ([]Estimate, error)

func NewManager(opts ...Option) *Manager {
m := &Manager{}

for _, opt := range opts {
opt(m)
}

return m
}

func WithEstimator(e Estimator) Option {
return func(manager *Manager) {
manager.estimate = e
}
}

func (m *Manager) Valuation(address string) ([]Estimate, error) {
estimate, err := m.estimate(address)
if err != nil {
return nil, fmt.Errorf("error while estimating valuation: %w", err)
}

return estimate, nil
}
107 changes: 107 additions & 0 deletions pkg/redfin/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package redfin

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

type Client struct {
httpClient *http.Client
}

type SearchPropertiesResponse struct {
Version int
Payload struct {
Sections []struct {
Rows []struct {
ID string
Name string
SubName string
URL string
Active bool
}
}
ExactMatch struct {
ID string
Name string
SubName string
URL string
Active bool
}
}
}

type AVMResponse struct {
Version int
Payload struct {
Root struct {
AVMInfo struct {
PredictedValue float64
}
} `json:"__root"`
}
}

func NewClient(httpClient *http.Client) *Client {
return &Client{httpClient}
}

func (c *Client) SearchProperties(address string) (*SearchPropertiesResponse, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("https://www.redfin.com/stingray/do/location-autocomplete?location=%s&v=3", url.QueryEscape(address)), nil)
if err != nil {
return nil, fmt.Errorf("error building redfin api request: %w", err)
}
req.Header.Set("User-Agent", "Redfin/402.1.0.6002 CFNetwork/1240.0.4 Darwin/20.5.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error querying redfin api: %w", err)
}
defer resp.Body.Close()
var result *SearchPropertiesResponse

b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading redfin api response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("error querying redfin api: %s", string(b))
}
err = json.Unmarshal([]byte(strings.TrimPrefix(string(b), "{}&&")), &result)
if err != nil {
return nil, fmt.Errorf("error json decoding redfin api response: %w", err)
}

return result, nil
}

func (c *Client) GetAutomatedValuationModel(propertyId string) (*AVMResponse, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("https://www.redfin.com/stingray/mobile/api/home/details/avm?propertyId=%s&accessLevel=2", url.QueryEscape(propertyId)), nil)
if err != nil {
return nil, fmt.Errorf("error building redfin api request: %w", err)
}
req.Header.Set("User-Agent", "Redfin/402.1.0.6002 CFNetwork/1240.0.4 Darwin/20.5.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error querying redfin api: %w", err)
}
defer resp.Body.Close()
var result *AVMResponse

b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading redfin api response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("error querying redfin api: %s", string(b))
}
err = json.Unmarshal([]byte(strings.TrimPrefix(string(b), "{}&&")), &result)
if err != nil {
return nil, fmt.Errorf("error json decoding redfin api response: %w", err)
}

return result, nil
}
Loading