package driver import ( "context" "errors" "fmt" "log" "time" "git.pengzhan.dev/aimaren/internal/crawler" "git.pengzhan.dev/aimaren/internal/notifier" "git.pengzhan.dev/aimaren/internal/storage" ) const hermesBagsURL = "https://www.hermes.com/us/en/category/women/bags-and-small-leather-goods/bags-and-clutches/" // Driver orchestrates the crawling, state management, and notification process. type Driver struct { store storage.Storer notify notifier.Notifier } func New(store storage.Storer, notify notifier.Notifier) *Driver { return &Driver{store: store, notify: notify} } // Run now uses the consolidated AppState model. func (d *Driver) Run(ctx context.Context) error { log.Println("🚀 Kicking off new crawl cycle...") scrapedBags, err := crawler.Scrape(ctx, hermesBagsURL) if err != nil { return fmt.Errorf("scraping failed: %w", err) } if len(scrapedBags) == 0 { return errors.New("scraper found 0 items, indicating a possible block or site change") } log.Printf("✅ Scraped %d items successfully.", len(scrapedBags)) // Fetch the entire application state in one call. appState, err := d.store.FetchAppState(ctx) if err != nil { return fmt.Errorf("could not fetch app state from storage: %w", err) } // Process all changes, which modifies the appState object directly. hasChanges, notifications := d.processChanges(appState, scrapedBags) // If there are notifications, broadcast to all chat IDs from the app state. if len(notifications) > 0 { if len(appState.ChatIDs) > 0 { log.Printf("✨ Found %d events. Broadcasting to %d subscribers...", len(notifications), len(appState.ChatIDs)) for _, msg := range notifications { // The notifier now needs the list of IDs to send to. d.notify.Broadcast(appState.ChatIDs, msg) } } else { log.Println("✨ Found events, but no subscribers to notify.") } } // If the state has changed, persist it back to storage. if hasChanges { log.Println("💾 State has changed. Updating storage...") if err := d.store.UpdateAppState(ctx, appState); err != nil { return fmt.Errorf("failed to update app state: %w", err) } log.Println("✅ Storage state updated successfully.") } else { log.Println("✨ No changes detected that require a state update.") } return nil } // processChanges now takes and modifies the AppState directly. func (d *Driver) processChanges(appState *storage.AppState, scrapedBags map[string]crawler.Bag) (bool, []string) { var notifications []string if appState == nil { log.Println("⚠️ AppState is nil, initializing a new one.") appState = &storage.AppState{ Bags: make(map[string]crawler.Bag), ChatIDs: make([]int64, 0), } } if appState.Bags == nil { log.Println("⚠️ AppState.Bags is nil, initializing a new map.") appState.Bags = make(map[string]crawler.Bag) } hasChanges := false for sku, newBag := range scrapedBags { oldBag, exists := appState.Bags[sku] if !exists || (exists && oldBag.DeleteTimestamp != nil) { hasChanges = true msg := fmt.Sprintf("✨ NEW/RETURNED BAG ✨\n\nName: %s\nAvailable: %t\nURL: %s", newBag.Name, newBag.Availability, newBag.URL) notifications = append(notifications, msg) newBag.CreatedTimestamp = time.Now() newBag.UpdatedTimestamp = time.Now() newBag.DeleteTimestamp = nil appState.Bags[sku] = newBag continue } if oldBag.Availability != newBag.Availability { hasChanges = true status := "In Stock!" if !newBag.Availability { status = "Sold Out" } msg := fmt.Sprintf("🚨 STOCK ALERT: %s 🚨\n\nName: %s \nURL: %s", status, newBag.Name, newBag.URL) notifications = append(notifications, msg) oldBag.Availability = newBag.Availability oldBag.UpdatedTimestamp = time.Now() appState.Bags[sku] = oldBag } } for sku, oldBag := range appState.Bags { if _, exists := scrapedBags[sku]; !exists && oldBag.DeleteTimestamp == nil { hasChanges = true now := time.Now() oldBag.DeleteTimestamp = &now appState.Bags[sku] = oldBag log.Printf("✅ Bag '%s' marked as removed.", oldBag.Name) } } return hasChanges, notifications }