orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "regexp"
8 "strconv"
9 "strings"
10 "time"
11)
12
13// What is selling on Steam. The store's own front end API, no key, no account.
14const (
15 // The store search behind the top sellers tab. category1=998 is Steam's
16 // own Games filter, applied before the list is cut, which keeps out the
17 // hardware and DLC the store flags type 0 the same as a game.
18 steamURL = "https://store.steampowered.com/search/results/?query&start=0&count=50&filter=topsellers&category1=998&cc=us&l=en&json=1"
19 steamEvery = time.Hour
20 steamShown = 6
21
22 // The search answers a name and an image and no price, so appdetails is
23 // where the money and the genres come from. It also says what a thing is,
24 // which catches whatever the store filter let through.
25 detailsURL = "https://store.steampowered.com/api/appdetails?cc=us&l=en&appids="
26 playersURL = "https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid="
27
28 // The store's own review summary. num_per_page=0 asks for the counts
29 // without the reviews themselves, which is the whole of what a row needs
30 // and a fraction of the response.
31 reviewsURL = "https://store.steampowered.com/appreviews/%d?json=1&language=all&purchase_type=all&num_per_page=0"
32
33 // How far down the chart to look for six games, which is headroom for a
34 // lookup that fails rather than for the chart itself.
35 steamCandidates = 12
36)
37
38// The search hands back a capsule image rather than an app id, and the id is
39// the only part of the URL that is stable across the several CDN hosts and
40// path shapes Valve has used.
41var steamAppID = regexp.MustCompile(`/apps/(\d+)/`)
42
43type Game struct {
44 Name string `json:"name"`
45 URL string `json:"url"`
46 Price string `json:"price"`
47 Discount int `json:"discount"`
48 Tags []string `json:"tags"`
49 Players string `json:"players"`
50
51 // What Steam's own users make of it. Rating is the percentage positive and
52 // Verdict is Valve's word for that percentage, which is the label anyone
53 // who uses the store already reads. Reviews is how much it rests on, since
54 // 100% of nine reviews and 94% of four hundred thousand are different
55 // claims.
56 Rating int `json:"rating"`
57 Verdict string `json:"verdict"`
58 Reviews string `json:"reviews"`
59 Reviewed bool `json:"reviewed"`
60}
61
62type steamPayload struct {
63 Items []struct {
64 Name string `json:"name"`
65 Logo string `json:"logo"`
66 } `json:"items"`
67}
68
69type appDetails struct {
70 Success bool `json:"success"`
71 Data struct {
72 Type string `json:"type"`
73 Name string `json:"name"`
74 IsFree bool `json:"is_free"`
75 Price *struct {
76 Final int `json:"final"`
77 DiscountPercent int `json:"discount_percent"`
78 } `json:"price_overview"`
79 Genres []struct {
80 Description string `json:"description"`
81 } `json:"genres"`
82 } `json:"data"`
83}
84
85type reviewSummary struct {
86 Success int `json:"success"`
87 Summary struct {
88 Score int `json:"review_score"`
89 Desc string `json:"review_score_desc"`
90 Positive int `json:"total_positive"`
91 Total int `json:"total_reviews"`
92 } `json:"query_summary"`
93}
94
95type playerCount struct {
96 Response struct {
97 PlayerCount int `json:"player_count"`
98 Result int `json:"result"`
99 } `json:"response"`
100}
101
102func fetchSteam(ctx context.Context, g *Guard) ([]Game, error) {
103 var payload steamPayload
104 if err := getJSON(ctx, g, "steam", steamURL, &payload); err != nil {
105 return nil, err
106 }
107
108 seen := map[int]bool{}
109 out := make([]Game, 0, steamShown)
110 looked := 0
111
112 for _, it := range payload.Items {
113 if len(out) == steamShown || looked >= steamCandidates {
114 break
115 }
116
117 m := steamAppID.FindStringSubmatch(it.Logo)
118 if m == nil {
119 continue
120 }
121 id, err := strconv.Atoi(m[1])
122 if err != nil || id == 0 || seen[id] {
123 continue
124 }
125 seen[id] = true
126 looked++
127
128 d, err := details(ctx, g, id)
129 if err != nil {
130 // The four Steam endpoints share one breaker, so a refusal here
131 // refuses every lookup left in the loop. Carrying on would hand
132 // back the two rows it has as though that were the chart.
133 break
134 }
135 if d == nil {
136 // Real entry, not a game. The store filter usually catches these.
137 continue
138 }
139
140 name := strings.TrimSpace(d.Data.Name)
141 if name == "" {
142 name = strings.TrimSpace(it.Name)
143 }
144 if name == "" {
145 continue
146 }
147
148 game := Game{
149 Name: name,
150 URL: fmt.Sprintf("https://store.steampowered.com/app/%d/", id),
151 Price: steamPrice(d),
152 Discount: discount(d),
153 Tags: gameTags(d),
154 Players: playersOnline(ctx, g, id),
155 }
156 rating(ctx, g, id, &game)
157 out = append(out, game)
158 }
159
160 if len(out) == 0 {
161 return nil, fmt.Errorf("steam: no games in the top sellers list")
162 }
163 return out, nil
164}
165
166// keepSteam reports whether a fresh poll should replace what is on screen. A
167// short list means the lookups gave out partway rather than the store selling
168// out, and the poll is hourly, so two rows would sit there for the rest of it.
169func keepSteam(fresh, shown []Game) bool {
170 return len(fresh) >= steamShown || len(fresh) >= len(shown)
171}
172
173// details looks one app up. A nil payload with a nil error means the lookup
174// landed and the thing is not a game, which costs a row. An error means the
175// lookup did not land at all, which is a different answer and one the caller
176// must not read as an empty chart.
177func details(ctx context.Context, g *Guard, id int) (*appDetails, error) {
178 var payload map[string]appDetails
179 if err := getJSON(ctx, g, "steam", detailsURL+strconv.Itoa(id), &payload); err != nil {
180 return nil, err
181 }
182
183 // appdetails is keyed by the app id as a string, which is why the response
184 // is a map. It answers success false both for things that are not apps and
185 // for an app the store will not show this region, and neither is a row.
186 d, ok := payload[strconv.Itoa(id)]
187 if !ok || !d.Success || d.Data.Type != "game" {
188 return nil, nil
189 }
190 return &d, nil
191}
192
193// gameTags reports the genres, three at most. The list runs to six and the
194// rest is noise at this width.
195func gameTags(d *appDetails) []string {
196 tags := make([]string, 0, 3)
197 for _, genre := range d.Data.Genres {
198 if len(tags) == 3 {
199 break
200 }
201 tags = append(tags, strings.ToUpper(genre.Description))
202 }
203 return tags
204}
205
206func discount(d *appDetails) int {
207 if d.Data.Price == nil {
208 return 0
209 }
210 return d.Data.Price.DiscountPercent
211}
212
213// rating fills in the review summary, and leaves the row alone if it cannot.
214// A game with almost no reviews gets none: Valve does not call a percentage a
215// verdict under ten of them either, and "100% POSITIVE" off three reviews is
216// the most misleading thing this panel could print.
217func rating(ctx context.Context, g *Guard, id int, game *Game) {
218 var payload reviewSummary
219 if err := getJSON(ctx, g, "steam", fmt.Sprintf(reviewsURL, id), &payload); err != nil {
220 return
221 }
222
223 q := payload.Summary
224 if payload.Success != 1 || q.Total < 10 {
225 return
226 }
227
228 game.Rating = int(math.Round(float64(q.Positive) / float64(q.Total) * 100))
229 game.Verdict = strings.ToUpper(q.Desc)
230 game.Reviews = compactCount(q.Total)
231 game.Reviewed = true
232}
233
234// playersOnline is a nice to have, so a failure costs the number and not the
235// row it would have sat on.
236func playersOnline(ctx context.Context, g *Guard, id int) string {
237 var payload playerCount
238 if err := getJSON(ctx, g, "steam", playersURL+strconv.Itoa(id), &payload); err != nil {
239 return ""
240 }
241 if payload.Response.Result != 1 || payload.Response.PlayerCount <= 0 {
242 return ""
243 }
244 return compactCount(payload.Response.PlayerCount)
245}
246
247// compactCount keeps a player count to four characters, since the column is
248// narrow and nobody needs the last three digits of 431,908.
249func compactCount(n int) string {
250 switch {
251 case n >= 1_000_000:
252 return fmt.Sprintf("%.1fM", float64(n)/1e6)
253 case n >= 10_000:
254 return fmt.Sprintf("%.0fK", float64(n)/1e3)
255 case n >= 1_000:
256 return fmt.Sprintf("%.1fK", float64(n)/1e3)
257 default:
258 return strconv.Itoa(n)
259 }
260}
261
262// steamPrice reads the money off appdetails. A game that is neither free nor
263// priced has not been released yet, and the top sellers chart carries plenty
264// of those on preorder.
265func steamPrice(d *appDetails) string {
266 if d.Data.IsFree {
267 return "FREE"
268 }
269 if d.Data.Price == nil || d.Data.Price.Final <= 0 {
270 return "TBA"
271 }
272 return fmt.Sprintf("$%.2f", float64(d.Data.Price.Final)/100)
273}