Quellcode durchsuchen

Add Steam provider (free-to-keep scrape + free-weekend API), provider interface, --store flag

Paul Klumpp vor 3 Tagen
Ursprung
Commit
35663aa3b3
9 geänderte Dateien mit 495 neuen und 87 gelöschten Zeilen
  1. 5 2
      AGENTS.md
  2. 19 15
      README.md
  3. 86 24
      cmd/cheapo/main.go
  4. 1 0
      go.mod
  5. 2 0
      go.sum
  6. 10 18
      internal/epic/client.go
  7. 31 0
      internal/game/game.go
  8. 309 0
      internal/steam/client.go
  9. 32 28
      internal/watch/watch.go

+ 5 - 2
AGENTS.md

@@ -80,5 +80,8 @@ How it gets the info:
 - Wrap errors with context: `fmt.Errorf("epic: ...: %w", err)`.
 - Wrap errors with context: `fmt.Errorf("epic: ...: %w", err)`.
 - User-facing strings in German (CLI output, help texts, error messages),
 - User-facing strings in German (CLI output, help texts, error messages),
   code comments in English.
   code comments in English.
-- New store platforms (Prime, GOG, …) follow the `epic` package pattern
-  (client + `GetFreeGames(ctx)`), so `watch` can stay generic.
+- **Provider pattern:** every store lives in `internal/<store>/` and implements
+  `game.Provider` (`Name()` + `GetFreeGames(ctx) ([]game.Game, error)`); the
+  shared type is `game.Game` (`internal/game`). New stores (e.g. GOG)
+  follow this pattern and are registered in `newProviders()` (`cmd/cheapo/main.go`).
+- New store platforms (Prime, GOG, …) follow the provider pattern (above).

+ 19 - 15
README.md

@@ -2,15 +2,15 @@
 
 
 A CLI that finds all those free games from all those game stores. And, if you want, posts them to a Chatto channel.
 A CLI that finds all those free games from all those game stores. And, if you want, posts them to a Chatto channel.
 
 
