client.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. // Package steam implements a Steam free-games provider.
  2. //
  3. // Two sources, both without login (see AGENTS.md, free-games-notifier):
  4. // - Free to Keep: HTML scrape of the search page
  5. // https://store.steampowered.com/search/?maxprice=free&specials=1
  6. // - Free Weekend: JSON API https://store.steampowered.com/api/featuredcategories/
  7. package steam
  8. import (
  9. "context"
  10. "encoding/json"
  11. "fmt"
  12. "io"
  13. "net/http"
  14. "net/url"
  15. "regexp"
  16. "strings"
  17. "time"
  18. "gogs.netdome.biz/paul/cheapo/internal/game"
  19. "golang.org/x/net/html"
  20. )
  21. const (
  22. searchURL = "https://store.steampowered.com/search/?maxprice=free&specials=1"
  23. featuredURL = "https://store.steampowered.com/api/featuredcategories/"
  24. appDetailsURL = "https://store.steampowered.com/api/appdetails"
  25. userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
  26. )
  27. // Client queries the public Steam storefront.
  28. type Client struct {
  29. HTTP *http.Client
  30. }
  31. // NewClient returns a Steam client.
  32. func NewClient() *Client {
  33. return &Client{HTTP: &http.Client{Timeout: 30 * time.Second}}
  34. }
  35. // Name implements game.Provider.
  36. func (c *Client) Name() string { return "steam" }
  37. func (c *Client) get(ctx context.Context, u string) (*http.Response, error) {
  38. req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
  39. if err != nil {
  40. return nil, fmt.Errorf("steam: %w", err)
  41. }
  42. req.Header.Set("User-Agent", userAgent)
  43. resp, err := c.HTTP.Do(req)
  44. if err != nil {
  45. return nil, fmt.Errorf("steam: request failed: %w", err)
  46. }
  47. if resp.StatusCode != http.StatusOK {
  48. resp.Body.Close()
  49. return nil, fmt.Errorf("steam: unexpected status %s for %s", resp.Status, u)
  50. }
  51. return resp, nil
  52. }
  53. // GetFreeGames returns all current Steam free offers (free to keep + free weekend).
  54. func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
  55. keep, err := c.getFreeToKeep(ctx)
  56. if err != nil {
  57. return nil, err
  58. }
  59. weekend, err := c.getFreeWeekend(ctx)
  60. if err != nil {
  61. // The weekend API is flaky/nonessential; don't fail the whole query.
  62. weekend = nil
  63. }
  64. games := append(keep, weekend...)
  65. return games, nil
  66. }
  67. // --- Free to Keep (HTML search page) ---
  68. var (
  69. reAppID = regexp.MustCompile(`/app/(\d+)`)
  70. rePriceStrip = regexp.MustCompile(`\s+`)
  71. )
  72. func (c *Client) getFreeToKeep(ctx context.Context) ([]game.Game, error) {
  73. resp, err := c.get(ctx, searchURL)
  74. if err != nil {
  75. return nil, err
  76. }
  77. defer resp.Body.Close()
  78. doc, err := html.Parse(resp.Body)
  79. if err != nil {
  80. return nil, fmt.Errorf("steam: parse search page: %w", err)
  81. }
  82. var games []game.Game
  83. seen := map[string]bool{}
  84. var walk func(*html.Node)
  85. walk = func(n *html.Node) {
  86. if n.Type == html.ElementNode && n.Data == "a" {
  87. cls := classAttr(n)
  88. if strings.Contains(cls, "search_result_row") {
  89. if g := parseResultRow(n); g != nil && !seen[g.ID] {
  90. seen[g.ID] = true
  91. games = append(games, *g)
  92. }
  93. }
  94. }
  95. for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
  96. walk(ch)
  97. }
  98. }
  99. walk(doc)
  100. return games, nil
  101. }
  102. func classAttr(n *html.Node) string {
  103. for _, a := range n.Attr {
  104. if a.Key == "class" {
  105. return a.Val
  106. }
  107. }
  108. return ""
  109. }
  110. // parseResultRow extracts one game from a `a.search_result_row` element.
  111. func parseResultRow(a *html.Node) *game.Game {
  112. href := ""
  113. for _, at := range a.Attr {
  114. if at.Key == "href" {
  115. href = at.Val
  116. }
  117. }
  118. m := reAppID.FindStringSubmatch(href)
  119. if m == nil {
  120. return nil
  121. }
  122. appID := m[1]
  123. var title, price, discount string
  124. var find func(*html.Node)
  125. find = func(n *html.Node) {
  126. if n.Type == html.ElementNode {
  127. cls := classAttr(n)
  128. switch {
  129. case strings.Contains(cls, "title") && title == "":
  130. title = textOf(n)
  131. case strings.Contains(cls, "discount_final_price") && price == "":
  132. price = textOf(n)
  133. case strings.Contains(cls, "discount_pct") && discount == "":
  134. discount = textOf(n)
  135. }
  136. }
  137. for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
  138. find(ch)
  139. }
  140. }
  141. find(a)
  142. // Free filter: price text "Free" (localized variants start with "Free" or
  143. // are "0"), or a 100 % discount badge.
  144. price = rePriceStrip.ReplaceAllString(price, " ")
  145. free := strings.HasPrefix(price, "Free") || price == "0" ||
  146. strings.Contains(discount, "-100%") || strings.Contains(discount, "100%")
  147. if !free {
  148. return nil
  149. }
  150. return &game.Game{
  151. Title: strings.TrimSpace(title),
  152. ID: "steam-" + appID,
  153. Slug: appID,
  154. URL: "https://store.steampowered.com/app/" + appID + "/",
  155. Store: "steam",
  156. Kind: "free-to-keep",
  157. PriceFmt: strings.TrimSpace(strings.TrimPrefix(price, "Statt ")),
  158. }
  159. }
  160. func textOf(n *html.Node) string {
  161. var b strings.Builder
  162. var visit func(*html.Node)
  163. visit = func(x *html.Node) {
  164. if x.Type == html.TextNode {
  165. b.WriteString(x.Data)
  166. }
  167. for ch := x.FirstChild; ch != nil; ch = ch.NextSibling {
  168. visit(ch)
  169. }
  170. }
  171. visit(n)
  172. return b.String()
  173. }
  174. // --- Free Weekend (featured categories JSON API) ---
  175. type featuredItem struct {
  176. Name string `json:"name"`
  177. HeaderImage string `json:"header_image"`
  178. URL string `json:"url"`
  179. Body string `json:"body"`
  180. }
  181. type featuredSection struct {
  182. ID interface{} `json:"id"`
  183. Name string `json:"name"`
  184. Items []featuredItem `json:"items"`
  185. }
  186. func (c *Client) getFreeWeekend(ctx context.Context) ([]game.Game, error) {
  187. resp, err := c.get(ctx, featuredURL)
  188. if err != nil {
  189. return nil, err
  190. }
  191. defer resp.Body.Close()
  192. body, err := io.ReadAll(resp.Body)
  193. if err != nil {
  194. return nil, fmt.Errorf("steam: read featured response: %w", err)
  195. }
  196. var sections map[string]featuredSection
  197. if err := json.Unmarshal(body, &sections); err != nil {
  198. return nil, fmt.Errorf("steam: decode featured response: %w", err)
  199. }
  200. var games []game.Game
  201. seen := map[string]bool{}
  202. for _, sec := range sections {
  203. for _, it := range sec.Items {
  204. name := strings.ToLower(it.Name)
  205. bod := strings.ToLower(it.Body)
  206. if !strings.Contains(name, "free weekend") &&
  207. !strings.Contains(bod, "free weekend") &&
  208. !strings.Contains(bod, "play for free") {
  209. continue
  210. }
  211. title, appID := itemTitleAndAppID(ctx, c, it)
  212. if seen[appID+title] {
  213. continue
  214. }
  215. seen[appID+title] = true
  216. g := game.Game{
  217. Title: title,
  218. Store: "steam",
  219. Kind: "free-weekend",
  220. URL: it.URL,
  221. Seller: "",
  222. PriceFmt: strings.TrimSpace(it.Body),
  223. }
  224. if appID != "" {
  225. g.ID = "steam-" + appID
  226. g.Slug = appID
  227. } else {
  228. g.ID = "steam-weekend-" + title
  229. }
  230. games = append(games, g)
  231. }
  232. }
  233. return games, nil
  234. }
  235. var reWeekendTitle = regexp.MustCompile(`(?i)play\s+(.+?)\s+for\s+free`)
  236. // itemTitleAndAppID resolves a meaningful title and the appid for a weekend item.
  237. func itemTitleAndAppID(ctx context.Context, c *Client, it featuredItem) (string, string) {
  238. appID := ""
  239. if m := reAppID.FindStringSubmatch(it.URL); m != nil {
  240. appID = m[1]
  241. }
  242. // Prefer a specific name over generic "Free Weekend" labels.
  243. generic := map[string]bool{"free weekend": true, "free to play": true, "": true}
  244. if !generic[strings.ToLower(it.Name)] {
  245. return it.Name, appID
  246. }
  247. if m := reWeekendTitle.FindStringSubmatch(it.Body); m != nil {
  248. return strings.TrimSpace(m[1]), appID
  249. }
  250. if appID != "" {
  251. if name := fetchAppName(ctx, c, appID); name != "" {
  252. return name, appID
  253. }
  254. }
  255. if it.Name != "" {
  256. return it.Name, appID
  257. }
  258. return "Unknown Free Weekend Game", appID
  259. }
  260. func fetchAppName(ctx context.Context, c *Client, appID string) string {
  261. u := fmt.Sprintf("%s?appids=%s&l=en", appDetailsURL, url.QueryEscape(appID))
  262. resp, err := c.get(ctx, u)
  263. if err != nil {
  264. return ""
  265. }
  266. defer resp.Body.Close()
  267. var data map[string]struct {
  268. Data struct {
  269. Name string `json:"name"`
  270. } `json:"data"`
  271. }
  272. if json.NewDecoder(resp.Body).Decode(&data) != nil {
  273. return ""
  274. }
  275. if d, ok := data[appID]; ok {
  276. return d.Data.Name
  277. }
  278. return ""
  279. }