This repository has been archived by the owner on Nov 1, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
/
main.go
395 lines (342 loc) · 11.5 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
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")
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")
retention := flag.Int("retention", 0, "Automatically remove products from the database with this number of days old (disabled by default)")
api := flag.Bool("api", false, "Start the HTTP API")
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)")
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
var db *gorm.DB
if config.HasDatabase() {
db, err = NewDatabaseFromConfig(config.DatabaseConfig)
} else {
db, err = NewDatabaseFromFile(*databaseFileName)
}
if err != nil {
log.Fatalf("cannot connect to database: %s", err)
}
log.Debugf("connected to database")
// 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")
}
// 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 {
log.Warnf("cannot remove stale product %s (%s): %s", p.Name, p.URL, trx.Error)
} else {
log.Printf("stale product %s (%s) removed from database", p.Name, p.URL)
}
}
}
// start monitoring
if *monitor {
os.Exit(Monitor(db, *warningTimeout, *criticalTimeout))
}
// 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)
}
}
// 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)
}
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{}
if config.HasURLs() {
// create a parser for all web pages
for _, url := range config.URLs {
parser := NewURLParser(url, config.BrowserAddress)
parsers = append(parsers, parser)
log.Debugf("parser %s registered", parser)
}
}
if config.HasAmazon() {
// create a parser for all marketplaces
for _, marketplace := range config.AmazonConfig.Marketplaces {
parser := NewAmazonParser(marketplace.Name, marketplace.PartnerTag, config.AmazonConfig.AccessKey, config.AmazonConfig.SecretKey, config.AmazonConfig.Searches, config.AmazonConfig.AmazonFulfilled, config.AmazonConfig.AmazonMerchant, config.AmazonConfig.AffiliateLinks)
if err != nil {
log.Warnf("could not create Amazon parser for marketplace %s: %s", marketplace, err)
continue
}
parsers = append(parsers, parser)
log.Debugf("parser %s registered", parser)
}
}
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)
}
}
// parse asynchronously
var wg sync.WaitGroup
jobsCount := 0
for _, parser := range parsers {
for {
if jobsCount < *workers {
wg.Add(1)
jobsCount++
go handleProducts(parser, notifiers, filters, db, &wg)
break
} 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, filters []Filter, 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
}
for _, product := range products {
// skip products not matching all filters
included := true
for _, filter := range filters {
if included && !filter.Include(product) {
included = false
continue
}
}
if !included {
continue
}
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 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)
}
}
} else if closeThread {
for _, notifier := range notifiers {
if err := notifier.NotifyWhenNotAvailable(dbProduct.URL, duration); err != nil {
log.Errorf("%s", err)
}
}
}
}
// 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)
}
}
}
func showVersion() {
if GitCommit != "" {
AppVersion = fmt.Sprintf("%s-%s", AppVersion, GitCommit)
}
fmt.Printf("%s version %s (compiled with %s)\n", AppName, AppVersion, GoVersion)
}