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:
		
					parent
					
						
							
								ba791435b7
							
						
					
				
			
			
				commit
				
					
						da532104f8
					
				
			
		
					 9 changed files with 231 additions and 28 deletions
				
			
		|  | @ -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`) | ||||
|  |  | |||
|  | @ -16,6 +16,7 @@ type Config struct { | |||
| 	URLs           []string     `json:"urls"` | ||||
| 	IncludeRegex   string       `json:"include_regex"` | ||||
| 	ExcludeRegex   string       `json:"exclude_regex"` | ||||
| 	PriceRanges    []PriceRange `json:"price_ranges"` | ||||
| 	BrowserAddress string       `json:"browser_address"` | ||||
| } | ||||
| 
 | ||||
|  | @ -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
									
								
							
							
						
						
									
										96
									
								
								currency_converter.go
									
										
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										44
									
								
								currency_converter_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								currency_converter_test.go
									
										
									
									
									
										Normal 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) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | @ -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 | ||||
| 	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, | ||||
| 		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) | ||||
| 
 | ||||
| 	// 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 | ||||
| 	} | ||||
| 
 | ||||
| 	// 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 | ||||
| } | ||||
| 	log.Debugf("product %s included because price range filter is not applicable", product.Name) | ||||
| 	return true | ||||
| } | ||||
|  |  | |||
|  | @ -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
									
										
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
										
									
									
									
								
							|  | @ -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
									
										
									
									
									
								
							
							
						
						
									
										2
									
								
								go.sum
									
										
									
									
									
								
							|  | @ -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
									
										
									
									
									
								
							
							
						
						
									
										10
									
								
								main.go
									
										
									
									
									
								
							|  | @ -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{} | ||||
|  |  | |||
		Reference in a new issue