// 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" ) // 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, } } // 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"` DiscountPercentage int `json:"discountPercentage"` } `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 { DiscountPrice int `json:"discountPrice"` // current price in cents; 0 = free OriginalPrice int `json:"originalPrice"` // list price in cents FmtPrice struct { OriginalPrice string `json:"originalPrice"` DiscountPrice string `json:"discountPrice"` // formatted current price } `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 []element `json:"elements"` } `json:"searchStore"` } `json:"Catalog"` } `json:"data"` } // 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) } 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) } 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. When // freeOnly is true, only 100 % discounts pass; otherwise discounted deals are // included too. func buildGames(el element, kind string, windows []offer, keep func(offer) bool, freeOnly 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 { // Semantics verified against the live API (Oct 2026): the ONLY // reliable free indicator is el.price.totalPrice.discountPrice == 0 // (the element's current price). The offer's discountSetting fields // are unreliable: discountPrice is null (=0 in Go) even for // non-free offers, and discountPercentage carries inconsistent // values (e.g. Castlevania 40 while the store shows −60 %). // - freeOnly: element current price must be 0. // - deals: current price > 0; discount display from // fmtPrice (original → current). currentlyFree := el.Price.TotalPrice.DiscountPrice == 0 if freeOnly != currentlyFree { 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 !currentlyFree { g.Kind = "deal" g.FinalPriceFmt = el.Price.TotalPrice.FmtPrice.DiscountPrice if orig := el.Price.TotalPrice.FmtPrice.OriginalPrice; orig != "" { g.PriceFmt = orig } // Compute the discount percent from the prices (cents). if cur := el.Price.TotalPrice.DiscountPrice; cur > 0 && cur < el.Price.TotalPrice.OriginalPrice { orig := el.Price.TotalPrice.OriginalPrice g.DiscountPct = (orig - cur) * 100 / orig } } 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) { return c.getPromoted(ctx, true) } // GetDeals returns currently discounted games (any discount, including free), // sorted by end date (soonest first). func (c *Client) GetDeals(ctx context.Context) ([]game.Game, error) { return c.getPromoted(ctx, false) } func (c *Client) getPromoted(ctx context.Context, freeOnly bool) ([]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.PromotionalOffers { games = buildGames(el, "free-now", group.PromotionalOffers, func(o offer) bool { return !now.Before(o.StartDate) && !now.After(o.EndDate) }, freeOnly, 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) }, true, games) } } sort.Slice(games, func(i, j int) bool { return games[i].StartDate.Before(games[j].StartDate) }) 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 }