|
|
@@ -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]
|
|
|
+}
|