// Package epic implements a client for the Epic Games Store's public // storefront backend to discover free games. // // Reference: vogler/free-games-claimer (epic-games.js) scrapes the rendered // "Free Games" page; it also notes (epic-games.js, line ~151) that the page is // built from the JSON served by the storefront backend. We query that JSON // directly, so no browser is needed. package epic import ( "context" "encoding/json" "fmt" "net/http" "net/url" "sort" "time" "gogs.netdome.biz/paul/cheapo/internal/game" ) const ( // promotionsURL is the public backend endpoint that powers the // "Free Games" page of the Epic Games Store. promotionsURL = "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions" defaultCountry = "DE" defaultLocale = "de" 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 BaseURL string // override for tests Country string // ISO 3166 country code, e.g. "DE" Locale string // e.g. "de", "en-US" } // NewClient returns a client with defaults for Germany. func NewClient() *Client { return &Client{ HTTP: &http.Client{Timeout: 30 * time.Second}, BaseURL: promotionsURL, Country: defaultCountry, Locale: defaultLocale, } } 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"` } `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) { u, err := url.Parse(c.BaseURL) if err != nil { return nil, fmt.Errorf("epic: bad base URL: %w", err) } q := u.Query() q.Set("locale", c.Locale) q.Set("country", c.Country) q.Set("allowCountries", c.Country) u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("epic: %w", err) } req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json") resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("epic: request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("epic: unexpected status %s", resp.Status) } var pr promotionsResponse if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { return nil, fmt.Errorf("epic: decode response: %w", err) } now := time.Now() var games []game.Game for _, el := range pr.Data.Catalog.SearchStore.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) } } } sort.Slice(games, func(i, j int) bool { return games[i].EndDate.Before(games[j].EndDate) }) return games, nil } // indexOfID returns the index of the game with the given ID, or -1. func indexOfID(games []game.Game, id string) int { for i, g := range games { if g.ID == id { return i } } return -1 }