Ver Fonte

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

Paul Klumpp há 3 dias atrás
pai
commit
35663aa3b3
9 ficheiros alterados com 495 adições e 87 exclusões
  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)`.
 - User-facing strings in German (CLI output, help texts, error messages),
   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.
 
-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)
-(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
 
@@ -64,21 +64,25 @@ so they can be used e.g. by Hermes or other MCP clients. Planned:
   wraps it.
 - **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/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
 
 ```
-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

+ 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
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"os"
@@ -15,6 +17,8 @@ import (
 	"github.com/spf13/cobra"
 
 	"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"
 )
 
@@ -22,22 +26,25 @@ var (
 	flagCountry string
 	flagLocale  string
 	flagJSON    bool
+	flagStore   string
 )
 
 func main() {
 	root := &cobra.Command{
 		Use:   "cheapo",
 		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,
 	}
 
-	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().BoolVar(&flagJSON, "json", false, "output as JSON")
+	root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, all")
 
 	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.Country = flagCountry
 	c.Locale = flagLocale
 	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 {
 		enc := json.NewEncoder(os.Stdout)
 		enc.SetIndent("", "  ")
@@ -65,14 +109,19 @@ func printGames(cmd *cobra.Command, games []epic.Game) error {
 	}
 	out := cmd.OutOrStdout()
 	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 != "" {
 			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, "   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.Fprintln(out)
 	}
@@ -85,8 +134,8 @@ func listCmd() *cobra.Command {
 		Aliases: []string{"ls"},
 		Short:   "List games currently free",
 		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 printGames(cmd, games)
@@ -102,28 +151,41 @@ func watchCmd() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "watch",
 		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).
+A store that is currently unreachable does not abort watching.
 Exit with Ctrl+C.`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
 			defer stop()
 
-			c := newClient()
+			provs, err := newProviders()
+			if err != nil {
+				return err
+			}
+
 			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"))
 				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/spf13/cobra v1.10.2 // 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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 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=

+ 10 - 18
internal/epic/client.go

@@ -15,6 +15,8 @@ import (
 	"net/url"
 	"sort"
 	"time"
+
+	"gogs.netdome.biz/paul/cheapo/internal/game"
 )
 
 const (
@@ -28,19 +30,8 @@ const (
 	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.
 type Client struct {
@@ -103,7 +94,7 @@ type promotionsResponse struct {
 
 // 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, error) {
+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)
@@ -137,7 +128,7 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]Game, error) {
 	}
 
 	now := time.Now()
-	var games []Game
+	var games []game.Game
 	for _, el := range pr.Data.Catalog.SearchStore.Elements {
 		if el.Promotions == nil {
 			continue
@@ -158,12 +149,13 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]Game, error) {
 					// catalog mapping's page slug.
 					slug = el.CatalogNs.Mappings[0].PageSlug
 				}
-				g := Game{
+				g := game.Game{
 					Title:       el.Title,
-					ID:          el.ID,
+					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,
@@ -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.
-func indexOfID(games []Game, id string) int {
+func indexOfID(games []game.Game, id string) int {
 	for i, g := range games {
 		if g.ID == id {
 			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"
 	"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.
 type Notifier interface {
-	Notify(games []epic.Game)
+	Notify(games []game.Game)
 }
 
 // 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 {
-	Client   *epic.Client
-	Interval time.Duration
+	Providers []game.Provider
+	Interval  time.Duration
 
 	seen map[string]bool
 }
 
 // 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 {
 		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 {
 	t := time.NewTicker(w.Interval)
 	defer t.Stop()
 
 	first := true
 	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 {
-			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
 		}