Parcourir la source

Add epic upcoming free games: cheapo list --upcoming (upcomingPromotionalOffers)

Paul Klumpp il y a 3 jours
Parent
commit
259a347744
3 fichiers modifiés avec 174 ajouts et 87 suppressions
  1. 7 3
      README.md
  2. 39 5
      cmd/cheapo/main.go
  3. 128 79
      internal/epic/client.go

+ 7 - 3
README.md

@@ -38,6 +38,11 @@ cheapo watch
 # Different interval, single check
 cheapo watch --interval 5m
 cheapo watch --once
+
+# Other stores / upcoming free games (preview, Epic only)
+cheapo list --store steam
+cheapo list --store all
+cheapo list --upcoming
 ```
 
 Example output:
@@ -71,9 +76,8 @@ so they can be used e.g. by Hermes or other MCP clients. Planned:
   `store.steampowered.com/search/?maxprice=free&specials=1`, free weekend via
   `store.steampowered.com/api/featuredcategories/` (details: `AGENTS.md`).
   With `--store` you choose `epic`, `steam` or `all` (default).
-- **Epic "upcoming"** (idea, not implemented yet): The Epic endpoint also
-  returns `upcomingPromotionalOffers` — a preview of future free promotions
-  (see `AGENTS.md`). Could be added as `cheapo list --upcoming`.
+- **Epic "upcoming"** ✅ done: `cheapo list --upcoming` shows announced
+  upcoming free promotions (Epic only; Steam ignores the flag).
 
 ## Architecture
 

+ 39 - 5
cmd/cheapo/main.go

@@ -116,10 +116,14 @@ func printGames(cmd *cobra.Command, games []game.Game) error {
 		if g.Seller != "" {
 			fmt.Fprintf(out, "   By: %s\n", g.Seller)
 		}
-		if g.PriceFmt != "" && g.Store != "steam" {
+		if g.PriceFmt != "" && g.Store != "steam" && g.PriceFmt != "0" {
 			fmt.Fprintf(out, "   Instead of: %s\n", g.PriceFmt)
 		}
-		if !g.EndDate.IsZero() {
+		if g.Kind == "upcoming" && !g.StartDate.IsZero() {
+			fmt.Fprintf(out, "   Free from: %s (until %s)\n",
+				g.StartDate.Local().Format("02.01.2006 15:04"),
+				g.EndDate.Local().Format("02.01.2006 15:04"))
+		} else if !g.EndDate.IsZero() {
 			fmt.Fprintf(out, "   Free until: %s\n", g.EndDate.Local().Format("02.01.2006 15:04 (MST)"))
 		}
 		fmt.Fprintf(out, "   %s\n", g.URL)
@@ -129,18 +133,48 @@ func printGames(cmd *cobra.Command, games []game.Game) error {
 }
 
 func listCmd() *cobra.Command {
-	return &cobra.Command{
+	var upcoming bool
+	cmd := &cobra.Command{
 		Use:     "list",
 		Aliases: []string{"ls"},
-		Short:   "List games currently free",
+		Short:   "List games currently free (epic: optionally upcoming ones)",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			games, err := fetchAll(cmd.Context())
+			ctx := cmd.Context()
+			if upcoming {
+				// Upcoming windows only exist for Epic.
+				var all []game.Game
+				var hadErr error
+				provs, err := newProviders()
+				if err != nil {
+					return err
+				}
+				for _, p := range provs {
+					u, ok := p.(*epic.Client)
+					if !ok {
+						continue
+					}
+					games, err := u.GetUpcomingGames(ctx)
+					if err != nil {
+						fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
+						hadErr = err
+						continue
+					}
+					all = append(all, games...)
+				}
+				if len(all) == 0 && hadErr != nil {
+					return hadErr
+				}
+				return printGames(cmd, all)
+			}
+			games, err := fetchAll(ctx)
 			if err != nil && len(games) == 0 {
 				return err
 			}
 			return printGames(cmd, games)
 		},
 	}
+	cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Show announced upcoming free games (Epic only)")
+	return cmd
 }
 
 func watchCmd() *cobra.Command {

+ 128 - 79
internal/epic/client.go

@@ -30,9 +30,6 @@ const (
 	userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
 )
 
-// Name implements game.Provider.
-func (c *Client) Name() string { return "epic" }
-
 // Client queries the Epic storefront backend.
 type Client struct {
 	HTTP    *http.Client
@@ -51,50 +48,59 @@ func NewClient() *Client {
 	}
 }
 
+// Name implements game.Provider.
+func (c *Client) Name() string { return "epic" }
+
+type offer struct {
+	StartDate time.Time `json:"startDate"`
+	EndDate   time.Time `json:"endDate"`
+	Discount  struct {
+		DiscountPrice int `json:"discountPrice"`
+	} `json:"discountSetting"`
+}
+
+type element struct {
+	ID          string `json:"id"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
+	URLSlug     string `json:"urlSlug"`
+	CatalogNs   struct {
+		Mappings []struct {
+			PageSlug string `json:"pageSlug"`
+		} `json:"mappings"`
+	} `json:"catalogNs"`
+	Seller struct {
+		Name string `json:"displayName"`
+	} `json:"seller"`
+	Price struct {
+		TotalPrice struct {
+			FmtPrice struct {
+				OriginalPrice string `json:"originalPrice"`
+			} `json:"fmtPrice"`
+		} `json:"totalPrice"`
+	} `json:"price"`
+	Promotions *struct {
+		PromotionalOffers []struct {
+			PromotionalOffers []offer `json:"promotionalOffers"`
+		} `json:"promotionalOffers"`
+		UpcomingPromotionalOffers []struct {
+			PromotionalOffers []offer `json:"promotionalOffers"`
+		} `json:"upcomingPromotionalOffers"`
+	} `json:"promotions"`
+}
+
 type promotionsResponse struct {
 	Data struct {
 		Catalog struct {
 			SearchStore struct {
-				Elements []struct {
-					ID          string `json:"id"`
-					Title       string `json:"title"`
-					Description string `json:"description"`
-					URLSlug     string `json:"urlSlug"`
-					CatalogNs   struct {
-						Mappings []struct {
-							PageSlug string `json:"pageSlug"`
-						} `json:"mappings"`
-					} `json:"catalogNs"`
-					Seller struct {
-						Name string `json:"displayName"`
-					} `json:"seller"`
-					Price struct {
-						TotalPrice struct {
-							FmtPrice struct {
-								OriginalPrice string `json:"originalPrice"`
-							} `json:"fmtPrice"`
-						} `json:"totalPrice"`
-					} `json:"price"`
-					Promotions *struct {
-						PromotionalOffers []struct {
-							PromotionalOffers []struct {
-								StartDate time.Time `json:"startDate"`
-								EndDate   time.Time `json:"endDate"`
-								Discount  struct {
-									DiscountPrice int `json:"discountPrice"`
-								} `json:"discountSetting"`
-							} `json:"promotionalOffers"`
-						} `json:"promotionalOffers"`
-					} `json:"promotions"`
-				} `json:"elements"`
+				Elements []element `json:"elements"`
 			} `json:"searchStore"`
 		} `json:"Catalog"`
 	} `json:"data"`
 }
 
