diff --git a/sites/steam/steam.go b/sites/steam/steam.go new file mode 100644 index 0000000..dabd8fd --- /dev/null +++ b/sites/steam/steam.go @@ -0,0 +1,256 @@ +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 "" +} diff --git a/sites/steam/steam_test.go b/sites/steam/steam_test.go new file mode 100644 index 0000000..74f13d0 --- /dev/null +++ b/sites/steam/steam_test.go @@ -0,0 +1,319 @@ +package steam + +import ( + "context" + "testing" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/extractortest" +) + +func makeGameDoc(appID int) *extractortest.MockDocument { + return &extractortest.MockDocument{ + URLValue: "https://store.steampowered.com/app/1245620", + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "#appHubAppName": { + &extractortest.MockNode{TextValue: "ELDEN RING"}, + }, + "div.game_area_purchase_game div.game_purchase_price": { + &extractortest.MockNode{TextValue: "$59.99"}, + }, + }, + }, + } +} + +func makeDiscountedGameDoc() *extractortest.MockDocument { + return &extractortest.MockDocument{ + URLValue: "https://store.steampowered.com/app/292030", + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "#appHubAppName": { + &extractortest.MockNode{TextValue: "The Witcher 3: Wild Hunt"}, + }, + "div.game_area_purchase_game div.discount_pct": { + &extractortest.MockNode{TextValue: "-80%"}, + }, + "div.game_area_purchase_game div.discount_original_price": { + &extractortest.MockNode{TextValue: "$39.99"}, + }, + "div.game_area_purchase_game div.discount_final_price": { + &extractortest.MockNode{TextValue: "$7.99"}, + }, + }, + }, + } +} + +func makeFreeGameDoc() *extractortest.MockDocument { + return &extractortest.MockDocument{ + URLValue: "https://store.steampowered.com/app/440", + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "#appHubAppName": { + &extractortest.MockNode{TextValue: "Team Fortress 2"}, + }, + "div.game_area_purchase_game_wrapper .game_purchase_price": { + &extractortest.MockNode{TextValue: "Free To Play"}, + }, + }, + }, + } +} + +func TestExtractGamePrice_Regular(t *testing.T) { + doc := makeGameDoc(1245620) + + data, err := extractGamePrice(doc, 1245620) + if err != nil { + t.Fatalf("extractGamePrice() error: %v", err) + } + + if data.AppID != 1245620 { + t.Errorf("AppID = %d, want 1245620", data.AppID) + } + if data.Name != "ELDEN RING" { + t.Errorf("Name = %q, want %q", data.Name, "ELDEN RING") + } + if data.Price != 59.99 { + t.Errorf("Price = %v, want 59.99", data.Price) + } + if data.OnSale { + t.Error("OnSale = true, want false") + } + if data.Currency != "USD" { + t.Errorf("Currency = %q, want %q", data.Currency, "USD") + } +} + +func TestExtractGamePrice_Discounted(t *testing.T) { + doc := makeDiscountedGameDoc() + + data, err := extractGamePrice(doc, 292030) + if err != nil { + t.Fatalf("extractGamePrice() error: %v", err) + } + + if data.Name != "The Witcher 3: Wild Hunt" { + t.Errorf("Name = %q, want %q", data.Name, "The Witcher 3: Wild Hunt") + } + if !data.OnSale { + t.Error("OnSale = false, want true") + } + if data.DiscountPct != 80 { + t.Errorf("DiscountPct = %d, want 80", data.DiscountPct) + } + if data.OrigPrice != 39.99 { + t.Errorf("OrigPrice = %v, want 39.99", data.OrigPrice) + } + if data.Price != 7.99 { + t.Errorf("Price = %v, want 7.99", data.Price) + } +} + +func TestExtractGamePrice_FreeToPlay(t *testing.T) { + doc := makeFreeGameDoc() + + data, err := extractGamePrice(doc, 440) + if err != nil { + t.Fatalf("extractGamePrice() error: %v", err) + } + + if data.Name != "Team Fortress 2" { + t.Errorf("Name = %q, want %q", data.Name, "Team Fortress 2") + } + if !data.FreeToPlay { + t.Error("FreeToPlay = false, want true") + } + if data.Price != 0 { + t.Errorf("Price = %v, want 0", data.Price) + } +} + +func TestExtractSearchResults(t *testing.T) { + doc := &extractortest.MockDocument{ + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "a.search_result_row": { + &extractortest.MockNode{ + Attrs: map[string]string{"data-ds-appid": "1245620"}, + Children: map[string]extractor.Nodes{ + "span.title": {&extractortest.MockNode{TextValue: "ELDEN RING"}}, + "div.discount_final_price": {&extractortest.MockNode{TextValue: "$59.99"}}, + }, + }, + &extractortest.MockNode{ + Attrs: map[string]string{"data-ds-appid": "292030"}, + Children: map[string]extractor.Nodes{ + "span.title": {&extractortest.MockNode{TextValue: "The Witcher 3: Wild Hunt"}}, + "div.discount_pct": {&extractortest.MockNode{TextValue: "-80%"}}, + "div.discount_original_price": {&extractortest.MockNode{TextValue: "$39.99"}}, + "div.discount_final_price": {&extractortest.MockNode{TextValue: "$7.99"}}, + }, + }, + &extractortest.MockNode{ + Attrs: map[string]string{"data-ds-appid": "440"}, + Children: map[string]extractor.Nodes{ + "span.title": {&extractortest.MockNode{TextValue: "Team Fortress 2"}}, + "div.search_price": {&extractortest.MockNode{TextValue: "Free To Play"}}, + }, + }, + }, + }, + }, + } + + results, err := extractSearchResults(doc) + if err != nil { + t.Fatalf("extractSearchResults() error: %v", err) + } + + if len(results) != 3 { + t.Fatalf("len(results) = %d, want 3", len(results)) + } + + // Regular price game + if results[0].AppID != 1245620 { + t.Errorf("results[0].AppID = %d, want 1245620", results[0].AppID) + } + if results[0].Name != "ELDEN RING" { + t.Errorf("results[0].Name = %q, want %q", results[0].Name, "ELDEN RING") + } + if results[0].Price != 59.99 { + t.Errorf("results[0].Price = %v, want 59.99", results[0].Price) + } + + // Discounted game + if results[1].AppID != 292030 { + t.Errorf("results[1].AppID = %d, want 292030", results[1].AppID) + } + if !results[1].OnSale { + t.Error("results[1].OnSale = false, want true") + } + if results[1].DiscountPct != 80 { + t.Errorf("results[1].DiscountPct = %d, want 80", results[1].DiscountPct) + } + if results[1].Price != 7.99 { + t.Errorf("results[1].Price = %v, want 7.99", results[1].Price) + } + + // Free to play game + if results[2].AppID != 440 { + t.Errorf("results[2].AppID = %d, want 440", results[2].AppID) + } + if !results[2].FreeToPlay { + t.Error("results[2].FreeToPlay = false, want true") + } +} + +func TestGetGamePrice_MockBrowser(t *testing.T) { + doc := makeGameDoc(1245620) + + browser := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + "https://store.steampowered.com/app/1245620": doc, + }, + } + + data, err := DefaultConfig.GetGamePrice(context.Background(), browser, 1245620) + if err != nil { + t.Fatalf("GetGamePrice() error: %v", err) + } + + if data.Name != "ELDEN RING" { + t.Errorf("Name = %q, want %q", data.Name, "ELDEN RING") + } + if data.Price != 59.99 { + t.Errorf("Price = %v, want 59.99", data.Price) + } +} + +func TestSearchGames_MockBrowser(t *testing.T) { + doc := &extractortest.MockDocument{ + URLValue: "https://store.steampowered.com/search/?term=elden+ring", + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "a.search_result_row": { + &extractortest.MockNode{ + Attrs: map[string]string{"data-ds-appid": "1245620"}, + Children: map[string]extractor.Nodes{ + "span.title": {&extractortest.MockNode{TextValue: "ELDEN RING"}}, + "div.discount_final_price": {&extractortest.MockNode{TextValue: "$59.99"}}, + }, + }, + }, + }, + }, + } + + browser := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + "https://store.steampowered.com/search/?term=elden+ring": doc, + }, + } + + results, err := DefaultConfig.SearchGames(context.Background(), browser, "elden ring") + if err != nil { + t.Fatalf("SearchGames() error: %v", err) + } + + if len(results) != 1 { + t.Fatalf("len(results) = %d, want 1", len(results)) + } + if results[0].Name != "ELDEN RING" { + t.Errorf("Name = %q, want %q", results[0].Name, "ELDEN RING") + } +} + +func TestExtractGamePrice_Empty(t *testing.T) { + doc := &extractortest.MockDocument{ + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{}, + }, + } + + data, err := extractGamePrice(doc, 0) + if err != nil { + t.Fatalf("extractGamePrice() error: %v", err) + } + + if data.Name != "" || data.Price != 0 { + t.Error("expected zero values for empty doc") + } +} + +func TestExtractCurrency(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"$59.99", "USD"}, + {"€49.99", "EUR"}, + {"£39.99", "GBP"}, + {"59.99", ""}, + } + + for _, tt := range tests { + got := extractCurrency(tt.input) + if got != tt.want { + t.Errorf("extractCurrency(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestAppURL(t *testing.T) { + got := appURL(1245620) + want := "https://store.steampowered.com/app/1245620" + if got != want { + t.Errorf("appURL(1245620) = %q, want %q", got, want) + } +} + +func TestSearchURL(t *testing.T) { + got := searchURL("elden ring") + want := "https://store.steampowered.com/search/?term=elden+ring" + if got != want { + t.Errorf("searchURL(\"elden ring\") = %q, want %q", got, want) + } +}