From 349b1b9c6b1467cb535e00e2d81efcec8e3ed04f Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 15 Feb 2026 16:47:53 +0000 Subject: [PATCH] feature: add CoinGecko cryptocurrency price extractor Add sites/coingecko package with GetPrice() method that extracts structured crypto price data (name, symbol, price, 24h/7d change, market cap, volume, 24h high/low) from CoinGecko coin pages. Includes mock-based tests and parseLargeNumber helper for T/B/M suffixes. Closes #27 Co-Authored-By: Claude Opus 4.6 --- sites/coingecko/coingecko.go | 169 +++++++++++++++++++++++++ sites/coingecko/coingecko_test.go | 198 ++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 sites/coingecko/coingecko.go create mode 100644 sites/coingecko/coingecko_test.go diff --git a/sites/coingecko/coingecko.go b/sites/coingecko/coingecko.go new file mode 100644 index 0000000..ff96866 --- /dev/null +++ b/sites/coingecko/coingecko.go @@ -0,0 +1,169 @@ +package coingecko + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/internal/parse" +) + +// CoinPrice holds structured cryptocurrency price data from CoinGecko. +type CoinPrice struct { + Name string + Symbol string + Price float64 + Change24h float64 // percentage + Change7d float64 + MarketCap float64 + Volume24h float64 + High24h float64 + Low24h float64 +} + +// Config holds configuration for the CoinGecko extractor. +type Config struct{} + +// DefaultConfig is the default CoinGecko configuration. +var DefaultConfig = Config{} + +func (c Config) validate() Config { + return c +} + +// coinURL returns the CoinGecko URL for a given coin ID (e.g. "bitcoin", "ethereum"). +func coinURL(coin string) string { + return fmt.Sprintf("https://www.coingecko.com/en/coins/%s", strings.ToLower(coin)) +} + +// GetPrice extracts structured cryptocurrency price data from CoinGecko. +func (c Config) GetPrice(ctx context.Context, b extractor.Browser, coin string) (*CoinPrice, error) { + c = c.validate() + + u := coinURL(coin) + + slog.Info("fetching coin price", "url", u, "coin", coin) + doc, err := b.Open(ctx, u, extractor.OpenPageOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to open coin page: %w", err) + } + defer extractor.DeferClose(doc) + + timeout := 10 * time.Second + if err := doc.WaitForNetworkIdle(&timeout); err != nil { + slog.Warn("WaitForNetworkIdle failed", "err", err) + } + + return extractCoinPrice(doc) +} + +// GetPrice is a convenience function using DefaultConfig. +func GetPrice(ctx context.Context, b extractor.Browser, coin string) (*CoinPrice, error) { + return DefaultConfig.GetPrice(ctx, b, coin) +} + +func extractCoinPrice(doc extractor.Node) (*CoinPrice, error) { + var data CoinPrice + + // Coin name — typically in h1 on the coin page + names := doc.Select("div[data-coin-show-target='coin'] h1") + if len(names) == 0 { + names = doc.Select("h1") + } + if len(names) > 0 { + data.Name, _ = names[0].Text() + data.Name = strings.TrimSpace(data.Name) + } + + // Symbol — often shown as abbreviated ticker + syms := doc.Select("span[data-coin-symbol]") + if len(syms) > 0 { + txt, _ := syms[0].Text() + data.Symbol = strings.ToUpper(strings.TrimSpace(txt)) + } + + // Current price + prices := doc.Select("span[data-converter-target='price']") + if len(prices) == 0 { + prices = doc.Select("span[data-price-target='price']") + } + if len(prices) > 0 { + txt, _ := prices[0].Text() + data.Price = parse.NumericOnly(txt) + } + + // 24h change percentage + changes24h := doc.Select("span[data-price-change='24h']") + if len(changes24h) > 0 { + txt, _ := changes24h[0].Text() + val := parse.NumericOnly(txt) + if strings.Contains(txt, "-") { + val = -val + } + data.Change24h = val + } + + // 7d change percentage + changes7d := doc.Select("span[data-price-change='7d']") + if len(changes7d) > 0 { + txt, _ := changes7d[0].Text() + val := parse.NumericOnly(txt) + if strings.Contains(txt, "-") { + val = -val + } + data.Change7d = val + } + + // Market cap + mcaps := doc.Select("span[data-coin-stat='market-cap']") + if len(mcaps) > 0 { + txt, _ := mcaps[0].Text() + data.MarketCap = parseLargeNumber(txt) + } + + // 24h volume + vols := doc.Select("span[data-coin-stat='volume']") + if len(vols) > 0 { + txt, _ := vols[0].Text() + data.Volume24h = parseLargeNumber(txt) + } + + // 24h high + highs := doc.Select("span[data-coin-stat='high-24h']") + if len(highs) > 0 { + txt, _ := highs[0].Text() + data.High24h = parse.NumericOnly(txt) + } + + // 24h low + lows := doc.Select("span[data-coin-stat='low-24h']") + if len(lows) > 0 { + txt, _ := lows[0].Text() + data.Low24h = parse.NumericOnly(txt) + } + + return &data, nil +} + +// parseLargeNumber handles values like "$1,234,567,890" or "$1.5T" or "$250B". +func parseLargeNumber(s string) float64 { + s = strings.TrimSpace(s) + upper := strings.ToUpper(s) + + numeric := parse.NumericOnly(s) + + if strings.HasSuffix(upper, "T") { + return numeric * 1_000_000_000_000 + } + if strings.HasSuffix(upper, "B") { + return numeric * 1_000_000_000 + } + if strings.HasSuffix(upper, "M") { + return numeric * 1_000_000 + } + + return numeric +} diff --git a/sites/coingecko/coingecko_test.go b/sites/coingecko/coingecko_test.go new file mode 100644 index 0000000..c51ef0d --- /dev/null +++ b/sites/coingecko/coingecko_test.go @@ -0,0 +1,198 @@ +package coingecko + +import ( + "context" + "testing" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/extractortest" +) + +func makeBitcoinDoc() *extractortest.MockDocument { + return &extractortest.MockDocument{ + URLValue: "https://www.coingecko.com/en/coins/bitcoin", + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "div[data-coin-show-target='coin'] h1": { + &extractortest.MockNode{TextValue: "Bitcoin"}, + }, + "span[data-coin-symbol]": { + &extractortest.MockNode{TextValue: "btc"}, + }, + "span[data-converter-target='price']": { + &extractortest.MockNode{TextValue: "$97,234.56"}, + }, + "span[data-price-change='24h']": { + &extractortest.MockNode{TextValue: "-2.34%"}, + }, + "span[data-price-change='7d']": { + &extractortest.MockNode{TextValue: "5.67%"}, + }, + "span[data-coin-stat='market-cap']": { + &extractortest.MockNode{TextValue: "$1.92T"}, + }, + "span[data-coin-stat='volume']": { + &extractortest.MockNode{TextValue: "$28.5B"}, + }, + "span[data-coin-stat='high-24h']": { + &extractortest.MockNode{TextValue: "$98,100.00"}, + }, + "span[data-coin-stat='low-24h']": { + &extractortest.MockNode{TextValue: "$95,500.00"}, + }, + }, + }, + } +} + +func TestExtractCoinPrice(t *testing.T) { + doc := makeBitcoinDoc() + + data, err := extractCoinPrice(doc) + if err != nil { + t.Fatalf("extractCoinPrice() error: %v", err) + } + + if data.Name != "Bitcoin" { + t.Errorf("Name = %q, want %q", data.Name, "Bitcoin") + } + if data.Symbol != "BTC" { + t.Errorf("Symbol = %q, want %q", data.Symbol, "BTC") + } + if data.Price != 97234.56 { + t.Errorf("Price = %v, want 97234.56", data.Price) + } + if data.Change24h != -2.34 { + t.Errorf("Change24h = %v, want -2.34", data.Change24h) + } + if data.Change7d != 5.67 { + t.Errorf("Change7d = %v, want 5.67", data.Change7d) + } + if data.MarketCap != 1_920_000_000_000 { + t.Errorf("MarketCap = %v, want 1920000000000", data.MarketCap) + } + if data.Volume24h != 28_500_000_000 { + t.Errorf("Volume24h = %v, want 28500000000", data.Volume24h) + } + if data.High24h != 98100.00 { + t.Errorf("High24h = %v, want 98100.00", data.High24h) + } + if data.Low24h != 95500.00 { + t.Errorf("Low24h = %v, want 95500.00", data.Low24h) + } +} + +func TestExtractCoinPrice_NameFallback(t *testing.T) { + doc := &extractortest.MockDocument{ + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "h1": { + &extractortest.MockNode{TextValue: "Ethereum"}, + }, + "span[data-converter-target='price']": { + &extractortest.MockNode{TextValue: "$3,456.78"}, + }, + }, + }, + } + + data, err := extractCoinPrice(doc) + if err != nil { + t.Fatalf("extractCoinPrice() error: %v", err) + } + + if data.Name != "Ethereum" { + t.Errorf("Name = %q, want %q", data.Name, "Ethereum") + } + if data.Price != 3456.78 { + t.Errorf("Price = %v, want 3456.78", data.Price) + } +} + +func TestExtractCoinPrice_PriceFallback(t *testing.T) { + doc := &extractortest.MockDocument{ + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{ + "span[data-price-target='price']": { + &extractortest.MockNode{TextValue: "$0.5432"}, + }, + }, + }, + } + + data, err := extractCoinPrice(doc) + if err != nil { + t.Fatalf("extractCoinPrice() error: %v", err) + } + + if data.Price != 0.5432 { + t.Errorf("Price = %v, want 0.5432", data.Price) + } +} + +func TestExtractCoinPrice_Empty(t *testing.T) { + doc := &extractortest.MockDocument{ + MockNode: extractortest.MockNode{ + Children: map[string]extractor.Nodes{}, + }, + } + + data, err := extractCoinPrice(doc) + if err != nil { + t.Fatalf("extractCoinPrice() error: %v", err) + } + + if data.Name != "" || data.Price != 0 { + t.Error("expected zero values for empty doc") + } +} + +func TestGetPrice_MockBrowser(t *testing.T) { + doc := makeBitcoinDoc() + + browser := &extractortest.MockBrowser{ + Documents: map[string]*extractortest.MockDocument{ + "https://www.coingecko.com/en/coins/bitcoin": doc, + }, + } + + data, err := DefaultConfig.GetPrice(context.Background(), browser, "bitcoin") + if err != nil { + t.Fatalf("GetPrice() error: %v", err) + } + + if data.Name != "Bitcoin" { + t.Errorf("Name = %q, want %q", data.Name, "Bitcoin") + } + if data.Price != 97234.56 { + t.Errorf("Price = %v, want 97234.56", data.Price) + } +} + +func TestParseLargeNumber(t *testing.T) { + tests := []struct { + input string + want float64 + }{ + {"$1.92T", 1_920_000_000_000}, + {"$28.5B", 28_500_000_000}, + {"$250M", 250_000_000}, + {"$1,234,567", 1234567}, + {"$0.50", 0.50}, + } + + for _, tt := range tests { + got := parseLargeNumber(tt.input) + if got != tt.want { + t.Errorf("parseLargeNumber(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestCoinURL(t *testing.T) { + got := coinURL("Bitcoin") + want := "https://www.coingecko.com/en/coins/bitcoin" + if got != want { + t.Errorf("coinURL(\"Bitcoin\") = %q, want %q", got, want) + } +} -- 2.49.1