|
@@ -30,9 +30,6 @@ const (
|
|
|
userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
|
|
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.
|
|
// Client queries the Epic storefront backend.
|
|
|
type Client struct {
|
|
type Client struct {
|
|
|
HTTP *http.Client
|
|
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 {
|
|
type promotionsResponse struct {
|
|
|
Data struct {
|
|
Data struct {
|
|
|
Catalog struct {
|
|
Catalog struct {
|
|
|
SearchStore 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:"searchStore"`
|
|
|
} `json:"Catalog"`
|
|
} `json:"Catalog"`
|
|
|
} `json:"data"`
|
|
} `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)
|
|
u, err := url.Parse(c.BaseURL)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return nil, fmt.Errorf("epic: bad base URL: %w", err)
|
|
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 {
|
|
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
|
|
|
return nil, fmt.Errorf("epic: decode response: %w", err)
|
|
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()
|
|
now := time.Now()
|
|
|
var games []game.Game
|
|
var games []game.Game
|
|
|
- for _, el := range pr.Data.Catalog.SearchStore.Elements {
|
|
|
|
|
|
|
+ for _, el := range elements {
|
|
|
if el.Promotions == nil {
|
|
if el.Promotions == nil {
|
|
|
continue
|
|
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 {
|
|
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
|
|
return games, nil
|
|
|
}
|
|
}
|