-// GetFreeGames returns the games that are currently free (100 % discount,
-// active promotion window), sorted by end date (soonest first).
-func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
+// fetchPromotions retrieves and decodes the promotions document.
+func (c *Client) fetchPromotions(ctx context.Context) ([]element, error) {
 	u, err := url.Parse(c.BaseURL)
 	if err != nil {
 		return nil, fmt.Errorf("epic: bad base URL: %w", err)
@@ -126,57 +132,100 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
 	if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
 		return nil, fmt.Errorf("epic: decode response: %w", err)
 	}
+	return pr.Data.Catalog.SearchStore.Elements, nil
+}
+
+// buildGames converts the offer windows of one element into games. Windows
+// that do not satisfy keep are skipped; a game appearing with several windows
+// (e.g. extended giveaway) keeps the one with the latest end date.
+func buildGames(el element, kind string, windows []offer, keep func(offer) bool, games []game.Game) []game.Game {
+	slug := el.URLSlug
+	if len(el.CatalogNs.Mappings) > 0 && el.CatalogNs.Mappings[0].PageSlug != "" {
+		// urlSlug is sometimes a wrong ID (see vogler/free-games-claimer,
+		// epic-games.js comment on spirit-of-the-north): prefer the catalog
+		// mapping's page slug.
+		slug = el.CatalogNs.Mappings[0].PageSlug
+	}
+	for _, o := range windows {
+		if o.Discount.DiscountPrice != 0 || !keep(o) {
+			continue
+		}
+		g := game.Game{
+			Title:       el.Title,
+			ID:          "epic-" + el.ID,
+			Slug:        slug,
+			URL:         "https://store.epicgames.com/en-US/p/" + slug,
+			Store:       "epic",
+			Kind:        kind,
+			Description: el.Description,
+			Seller:      el.Seller.Name,
+			StartDate:   o.StartDate,
+			EndDate:     o.EndDate,
+			PriceFmt:    el.Price.TotalPrice.FmtPrice.OriginalPrice,
+		}
+		if i := indexOfID(games, g.ID); i >= 0 {
+			if g.EndDate.After(games[i].EndDate) {
+				games[i] = g
+			}
+			continue
+		}
+		games = append(games, g)
+	}
+	return games
+}
+
+func sortByEnd(games []game.Game) {
+	sort.Slice(games, func(i, j int) bool {
+		return games[i].EndDate.Before(games[j].EndDate)
+	})
+}
+
+// GetFreeGames returns the games that are currently free (100 % discount,
+// active promotion window), sorted by end date (soonest first).
+func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
+	elements, err := c.fetchPromotions(ctx)
+	if err != nil {
+		return nil, err
+	}
 
 	now := time.Now()
 	var games []game.Game
