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 <noreply@anthropic.com>
170 lines
4.1 KiB
Go
170 lines
4.1 KiB
Go
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
|
|
}
|