package steam import ( "context" "fmt" "log/slog" "strconv" "strings" "time" "gitea.stevedudenhoeffer.com/steve/go-extractor" "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/internal/parse" ) // GamePrice holds structured game price information from the Steam Store. type GamePrice struct { AppID int Name string Price float64 OrigPrice float64 // 0 if not on sale DiscountPct int // 0 if not on sale OnSale bool FreeToPlay bool Currency string } // Config holds configuration for the Steam Store extractor. type Config struct{} // DefaultConfig is the default Steam Store configuration. var DefaultConfig = Config{} func (c Config) validate() Config { return c } // appURL returns the Steam store page URL for a given app ID. func appURL(appID int) string { return fmt.Sprintf("https://store.steampowered.com/app/%d", appID) } // searchURL returns the Steam search URL for a given query. func searchURL(query string) string { return fmt.Sprintf("https://store.steampowered.com/search/?term=%s", strings.ReplaceAll(query, " ", "+")) } // handleAgeGate attempts to bypass Steam's age verification if present. func handleAgeGate(doc extractor.Document) { // Set the year dropdown to bypass the age gate years := doc.Select("#ageYear") if len(years) > 0 { _ = years[0].SetAttribute("value", "1990") // Click the View Page button btns := doc.Select("#view_product_page_btn") if len(btns) > 0 { _ = btns[0].Click() timeout := 5 * time.Second _ = doc.WaitForNetworkIdle(&timeout) } } } // GetGamePrice extracts price info for a Steam app by its ID. func (c Config) GetGamePrice(ctx context.Context, b extractor.Browser, appID int) (*GamePrice, error) { c = c.validate() u := appURL(appID) slog.Info("fetching steam game", "url", u, "appID", appID) doc, err := b.Open(ctx, u, extractor.OpenPageOptions{}) if err != nil { return nil, fmt.Errorf("failed to open steam page: %w", err) } defer extractor.DeferClose(doc) timeout := 10 * time.Second if err := doc.WaitForNetworkIdle(&timeout); err != nil { slog.Warn("WaitForNetworkIdle failed", "err", err) } handleAgeGate(doc) return extractGamePrice(doc, appID) } // GetGamePrice is a convenience function using DefaultConfig. func GetGamePrice(ctx context.Context, b extractor.Browser, appID int) (*GamePrice, error) { return DefaultConfig.GetGamePrice(ctx, b, appID) } // SearchGames searches the Steam store and returns results. func (c Config) SearchGames(ctx context.Context, b extractor.Browser, query string) ([]GamePrice, error) { c = c.validate() u := searchURL(query) slog.Info("searching steam", "url", u, "query", query) doc, err := b.Open(ctx, u, extractor.OpenPageOptions{}) if err != nil { return nil, fmt.Errorf("failed to open steam search: %w", err) } defer extractor.DeferClose(doc) timeout := 10 * time.Second if err := doc.WaitForNetworkIdle(&timeout); err != nil { slog.Warn("WaitForNetworkIdle failed", "err", err) } return extractSearchResults(doc) } // SearchGames is a convenience function using DefaultConfig. func SearchGames(ctx context.Context, b extractor.Browser, query string) ([]GamePrice, error) { return DefaultConfig.SearchGames(ctx, b, query) } func extractGamePrice(doc extractor.Node, appID int) (*GamePrice, error) { var data GamePrice data.AppID = appID // Game name names := doc.Select("#appHubAppName") if len(names) > 0 { data.Name, _ = names[0].Text() data.Name = strings.TrimSpace(data.Name) } // Check for free to play freeNodes := doc.Select("div.game_area_purchase_game_wrapper .game_purchase_price") if len(freeNodes) > 0 { txt, _ := freeNodes[0].Text() if strings.Contains(strings.ToLower(txt), "free") { data.FreeToPlay = true return &data, nil } } // Check for discount discounts := doc.Select("div.game_area_purchase_game div.discount_pct") if len(discounts) > 0 { data.OnSale = true txt, _ := discounts[0].Text() data.DiscountPct = int(parse.NumericOnly(txt)) // Original price origPrices := doc.Select("div.game_area_purchase_game div.discount_original_price") if len(origPrices) > 0 { txt, _ := origPrices[0].Text() data.OrigPrice = parse.NumericOnly(txt) data.Currency = extractCurrency(txt) } // Final/sale price finalPrices := doc.Select("div.game_area_purchase_game div.discount_final_price") if len(finalPrices) > 0 { txt, _ := finalPrices[0].Text() data.Price = parse.NumericOnly(txt) } } else { // No discount — regular price prices := doc.Select("div.game_area_purchase_game div.game_purchase_price") if len(prices) > 0 { txt, _ := prices[0].Text() data.Price = parse.NumericOnly(txt) data.Currency = extractCurrency(txt) } } return &data, nil } func extractSearchResults(doc extractor.Node) ([]GamePrice, error) { var results []GamePrice _ = doc.ForEach("a.search_result_row", func(n extractor.Node) error { var gp GamePrice // App ID from data-ds-appid attribute appIDStr, _ := n.Attr("data-ds-appid") if appIDStr != "" { id, err := strconv.Atoi(appIDStr) if err == nil { gp.AppID = id } } // Name titles := n.Select("span.title") if len(titles) > 0 { gp.Name, _ = titles[0].Text() gp.Name = strings.TrimSpace(gp.Name) } // Discount percentage pcts := n.Select("div.discount_pct") if len(pcts) > 0 { gp.OnSale = true txt, _ := pcts[0].Text() gp.DiscountPct = int(parse.NumericOnly(txt)) } // Original price (when on sale) origPrices := n.Select("div.discount_original_price") if len(origPrices) > 0 { txt, _ := origPrices[0].Text() gp.OrigPrice = parse.NumericOnly(txt) gp.Currency = extractCurrency(txt) } // Final price finalPrices := n.Select("div.discount_final_price") if len(finalPrices) > 0 { txt, _ := finalPrices[0].Text() gp.Price = parse.NumericOnly(txt) if gp.Currency == "" { gp.Currency = extractCurrency(txt) } } else { // Regular price (no sale) prices := n.Select("div.search_price") if len(prices) > 0 { txt, _ := prices[0].Text() txt = strings.TrimSpace(txt) if strings.Contains(strings.ToLower(txt), "free") { gp.FreeToPlay = true } else { gp.Price = parse.NumericOnly(txt) gp.Currency = extractCurrency(txt) } } } results = append(results, gp) return nil }) return results, nil } // extractCurrency returns the currency symbol from a price string. func extractCurrency(s string) string { s = strings.TrimSpace(s) if strings.HasPrefix(s, "$") { return "USD" } if strings.HasPrefix(s, "€") || strings.HasSuffix(s, "€") { return "EUR" } if strings.HasPrefix(s, "£") { return "GBP" } return "" }