client.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. // Package gog implements a GOG.com free-games provider.
  2. //
  3. // GOG runs one giveaway at a time ("Free Game" / big-spot banner). There is no
  4. // stable public giveaways API; instead, the store homepage embeds its section
  5. // data as JSON in <script id="gogcom-store-state" type="application/json">.
  6. // The giveaway section is the object containing an `endDate` plus a `product`
  7. // with `title` and `slug`. We parse that — no browser, no login.
  8. package gog
  9. import (
  10. "context"
  11. "encoding/json"
  12. "fmt"
  13. "io"
  14. "net/http"
  15. "strings"
  16. "time"
  17. "gogs.netdome.biz/paul/cheapo/internal/game"
  18. )
  19. const (
  20. homeURL = "https://www.gog.com/en"
  21. storeState = "gogcom-store-state"
  22. userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
  23. )
  24. // Client queries the public GOG storefront.
  25. type Client struct {
  26. HTTP *http.Client
  27. HomeURL string // override for tests; defaults to homeURL
  28. }
  29. // NewClient returns a GOG client.
  30. func NewClient() *Client {
  31. return &Client{HTTP: &http.Client{Timeout: 30 * time.Second}}
  32. }
  33. // Name implements game.Provider.
  34. func (c *Client) Name() string { return "gog" }
  35. type giveawayProduct struct {
  36. ID string `json:"id"`
  37. Title string `json:"title"`
  38. Slug string `json:"slug"`
  39. ProductType string `json:"productType"`
  40. CoverHorizontal string `json:"coverHorizontal"`
  41. }
  42. type giveawaySection struct {
  43. EndDate time.Time `json:"endDate"`
  44. StartDate time.Time `json:"startDate"`
  45. Product *giveawayProduct `json:"product"`
  46. }
  47. // GetFreeGames returns the current GOG giveaway as a one-element list
  48. // (empty if there is no active giveaway).
  49. func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
  50. sec, err := c.fetchGiveaway(ctx)
  51. if err != nil {
  52. return nil, err
  53. }
  54. if sec == nil || sec.Product == nil || sec.Product.Slug == "" {
  55. return nil, nil
  56. }
  57. p := sec.Product
  58. g := game.Game{
  59. Title: p.Title,
  60. ID: "gog-" + p.ID,
  61. Slug: p.Slug,
  62. URL: "https://www.gog.com/en/game/" + p.Slug,
  63. Store: "gog",
  64. Kind: "free-now",
  65. EndDate: sec.EndDate,
  66. }
  67. if sec.StartDate.After(time.Now()) {
  68. g.Kind = "upcoming"
  69. }
  70. return []game.Game{g}, nil
  71. }
  72. // fetchGiveaway loads the store homepage and extracts the giveaway section
  73. // from the embedded gogcom-store-state JSON. Returns nil if none is present.
  74. func (c *Client) fetchGiveaway(ctx context.Context) (*giveawaySection, error) {
  75. home := c.HomeURL
  76. if home == "" {
  77. home = homeURL
  78. }
  79. req, err := http.NewRequestWithContext(ctx, http.MethodGet, home, nil)
  80. if err != nil {
  81. return nil, fmt.Errorf("gog: %w", err)
  82. }
  83. req.Header.Set("User-Agent", userAgent)
  84. req.Header.Set("Accept", "text/html")
  85. resp, err := c.HTTP.Do(req)
  86. if err != nil {
  87. return nil, fmt.Errorf("gog: request failed: %w", err)
  88. }
  89. defer resp.Body.Close()
  90. if resp.StatusCode != http.StatusOK {
  91. return nil, fmt.Errorf("gog: unexpected status %s", resp.Status)
  92. }
  93. body, err := io.ReadAll(resp.Body)
  94. if err != nil {
  95. return nil, fmt.Errorf("gog: read response: %w", err)
  96. }
  97. state := extractStoreState(string(body))
  98. if state == "" {
  99. return nil, fmt.Errorf("gog: store state script not found on homepage")
  100. }
  101. return parseGiveawaySection([]byte(state))
  102. }
  103. // parseGiveawaySection finds the giveaway section in the embedded store-state
  104. // JSON. The section is identified by STRUCTURE (an object with `endDate` and
  105. // a `product` carrying slug/title), not by a fixed key/UUID — this survives
  106. // homepage layout changes. When several candidates exist, the one with the
  107. // latest endDate wins. Returns nil when no giveaway is present.
  108. func parseGiveawaySection(state []byte) (*giveawaySection, error) {
  109. var doc map[string]any
  110. if err := json.Unmarshal(state, &doc); err != nil {
  111. return nil, fmt.Errorf("gog: decode store state: %w", err)
  112. }
  113. var best *giveawaySection
  114. var hunt func(v any)
  115. hunt = func(v any) {
  116. switch t := v.(type) {
  117. case map[string]any:
  118. _, hasEnd := t["endDate"]
  119. prod, hasProd := t["product"].(map[string]any)
  120. if hasEnd && hasProd && prod["slug"] != nil && prod["title"] != nil {
  121. raw, _ := json.Marshal(t)
  122. var sec giveawaySection
  123. if json.Unmarshal(raw, &sec) == nil {
  124. if best == nil || sec.EndDate.After(best.EndDate) {
  125. secCopy := sec
  126. best = &secCopy
  127. }
  128. }
  129. }
  130. for _, v2 := range t {
  131. hunt(v2)
  132. }
  133. case []any:
  134. for _, v2 := range t {
  135. hunt(v2)
  136. }
  137. }
  138. }
  139. hunt(doc)
  140. return best, nil
  141. }
  142. // extractStoreState returns the content of the gogcom-store-state JSON script.
  143. func extractStoreState(html string) string {
  144. open := `<script id="` + storeState + `" type="application/json">`
  145. i := strings.Index(html, open)
  146. if i < 0 {
  147. return ""
  148. }
  149. rest := html[i+len(open):]
  150. j := strings.Index(rest, "</script>")
  151. if j < 0 {
  152. return ""
  153. }
  154. return rest[:j]
  155. }