Archived
1
0
Fork 0

feat: add price range filter (#26)

To avoid scalpers' price, the bot now understand filters on prices
using a minimum and maximum value, in a currency and a pattern to
detect the model.

Example:
```
"price_ranges": [
  {"model": "3090", "max": 3000, "currency": "EUR"},
  {"model": "3080", "max": 1600, "currency": "EUR"},
  {"model": "3070", "max": 1200, "currency": "EUR"}
],
```

More details in README.md.

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2021-05-23 02:32:30 +02:00
parent ba791435b7
commit da532104f8
No known key found for this signature in database
GPG key ID: FF42D23B580C89F7
9 changed files with 231 additions and 28 deletions

View file

@ -172,6 +172,7 @@ Options:
* `enable_replies`: reply to original message when product is not available anymore
* `include_regex` (optional): include products with a name matching this regexp
* `exclude_regex` (optional): exclude products with a name matching this regexp
* `price_ranges` (optional): define price ranges for products based on the model. List of rules containing `model` (regex to apply to the product name, string), `min` (minimum expected price, float), `max` (maximum expected price, float), `currency` (price currency used by the filter, string). For example `{"price_ranges":[{"model": "3090", "min": 0, "max": 3000, "currency": "EUR"}]}`
* `browser_address` (optional): set headless browser address (ex: `http://127.0.0.1:9222`)
* `api` (optional):
* `address`: listen address for the REST API (ex: `127.0.0.1:8000`)

View file

@ -13,10 +13,11 @@ type Config struct {
TelegramConfig `json:"telegram"`
APIConfig `json:"api"`
AmazonConfig `json:"amazon"`
URLs []string `json:"urls"`
IncludeRegex string `json:"include_regex"`
ExcludeRegex string `json:"exclude_regex"`
BrowserAddress string `json:"browser_address"`
URLs []string `json:"urls"`
IncludeRegex string `json:"include_regex"`
ExcludeRegex string `json:"exclude_regex"`
PriceRanges []PriceRange `json:"price_ranges"`
BrowserAddress string `json:"browser_address"`
}
// DatabaseConfig to store database configuration
@ -65,6 +66,14 @@ type AmazonConfig struct {
AffiliateLinks bool `json:"affiliate_links"`
}
// PriceRange to store rules to filter products with price outside of the range
type PriceRange struct {
Model string `json:"model"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Currency string `json:"currency"`
}
// NewConfig creates a Config struct
func NewConfig() *Config {
return &Config{}

96
currency_converter.go Normal file
View file

@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
log "github.com/sirupsen/logrus"
)
// CurrencyConverter to cache rates of different currency pairs
type CurrencyConverter struct {
rates map[string]float64
}
// NewCurrencyConverter to create a CurrencyConverter
func NewCurrencyConverter() *CurrencyConverter {
return &CurrencyConverter{
rates: make(map[string]float64),
}
}
// Convert an amount in a given currency to another currency
// Eventually fetch rate from a remote API then cache the result
func (c *CurrencyConverter) Convert(amount float64, fromCurrency string, toCurrency string) (float64, error) {
var err error
if fromCurrency == toCurrency {
return amount, nil
}
// exclude invalid currencies
if fromCurrency == "" || toCurrency == "" {
return 0.0, fmt.Errorf("invalid currency pair used for convertion (from='%s', to='%s')", fromCurrency, toCurrency)
}
// searching currency pair rate in cache
pair := fmt.Sprintf("%s%s", strings.ToLower(fromCurrency), strings.ToLower(toCurrency))
rate, exists := c.rates[pair]
if !exists {
// fetching rate from api
rate, err = c.getRate(fromCurrency, toCurrency)
if err != nil {
return 0.0, err
}
// store rate in cache
c.rates[pair] = rate
}
return rate * amount, nil
}
// CurrencyResponse to unmarshall JSON response from API
type CurrencyResponse struct {
Date string `json:"date"`
Rate float64 `json:"rate"`
}
// getRate retreives rate from a remote API
func (c *CurrencyConverter) getRate(fromCurrency string, toCurrency string) (float64, error) {
if fromCurrency == "" || toCurrency == "" {
return 0.0, fmt.Errorf("invalid currency pair used for convertion (from=%s, to=%s)", fromCurrency, toCurrency)
}
// lowering currency names to match the API route
fromCurrency = strings.ToLower(fromCurrency)
toCurrency = strings.ToLower(toCurrency)
log.Debugf("fetching %s%s rate from currency api", fromCurrency, toCurrency)
resp, err := http.Get("https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/" + fromCurrency + "/" + toCurrency + ".json")
if err != nil {
return 0.0, fmt.Errorf("could not retreive currency rates for pair %s%s: %s", fromCurrency, toCurrency, err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return 0.0, fmt.Errorf("could not parse currency rates response for pair %s%s: %s", fromCurrency, toCurrency, err)
}
// response has a dynamic name for the rate
// -> {"date": "2021-05-22", "usd": 1.218125}
// making it predictable
// -> {"date": "2021-05-22", "rate": 1.218125}}
bodyParsed := strings.Replace(string(body), toCurrency, "rate", 1)
var response CurrencyResponse
err = json.Unmarshal([]byte(bodyParsed), &response)
if err != nil {
return 0.0, err
}
return response.Rate, nil
}

View file

@ -0,0 +1,44 @@
package main
import (
"fmt"
"testing"
"github.com/jarcoal/httpmock"
)
func TestCurrencyConverterConvert(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpmock.RegisterResponder("GET", "https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/eur/chf.json",
httpmock.NewStringResponder(200, `{"date": "2021-05-22", "chf": 1.093894}`))
tests := []struct {
amount float64
source string
destination string
expected float64
}{
{1.0, "EUR", "EUR", 1.0}, // same currency (EUR/EUR)
{1.0, "EUR", "CHF", 1.093894}, // different currency (EUR/CHF)
{1.0, "EUR", "CHF", 1.093894}, // different currency (EUR/CHF) with cache
}
converter := NewCurrencyConverter()
for i, tc := range tests {
t.Run(fmt.Sprintf("TestCurrencyConverterConvert#%d", i), func(t *testing.T) {
converted, err := converter.Convert(tc.amount, tc.source, tc.destination)
if err != nil {
t.Errorf("could not convert %.2f from %s to %s: %s", tc.amount, tc.source, tc.destination, err)
} else if converted != tc.expected {
t.Errorf("to convert %.2f from %s to %s, got '%.2f', want '%.2f'", tc.amount, tc.source, tc.destination, converted, tc.expected)
} else {
t.Logf("to convert %.2f from %s to %s, got '%.2f', want '%.2f'", tc.amount, tc.source, tc.destination, converted, tc.expected)
}
})
}
}

View file

@ -6,15 +6,20 @@ import (
log "github.com/sirupsen/logrus"
)
// DefaultCurrency to fallback when no currency is provided
const DefaultCurrency = "USD"
// RangeFilter to store the pattern to match product model and price limits
type RangeFilter struct {
model *regexp.Regexp
min float64
max float64
model *regexp.Regexp
min float64
max float64
currency string
converter *CurrencyConverter
}
// NewRangeFilter to create a RangeFilter
func NewRangeFilter(regex string, min float64, max float64) (*RangeFilter, error) {
func NewRangeFilter(regex string, min float64, max float64, currency string, converter *CurrencyConverter) (*RangeFilter, error) {
var err error
var compiledRegex *regexp.Regexp
@ -26,23 +31,56 @@ func NewRangeFilter(regex string, min float64, max float64) (*RangeFilter, error
}
}
var detectedCurrency string
if currency != "" {
detectedCurrency = currency
} else {
detectedCurrency = DefaultCurrency
}
return &RangeFilter{
model: compiledRegex,
min: min,
max: max,
model: compiledRegex,
min: min,
max: max,
converter: converter,
currency: detectedCurrency,
}, nil
}
// Include returns false when a product name matches the model regex and price is outside of the range
// implements the Filter interface
func (f *RangeFilter) Include(product *Product) bool {
// include products with a missing model regex
if f.model == nil {
log.Debugf("product %s included because range filter model is missing", product.Name)
return true
}
if f.model.MatchString(product.Name) && product.Price < f.min || product.Price > f.max {
log.Debugf("product %s excluded because price for the model is outside of the range [%.2f-%.2f]", product.Name, f.min, f.max)
return false
// include products with a different model
if !f.model.MatchString(product.Name) {
log.Debugf("product %s included because range filter model hasn't been detected in the product name", product.Name)
return true
}
log.Debugf("product %s included because price range filter is not applicable", product.Name)
return true
// convert price to the filter currency
convertedPrice, err := f.converter.Convert(product.Price, product.PriceCurrency, f.currency)
if err != nil {
log.Warnf("could not convert price %.2f %s to %s for range filter: %s", product.Price, product.PriceCurrency, f.currency, err)
return true
}
// include prices with unlimited maximum if min is respected
if f.max == 0 && convertedPrice > f.max && f.min <= convertedPrice {
log.Debugf("product %s included because max value is unlimited and converted price of %.2f%s is higher than lower limit of %.2f%s", product.Name, convertedPrice, f.currency, f.min, f.currency)
return true
}
// include prices inside the range
if f.min <= convertedPrice && convertedPrice <= f.max {
log.Debugf("product %s included because range filter model matches and converted price is inside of the range", product.Name)
return true
}
log.Debugf("product %s excluded because range filter model matches and converted price is outside of the range", product.Name)
return false
}

View file

@ -7,36 +7,38 @@ import (
func TestRangeFilter(t *testing.T) {
tests := []struct {
name string // product name
price float64 // product price
product *Product
model string // model regex to apply on the product name
min float64 // minimum price
max float64 // maximum price
currency string // price currency
included bool // should be included or not
}{
{"MSI GeForce RTX 3090 GAMING X", 99.99, "3090", 50.0, 100.0, true}, // model match and price is in the range, should be included
{"MSI GeForce RTX 3090 GAMING X", 99.99, "3080", 50.0, 100.0, true}, // model doesn't match, should be included
{"MSI GeForce RTX 3090 GAMING X", 999.99, "3090", 50.0, 100.0, false}, // model match and price is outside of the range, shoud not be included
{"MSI GeForce RTX 3090 GAMING X", 99.99, "", 50.0, 100.0, true}, // model regex is missing, should be included
{&Product{Name: "MSI GeForce RTX 3090 GAMING X", Price: 99.99, PriceCurrency: "EUR"}, "3090", 50.0, 100.0, "EUR", true}, // model match and price is in the range, should be included
{&Product{Name: "MSI GeForce RTX 3090 GAMING X", Price: 99.99, PriceCurrency: "EUR"}, "3080", 50.0, 100.0, "EUR", true}, // model doesn't match, should be included
{&Product{Name: "MSI GeForce RTX 3090 GAMING X", Price: 999.99, PriceCurrency: "EUR"}, "3090", 50.0, 100.0, "EUR", false}, // model match and price is outside of the range, shoud not be included
{&Product{Name: "MSI GeForce RTX 3090 GAMING X", Price: 99.99, PriceCurrency: "EUR"}, "", 50.0, 100.0, "EUR", true}, // model regex is missing, should be included
{&Product{Name: "MSI GeForce RTX 3090 GAMING X", Price: 99.99, PriceCurrency: "EUR"}, "3090", 50.0, 0.0, "EUR", true}, // upper limit is missing, should be included
}
converter := NewCurrencyConverter()
for i, tc := range tests {
t.Run(fmt.Sprintf("TestRangeFilter#%d", i), func(t *testing.T) {
product := &Product{Name: tc.name, Price: tc.price}
filter, err := NewRangeFilter(tc.model, tc.min, tc.max)
filter, err := NewRangeFilter(tc.model, tc.min, tc.max, tc.currency, converter)
if err != nil {
t.Errorf("cannot create filter with model regex '%s' and price range [%.2f, %.2f]: %s", tc.model, tc.min, tc.max, err)
}
included := filter.Include(product)
included := filter.Include(tc.product)
if included != tc.included {
t.Errorf("product '%s' with model regex '%s' and range [%.2f, %.2f]: got included=%t, want included=%t", tc.name, tc.model, tc.min, tc.max, included, tc.included)
t.Errorf("product '%s' of price %.2f%s with model regex '%s' and range [%.2f, %.2f]: got included=%t, want included=%t", tc.product.Name, tc.product.Price, tc.product.PriceCurrency, tc.model, tc.min, tc.max, included, tc.included)
} else {
if included {
t.Logf("product '%s' included by model regex '%s' and range [%.2f, %.2f]", tc.name, tc.model, tc.min, tc.max)
t.Logf("product '%s' included by model regex '%s' and range [%.2f, %.2f]", tc.product.Name, tc.model, tc.min, tc.max)
} else {
t.Logf("product '%s' excluded by model regex '%s' and range [%.2f, %.2f]", tc.name, tc.model, tc.min, tc.max)
t.Logf("product '%s' excluded by model regex '%s' and range [%.2f, %.2f]", tc.product.Name, tc.model, tc.min, tc.max)
}
}

1
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/dghubble/oauth1 v0.7.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.0.0-rc1
github.com/gorilla/mux v1.8.0
github.com/jarcoal/httpmock v1.0.8 // indirect
github.com/sirupsen/logrus v1.8.0
github.com/spiegel-im-spiegel/pa-api v0.9.0
gorm.io/driver/mysql v1.0.5

2
go.sum
View file

@ -115,6 +115,8 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k=
github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=

10
main.go
View file

@ -179,6 +179,16 @@ func main() {
}
filters = append(filters, excludeFilter)
}
if len(config.PriceRanges) > 0 {
converter := NewCurrencyConverter()
for _, pr := range config.PriceRanges {
rangeFilter, err := NewRangeFilter(pr.Model, pr.Min, pr.Max, pr.Currency, converter)
if err != nil {
log.Fatalf("cannot create price range filter: %s", err)
}
filters = append(filters, rangeFilter)
}
}
// create parsers
parsers := []Parser{}