-Free games CLI in Go — lists current free game offers (currently: Epic Games
-Store) and can use `watch` to wait for **new** free games.
+Free games CLI in Go — lists current free game offers (Epic Games Store, Steam)
+and can use `watch` to wait for **new** free games.
 
 
 Inspired by / reference: [free-games-claimer](https://github.com/DoSpamu/free-games-claimer)
 Inspired by / reference: [free-games-claimer](https://github.com/DoSpamu/free-games-claimer)
-(a fork of [vogler/free-games-claimer](https://github.com/vogler/free-games-claimer)).
-Unlike the reference project, we need **no browser and no login**:
-we read the public storefront backend endpoint directly
-(`freeGamesPromotions`, see `AGENTS.md`). Claiming the games is not part
-of this tool — it only detects and reports them.
+(a fork of [vogler/free-games-claimer](https://github.com/vogler/free-games-claimer))
+and [free-games-notifier](https://github.com/jaixmario/free-games-notifier).
+Unlike the reference projects, we need **no browser and no login**:
+we read the public store endpoints directly (details: `AGENTS.md`).
+Claiming the games is not part of this tool — it only detects and reports them.
 
 
 ## Install / build
 ## Install / build
 
 
@@ -64,21 +64,25 @@ so they can be used e.g. by Hermes or other MCP clients. Planned:
   wraps it.
   wraps it.
 - **Not implemented yet** — deliberately a second step after the CLI stabilizes.
 - **Not implemented yet** — deliberately a second step after the CLI stabilizes.
 
 
-### More platforms (idea, from free-games-notifier)
+### More platforms
 
 
-- **Epic "upcoming"**: The Epic endpoint also returns
-  `upcomingPromotionalOffers` — a preview of future free promotions (see
-  `AGENTS.md`). Could be added as `cheapo list --upcoming`.
-- **Steam**: Free-to-keep via HTML scrape of
+- **Steam** ✅ done: `cheapo list --store steam` or `--store all`.
+  Free-to-keep via HTML scrape of
   `store.steampowered.com/search/?maxprice=free&specials=1`, free weekend via
   `store.steampowered.com/search/?maxprice=free&specials=1`, free weekend via
   `store.steampowered.com/api/featuredcategories/` (details: `AGENTS.md`).
   `store.steampowered.com/api/featuredcategories/` (details: `AGENTS.md`).
+  With `--store` you choose `epic`, `steam` or `all` (default).
+- **Epic "upcoming"** (idea, not implemented yet): The Epic endpoint also
+  returns `upcomingPromotionalOffers` — a preview of future free promotions
+  (see `AGENTS.md`). Could be added as `cheapo list --upcoming`.
 
 
 ## Architecture
 ## Architecture
 
 
 ```
 ```
-cmd/cheapo/       Cobra CLI (list, watch)
-internal/epic/    Client for the Epic storefront endpoint (freeGamesPromotions)
-internal/watch/   Polling/eventing: reports only newly added games
+cmd/cheapo/       Cobra CLI (list, watch; --store epic|steam|all)
+internal/game/    Provider-agnostic game type + provider interface
+internal/epic/    Epic client (freeGamesPromotions, public JSON endpoint)
+internal/steam/   Steam client (search scrape + featuredcategories API)
+internal/watch/   Polling/eventing across all providers: reports only new games
 ```
 ```
 
 
 ## Reference: how the original project works
 ## Reference: how the original project works

+ 86 - 24
cmd/cheapo/main.go

@@ -1,10 +1,12 @@
-// Command cheapo lists and watches free game offers (currently: Epic Games Store).
+// Command cheapo lists and watches free game offers (Epic Games Store, Steam).
 //
 //
-// Data is read from the public storefront backend, no browser and no login
-// needed. Reference implementation: vogler/free-games-claimer.
+// Data is read from public storefront backends, no browser and no login
+// needed. Reference implementations: vogler/free-games-claimer,
+// jaixmario/free-games-notifier.
 package main
 package main
 
 
 import (
 import (
+	"context"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"os"
 	"os"
@@ -15,6 +17,8 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 
 
 	"gogs.netdome.biz/paul/cheapo/internal/epic"
 	"gogs.netdome.biz/paul/cheapo/internal/epic"
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+	"gogs.netdome.biz/paul/cheapo/internal/steam"
 	"gogs.netdome.biz/paul/cheapo/internal/watch"
 	"gogs.netdome.biz/paul/cheapo/internal/watch"
 )
 )
 
 
@@ -22,22 +26,25 @@ var (
 	flagCountry string
 	flagCountry string
 	flagLocale  string
 	flagLocale  string
 	flagJSON    bool
 	flagJSON    bool
+	flagStore   string
 )
 )
 
 
 func main() {
 func main() {
 	root := &cobra.Command{
 	root := &cobra.Command{
 		Use:   "cheapo",
 		Use:   "cheapo",
 		Short: "cheapo — free games lister (Go edition)",
 		Short: "cheapo — free games lister (Go edition)",
-		Long: `cheapo lists and watches free game offers (currently: Epic Games Store).
+		Long: `cheapo lists and watches free game offers (Epic Games Store, Steam).
 
 
-Data is read from the public storefront backend, no browser and no login needed.
-Reference: https://github.com/vogler/free-games-claimer`,
+Data is read from public storefront backends, no browser and no login needed.
+References: https://github.com/vogler/free-games-claimer
+            https://github.com/jaixmario/free-games-notifier`,
 		SilenceUsage: true,
 		SilenceUsage: true,
 	}
 	}
 
 
-	root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region")
+	root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region (epic)")
 	root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "store locale (e.g. de, en-US)")
 	root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "store locale (e.g. de, en-US)")
 	root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
 	root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
+	root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, all")
 
 
 	root.AddCommand(listCmd(), watchCmd())
 	root.AddCommand(listCmd(), watchCmd())
 
 
@@ -46,14 +53,51 @@ Reference: https://github.com/vogler/free-games-claimer`,
 	}
 	}
 }
 }
 
 
-func newClient() *epic.Client {
+// newProviders returns the providers selected by --store.
+func newProviders() ([]game.Provider, error) {
+	var provs []game.Provider
+	switch flagStore {
+	case "epic":
+		provs = []game.Provider{newEpic()}
+	case "steam":
+		provs = []game.Provider{steam.NewClient()}
+	case "all":
+		provs = []game.Provider{newEpic(), steam.NewClient()}
+	default:
+		return nil, fmt.Errorf("unknown store %q (epic, steam, all)", flagStore)
+	}
+	return provs, nil
+}
+
+func newEpic() *epic.Client {
 	c := epic.NewClient()
 	c := epic.NewClient()
 	c.Country = flagCountry
 	c.Country = flagCountry
 	c.Locale = flagLocale
 	c.Locale = flagLocale
 	return c
 	return c
 }
 }
 
 
-func printGames(cmd *cobra.Command, games []epic.Game) error {
+// fetchAll queries all selected providers; a failing provider is reported on
+// stderr but does not abort the others.
+func fetchAll(ctx context.Context) ([]game.Game, error) {
+	provs, err := newProviders()
+	if err != nil {
+		return nil, err
+	}
+	var all []game.Game
+	var hadErr error
+	for _, p := range provs {
+		games, err := p.GetFreeGames(ctx)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
+			hadErr = err
+			continue
+		}
+		all = append(all, games...)
+	}
+	return all, hadErr
+}
+
+func printGames(cmd *cobra.Command, games []game.Game) error {
 	if flagJSON {
 	if flagJSON {
 		enc := json.NewEncoder(os.Stdout)
 		enc := json.NewEncoder(os.Stdout)
 		enc.SetIndent("", "  ")
 		enc.SetIndent("", "  ")
@@ -65,14 +109,19 @@ func printGames(cmd *cobra.Command, games []epic.Game) error {
 	}
 	}
 	out := cmd.OutOrStdout()
 	out := cmd.OutOrStdout()
 	for _, g := range games {
 	for _, g := range games {
-		fmt.Fprintf(out, "🎮 %s\n", g.Title)
+		fmt.Fprintf(out, "🎮 [%s] %s\n", g.Store, g.Title)
+		if g.Kind != "" && g.Kind != "free-now" {
+			fmt.Fprintf(out, "   Kind: %s\n", g.Kind)
+		}
 		if g.Seller != "" {
 		if g.Seller != "" {
 			fmt.Fprintf(out, "   By: %s\n", g.Seller)
 			fmt.Fprintf(out, "   By: %s\n", g.Seller)
 		}
 		}
-		if g.PriceFmt != "" {
+		if g.PriceFmt != "" && g.Store != "steam" {
 			fmt.Fprintf(out, "   Instead of: %s\n", g.PriceFmt)
 			fmt.Fprintf(out, "   Instead of: %s\n", g.PriceFmt)
 		}
 		}
-		fmt.Fprintf(out, "   Free until: %s\n", g.EndDate.Local().Format("02.01.2006 15:04 (MST)"))
+		if !g.EndDate.IsZero() {
+			fmt.Fprintf(out, "   Free until: %s\n", g.EndDate.Local().Format("02.01.2006 15:04 (MST)"))
+		}
 		fmt.Fprintf(out, "   %s\n", g.URL)
 		fmt.Fprintf(out, "   %s\n", g.URL)
 		fmt.Fprintln(out)
 		fmt.Fprintln(out)
 	}
 	}
@@ -85,8 +134,8 @@ func listCmd() *cobra.Command {
 		Aliases: []string{"ls"},
 		Aliases: []string{"ls"},
 		Short:   "List games currently free",
 		Short:   "List games currently free",
 		RunE: func(cmd *cobra.Command, args []string) error {
 		RunE: func(cmd *cobra.Command, args []string) error {
-			games, err := newClient().GetFreeGames(cmd.Context())
-			if err != nil {
+			games, err := fetchAll(cmd.Context())
+			if err != nil && len(games) == 0 {
 				return err
 				return err
 			}
 			}
 			return printGames(cmd, games)
 			return printGames(cmd, games)
@@ -102,28 +151,41 @@ func watchCmd() *cobra.Command {
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
 		Use:   "watch",
 		Use:   "watch",
 		Short: "Wait for new free games (polling)",
 		Short: "Wait for new free games (polling)",
-		Long: `Polls the store and reports every game that becomes newly free.
+		Long: `Polls all selected stores and reports every game that becomes newly free.
 The first pass records the current state as the baseline (no reports).
 The first pass records the current state as the baseline (no reports).
+A store that is currently unreachable does not abort watching.
 Exit with Ctrl+C.`,
 Exit with Ctrl+C.`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
 			ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
 			defer stop()
 			defer stop()
 
 
-			c := newClient()
+			provs, err := newProviders()
+			if err != nil {
+				return err
+			}
+
 			if once {
 			if once {
-				games, err := c.GetFreeGames(ctx)
-				if err != nil {
-					return err
+				var all []game.Game
+				for _, p := range provs {
+					games, err := p.GetFreeGames(ctx)
+					if err != nil {
+						fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
+						continue
+					}
+					all = append(all, games...)
 				}
 				}
-				return printGames(cmd, games)
+				return printGames(cmd, all)
 			}
 			}
 
 
-			w := watch.NewWatcher(c, interval)
-			return w.Run(ctx, false, watch.NotifierFunc(func(games []epic.Game) {
+			w := watch.NewWatcher(interval, provs...)
+			return w.Run(ctx, false, watch.NotifierFunc(func(games []game.Game) {
 				fmt.Fprintf(os.Stderr, "\n🔔 New free games (%s):\n", time.Now().Format("15:04:05"))
 				fmt.Fprintf(os.Stderr, "\n🔔 New free games (%s):\n", time.Now().Format("15:04:05"))
 				for _, g := range games {
 				for _, g := range games {
-					fmt.Fprintf(os.Stderr, "  🎮 %s — until %s — %s\n",
-						g.Title, g.EndDate.Local().Format("02.01."), g.URL)
+					end := "—"
+					if !g.EndDate.IsZero() {
+						end = g.EndDate.Local().Format("02.01.")
+					}
+					fmt.Fprintf(os.Stderr, "  🎮 [%s] %s — until %s — %s\n", g.Store, g.Title, end, g.URL)
 				}
 				}
 			}))
 			}))
 		},
 		},

+ 1 - 0
go.mod

@@ -6,4 +6,5 @@ require (
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/spf13/cobra v1.10.2 // indirect
 	github.com/spf13/cobra v1.10.2 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
+	golang.org/x/net v0.62.0 // indirect
 )
 )

+ 2 - 0
go.sum

@@ -7,4 +7,6 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
+golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 10 - 18
internal/epic/client.go

@@ -15,6 +15,8 @@ import (
 	"net/url"
 	"net/url"
 	"sort"
 	"sort"
 	"time"
 	"time"
+
+	"gogs.netdome.biz/paul/cheapo/internal/game"
 )
 )
 
 
 const (
 const (
@@ -28,19 +30,8 @@ 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"
 )
 )
 
 
-// Game describes one free game offer.
-type Game struct {
-	Title       string    `json:"title"`
-	ID          string    `json:"id"`
-	Slug        string    `json:"slug"` // store page slug, e.g. "monument-valley-02"
-	URL         string    `json:"url"`  // full store page URL
-	Store       string    `json:"store"`
-	Description string    `json:"description,omitempty"`
-	Seller      string    `json:"seller,omitempty"`
-	StartDate   time.Time `json:"start_date"`
-	EndDate     time.Time `json:"end_date"`                  // end of the free period
-	PriceFmt    string    `json:"price_formatted,omitempty"` // original price, formatted
-}
+// 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 {
@@ -103,7 +94,7 @@ type promotionsResponse struct {
 
 
 // GetFreeGames returns the games that are currently free (100 % discount,
 // GetFreeGames returns the games that are currently free (100 % discount,
 // active promotion window), sorted by end date (soonest first).
 // active promotion window), sorted by end date (soonest first).
-func (c *Client) GetFreeGames(ctx context.Context) ([]Game, error) {
+func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, 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)
@@ -137,7 +128,7 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]Game, error) {
 	}
 	}
 
 
 	now := time.Now()
 	now := time.Now()
-	var games []Game
+	var games []game.Game
 	for _, el := range pr.Data.Catalog.SearchStore.Elements {
 	for _, el := range pr.Data.Catalog.SearchStore.Elements {
 		if el.Promotions == nil {
 		if el.Promotions == nil {
 			continue
 			continue
@@ -158,12 +149,13 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]Game, error) {
 					// catalog mapping's page slug.
 					// catalog mapping's page slug.
 					slug = el.CatalogNs.Mappings[0].PageSlug
 					slug = el.CatalogNs.Mappings[0].PageSlug
 				}
 				}
-				g := Game{
+				g := game.Game{
 					Title:       el.Title,
 					Title:       el.Title,
-					ID:          el.ID,
+					ID:          "epic-" + el.ID,
 					Slug:        slug,
 					Slug:        slug,
 					URL:         "https://store.epicgames.com/en-US/p/" + slug,
 					URL:         "https://store.epicgames.com/en-US/p/" + slug,
 					Store:       "epic",
 					Store:       "epic",
+					Kind:        "free-now",
 					Description: el.Description,
 					Description: el.Description,
 					Seller:      el.Seller.Name,
 					Seller:      el.Seller.Name,
 					StartDate:   offer.StartDate,
 					StartDate:   offer.StartDate,
@@ -190,7 +182,7 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]Game, error) {
 }
 }
 
 
 // indexOfID returns the index of the game with the given ID, or -1.
 // indexOfID returns the index of the game with the given ID, or -1.
-func indexOfID(games []Game, id string) int {
+func indexOfID(games []game.Game, id string) int {
 	for i, g := range games {
 	for i, g := range games {
 		if g.ID == id {
 		if g.ID == id {
 			return i
 			return i

+ 31 - 0
internal/game/game.go

@@ -0,0 +1,31 @@
+// Package game defines the provider-agnostic free-game offer type shared by
+// all store clients (epic, steam, ...).
+package game
+
+import (
+	"context"
+	"time"
+)
+
+// Game describes one free game offer, independent of the store it came from.
+type Game struct {
+	Title       string    `json:"title"`
+	ID          string    `json:"id"`   // store-stable ID (epic offer id / steam appid)
+	Slug        string    `json:"slug"` // store page slug or empty
+	URL         string    `json:"url"`  // full store page URL
+	Store       string    `json:"store"`
+	Kind        string    `json:"kind,omitempty"` // e.g. "free-now", "upcoming", "free-to-keep", "free-weekend"
+	Description string    `json:"description,omitempty"`
+	Seller      string    `json:"seller,omitempty"`
+	StartDate   time.Time `json:"start_date,omitempty"`
+	EndDate     time.Time `json:"end_date,omitempty"`        // end of the free period, zero = unknown
+	PriceFmt    string    `json:"price_formatted,omitempty"` // original price, formatted
+}
+
+// Provider is implemented by every store client.
+type Provider interface {
+	// Name returns the store identifier, e.g. "epic" or "steam".
+	Name() string
+	// GetFreeGames returns the games that are currently free.
+	GetFreeGames(ctx context.Context) ([]Game, error)
+}

+ 309 - 0
internal/steam/client.go

@@ -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, &sections); 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 ""
+}

+ 32 - 28
internal/watch/watch.go

@@ -6,64 +6,68 @@ import (
 	"log"
 	"log"
 	"time"
 	"time"
 
 
-	"gogs.netdome.biz/paul/cheapo/internal/epic"
+	"gogs.netdome.biz/paul/cheapo/internal/game"
 )
 )
 
 
 // Notifier is called for every game that newly became free.
 // Notifier is called for every game that newly became free.
 type Notifier interface {
 type Notifier interface {
-	Notify(games []epic.Game)
+	Notify(games []game.Game)
 }
 }
 
 
 // NotifierFunc adapts a function to Notifier.
 // NotifierFunc adapts a function to Notifier.
-type NotifierFunc func(games []epic.Game)
+type NotifierFunc func(games []game.Game)
 
 
-func (f NotifierFunc) Notify(games []epic.Game) { f(games) }
+func (f NotifierFunc) Notify(games []game.Game) { f(games) }
 
 
-// Watcher polls the client and reports games that were not seen before.
+// Watcher polls providers and reports games that were not seen before.
 type Watcher struct {
 type Watcher struct {
-	Client   *epic.Client
-	Interval time.Duration
+	Providers []game.Provider
+	Interval  time.Duration
 
 
 	seen map[string]bool
 	seen map[string]bool
 }
 }
 
 
 // NewWatcher creates a watcher. Interval defaults to 15m.
 // NewWatcher creates a watcher. Interval defaults to 15m.
-func NewWatcher(c *epic.Client, interval time.Duration) *Watcher {
+func NewWatcher(interval time.Duration, providers ...game.Provider) *Watcher {
 	if interval <= 0 {
 	if interval <= 0 {
 		interval = 15 * time.Minute
 		interval = 15 * time.Minute
 	}
 	}
-	return &Watcher{Client: c, Interval: interval, seen: map[string]bool{}}
+	return &Watcher{Providers: providers, Interval: interval, seen: map[string]bool{}}
 }
 }
 
 
-// Run polls until ctx is done. On the first pass it reports all current free
-// games as "new" unless baselineOnly is true (useful to seed the seen-set
-// without notifying).
+// Run polls all providers until ctx is done. On the first pass it reports all
+// current free games as "new" unless baselineOnly is true (useful to seed the
+// seen-set without notifying).
 func (w *Watcher) Run(ctx context.Context, baselineOnly bool, n Notifier) error {
 func (w *Watcher) Run(ctx context.Context, baselineOnly bool, n Notifier) error {
 	t := time.NewTicker(w.Interval)
 	t := time.NewTicker(w.Interval)
 	defer t.Stop()
 	defer t.Stop()
 
 
 	first := true
 	first := true
 	for {
 	for {
-		games, err := w.Client.GetFreeGames(ctx)
-		if err != nil {
-			return err
-		}
-
-		var fresh []epic.Game
-		for _, g := range games {
-			if !w.seen[g.ID] {
-				fresh = append(fresh, g)
+		var total int
+		for _, p := range w.Providers {
+			games, err := p.GetFreeGames(ctx)
+			if err != nil {
+				// One flaky provider shall not kill the watcher.
+				log.Printf("watcher: %s: %v", p.Name(), err)
+				continue
 			}
 			}
-		}
-		for _, g := range games {
-			w.seen[g.ID] = true
-		}
+			total += len(games)
 
 
-		if len(fresh) > 0 && !(first && baselineOnly) {
-			n.Notify(fresh)
+			var fresh []game.Game
+			for _, g := range games {
+				if !w.seen[g.ID] {
+					fresh = append(fresh, g)
+				}
+				w.seen[g.ID] = true
+			}
+			if len(fresh) > 0 && !(first && baselineOnly) {
+				n.Notify(fresh)
+			}
 		}
 		}
 		if first {
 		if first {
-			log.Printf("watcher: baseline set with %d current free games, polling every %s", len(games), w.Interval)
+			log.Printf("watcher: baseline set with %d current free games across %d providers, polling every %s",
+				total, len(w.Providers), w.Interval)
 			first = false
 			first = false
 		}
 		}