client.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  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. "sort"
  17. "strconv"
  18. "strings"
  19. "time"
  20. "gogs.netdome.biz/paul/cheapo/internal/game"
  21. "golang.org/x/net/html"
  22. )
  23. const (
  24. searchURL = "https://store.steampowered.com/search/?maxprice=free&specials=1"
  25. dealsURL = "https://store.steampowered.com/search/?specials=1"
  26. featuredURL = "https://store.steampowered.com/api/featuredcategories/"
  27. appDetailsURL = "https://store.steampowered.com/api/appdetails"
  28. userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
  29. )
  30. // Client queries the public Steam storefront.
  31. type Client struct {
  32. HTTP *http.Client
  33. }
  34. // NewClient returns a Steam client.
  35. func NewClient() *Client {
  36. return &Client{HTTP: &http.Client{Timeout: 30 * time.Second}}
  37. }
  38. // Name implements game.Provider.
  39. func (c *Client) Name() string { return "steam" }
  40. func (c *Client) get(ctx context.Context, u string) (*http.Response, error) {
  41. req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
  42. if err != nil {
  43. return nil, fmt.Errorf("steam: %w", err)
  44. }
  45. req.Header.Set("User-Agent", userAgent)
  46. resp, err := c.HTTP.Do(req)
  47. if err != nil {
  48. return nil, fmt.Errorf("steam: request failed: %w", err)
  49. }
  50. if resp.StatusCode != http.StatusOK {
  51. resp.Body.Close()
  52. return nil, fmt.Errorf("steam: unexpected status %s for %s", resp.Status, u)
  53. }
  54. return resp, nil
  55. }
  56. // GetFreeGames returns all current Steam free offers (free to keep + free weekend).
  57. func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
  58. keep, err := c.getFreeToKeep(ctx)
  59. if err != nil {
  60. return nil, err
  61. }
  62. weekend, err := c.getFreeWeekend(ctx)
  63. if err != nil {
  64. // The weekend API is flaky/nonessential; don't fail the whole query.
  65. weekend = nil
  66. }
  67. games := append(keep, weekend...)
  68. return games, nil
  69. }
  70. // --- Free to Keep (HTML search page) ---
  71. var (
  72. reAppID = regexp.MustCompile(`/app/(\d+)`)
  73. rePriceStrip = regexp.MustCompile(`\s+`)
  74. reDiscountPct = regexp.MustCompile(`-(\d+)%`)
  75. )
  76. func (c *Client) getFreeToKeep(ctx context.Context) ([]game.Game, error) {
  77. resp, err := c.get(ctx, searchURL)
  78. if err != nil {
  79. return nil, err
  80. }
  81. defer resp.Body.Close()
  82. return c.parseSearchRows(resp.Body, parseResultRow)
  83. }
  84. // GetDeals returns currently discounted Steam games (specials search page,
  85. // any discount), sorted by discount (deepest first).
  86. func (c *Client) GetDeals(ctx context.Context) ([]game.Game, error) {
  87. resp, err := c.get(ctx, dealsURL)
  88. if err != nil {
  89. return nil, err
  90. }
  91. defer resp.Body.Close()
  92. games, err := c.parseSearchRows(resp.Body, parseDealRow)
  93. if err != nil {
  94. return nil, err
  95. }
  96. sort.Slice(games, func(i, j int) bool {
  97. return games[i].DiscountPct > games[j].DiscountPct
  98. })
  99. return games, nil
  100. }
  101. // parseSearchRows walks a Steam search results page and applies parse per
  102. // `a.search_result_row`, deduplicating by ID.
  103. func (c *Client) parseSearchRows(r io.Reader, parse func(*html.Node) *game.Game) ([]game.Game, error) {
  104. doc, err := html.Parse(r)
  105. if err != nil {
  106. return nil, fmt.Errorf("steam: parse search page: %w", err)
  107. }
  108. var games []game.Game
  109. seen := map[string]bool{}
  110. var walk func(*html.Node)
  111. walk = func(n *html.Node) {
  112. if n.Type == html.ElementNode && n.Data == "a" {
  113. cls := classAttr(n)
  114. if strings.Contains(cls, "search_result_row") {
  115. if g := parse(n); g != nil && !seen[g.ID] {
  116. seen[g.ID] = true
  117. games = append(games, *g)
  118. }
  119. }
  120. }
  121. for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
  122. walk(ch)
  123. }
  124. }
  125. walk(doc)
  126. return games, nil
  127. }
  128. func classAttr(n *html.Node) string {
  129. for _, a := range n.Attr {
  130. if a.Key == "class" {
  131. return a.Val
  132. }
  133. }
  134. return ""
  135. }
  136. // parseResultRow extracts one game from a `a.search_result_row` element.
  137. func parseResultRow(a *html.Node) *game.Game {
  138. href := ""
  139. for _, at := range a.Attr {
  140. if at.Key == "href" {
  141. href = at.Val
  142. }
  143. }
  144. m := reAppID.FindStringSubmatch(href)
  145. if m == nil {
  146. return nil
  147. }
  148. appID := m[1]
  149. var title, price, discount string
  150. var find func(*html.Node)
  151. find = func(n *html.Node) {
  152. if n.Type == html.ElementNode {
  153. cls := classAttr(n)
  154. switch {
  155. case strings.Contains(cls, "title") && title == "":
  156. title = textOf(n)
  157. case strings.Contains(cls, "discount_final_price") && price == "":
  158. price = textOf(n)
  159. case strings.Contains(cls, "discount_pct") && discount == "":
  160. discount = textOf(n)
  161. }
  162. }
  163. for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
  164. find(ch)
  165. }
  166. }
  167. find(a)
  168. // Free filter: price text "Free" (localized variants start with "Free" or
  169. // are "0"), or a 100 % discount badge.
  170. price = rePriceStrip.ReplaceAllString(price, " ")
  171. free := strings.HasPrefix(price, "Free") || price == "0" ||
  172. strings.Contains(discount, "-100%") || strings.Contains(discount, "100%")
  173. if !free {
  174. return nil
  175. }
  176. return &game.Game{
  177. Title: strings.TrimSpace(title),
  178. ID: "steam-" + appID,
  179. Slug: appID,
  180. URL: "https://store.steampowered.com/app/" + appID + "/",
  181. Store: "steam",
  182. Kind: "free-to-keep",
  183. PriceFmt: strings.TrimSpace(strings.TrimPrefix(price, "Statt ")),
  184. }
  185. }
  186. // parseDealRow extracts one discounted game from a `a.search_result_row` on
  187. // the specials page (any discount, with original/final price and percent).
  188. func parseDealRow(a *html.Node) *game.Game {
  189. href := ""
  190. for _, at := range a.Attr {
  191. if at.Key == "href" {
  192. href = at.Val
  193. }
  194. }
  195. m := reAppID.FindStringSubmatch(href)
  196. if m == nil {
  197. return nil
  198. }
  199. appID := m[1]
  200. var title, orig, final, discount string
  201. var find func(*html.Node)
  202. find = func(n *html.Node) {
  203. if n.Type == html.ElementNode {
  204. cls := classAttr(n)
  205. switch {
  206. case strings.Contains(cls, "title") && title == "":
  207. title = textOf(n)
  208. case strings.Contains(cls, "discount_original_price") && orig == "":
  209. orig = textOf(n)
  210. case strings.Contains(cls, "discount_final_price") && final == "":
  211. final = textOf(n)
  212. case strings.Contains(cls, "discount_pct") && discount == "":
  213. discount = textOf(n)
  214. }
  215. }
  216. for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
  217. find(ch)
  218. }
  219. }
  220. find(a)
  221. if strings.TrimSpace(final) == "" {
  222. return nil
  223. }
  224. pct := 0
  225. if dm := reDiscountPct.FindStringSubmatch(discount); dm != nil {
  226. pct, _ = strconv.Atoi(dm[1])
  227. }
  228. if pct <= 0 {
  229. return nil
  230. }
  231. return &game.Game{
  232. Title: strings.TrimSpace(title),
  233. ID: "steam-" + appID,
  234. Slug: appID,
  235. URL: "https://store.steampowered.com/app/" + appID + "/",
  236. Store: "steam",
  237. Kind: "deal",
  238. PriceFmt: strings.TrimSpace(orig),
  239. FinalPriceFmt: strings.TrimSpace(final),
  240. DiscountPct: pct,
  241. }
  242. }
  243. func textOf(n *html.Node) string {
  244. var b strings.Builder
  245. var visit func(*html.Node)
  246. visit = func(x *html.Node) {
  247. if x.Type == html.TextNode {
  248. b.WriteString(x.Data)
  249. }
  250. for ch := x.FirstChild; ch != nil; ch = ch.NextSibling {
  251. visit(ch)
  252. }
  253. }
  254. visit(n)
  255. return b.String()
  256. }
  257. // --- Free Weekend (featured categories JSON API) ---
  258. type featuredItem struct {
  259. Name string `json:"name"`
  260. HeaderImage string `json:"header_image"`
  261. URL string `json:"url"`
  262. Body string `json:"body"`
  263. }
  264. type featuredSection struct {
  265. ID interface{} `json:"id"`
  266. Name string `json:"name"`
  267. Items []featuredItem `json:"items"`
  268. }
  269. // decodeFeaturedSections decodes the featuredcategories JSON. The response
  270. // mixes section objects with scalar values (e.g. "status": 1), which fails
  271. // strict map decoding — decode per key so scalar junk keys are skipped.
  272. func decodeFeaturedSections(body []byte) (map[string]featuredSection, error) {
  273. var sections map[string]featuredSection
  274. if err := json.Unmarshal(body, &sections); err != nil {
  275. var loose map[string]json.RawMessage
  276. if json.Unmarshal(body, &loose) != nil {
  277. return nil, err
  278. }
  279. sections = make(map[string]featuredSection, len(loose))
  280. for k, v := range loose {
  281. var sec featuredSection
  282. if json.Unmarshal(v, &sec) == nil && sec.ID != nil {
  283. sections[k] = sec
  284. }
  285. }
  286. }
  287. return sections, nil
  288. }
  289. func (c *Client) getFreeWeekend(ctx context.Context) ([]game.Game, error) {
  290. resp, err := c.get(ctx, featuredURL)
  291. if err != nil {
  292. return nil, err
  293. }
  294. defer resp.Body.Close()
  295. body, err := io.ReadAll(resp.Body)
  296. if err != nil {
  297. return nil, fmt.Errorf("steam: read featured response: %w", err)
  298. }
  299. sections, err := decodeFeaturedSections(body)
  300. if err != nil {
  301. return nil, fmt.Errorf("steam: decode featured response: %w", err)
  302. }
  303. var games []game.Game
  304. seen := map[string]bool{}
  305. for _, sec := range sections {
  306. for _, it := range sec.Items {
  307. name := strings.ToLower(it.Name)
  308. bod := strings.ToLower(it.Body)
  309. if !strings.Contains(name, "free weekend") &&
  310. !strings.Contains(bod, "free weekend") &&
  311. !strings.Contains(bod, "play for free") {
  312. continue
  313. }
  314. title, appID := itemTitleAndAppID(ctx, c, it)
  315. if seen[appID+title] {
  316. continue
  317. }
  318. seen[appID+title] = true
  319. g := game.Game{
  320. Title: title,
  321. Store: "steam",
  322. Kind: "free-weekend",
  323. URL: it.URL,
  324. Seller: "",
  325. PriceFmt: strings.TrimSpace(it.Body),
  326. }
  327. if appID != "" {
  328. g.ID = "steam-" + appID
  329. g.Slug = appID
  330. } else {
  331. g.ID = "steam-weekend-" + title
  332. }
  333. games = append(games, g)
  334. }
  335. }
  336. return games, nil
  337. }
  338. var reWeekendTitle = regexp.MustCompile(`(?i)play\s+(.+?)\s+for\s+free`)
  339. // itemTitleAndAppID resolves a meaningful title and the appid for a weekend item.
  340. func itemTitleAndAppID(ctx context.Context, c *Client, it featuredItem) (string, string) {
  341. appID := ""
  342. if m := reAppID.FindStringSubmatch(it.URL); m != nil {
  343. appID = m[1]
  344. }
  345. // Prefer a specific name over generic "Free Weekend" labels.
  346. generic := map[string]bool{"free weekend": true, "free to play": true, "": true}
  347. if !generic[strings.ToLower(it.Name)] {
  348. return it.Name, appID
  349. }
  350. if m := reWeekendTitle.FindStringSubmatch(it.Body); m != nil {
  351. return strings.TrimSpace(m[1]), appID
  352. }
  353. if appID != "" {
  354. if name := fetchAppName(ctx, c, appID); name != "" {
  355. return name, appID
  356. }
  357. }
  358. if it.Name != "" {
  359. return it.Name, appID
  360. }
  361. return "Unknown Free Weekend Game", appID
  362. }
  363. func fetchAppName(ctx context.Context, c *Client, appID string) string {
  364. u := fmt.Sprintf("%s?appids=%s&l=en", appDetailsURL, url.QueryEscape(appID))
  365. resp, err := c.get(ctx, u)
  366. if err != nil {
  367. return ""
  368. }
  369. defer resp.Body.Close()
  370. var data map[string]struct {
  371. Data struct {
  372. Name string `json:"name"`
  373. } `json:"data"`
  374. }
  375. if json.NewDecoder(resp.Body).Decode(&data) != nil {
  376. return ""
  377. }
  378. if d, ok := data[appID]; ok {
  379. return d.Data.Name
  380. }
  381. return ""
  382. }