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 <noreply@anthropic.com>
This commit is contained in:
169
sites/coingecko/coingecko.go
Normal file
169
sites/coingecko/coingecko.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user