Archived
1
0
Fork 0
This repository has been archived on 2024-12-18. You can view files and clone it, but cannot push or open issues or pull requests.
restockbot/main.go
Julien Riou ab5abcd171
Select or create shop before parsing
A shop map was created to group URLs by shops and process them in order. Now
that we have Amazon and each URL can be parsed independently, there is no need
to group them anymore. Moreover, shops were passed as an argument to the
handleProducts function. Shop name can be deduced by the parser itself. The
parser has a reference to the database. The parser now select or create the shop
before parsing products.

Signed-off-by: Julien Riou <julien@riou.xyz>
2021-04-01 17:50:50 +02:00

305 lines
8.5 KiB
Go

package main
import (
"flag"
"fmt"
"math/rand"
"sync"
"time"
"os"
log "github.com/sirupsen/logrus"
"gorm.io/driver/sqlite"
"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")
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")
api := flag.Bool("api", false, "Start the HTTP API")
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
db, err := gorm.Open(sqlite.Open(*databaseFileName), &gorm.Config{})
if err != nil {
log.Fatalf("cannot connect to database: %s", err)
}
log.Debugf("connected to database %s", *databaseFileName)
// 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")
}
// start the api
if *api {
log.Fatal(StartAPI(db, config.ApiConfig))
}
// 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)
}
if config.HasTelegram() {
telegramNotifier, err := NewTelegramNotifier(&config.TelegramConfig, db)
if err != nil {
log.Fatalf("cannot create telegram client: %s", err)
}
notifiers = append(notifiers, telegramNotifier)
}
}
// create parsers
parsers := []Parser{}
if config.HasURLs() {
for _, url := range config.URLs {
// create parser
parser, err := NewURLParser(url, config.BrowserAddress, config.IncludeRegex, config.ExcludeRegex)
if err != nil {
log.Warnf("could not create URL parser for '%s'", url)
continue
}
parsers = append(parsers, parser)
log.Debugf("parser %s registered", parser)
}
}
if config.HasAmazon() {
for _, marketplace := range config.AmazonConfig.Marketplaces {
// create parser
parser, err := NewAmazonParser(marketplace.Name, marketplace.PartnerTag, config.AmazonConfig.AccessKey, config.AmazonConfig.SecretKey, config.AmazonConfig.Searches, config.IncludeRegex, config.ExcludeRegex, config.AmazonConfig.AmazonFulfilled, config.AmazonConfig.AmazonMerchant, config.AmazonConfig.AffiliateLinks)
if err != nil {
log.Warnf("could not create Amazon parser: %s", err)
continue
}
parsers = append(parsers, parser)
log.Debugf("parser %s registered", parser)
}
}
// parse asynchronously
var wg sync.WaitGroup
jobsCount := 0
for _, parser := range parsers {
if jobsCount < *workers {
wg.Add(1)
jobsCount++
go handleProducts(parser, notifiers, db, &wg)
} else {
log.Debugf("waiting for intermediate jobs to end")
wg.Wait()
jobsCount = 0
}
}
log.Debugf("waiting for all jobs to end")
wg.Wait()
}
// For parser to return a list of products, then eventually send notifications
func handleProducts(parser Parser, notifiers []Notifier, db *gorm.DB, wg *sync.WaitGroup) {
defer wg.Done()
log.Debugf("parsing with %s", parser)
// 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
products, err := parser.Parse()
if err != nil {
log.Warnf("cannot parse: %s", err)
return
}
log.Debugf("parsed")
// insert or update products to database
for _, product := range products {
log.Debugf("detected product %+v", product)
if !product.IsValid() {
log.Warnf("parsed malformatted product: %+v", product)
continue
}
// 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
}
// 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)
// detect availability change
duration := time.Now().Sub(dbProduct.UpdatedAt).Truncate(time.Second)
createThread := false
closeThread := false
// 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
}
// 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)
createThread = true
} else {
log.Infof("product %s on %s is not available anymore", product.Name, shop.Name)
closeThread = true
}
}
// 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
}
log.Debugf("product %s updated in database", dbProduct.Name)
}
// send notifications
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)
}
}
} else if closeThread {
for _, notifier := range notifiers {
if err := notifier.NotifyWhenNotAvailable(dbProduct.URL, duration); err != nil {
log.Errorf("%s", err)
}
}
}
}
}
func showVersion() {
if GitCommit != "" {
AppVersion = fmt.Sprintf("%s-%s", AppVersion, GitCommit)
}
fmt.Printf("%s version %s (compiled with %s)\n", AppName, AppVersion, GoVersion)
}