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 }