client.go 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  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. }
  28. // NewClient returns a GOG client.
  29. func NewClient() *Client {
  30. return &Client{HTTP: &http.Client{Timeout: 30 * time.Second}}
  31. }
  32. // Name implements game.Provider.
  33. func (c *Client) Name() string { return "gog" }
  34. type giveawayProduct struct {
  35. ID string `json:"id"`
  36. Title string `json:"title"`
  37. Slug string `json:"slug"`
  38. ProductType string `json:"productType"`
  39. CoverHorizontal string `json:"coverHorizontal"`
  40. }
  41. type giveawaySection struct {
  42. EndDate time.Time `json:"endDate"`
  43. StartDate time.Time `json:"startDate"`
  44. Product *giveawayProduct `json:"product"`
  45. }
  46. // GetFreeGames returns the current GOG giveaway as a one-element list
  47. // (empty if there is no active giveaway).
  48. func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
  49. sec, err := c.fetchGiveaway(ctx)
  50. if err != nil {
  51. return nil, err
  52. }
  53. if sec == nil || sec.Product == nil || sec.Product.Slug == "" {
  54. return nil, nil
  55. }
  56. p := sec.Product
  57. g := game.Game{
  58. Title: p.Title,
  59. ID: "gog-" + p.ID,
  60. Slug: p.Slug,
  61. URL: "https://www.gog.com/en/game/" + p.Slug,
  62. Store: "gog",
  63. Kind: "free-now",
  64. EndDate: sec.EndDate,
  65. }
  66. if sec.StartDate.After(time.Now()) {
  67. g.Kind = "upcoming"
  68. }
  69. return []game.Game{g}, nil
  70. }
  71. // fetchGiveaway loads the store homepage and extracts the giveaway section
  72. // from the embedded gogcom-store-state JSON. Returns nil if none is present.
  73. func (c *Client) fetchGiveaway(ctx context.Context) (*giveawaySection, error) {
  74. req, err := http.NewRequestWithContext(ctx, http.MethodGet, homeURL, nil)
  75. if err != nil {
  76. return nil, fmt.Errorf("gog: %w", err)
  77. }
  78. req.Header.Set("User-Agent", userAgent)
  79. req.Header.Set("Accept", "text/html")
  80. resp, err := c.HTTP.Do(req)
  81. if err != nil {
  82. return nil, fmt.Errorf("gog: request failed: %w", err)
  83. }
  84. defer resp.Body.Close()
  85. if resp.StatusCode != http.StatusOK {
  86. return nil, fmt.Errorf("gog: unexpected status %s", resp.Status)
  87. }
  88. body, err := io.ReadAll(resp.Body)
  89. if err != nil {
  90. return nil, fmt.Errorf("gog: read response: %w", err)
  91. }
  92. html := string(body)
  93. state := extractStoreState(html)
  94. if state == "" {
  95. return nil, fmt.Errorf("gog: store state script not found on homepage")
  96. }
  97. var doc map[string]any
  98. if err := json.Unmarshal([]byte(state), &doc); err != nil {
  99. return nil, fmt.Errorf("gog: decode store state: %w", err)
  100. }
  101. var found *giveawaySection
  102. var hunt func(v any)
  103. hunt = func(v any) {
  104. if found != nil {
  105. return
  106. }
  107. switch t := v.(type) {
  108. case map[string]any:
  109. _, hasEnd := t["endDate"]
  110. prod, hasProd := t["product"].(map[string]any)
  111. if hasEnd && hasProd && prod["slug"] != nil && prod["title"] != nil {
  112. raw, _ := json.Marshal(t)
  113. var sec giveawaySection
  114. if json.Unmarshal(raw, &sec) == nil {
  115. found = &sec
  116. return
  117. }
  118. }
  119. for _, v2 := range t {
  120. hunt(v2)
  121. }
  122. case []any:
  123. for _, v2 := range t {
  124. hunt(v2)
  125. }
  126. }
  127. }
  128. hunt(doc)
  129. return found, nil
  130. }
  131. // extractStoreState returns the content of the gogcom-store-state JSON script.
  132. func extractStoreState(html string) string {
  133. open := `<script id="` + storeState + `" type="application/json">`
  134. i := strings.Index(html, open)
  135. if i < 0 {
  136. return ""
  137. }
  138. rest := html[i+len(open):]
  139. j := strings.Index(rest, "</script>")
  140. if j < 0 {
  141. return ""
  142. }
  143. return rest[:j]
  144. }