|
|
@@ -0,0 +1,309 @@
|
|
|
+// 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"
|
|
|
+ "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"
|
|
|
+ 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+`)
|
|
|
+)
|
|
|
+
|
|
|
+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()
|
|
|
+
|
|
|
+ doc, err := html.Parse(resp.Body)
|
|
|
+ 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 := parseResultRow(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 ")),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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 ""
|
|
|
+}
|