feature: add CoinGecko cryptocurrency price extractor
All checks were successful
CI / build (pull_request) Successful in 46s
CI / vet (pull_request) Successful in 1m20s
CI / test (pull_request) Successful in 1m23s

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:
2026-02-15 16:47:53 +00:00
parent d0b3131d98
commit 349b1b9c6b
2 changed files with 367 additions and 0 deletions

View 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
}