feature: add Steam Store game price extractor
Add sites/steam package with GetGamePrice() and SearchGames() methods. Handles regular prices, discounted games, and free-to-play titles. Includes age gate bypass logic and currency detection. Closes #28 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
256
sites/steam/steam.go
Normal file
256
sites/steam/steam.go
Normal file
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user