find-stone-beer / commit
Initial commit of beer-finding stuff.
.gitignore | 1 + .vscode/launch.json | 28 ++++++ Makefile | 2 + README.md | 25 ++++++ beer.go | 58 +++++++++++++ find-stone.go | 138 +++++++++++++++++++++++++++++ go.mod | 14 +++ go.sum | 15 ++++ main.go | 245 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 16 ++++ seller-info.go | 49 +++++++++++ seller.go | 82 ++++++++++++++++++ 12 files changed, 673 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06cf653 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +cache diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100755 index 0000000..2e1bef3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}" + }, + { + "name": "Launch file", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${file}" + }, + { + "type": "bashdb", + "request": "launch", + "name": "Debug repo.sh", + "cwd": "${workspaceFolder}", + "program": "${workspaceFolder}/repro.sh", + "terminalKind": "integrated" + } + ] +} + \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..50ee7bf --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +run: + go run . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..5a785b0 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# find-stone-beer + +Work notes on find-stone-beer + +## Pushover info + +Application: FindStoneBeer +secret data is in gitlab + +## removed code + +``` +testSeller, err := getSellerInfo("16826826") +if err != nil { + panic(err) +} +fmt.Printf("Test seller 16826826: %v\n", testSeller) +for _, beer := range testSeller.Beers { + foundBrand, brand := findStoneInfo.FindBrandById(beer.Brand) + if !foundBrand { + panic("Could not find brand") + } + fmt.Printf("Beer: %s (%s)\n", brand.Name, beer.Brand) +} +``` diff --git a/beer.go b/beer.go new file mode 100644 index 0000000..7cd7684 --- /dev/null +++ b/beer.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "strconv" + "time" +) + +type Beer struct { + Brand string `json:"brand"` + Package string `json:"package"` + Delivered time.Time `json:"delivered"` + Expires time.Time `json:"expires"` +} + +func (b *Beer) UnmarshalJSON(data []byte) error { + var objmap map[string]json.RawMessage + err := json.Unmarshal(data, &objmap) + if err != nil { + return err + } + for key, value := range objmap { + if key == "brand" { + err = json.Unmarshal(value, &b.Brand) + if err != nil { + return err + } + } else if key == "package" { + err = json.Unmarshal(value, &b.Package) + if err != nil { + return err + } + } else if key == "delivered" { + var delivered string + err = json.Unmarshal(value, &delivered) + if err != nil { + return err + } + deliveredSeconds, err := strconv.ParseInt(delivered, 10, 64) + if err != nil { + return err + } + b.Delivered = time.Unix(deliveredSeconds, 0) + } else if key == "expires" { + var expires string + err = json.Unmarshal(value, &expires) + if err != nil { + return err + } + expiresSeconds, err := strconv.ParseInt(expires, 10, 64) + if err != nil { + return err + } + b.Expires = time.Unix(expiresSeconds, 0) + } + } + return nil +} diff --git a/find-stone.go b/find-stone.go new file mode 100644 index 0000000..a721410 --- /dev/null +++ b/find-stone.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" +) + +type Core struct { + Findstone Findstone `json:"findstone"` +} + +func (c *Core) UnmarshalJSON(data []byte) error { + var objmap map[string]json.RawMessage + err := json.Unmarshal(data, &objmap) + if err != nil { + return err + } + for key, value := range objmap { + if key == "findstone" { + err = json.Unmarshal(value, &c.Findstone) + if err != nil { + return err + } + } + } + return nil +} + +type Findstone struct { + Brands map[string]Brand `json:"brands"` + PackageTypes map[string]PackageType `json:"package_types"` +} + +func (f *Findstone) UnmarshalJSON(data []byte) error { + var objmap map[string]json.RawMessage + err := json.Unmarshal(data, &objmap) + if err != nil { + return err + } + for key, value := range objmap { + if key == "brands" { + err = json.Unmarshal(value, &f.Brands) + if err != nil { + return err + } + } else if key == "package_types" { + err = json.Unmarshal(value, &f.PackageTypes) + if err != nil { + return err + } + } + } + return nil +} +func (f *Findstone) FindBrandById(targetId string) (bool, Brand) { + for id, brand := range f.Brands { + if id == targetId { + return true, brand + } + foundBrand, brand := brand.FindBrandById(targetId) + if foundBrand { + return true, brand + } + } + return false, Brand{} +} + +type PackageType struct { + Name string `json:"name"` + ReleaseDate int `json:"release_date"` +} + +type Brand struct { + Parent *Brand + Name string `json:"name"` + ReleaseDate int `json:"release_date"` + BrandColor string `json:"brand_color"` + BeerStyle string `json:"beer_style"` + Logo string `json:"logo"` + Abv string `json:"abv"` + Children map[string]Brand `json:"children"` +} + +func (b *Brand) UnmarshalJSON(data []byte) error { + brand := &struct { + Name string `json:"name"` + ReleaseDate int `json:"release_date"` + BrandColor string `json:"brand_color"` + BeerStyle string `json:"beer_style"` + Logo string `json:"logo"` + Abv string `json:"abv"` + Children map[string]json.RawMessage `json:"children"` + }{} + json.Unmarshal(data, &brand) + + newBrand := Brand{ + Name: brand.Name, + ReleaseDate: brand.ReleaseDate, + BrandColor: brand.BrandColor, + BeerStyle: brand.BeerStyle, + Logo: brand.Logo, + Abv: brand.Abv, + } + + newBrand.Children = map[string]Brand{} + for key, value := range brand.Children { + var childName string + err := json.Unmarshal(value, &childName) + if err != nil { + return err + } + childBrand := Brand{ + Name: childName, + ReleaseDate: brand.ReleaseDate, + BrandColor: brand.BrandColor, + BeerStyle: brand.BeerStyle, + Logo: brand.Logo, + Abv: brand.Abv, + Parent: &newBrand, + } + newBrand.Children[key] = childBrand + } + + *b = newBrand + return nil +} + +func (b *Brand) FindBrandById(targetId string) (bool, Brand) { + for id, child := range b.Children { + if id == targetId { + return true, child + } + foundBrand, brand := child.FindBrandById(targetId) + if foundBrand { + return true, brand + } + } + return false, Brand{} +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29e16d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module find_stone_beer + +go 1.21 + +require github.com/stretchr/testify v1.8.4 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jftuga/geodist v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect + golang.org/x/net v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8699fbe --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jftuga/geodist v1.0.0 h1:PFPQlZtj10u8ETAYTyxE0DWMl1bwA+Xzrqb4+oLkkC0= +github.com/jftuga/geodist v1.0.0/go.mod h1:BohEDxpZ8S5ADAxW/9EKPSKWOVl0+3wHENIT40m4UO4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ= +golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..50164ed --- /dev/null +++ b/main.go @@ -0,0 +1,245 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jftuga/geodist" + "golang.org/x/net/html" +) + +var coords = geodist.Coord{Lat: 47.632, Lon: -122.153} +var rangeMiles = 5 +var maxDeliveryWindowDays = 14 + +func main() { + findStoneInfo, err := getFindstoneInfo() + if err != nil { + panic(err) + } + + sellers, err := getSellers(coords) + if err != nil { + panic(err) + } + + // filter sellers that sell arrogant bastard + arrogantBastardSellers := []Seller{} + for _, seller := range sellers { + for _, brandId := range seller.AllBrands { + foundBrand, brand := findStoneInfo.FindBrandById(brandId) + if foundBrand { + if strings.Contains(strings.ToLower(brand.Name), "arrogant") { + arrogantBastardSellers = append(arrogantBastardSellers, seller) + } + } else { + panic("Could not find brand") + } + } + } + + messageSB := strings.Builder{} + now := time.Now() + messageSB.WriteString(fmt.Sprintf("Found %d sellers and %d Arrogant Bastard sellers\n", len(sellers), len(arrogantBastardSellers))) + for _, seller := range arrogantBastardSellers { + dist, _, err := geodist.VincentyDistance(coords, seller.Pos) + if err != nil { + panic(err) + } + if dist > float64(rangeMiles) { + continue + } + + shouldPrint := false + sb := strings.Builder{} + sb.WriteString(fmt.Sprintf("%s (%d miles away) - %s\n", seller.Title, int(math.Ceil(dist)), seller.Addr)) + + sellerInfo, err := getSellerInfo(seller.Nid) + if err != nil { + panic(err) + } + for _, beer := range sellerInfo.Beers { + ok, brand := findStoneInfo.FindBrandById(beer.Brand) + if !ok { + panic("Could not find brand") + } + if !strings.Contains(strings.ToLower(brand.Name), "arrogant") { + continue + } + lastDeliveredDuration := now.Sub(beer.Delivered) + daysSinceDelivery := int(lastDeliveredDuration.Hours() / 24) + if daysSinceDelivery > maxDeliveryWindowDays { + continue + } + sb.WriteString(fmt.Sprintf("\t%s - Last delivered %d days ago, \n\n", brand.Name, daysSinceDelivery)) + shouldPrint = true + } + if shouldPrint { + messageSB.WriteString(sb.String()) + messageSB.WriteString("\n") + } + } + + fmt.Print(messageSB.String()) + +} + +func getSellers(coords geodist.Coord) ([]Seller, error) { + lat := coords.Lat + latInt := int(lat) + lon := coords.Lon + lonInt := int(lon) + url := fmt.Sprintf("https://find.stonebrewing.com/api/search/%f,%f/%d,%d", lat, lon, latInt, lonInt) + bytes, err := getCachedUrl(url, "find-stone-beer", "2006-01-02") + if err != nil { + return nil, err + } + + var sellers []Seller + err = json.Unmarshal(bytes, &sellers) + if err != nil { + panic(err) + } + return sellers, nil +} +func getSellerInfo(id string) (SellerInfo, error) { + url := fmt.Sprintf("https://find.stonebrewing.com/api/location/%s", id) + bytes, err := getCachedUrl(url, fmt.Sprintf("find-stone-beer-seller-%s", id), "2006-01-02") + if err != nil { + return SellerInfo{}, err + } + + var sellerInfo SellerInfo + err = json.Unmarshal(bytes, &sellerInfo) + if err != nil { + return SellerInfo{}, err + } + return sellerInfo, nil +} +func getFindstoneInfo() (Findstone, error) { + data, err := getCachedUrlFunc( + "https://find.stonebrewing.com", + "find-stone-beer-core", + "2006-01-02", + func(d []byte) ([]byte, error) { + reader := bytes.NewReader(d) + doc, err := html.Parse(reader) + if err != nil { + return nil, err + } + + var findNodeWithText func(node *html.Node, text string) (bool, *html.Node) + findNodeWithText = func(node *html.Node, text string) (bool, *html.Node) { + if node.Type == html.TextNode && strings.Contains(node.Data, text) { + return true, node + } + + if node.FirstChild != nil { + found, child := findNodeWithText(node.FirstChild, text) + if found { + return true, child + } + } + + if node.NextSibling != nil { + found, sibling := findNodeWithText(node.NextSibling, text) + if found { + return true, sibling + } + } + + return false, nil + } + found, node := findNodeWithText(doc, "6376") + if !found { + return nil, errors.New("could not find node") + } + + firstCurly := strings.Index(node.Data, "{") + lastCurly := strings.LastIndex(node.Data, "}") + jsonText := node.Data[firstCurly : lastCurly+1] + return []byte(jsonText), nil + }) + if err != nil { + return Findstone{}, err + } + + var core = Core{} + err = json.Unmarshal(data, &core) + if err != nil { + return Findstone{}, err + } + return core.Findstone, nil +} + +func getCachedUrl(url string, id string, cacheFileTimeFormat string) ([]byte, error) { + return getCachedUrlFunc(url, id, cacheFileTimeFormat, func(bytes []byte) ([]byte, error) { + return bytes, nil + }) +} +func getCachedUrlFunc(url string, id string, cacheFileTimeFormat string, byteProcessor func([]byte) ([]byte, error)) ([]byte, error) { + now := time.Now() + nowFormatted := now.Format(cacheFileTimeFormat) + cacheFile := fmt.Sprintf("cache/%s-%s.txt", id, nowFormatted) + dir := filepath.Dir(cacheFile) + _, err := os.Stat(dir) + dirExists := err == nil || !os.IsNotExist(err) + if !dirExists { + err = os.Mkdir(dir, 0755) + if err != nil { + return nil, err + } + } + + _, err = os.Stat(cacheFile) + fileExists := err == nil || !os.IsNotExist(err) + if fileExists { + f, err := os.Open(cacheFile) + if err != nil { + return nil, err + } + defer f.Close() + bytes, err := io.ReadAll(f) + if err != nil { + return nil, err + } + return bytes, nil + } else { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + body, err = byteProcessor(body) + if err != nil { + return nil, err + } + + f, err := os.Create(cacheFile) + if err != nil { + return nil, err + } + defer f.Close() + _, err = f.Write(body) + if err != nil { + return nil, err + } + + return body, nil + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..d2f3488 --- /dev/null +++ b/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSellerInfoUmarshal(t *testing.T) { + jsonText := `{"phone":"4259993952","beers":[{"brand":"6396","package":"bottles_or_cans","delivered":"1689922800","expires":"1697698800"},{"brand":"6376","package":"bottles_or_cans","delivered":"1692169200","expires":"1699945200"},{"brand":"6406","package":"bottles_or_cans","delivered":"1692169200","expires":"1699945200"},{"brand":"2311","package":"cans","delivered":"1686294000","expires":"1694070000"},{"brand":"1616","package":"cans","delivered":"1685084400","expires":"1692860400"},{"brand":"6316","package":"cans","delivered":"1689922800","expires":"1697698800"},{"brand":"1551","package":"cans","delivered":"1686294000","expires":"1694070000"},{"brand":"6371","package":"bottles_or_cans","delivered":"1692169200","expires":"1699945200"},{"brand":"119","package":"bottles","delivered":"1688108400","expires":"1695884400"},{"brand":"5911","package":"cans","delivered":"1689318000","expires":"1697094000"},{"brand":"2121","package":"bottles","delivered":"1692342000","expires":"1700118000"}],"lat":"47.629479500000","lon":"-122.141579200000","json":null}` + + sellerInfo := SellerInfo{} + err := json.Unmarshal([]byte(jsonText), &sellerInfo) + assert.Nil(t, err) +} diff --git a/seller-info.go b/seller-info.go new file mode 100644 index 0000000..ff8b25f --- /dev/null +++ b/seller-info.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "strconv" + + "github.com/jftuga/geodist" +) + +type SellerInfo struct { + Phone string + Beers []Beer + // Lat float64 + // Lon float64 + Pos geodist.Coord + JSON any +} + +func (s *SellerInfo) UnmarshalJSON(data []byte) error { + sellerInfo := &struct { + Phone string `json:"phone"` + Beers []Beer `json:"beers"` + Lat string `json:"lat"` + Lon string `json:"lon"` + JSON any `json:"json"` + }{} + json.Unmarshal(data, &sellerInfo) + + newSellerInfo := SellerInfo{ + Phone: sellerInfo.Phone, + Beers: sellerInfo.Beers, + JSON: sellerInfo.JSON, + } + + if len(sellerInfo.Lat) > 0 && len(sellerInfo.Lon) > 0 { + lat, err := strconv.ParseFloat(sellerInfo.Lat, 64) + if err != nil { + return err + } + lon, err := strconv.ParseFloat(sellerInfo.Lon, 64) + if err != nil { + return err + } + newSellerInfo.Pos = geodist.Coord{Lat: lat, Lon: lon} + } + + *s = newSellerInfo + return nil +} diff --git a/seller.go b/seller.go new file mode 100644 index 0000000..7f7cf2e --- /dev/null +++ b/seller.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "strconv" + + "github.com/jftuga/geodist" +) + +type Seller struct { + Nid string + Title string + Addr string + Addr1 string + Addr2 string + Distance float64 + Phone string + Cot string + Pos geodist.Coord + // Lat float64 + // Lng float64 + LocationCategory string + AllBrands []string + AllPackages []string +} + +func (s *Seller) UnmarshalJSON(data []byte) error { + seller := &struct { + Nid string `json:"nid"` + Title string `json:"title"` + Addr string `json:"addr"` + Addr1 string `json:"addr_1"` + Addr2 string `json:"addr_2"` + Distance string `json:"distance"` + Phone string `json:"phone"` + Cot string `json:"cot"` + Lat string `json:"lat"` + Lng string `json:"lng"` + LocationCategory string `json:"location_category"` + AllBrands []string `json:"all_brands"` + AllPackages []string `json:"all_packages"` + }{} + json.Unmarshal(data, &seller) + + newSeller := Seller{ + Nid: seller.Nid, + Title: seller.Title, + Addr: seller.Addr, + Addr1: seller.Addr1, + Addr2: seller.Addr2, + Phone: seller.Phone, + Cot: seller.Cot, + LocationCategory: seller.LocationCategory, + AllBrands: seller.AllBrands, + AllPackages: seller.AllPackages, + } + + if len(seller.Distance) != 0 { + dist, err := strconv.ParseFloat(seller.Distance, 64) + if err != nil { + return err + } + newSeller.Distance = dist + } + + if len(seller.Lat) > 0 && len(seller.Lng) > 0 { + lat, err := strconv.ParseFloat(seller.Lat, 64) + if err != nil { + return err + } + + lng, err := strconv.ParseFloat(seller.Lng, 64) + if err != nil { + return err + } + + newSeller.Pos = geodist.Coord{Lat: lat, Lon: lng} + } + + *s = newSeller + return nil +}