// Package steam implements a Steam free-games provider. // // Two sources, both without login (see AGENTS.md, free-games-notifier): // - Free to Keep: HTML scrape of the search page // https://store.steampowered.com/search/?maxprice=free&specials=1 // - Free Weekend: JSON API https://store.steampowered.com/api/featuredcategories/ package steam import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "sort" "strconv" "strings" "time" "gogs.netdome.biz/paul/cheapo/internal/game" "golang.org/x/net/html" ) const ( searchURL = "https://store.steampowered.com/search/?maxprice=free&specials=1" dealsURL = "https://store.steampowered.com/search/?specials=1" featuredURL = "https://store.steampowered.com/api/featuredcategories/" appDetailsURL = "https://store.steampowered.com/api/appdetails" userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0" ) // Client queries the public Steam storefront. type Client struct { HTTP *http.Client } // NewClient returns a Steam client. func NewClient() *Client { return &Client{HTTP: &http.Client{Timeout: 30 * time.Second}} } // Name implements game.Provider. func (c *Client) Name() string { return "steam" } func (c *Client) get(ctx context.Context, u string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, fmt.Errorf("steam: %w", err) } req.Header.Set("User-Agent", userAgent) resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("steam: request failed: %w", err) } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("steam: unexpected status %s for %s", resp.Status, u) } return resp, nil } // GetFreeGames returns all current Steam free offers (free to keep + free weekend). func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) { keep, err := c.getFreeToKeep(ctx) if err != nil { return nil, err } weekend, err := c.getFreeWeekend(ctx) if err != nil { // The weekend API is flaky/nonessential; don't fail the whole query. weekend = nil } games := append(keep, weekend...) return games, nil } // --- Free to Keep (HTML search page) --- var ( reAppID = regexp.MustCompile(`/app/(\d+)`) rePriceStrip = regexp.MustCompile(`\s+`) reDiscountPct = regexp.MustCompile(`-(\d+)%`) ) func (c *Client) getFreeToKeep(ctx context.Context) ([]game.Game, error) { resp, err := c.get(ctx, searchURL) if err != nil { return nil, err } defer resp.Body.Close() return c.parseSearchRows(resp.Body, parseResultRow) } // GetDeals returns currently discounted Steam games (specials search page, // any discount), sorted by discount (deepest first). func (c *Client) GetDeals(ctx context.Context) ([]game.Game, error) { resp, err := c.get(ctx, dealsURL) if err != nil { return nil, err } defer resp.Body.Close() games, err := c.parseSearchRows(resp.Body, parseDealRow) if err != nil { return nil, err } sort.Slice(games, func(i, j int) bool { return games[i].DiscountPct > games[j].DiscountPct }) return games, nil } // parseSearchRows walks a Steam search results page and applies parse per // `a.search_result_row`, deduplicating by ID. func (c *Client) parseSearchRows(r io.Reader, parse func(*html.Node) *game.Game) ([]game.Game, error) { doc, err := html.Parse(r) if err != nil { return nil, fmt.Errorf("steam: parse search page: %w", err) } var games []game.Game seen := map[string]bool{} var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "a" { cls := classAttr(n) if strings.Contains(cls, "search_result_row") { if g := parse(n); g != nil && !seen[g.ID] { seen[g.ID] = true games = append(games, *g) } } } for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { walk(ch) } } walk(doc) return games, nil } func classAttr(n *html.Node) string { for _, a := range n.Attr { if a.Key == "class" { return a.Val } } return "" } // parseResultRow extracts one game from a `a.search_result_row` element. func parseResultRow(a *html.Node) *game.Game { href := "" for _, at := range a.Attr { if at.Key == "href" { href = at.Val } } m := reAppID.FindStringSubmatch(href) if m == nil { return nil } appID := m[1] var title, price, discount string var find func(*html.Node) find = func(n *html.Node) { if n.Type == html.ElementNode { cls := classAttr(n) switch { case strings.Contains(cls, "title") && title == "": title = textOf(n) case strings.Contains(cls, "discount_final_price") && price == "": price = textOf(n) case strings.Contains(cls, "discount_pct") && discount == "": discount = textOf(n) } } for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { find(ch) } } find(a) // Free filter: price text "Free" (localized variants start with "Free" or // are "0"), or a 100 % discount badge. price = rePriceStrip.ReplaceAllString(price, " ") free := strings.HasPrefix(price, "Free") || price == "0" || strings.Contains(discount, "-100%") || strings.Contains(discount, "100%") if !free { return nil } return &game.Game{ Title: strings.TrimSpace(title), ID: "steam-" + appID, Slug: appID, URL: "https://store.steampowered.com/app/" + appID + "/", Store: "steam", Kind: "free-to-keep", PriceFmt: strings.TrimSpace(strings.TrimPrefix(price, "Statt ")), } } // parseDealRow extracts one discounted game from a `a.search_result_row` on // the specials page (any discount, with original/final price and percent). func parseDealRow(a *html.Node) *game.Game { href := "" for _, at := range a.Attr { if at.Key == "href" { href = at.Val } } m := reAppID.FindStringSubmatch(href) if m == nil { return nil } appID := m[1] var title, orig, final, discount string var find func(*html.Node) find = func(n *html.Node) { if n.Type == html.ElementNode { cls := classAttr(n) switch { case strings.Contains(cls, "title") && title == "": title = textOf(n) case strings.Contains(cls, "discount_original_price") && orig == "": orig = textOf(n) case strings.Contains(cls, "discount_final_price") && final == "": final = textOf(n) case strings.Contains(cls, "discount_pct") && discount == "": discount = textOf(n) } } for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { find(ch) } } find(a) if strings.TrimSpace(final) == "" { return nil } pct := 0 if dm := reDiscountPct.FindStringSubmatch(discount); dm != nil { pct, _ = strconv.Atoi(dm[1]) } if pct <= 0 { return nil } return &game.Game{ Title: strings.TrimSpace(title), ID: "steam-" + appID, Slug: appID, URL: "https://store.steampowered.com/app/" + appID + "/", Store: "steam", Kind: "deal", PriceFmt: strings.TrimSpace(orig), FinalPriceFmt: strings.TrimSpace(final), DiscountPct: pct, } } func textOf(n *html.Node) string { var b strings.Builder var visit func(*html.Node) visit = func(x *html.Node) { if x.Type == html.TextNode { b.WriteString(x.Data) } for ch := x.FirstChild; ch != nil; ch = ch.NextSibling { visit(ch) } } visit(n) return b.String() } // --- Free Weekend (featured categories JSON API) --- type featuredItem struct { Name string `json:"name"` HeaderImage string `json:"header_image"` URL string `json:"url"` Body string `json:"body"` } type featuredSection struct { ID interface{} `json:"id"` Name string `json:"name"` Items []featuredItem `json:"items"` } func (c *Client) getFreeWeekend(ctx context.Context) ([]game.Game, error) { resp, err := c.get(ctx, featuredURL) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("steam: read featured response: %w", err) } var sections map[string]featuredSection if err := json.Unmarshal(body, §ions); err != nil { return nil, fmt.Errorf("steam: decode featured response: %w", err) } var games []game.Game seen := map[string]bool{} for _, sec := range sections { for _, it := range sec.Items { name := strings.ToLower(it.Name) bod := strings.ToLower(it.Body) if !strings.Contains(name, "free weekend") && !strings.Contains(bod, "free weekend") && !strings.Contains(bod, "play for free") { continue } title, appID := itemTitleAndAppID(ctx, c, it) if seen[appID+title] { continue } seen[appID+title] = true g := game.Game{ Title: title, Store: "steam", Kind: "free-weekend", URL: it.URL, Seller: "", PriceFmt: strings.TrimSpace(it.Body), } if appID != "" { g.ID = "steam-" + appID g.Slug = appID } else { g.ID = "steam-weekend-" + title } games = append(games, g) } } return games, nil } var reWeekendTitle = regexp.MustCompile(`(?i)play\s+(.+?)\s+for\s+free`) // itemTitleAndAppID resolves a meaningful title and the appid for a weekend item. func itemTitleAndAppID(ctx context.Context, c *Client, it featuredItem) (string, string) { appID := "" if m := reAppID.FindStringSubmatch(it.URL); m != nil { appID = m[1] } // Prefer a specific name over generic "Free Weekend" labels. generic := map[string]bool{"free weekend": true, "free to play": true, "": true} if !generic[strings.ToLower(it.Name)] { return it.Name, appID } if m := reWeekendTitle.FindStringSubmatch(it.Body); m != nil { return strings.TrimSpace(m[1]), appID } if appID != "" { if name := fetchAppName(ctx, c, appID); name != "" { return name, appID } } if it.Name != "" { return it.Name, appID } return "Unknown Free Weekend Game", appID } func fetchAppName(ctx context.Context, c *Client, appID string) string { u := fmt.Sprintf("%s?appids=%s&l=en", appDetailsURL, url.QueryEscape(appID)) resp, err := c.get(ctx, u) if err != nil { return "" } defer resp.Body.Close() var data map[string]struct { Data struct { Name string `json:"name"` } `json:"data"` } if json.NewDecoder(resp.Body).Decode(&data) != nil { return "" } if d, ok := data[appID]; ok { return d.Data.Name } return "" }