-	for _, el := range pr.Data.Catalog.SearchStore.Elements {
+	for _, el := range elements {
 		if el.Promotions == nil {
 			continue
 		}
-		for _, offerGroup := range el.Promotions.PromotionalOffers {
-			for _, offer := range offerGroup.PromotionalOffers {
-				// Free = 100 % discount, and the window must be active right now.
-				if offer.Discount.DiscountPrice != 0 {
-					continue
-				}
-				if now.Before(offer.StartDate) || now.After(offer.EndDate) {
-					continue
-				}
-				slug := el.URLSlug
-				if len(el.CatalogNs.Mappings) > 0 && el.CatalogNs.Mappings[0].PageSlug != "" {
-					// urlSlug is sometimes a wrong ID (see vogler/free-games-claimer,
-					// epic-games.js comment on spirit-of-the-north): prefer the
-					// catalog mapping's page slug.
-					slug = el.CatalogNs.Mappings[0].PageSlug
-				}
-				g := game.Game{
-					Title:       el.Title,
-					ID:          "epic-" + el.ID,
-					Slug:        slug,
-					URL:         "https://store.epicgames.com/en-US/p/" + slug,
-					Store:       "epic",
-					Kind:        "free-now",
-					Description: el.Description,
-					Seller:      el.Seller.Name,
-					StartDate:   offer.StartDate,
-					EndDate:     offer.EndDate,
-					PriceFmt:    el.Price.TotalPrice.FmtPrice.OriginalPrice,
-				}
-				// A game can appear with several promotion windows (e.g. extended
-				// giveaway); keep the one with the latest end date.
-				if i := indexOfID(games, g.ID); i >= 0 {
-					if g.EndDate.After(games[i].EndDate) {
-						games[i] = g
-					}
-					continue
-				}
-				games = append(games, g)
-			}
+		for _, group := range el.Promotions.PromotionalOffers {
+			games = buildGames(el, "free-now", group.PromotionalOffers,
+				func(o offer) bool { return !now.Before(o.StartDate) && !now.After(o.EndDate) }, games)
+		}
+	}
+
+	sortByEnd(games)
+	return games, nil
+}
+
+// GetUpcomingGames returns games whose free period has not started yet
+// (announced upcoming giveaways), sorted by start date (soonest first).
+func (c *Client) GetUpcomingGames(ctx context.Context) ([]game.Game, error) {
+	elements, err := c.fetchPromotions(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	now := time.Now()
+	var games []game.Game
+	for _, el := range elements {
+		if el.Promotions == nil {
+			continue
+		}
+		for _, group := range el.Promotions.UpcomingPromotionalOffers {
+			games = buildGames(el, "upcoming", group.PromotionalOffers,
+				func(o offer) bool { return o.StartDate.After(now) }, games)
 		}
 	}
 
 	sort.Slice(games, func(i, j int) bool {
-		return games[i].EndDate.Before(games[j].EndDate)
+		return games[i].StartDate.Before(games[j].StartDate)
 	})
 	return games, nil
 }