Sfoglia il codice sorgente

Add GOG provider (homepage store-state JSON), --upcoming for Epic, provider-based watch

Paul Klumpp 3 giorni fa
parent
commit
9610ca6d32
3 ha cambiato i file con 187 aggiunte e 4 eliminazioni
  1. 22 2
      README.md
  2. 5 2
      cmd/cheapo/main.go
  3. 160 0
      internal/gog/client.go

+ 22 - 2
README.md

@@ -41,6 +41,7 @@ cheapo watch --once
 
 # Other stores / upcoming free games (preview, Epic only)
 cheapo list --store steam
+cheapo list --store gog
 cheapo list --store all
 cheapo list --upcoming
 ```
@@ -75,17 +76,36 @@ so they can be used e.g. by Hermes or other MCP clients. Planned:
   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).
+  With `--store` you choose `epic`, `steam`, `gog` or `all` (default).
+- **GOG** ✅ done: `cheapo list --store gog`. GOG has no public giveaway
+  API; the provider reads the embedded section JSON of the homepage
+  (`<script id="gogcom-store-state">`, object with `endDate` + `product`).
+  Exactly one active giveaway; `kind: upcoming` if the start period lies
+  in the future.
 - **Epic "upcoming"** ✅ done: `cheapo list --upcoming` shows announced
   upcoming free promotions (Epic only; Steam ignores the flag).
 
+## More stores — feasibility (as of: Oct 2026)
+
+| Store | Feasible without browser/login? | Source |
+|-------|------------------------------|--------|
+| Epic | ✅ done | public JSON endpoint `freeGamesPromotions` |
+| Steam | ✅ done | search HTML scrape + `featuredcategories` JSON API |
+| GOG | ✅ done | embedded section JSON of the homepage (`gogcom-store-state`) |
+| Prime Gaming | ⚠️ hard | requires Amazon login (session cookies); reference: vogler (Playwright). No public API. |
+| AliExpress | ⚠️ hard | login + quirks (exit code != 0 without coins); only points bonuses, hardly any games |
+| itch.io | ✅ feasible | `https://itch.io/games/free` (HTML scrape); no login for detection |
+| Humble Bundle | ⚠️ medium | changing promo structure; no stable free-games JSON, scraping required |
+| Ubisoft Connect | ❌ | login + client required, no public API |
+
 ## Architecture
 
 ```
-cmd/cheapo/       Cobra CLI (list, watch; --store epic|steam|all)
+cmd/cheapo/       Cobra CLI (list, watch; --store epic|steam|gog|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/gog/     GOG client (homepage section JSON, gogcom-store-state)
 internal/watch/   Polling/eventing across all providers: reports only new games
 ```
 

+ 5 - 2
cmd/cheapo/main.go

@@ -18,6 +18,7 @@ import (
 
 	"gogs.netdome.biz/paul/cheapo/internal/epic"
 	"gogs.netdome.biz/paul/cheapo/internal/game"
+	"gogs.netdome.biz/paul/cheapo/internal/gog"
 	"gogs.netdome.biz/paul/cheapo/internal/steam"
 	"gogs.netdome.biz/paul/cheapo/internal/watch"
 )
@@ -61,10 +62,12 @@ func newProviders() ([]game.Provider, error) {
 		provs = []game.Provider{newEpic()}
 	case "steam":
 		provs = []game.Provider{steam.NewClient()}
+	case "gog":
+		provs = []game.Provider{gog.NewClient()}
 	case "all":
-		provs = []game.Provider{newEpic(), steam.NewClient()}
+		provs = []game.Provider{newEpic(), steam.NewClient(), gog.NewClient()}
 	default:
-		return nil, fmt.Errorf("unknown store %q (epic, steam, all)", flagStore)
+		return nil, fmt.Errorf("unknown store %q (epic, steam, gog, all)", flagStore)
 	}
 	return provs, nil
 }

+ 160 - 0
internal/gog/client.go

