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
}

View File

@@ -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)
}
}