| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160 |
- // 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]
- }
|