@@ -0,0 +1,160 @@
+// Package gog implements a GOG.com free-games provider.
+//
+// GOG runs one giveaway at a time ("Free Game" / big-spot banner). There is no
+// stable public giveaways API; instead, the store homepage embeds its section
+// data as JSON in <script id="gogcom-store-state" type="application/json">.
+// The giveaway section is the object containing an `endDate` plus a `product`
+// with `title` and `slug`. We parse that — no browser, no login.
+package gog
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+)
+
+const (
+	homeURL    = "https://www.gog.com/en"
+	storeState = "gogcom-store-state"
+	userAgent  = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
+)
+
+// Client queries the public GOG storefront.
+type Client struct {
+	HTTP *http.Client
+}
+
+// NewClient returns a GOG client.
+func NewClient() *Client {
+	return &Client{HTTP: &http.Client{Timeout: 30 * time.Second}}
+}
+
+// Name implements game.Provider.
+func (c *Client) Name() string { return "gog" }
+
+type giveawayProduct struct {
+	ID              string `json:"id"`
+	Title           string `json:"title"`
+	Slug            string `json:"slug"`
+	ProductType     string `json:"productType"`
+	CoverHorizontal string `json:"coverHorizontal"`
+}
+
+type giveawaySection struct {
+	EndDate   time.Time        `json:"endDate"`
+	StartDate time.Time        `json:"startDate"`
+	Product   *giveawayProduct `json:"product"`
+}
+
+// GetFreeGames returns the current GOG giveaway as a one-element list
+// (empty if there is no active giveaway).
+func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
+	sec, err := c.fetchGiveaway(ctx)
+	if err != nil {
+		return nil, err
+	}
+	if sec == nil || sec.Product == nil || sec.Product.Slug == "" {
+		return nil, nil
+	}
+
+	p := sec.Product
+	g := game.Game{
+		Title:   p.Title,
+		ID:      "gog-" + p.ID,
+		Slug:    p.Slug,
+		URL:     "https://www.gog.com/en/game/" + p.Slug,
+		Store:   "gog",
+		Kind:    "free-now",
+		EndDate: sec.EndDate,
+	}
+	if sec.StartDate.After(time.Now()) {
+		g.Kind = "upcoming"
+	}
+	return []game.Game{g}, nil
+}
+
+// fetchGiveaway loads the store homepage and extracts the giveaway section
+// from the embedded gogcom-store-state JSON. Returns nil if none is present.
+func (c *Client) fetchGiveaway(ctx context.Context) (*giveawaySection, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, homeURL, nil)
+	if err != nil {
+		return nil, fmt.Errorf("gog: %w", err)
+	}
+	req.Header.Set("User-Agent", userAgent)
+	req.Header.Set("Accept", "text/html")
+
+	resp, err := c.HTTP.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("gog: request failed: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("gog: unexpected status %s", resp.Status)
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("gog: read response: %w", err)
+	}
+	html := string(body)
+
+	state := extractStoreState(html)
+	if state == "" {
+		return nil, fmt.Errorf("gog: store state script not found on homepage")
+	}
+
+	var doc map[string]any
+	if err := json.Unmarshal([]byte(state), &doc); err != nil {
+		return nil, fmt.Errorf("gog: decode store state: %w", err)
+	}
+
+	var found *giveawaySection
+	var hunt func(v any)
+	hunt = func(v any) {
+		if found != nil {
+			return
+		}
+		switch t := v.(type) {
+		case map[string]any:
+			_, hasEnd := t["endDate"]
+			prod, hasProd := t["product"].(map[string]any)
+			if hasEnd && hasProd && prod["slug"] != nil && prod["title"] != nil {
+				raw, _ := json.Marshal(t)
+				var sec giveawaySection
+				if json.Unmarshal(raw, &sec) == nil {
+					found = &sec
+					return
+				}
+			}
+			for _, v2 := range t {
+				hunt(v2)
+			}
+		case []any:
+			for _, v2 := range t {
+				hunt(v2)
+			}
+		}
+	}
+	hunt(doc)
+	return found, nil
+}
+
+// extractStoreState returns the content of the gogcom-store-state JSON script.
+func extractStoreState(html string) string {
+	open := `<script id="` + storeState + `" type="application/json">`
+	i := strings.Index(html, open)
+	if i < 0 {
+		return ""
+	}
+	rest := html[i+len(open):]
+	j := strings.Index(rest, "</script>")
+	if j < 0 {
+		return ""
+	}
+	return rest[:j]
+}