repos
/ orchard main

orchard

mirror

Every 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

8.2 KB · 274 lines · Go Raw History
  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}