2021-02-23 18:29:27 +01:00
package main
import (
"flag"
"fmt"
"math/rand"
"sync"
"time"
"os"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// initialize logging
func init ( ) {
log . SetFormatter ( & log . TextFormatter {
DisableColors : true ,
} )
log . SetOutput ( os . Stdout )
}
// AppName to store application name
var AppName string = "restockbot"
// AppVersion to set version at compilation time
var AppVersion string = "9999"
// GitCommit to set git commit at compilation time (can be empty)
var GitCommit string
// GoVersion to set Go version at compilation time
var GoVersion string
func main ( ) {
rand . Seed ( time . Now ( ) . UnixNano ( ) )
var err error
config := NewConfig ( )
version := flag . Bool ( "version" , false , "Print version and exit" )
quiet := flag . Bool ( "quiet" , false , "Log errors only" )
verbose := flag . Bool ( "verbose" , false , "Print more logs" )
debug := flag . Bool ( "debug" , false , "Print even more logs" )
databaseFileName := flag . String ( "database" , AppName + ".db" , "Database file name" )
configFileName := flag . String ( "config" , AppName + ".json" , "Configuration file name" )
logFileName := flag . String ( "log-file" , "" , "Log file name" )
disableNotifications := flag . Bool ( "disable-notifications" , false , "Do not send notifications" )
2021-04-06 09:25:15 +02:00
workers := flag . Int ( "workers" , 1 , "Number of workers for parsing shops" )
pidFile := flag . String ( "pid-file" , "" , "Write process ID to this file to disable concurrent executions" )
pidWaitTimeout := flag . Int ( "pid-wait-timeout" , 0 , "Seconds to wait before giving up when another instance is running" )
2021-04-06 09:22:22 +02:00
retention := flag . Int ( "retention" , 0 , "Automatically remove products from the database with this number of days old (disabled by default)" )
2021-03-25 18:00:29 +01:00
api := flag . Bool ( "api" , false , "Start the HTTP API" )
2022-08-31 18:40:52 +02:00
monitor := flag . Bool ( "monitor" , false , "Perform health check with Nagios output" )
warningTimeout := flag . Int ( "monitor-warning-timeout" , 300 , "Raise a warning alert when the last execution time has reached this number of seconds (see -monitor)" )
criticalTimeout := flag . Int ( "monitor-critical-timeout" , 600 , "Raise a critical alert when the last execution time has reached this number of seconds (see -monitor)" )
2021-02-23 18:29:27 +01:00
flag . Parse ( )
if * version {
showVersion ( )
return
}
log . SetLevel ( log . WarnLevel )
if * debug {
log . SetLevel ( log . DebugLevel )
}
if * verbose {
log . SetLevel ( log . InfoLevel )
}
if * quiet {
log . SetLevel ( log . ErrorLevel )
}
if * logFileName != "" {
fd , err := os . OpenFile ( * logFileName , os . O_WRONLY | os . O_CREATE | os . O_APPEND , 0644 )
if err != nil {
fmt . Printf ( "cannot open file for logging: %s\n" , err )
}
log . SetOutput ( fd )
}
if * configFileName != "" {
err = config . Read ( * configFileName )
if err != nil {
log . Fatalf ( "cannot parse configuration file: %s" , err )
}
}
log . Debugf ( "configuration file %s parsed" , * configFileName )
// handle PID file
if * pidFile != "" {
if err := waitPid ( * pidFile , * pidWaitTimeout ) ; err != nil {
log . Warnf ( "%s" , err )
return
}
if err := writePid ( * pidFile ) ; err != nil {
log . Fatalf ( "cannot write PID file: %s" , err )
}
defer removePid ( * pidFile )
}
// connect to the database
2021-04-06 10:33:24 +02:00
var db * gorm . DB
if config . HasDatabase ( ) {
db , err = NewDatabaseFromConfig ( config . DatabaseConfig )
} else {
db , err = NewDatabaseFromFile ( * databaseFileName )
}
2021-02-23 18:29:27 +01:00
if err != nil {
log . Fatalf ( "cannot connect to database: %s" , err )
}
2021-04-06 10:33:24 +02:00
log . Debugf ( "connected to database" )
2021-02-23 18:29:27 +01:00
// create tables
if err := db . AutoMigrate ( & Product { } ) ; err != nil {
log . Fatalf ( "cannot create products table" )
}
if err := db . AutoMigrate ( & Shop { } ) ; err != nil {
log . Fatalf ( "cannot create shops table" )
}
2021-04-06 09:22:22 +02:00
// delete products not updated since retention
if * retention != 0 {
var oldProducts [ ] Product
retentionDate := time . Now ( ) . Local ( ) . Add ( - time . Hour * 24 * time . Duration ( * retention ) )
trx := db . Where ( "updated_at < ?" , retentionDate ) . Find ( & oldProducts )
if trx . Error != nil {
log . Warnf ( "cannot find stale products: %s" , trx . Error )
}
for _ , p := range oldProducts {
log . Debugf ( "found old product: %s" , p . Name )
if trx = db . Unscoped ( ) . Delete ( & p ) ; trx . Error != nil {
2021-04-12 08:49:11 +02:00
log . Warnf ( "cannot remove stale product %s (%s): %s" , p . Name , p . URL , trx . Error )
2021-04-15 15:13:00 +02:00
} else {
2021-04-15 15:25:54 +02:00
log . Printf ( "stale product %s (%s) removed from database" , p . Name , p . URL )
2021-04-06 09:22:22 +02:00
}
}
}
2022-08-31 18:40:52 +02:00
// start monitoring
if * monitor {
os . Exit ( Monitor ( db , * warningTimeout , * criticalTimeout ) )
}
2021-03-25 18:00:29 +01:00
// start the api
if * api {
2021-04-01 17:57:17 +02:00
log . Fatal ( StartAPI ( db , config . APIConfig ) )
2021-03-25 18:00:29 +01:00
}
2021-02-23 18:29:27 +01:00
// register notifiers
notifiers := [ ] Notifier { }
if ! * disableNotifications {
if config . HasTwitter ( ) {
twitterNotifier , err := NewTwitterNotifier ( & config . TwitterConfig , db )
if err != nil {
log . Fatalf ( "cannot create twitter client: %s" , err )
}
notifiers = append ( notifiers , twitterNotifier )
}
2021-03-23 18:12:44 +01:00
if config . HasTelegram ( ) {
telegramNotifier , err := NewTelegramNotifier ( & config . TelegramConfig , db )
if err != nil {
log . Fatalf ( "cannot create telegram client: %s" , err )
}
notifiers = append ( notifiers , telegramNotifier )
}
2021-02-23 18:29:27 +01:00
}
2021-05-19 17:43:31 +02:00
// register filters
filters := [ ] Filter { }
if config . IncludeRegex != "" {
includeFilter , err := NewIncludeFilter ( config . IncludeRegex )
if err != nil {
log . Fatalf ( "cannot create include filter: %s" , err )
}
filters = append ( filters , includeFilter )
}
if config . ExcludeRegex != "" {
excludeFilter , err := NewExcludeFilter ( config . ExcludeRegex )
if err != nil {
log . Fatalf ( "cannot create exclude filter: %s" , err )
}
filters = append ( filters , excludeFilter )
}
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>
2021-05-23 02:32:30 +02:00
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 )
}
}
2021-05-19 17:43:31 +02:00
2021-04-01 17:50:50 +02:00
// create parsers
2021-03-31 17:48:47 +02:00
parsers := [ ] Parser { }
2021-02-23 18:29:27 +01:00
2021-03-31 17:48:47 +02:00
if config . HasURLs ( ) {
2021-05-19 17:43:31 +02:00
// create a parser for all web pages
2021-04-01 17:50:50 +02:00
for _ , url := range config . URLs {
2021-05-19 17:43:31 +02:00
parser := NewURLParser ( url , config . BrowserAddress )
2021-04-01 17:50:50 +02:00
parsers = append ( parsers , parser )
log . Debugf ( "parser %s registered" , parser )
2021-02-23 18:29:27 +01:00
}
}
2021-03-23 09:00:10 +01:00
2021-03-31 17:48:47 +02:00
if config . HasAmazon ( ) {
2021-05-19 17:43:31 +02:00
// create a parser for all marketplaces
2021-03-31 17:48:47 +02:00
for _ , marketplace := range config . AmazonConfig . Marketplaces {
2021-05-19 17:43:31 +02:00
parser := NewAmazonParser ( marketplace . Name , marketplace . PartnerTag , config . AmazonConfig . AccessKey , config . AmazonConfig . SecretKey , config . AmazonConfig . Searches , config . AmazonConfig . AmazonFulfilled , config . AmazonConfig . AmazonMerchant , config . AmazonConfig . AffiliateLinks )
2021-03-31 17:48:47 +02:00
if err != nil {
2021-05-19 17:43:31 +02:00
log . Warnf ( "could not create Amazon parser for marketplace %s: %s" , marketplace , err )
2021-03-31 17:48:47 +02:00
continue
}
parsers = append ( parsers , parser )
log . Debugf ( "parser %s registered" , parser )
}
}
2022-07-22 19:34:03 +02:00
if config . HasNvidiaFE ( ) {
// create a parser for all locations
for _ , location := range config . NvidiaFEConfig . Locations {
parser , err := NewNvidiaFRParser ( location , config . NvidiaFEConfig . GPUs , config . NvidiaFEConfig . UserAgent , config . NvidiaFEConfig . Timeout )
if err != nil {
log . Warnf ( "could not create NVIDIA FE parser for location %s: %s" , location , err )
continue
}
parsers = append ( parsers , parser )
log . Debugf ( "parser %s registered" , parser )
}
}
2021-03-31 17:48:47 +02:00
// parse asynchronously
var wg sync . WaitGroup
jobsCount := 0
for _ , parser := range parsers {
2021-04-12 09:21:24 +02:00
for {
if jobsCount < * workers {
wg . Add ( 1 )
jobsCount ++
2021-05-19 17:43:31 +02:00
go handleProducts ( parser , notifiers , filters , db , & wg )
2021-04-12 09:21:24 +02:00
break
} else {
log . Debugf ( "waiting for intermediate jobs to end" )
wg . Wait ( )
jobsCount = 0
}
2021-03-31 17:48:47 +02:00
}
}
2021-02-23 18:29:27 +01:00
log . Debugf ( "waiting for all jobs to end" )
wg . Wait ( )
}
2021-04-01 17:50:50 +02:00
// For parser to return a list of products, then eventually send notifications
2021-05-19 17:43:31 +02:00
func handleProducts ( parser Parser , notifiers [ ] Notifier , filters [ ] Filter , db * gorm . DB , wg * sync . WaitGroup ) {
2021-02-23 18:29:27 +01:00
defer wg . Done ( )
2021-03-23 09:00:10 +01:00
log . Debugf ( "parsing with %s" , parser )
2021-04-01 17:50:50 +02:00
// read shop from database or create it
var shop Shop
shopName , err := parser . ShopName ( )
if err != nil {
log . Warnf ( "cannot extract shop name from parser: %s" , err )
return
}
trx := db . Where ( Shop { Name : shopName } ) . FirstOrCreate ( & shop )
if trx . Error != nil {
log . Warnf ( "cannot create or select shop %s to/from database: %s" , shopName , trx . Error )
return
}
// parse products
2021-03-23 09:00:10 +01:00
products , err := parser . Parse ( )
if err != nil {
log . Warnf ( "cannot parse: %s" , err )
2021-02-23 18:29:27 +01:00
return
}
2021-03-23 09:00:10 +01:00
for _ , product := range products {
2021-02-23 18:29:27 +01:00
2021-05-19 17:43:31 +02:00
// skip products not matching all filters
included := true
for _ , filter := range filters {
if included && ! filter . Include ( product ) {
included = false
continue
}
}
if ! included {
continue
}
2021-03-23 09:00:10 +01:00
log . Debugf ( "detected product %+v" , product )
if ! product . IsValid ( ) {
log . Warnf ( "parsed malformatted product: %+v" , product )
2021-02-23 18:29:27 +01:00
continue
}
2021-03-23 09:00:10 +01:00
// check if product is already in the database
// sometimes new products are detected on the website, directly available, without reference in the database
// the bot has to send a notification instead of blindly creating it in the database and check availability afterwards
var count int64
trx := db . Model ( & Product { } ) . Where ( Product { URL : product . URL } ) . Count ( & count )
if trx . Error != nil {
log . Warnf ( "cannot see if product %s already exists in the database: %s" , product . Name , trx . Error )
continue
}
2021-02-23 18:29:27 +01:00
2021-03-23 09:00:10 +01:00
// fetch product from database or create it if it doesn't exist
var dbProduct Product
trx = db . Where ( Product { URL : product . URL } ) . Attrs ( Product { Name : product . Name , Shop : shop , Price : product . Price , PriceCurrency : product . PriceCurrency , Available : product . Available } ) . FirstOrCreate ( & dbProduct )
if trx . Error != nil {
log . Warnf ( "cannot fetch product %s from database: %s" , product . Name , trx . Error )
continue
}
log . Debugf ( "product %s found in database" , dbProduct . Name )
2021-02-23 18:29:27 +01:00
2021-03-23 09:00:10 +01:00
// detect availability change
duration := time . Now ( ) . Sub ( dbProduct . UpdatedAt ) . Truncate ( time . Second )
createThread := false
closeThread := false
2021-02-23 18:29:27 +01:00
2021-03-23 09:00:10 +01:00
// non-existing product directly available
if count == 0 && product . Available {
log . Infof ( "product %s on %s is now available" , product . Name , shop . Name )
createThread = true
}
2021-02-23 18:29:27 +01:00
2021-03-23 09:00:10 +01:00
// existing product with availability change
if count > 0 && ( dbProduct . Available != product . Available ) {
if product . Available {
log . Infof ( "product %s on %s is now available" , product . Name , shop . Name )
2021-02-23 18:29:27 +01:00
createThread = true
2021-03-23 09:00:10 +01:00
} else {
log . Infof ( "product %s on %s is not available anymore" , product . Name , shop . Name )
closeThread = true
2021-02-23 18:29:27 +01:00
}
2021-03-23 09:00:10 +01:00
}
2021-02-23 18:29:27 +01:00
2021-03-23 09:00:10 +01:00
// update product in database before sending notification
// if there is a database failure, we don't want the bot to send a notification at each run
if dbProduct . ToMerge ( product ) {
dbProduct . Merge ( product )
trx = db . Save ( & dbProduct )
if trx . Error != nil {
log . Warnf ( "cannot save product %s to database: %s" , dbProduct . Name , trx . Error )
continue
2021-02-23 18:29:27 +01:00
}
2021-03-23 09:00:10 +01:00
log . Debugf ( "product %s updated in database" , dbProduct . Name )
}
2021-02-23 18:29:27 +01:00
2021-03-23 09:00:10 +01:00
// send notifications
2021-07-07 15:36:03 +02:00
if duration > 0 {
if createThread {
for _ , notifier := range notifiers {
if err := notifier . NotifyWhenAvailable ( shop . Name , dbProduct . Name , dbProduct . Price , dbProduct . PriceCurrency , dbProduct . URL ) ; err != nil {
log . Errorf ( "%s" , err )
}
2021-02-23 18:29:27 +01:00
}
2021-07-07 15:36:03 +02:00
} else if closeThread {
for _ , notifier := range notifiers {
if err := notifier . NotifyWhenNotAvailable ( dbProduct . URL , duration ) ; err != nil {
log . Errorf ( "%s" , err )
}
2021-02-23 18:29:27 +01:00
}
}
}
2021-04-06 09:22:22 +02:00
// keep track of active products
dbProduct . UpdatedAt = time . Now ( ) . Local ( )
if trx := db . Save ( & dbProduct ) ; trx . Error != nil {
log . Warnf ( "cannot update product %s to database: %s" , dbProduct . Name , trx . Error )
}
2021-02-23 18:29:27 +01:00
}
}
func showVersion ( ) {
if GitCommit != "" {
AppVersion = fmt . Sprintf ( "%s-%s" , AppVersion , GitCommit )
}
fmt . Printf ( "%s version %s (compiled with %s)\n" , AppName , AppVersion , GoVersion )
}