addign odd

This commit is contained in:
OneTap Technologies 2025-04-11 13:57:32 +03:00
parent 92250d61a8
commit 1d6a533f7e
9 changed files with 318 additions and 218 deletions

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
// "context"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@ -35,39 +36,34 @@ import (
// @name Authorization // @name Authorization
// @BasePath / // @BasePath /
func main() { func main() {
// Load config
cfg, err := config.NewConfig() cfg, err := config.NewConfig()
if err != nil { if err != nil {
slog.Error("❌ Config error:", "err", err) slog.Error("❌ Config error:", "err", err)
os.Exit(1) os.Exit(1)
} }
// Connect to database
db, _, err := repository.OpenDB(cfg.DbUrl) db, _, err := repository.OpenDB(cfg.DbUrl)
if err != nil { if err != nil {
fmt.Println("❌ Database error:", err) fmt.Println("❌ Database error:", err)
os.Exit(1) os.Exit(1)
} }
// Init core services
logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel)
store := repository.NewStore(db) store := repository.NewStore(db)
v := customvalidator.NewCustomValidator(validator.New()) v := customvalidator.NewCustomValidator(validator.New())
// Auth and user services
authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry)
mockSms := mocksms.NewMockSMS() mockSms := mocksms.NewMockSMS()
mockEmail := mockemail.NewMockEmail() mockEmail := mockemail.NewMockEmail()
userSvc := user.NewService(store, store, mockSms, mockEmail) userSvc := user.NewService(store, store, mockSms, mockEmail)
// 🎯 Event & Odds fetching services
eventSvc := event.New(cfg.Bet365Token, store) eventSvc := event.New(cfg.Bet365Token, store)
oddsSvc := odds.New(cfg.Bet365Token, store) oddsSvc := odds.New(cfg.Bet365Token, store)
// 🕒 Start scheduled cron jobs
httpserver.StartDataFetchingCrons(eventSvc, oddsSvc) httpserver.StartDataFetchingCrons(eventSvc, oddsSvc)
// 🚀 Start HTTP server
app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{
JwtAccessKey: cfg.JwtKey, JwtAccessKey: cfg.JwtKey,
JwtAccessExpiry: cfg.AccessExpiry, JwtAccessExpiry: cfg.AccessExpiry,

View File

@ -88,17 +88,33 @@ CREATE TABLE events (
CREATE TABLE odds ( CREATE TABLE odds (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
event_id TEXT, -- Parsed from "FI" (Bet365 Event ID). Nullable in case of failures.
market_type TEXT NOT NULL, -- E.g., 'asian_handicap', 'goal_line', 'both_teams_to_score' -- Core IDs
header TEXT, -- E.g., '1', '2', 'Over', 'Under', 'Draw', 'Yes', 'No' event_id TEXT,
name TEXT, -- Bet name like "2.5", "Over 2.5 & Yes", etc. fi TEXT, -- ✅ from Market.FI
odds_value DOUBLE PRECISION, -- The numeric odds (e.g., 1.920) raw_event_id TEXT, -- Original event ID if different
handicap TEXT, -- Handicap value like "-0.5", "0.0, +0.5"
section TEXT NOT NULL, -- Odds section: 'asian_lines', 'goals', etc. -- Market info
category TEXT, -- Market category (e.g., 'sp') market_type TEXT NOT NULL, -- e.g., "asian_handicap"
market_id TEXT, -- Market ID from the API (e.g., "938", "50138") market_name TEXT, -- ✅ from Market.MarketName
fetched_at TIMESTAMP DEFAULT now(), -- When this record was fetched market_category TEXT, -- ✅ from Market.marketcatagory (like "asian_lines")
source TEXT DEFAULT 'b365api', -- Source identifier market_id TEXT, -- e.g., "938"
is_active BOOLEAN DEFAULT true, -- Optional deactivation flag
raw_event_id TEXT -- Original/failed event ID if event_id is nil or invalid -- Odds detail
header TEXT,
name TEXT,
handicap TEXT,
odds_value DOUBLE PRECISION,
-- Meta
section TEXT NOT NULL,
category TEXT,
raw_odds JSONB, -- ✅ store full odds array here
fetched_at TIMESTAMP DEFAULT now(),
source TEXT DEFAULT 'b365api',
is_active BOOLEAN DEFAULT true,
-- Conflict resolution key
UNIQUE (event_id, market_id, header, name, handicap)
); );

View File

@ -1,13 +1,38 @@
-- name: InsertNonLiveOdd :exec -- name: InsertNonLiveOdd :exec
INSERT INTO odds ( INSERT INTO odds (
event_id, market_type, header, name, odds_value, handicap, event_id,
section, category, market_id, is_active, source, fetched_at, raw_event_id fi,
raw_event_id,
market_type,
market_name,
market_category,
market_id,
header,
name,
handicap,
odds_value,
section,
category,
raw_odds,
is_active,
source,
fetched_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $1, $2, $3, $4, $5, $6, $7,
$7, $8, $9, true, 'b365api', now(), $10 $8, $9, $10, $11, $12, $13, $14,
); true, 'b365api', now()
)
ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET
odds_value = EXCLUDED.odds_value,
raw_odds = EXCLUDED.raw_odds,
market_type = EXCLUDED.market_type,
market_name = EXCLUDED.market_name,
market_category = EXCLUDED.market_category,
fetched_at = now(),
is_active = true,
source = 'b365api',
fi = EXCLUDED.fi,
raw_event_id = EXCLUDED.raw_event_id;
-- name: GetUpcomingEventIDs :many -- name: GetUpcomingEventIDs :many
SELECT id FROM events SELECT id FROM events

View File

@ -35,18 +35,22 @@ type Event struct {
type Odd struct { type Odd struct {
ID int32 ID int32
EventID pgtype.Text EventID pgtype.Text
Fi pgtype.Text
RawEventID pgtype.Text
MarketType string MarketType string
MarketName pgtype.Text
MarketCategory pgtype.Text
MarketID pgtype.Text
Header pgtype.Text Header pgtype.Text
Name pgtype.Text Name pgtype.Text
OddsValue pgtype.Float8
Handicap pgtype.Text Handicap pgtype.Text
OddsValue pgtype.Float8
Section string Section string
Category pgtype.Text Category pgtype.Text
MarketID pgtype.Text RawOdds []byte
FetchedAt pgtype.Timestamp FetchedAt pgtype.Timestamp
Source pgtype.Text Source pgtype.Text
IsActive pgtype.Bool IsActive pgtype.Bool
RawEventID pgtype.Text
} }
type Otp struct { type Otp struct {

View File

@ -38,39 +38,74 @@ func (q *Queries) GetUpcomingEventIDs(ctx context.Context) ([]string, error) {
const InsertNonLiveOdd = `-- name: InsertNonLiveOdd :exec const InsertNonLiveOdd = `-- name: InsertNonLiveOdd :exec
INSERT INTO odds ( INSERT INTO odds (
event_id, market_type, header, name, odds_value, handicap, event_id,
section, category, market_id, is_active, source, fetched_at, raw_event_id fi,
raw_event_id,
market_type,
market_name,
market_category,
market_id,
header,
name,
handicap,
odds_value,
section,
category,
raw_odds,
is_active,
source,
fetched_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $1, $2, $3, $4, $5, $6, $7,
$7, $8, $9, true, 'b365api', now(), $10 $8, $9, $10, $11, $12, $13, $14,
true, 'b365api', now()
) )
ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET
odds_value = EXCLUDED.odds_value,
raw_odds = EXCLUDED.raw_odds,
market_type = EXCLUDED.market_type,
market_name = EXCLUDED.market_name,
market_category = EXCLUDED.market_category,
fetched_at = now(),
is_active = true,
source = 'b365api',
fi = EXCLUDED.fi,
raw_event_id = EXCLUDED.raw_event_id
` `
type InsertNonLiveOddParams struct { type InsertNonLiveOddParams struct {
EventID pgtype.Text EventID pgtype.Text
Fi pgtype.Text
RawEventID pgtype.Text
MarketType string MarketType string
MarketName pgtype.Text
MarketCategory pgtype.Text
MarketID pgtype.Text
Header pgtype.Text Header pgtype.Text
Name pgtype.Text Name pgtype.Text
OddsValue pgtype.Float8
Handicap pgtype.Text Handicap pgtype.Text
OddsValue pgtype.Float8
Section string Section string
Category pgtype.Text Category pgtype.Text
MarketID pgtype.Text RawOdds []byte
RawEventID pgtype.Text
} }
func (q *Queries) InsertNonLiveOdd(ctx context.Context, arg InsertNonLiveOddParams) error { func (q *Queries) InsertNonLiveOdd(ctx context.Context, arg InsertNonLiveOddParams) error {
_, err := q.db.Exec(ctx, InsertNonLiveOdd, _, err := q.db.Exec(ctx, InsertNonLiveOdd,
arg.EventID, arg.EventID,
arg.Fi,
arg.RawEventID,
arg.MarketType, arg.MarketType,
arg.MarketName,
arg.MarketCategory,
arg.MarketID,
arg.Header, arg.Header,
arg.Name, arg.Name,
arg.OddsValue,
arg.Handicap, arg.Handicap,
arg.OddsValue,
arg.Section, arg.Section,
arg.Category, arg.Category,
arg.MarketID, arg.RawOdds,
arg.RawEventID,
) )
return err return err
} }

2
go.mod
View File

@ -9,6 +9,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/fiber-swagger v1.3.0
github.com/swaggo/swag v1.16.4 github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.36.0
@ -39,7 +40,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

View File

@ -1,14 +1,17 @@
package domain package domain
type OddsRecord struct { import (
EventID string "encoding/json"
MarketType string "time"
Header string )
Name string
OddsValue float64 type Market struct {
Handicap string EventID string // 7549892
Section string FI string // 147543881
Category string MarketCategory string // Corrected spelling and casing
MarketID string MarketType string // e.g., "asian_handicap", "goal_line"
RawEventID string MarketName string // e.g., "Asian Handicap"
MarketID string // e.g., "938"
UpdatedAt time.Time // parsed from "updated_at"
Odds []json.RawMessage // oddd is sometimes null
} }

View File

@ -2,63 +2,79 @@ package repository
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os"
"time"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
func (s *Store) SaveNonLiveOdd(ctx context.Context, o domain.OddsRecord) error { func (s *Store) SaveNonLiveOdd(ctx context.Context, m domain.Market) error {
rawOddsBytes, _ := json.Marshal(m.Odds)
params := dbgen.InsertNonLiveOddParams{ params := dbgen.InsertNonLiveOddParams{
EventID: pgtype.Text{ EventID: pgtype.Text{String: m.EventID, Valid: m.EventID != ""},
String: o.EventID, Fi: pgtype.Text{String: m.FI, Valid: m.FI != ""},
Valid: o.EventID != "", RawEventID: pgtype.Text{String: m.EventID, Valid: m.EventID != ""},
}, MarketType: m.MarketType,
MarketType: o.MarketType, MarketName: pgtype.Text{String: m.MarketName, Valid: m.MarketName != ""},
Header: pgtype.Text{ MarketCategory: pgtype.Text{String: m.MarketCategory, Valid: m.MarketCategory != ""},
String: o.Header, MarketID: pgtype.Text{String: m.MarketID, Valid: m.MarketID != ""},
Valid: o.Header != "", Header: pgtype.Text{String: "", Valid: false},
}, Name: pgtype.Text{String: "", Valid: false},
Name: pgtype.Text{ Handicap: pgtype.Text{String: "", Valid: false},
String: o.Name, OddsValue: pgtype.Float8{Float64: 0, Valid: false},
Valid: o.Name != "", Section: m.MarketCategory,
}, Category: pgtype.Text{String: "", Valid: false},
OddsValue: pgtype.Float8{ RawOdds: rawOddsBytes,
Float64: o.OddsValue,
Valid: true,
},
Handicap: pgtype.Text{
String: o.Handicap,
Valid: o.Handicap != "",
},
Section: o.Section,
Category: pgtype.Text{
String: o.Category,
Valid: o.Category != "",
},
MarketID: pgtype.Text{
String: o.MarketID,
Valid: o.MarketID != "",
},
RawEventID: pgtype.Text{
String: o.RawEventID,
Valid: o.RawEventID != "",
},
} }
err := s.queries.InsertNonLiveOdd(ctx, params) err := s.queries.InsertNonLiveOdd(ctx, params)
if err != nil { if err != nil {
fmt.Printf("❌ Failed to insert odd: event_id=%s | market=%s | odds=%.3f | error=%v\n", fmt.Printf("❌ Failed to insert/upsert market: event_id=%s | market_type=%s | err=%v\n",
o.EventID, o.MarketType, o.OddsValue, err) m.EventID, m.MarketType, err)
_ = writeFailedMarketLog(m, err)
} else { } else {
fmt.Printf("✅ Stored: event_id=%s | market=%s | odds=%.3f\n", o.EventID, o.MarketType, o.OddsValue) fmt.Printf("✅ Upserted market: event_id=%s | market_type=%s\n", m.EventID, m.MarketType)
} }
return err return err
} }
func writeFailedMarketLog(m domain.Market, err error) error {
logDir := "logs"
logFile := logDir + "/failed_markets.log"
if mkErr := os.MkdirAll(logDir, 0755); mkErr != nil {
return mkErr
}
f, fileErr := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if fileErr != nil {
return fileErr
}
defer f.Close()
entry := struct {
Time string `json:"time"`
Error string `json:"error"`
Record domain.Market `json:"record"`
}{
Time: time.Now().Format(time.RFC3339),
Error: err.Error(),
Record: m,
}
jsonData, _ := json.MarshalIndent(entry, "", " ")
_, writeErr := f.WriteString(string(jsonData) + "\n\n")
return writeErr
}
func (s *Store) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { func (s *Store) GetUpcomingEventIDs(ctx context.Context) ([]string, error) {
return s.queries.GetUpcomingEventIDs(ctx) return s.queries.GetUpcomingEventIDs(ctx)
} }

View File

@ -7,7 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"sync" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
@ -21,30 +21,48 @@ type ServiceImpl struct {
func New(token string, store *repository.Store) *ServiceImpl { func New(token string, store *repository.Store) *ServiceImpl {
return &ServiceImpl{token: token, store: store} return &ServiceImpl{token: token, store: store}
} }
func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error {
eventIDs, err := s.store.GetUpcomingEventIDs(ctx) sportIDs := []int{1, 13, 78, 18, 91, 16, 17}
for _, sportID := range sportIDs {
upcomingURL := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s", sportID, s.token)
resp, err := http.Get(upcomingURL)
if err != nil { if err != nil {
return fmt.Errorf("fetch upcoming event IDs: %w", err) fmt.Printf("❌ Failed to fetch upcoming for sport_id=%d: %v\n", sportID, err)
continue
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var data struct {
Success int `json:"success"`
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 {
fmt.Printf("❌ Failed to decode upcoming for sport_id=%d\nRaw: %s\n", sportID, string(body))
continue
} }
type OddsMarket struct { for _, ev := range data.Results {
ID string `json:"id"` if getString(ev["type"]) != "EV" {
Name string `json:"name"` continue
Odds []struct { }
ID string `json:"id"` eventID := getString(ev["ID"])
Odds string `json:"odds"` if eventID == "" {
Header string `json:"header,omitempty"` continue
Name string `json:"name,omitempty"`
Handicap string `json:"handicap,omitempty"`
} `json:"odds"`
} }
type OddsSection struct { prematchURL := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%s", s.token, eventID)
UpdatedAt string `json:"updated_at"` oddsResp, err := http.Get(prematchURL)
Sp map[string]OddsMarket `json:"sp"` if err != nil {
fmt.Printf("❌ Odds fetch failed for event_id=%s: %v\n", eventID, err)
continue
} }
defer oddsResp.Body.Close()
type StructuredOddsResponse struct { oddsBody, _ := io.ReadAll(oddsResp.Body)
var oddsData struct {
Success int `json:"success"` Success int `json:"success"`
Results []struct { Results []struct {
EventID string `json:"event_id"` EventID string `json:"event_id"`
@ -53,100 +71,87 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error {
Goals OddsSection `json:"goals"` Goals OddsSection `json:"goals"`
} `json:"results"` } `json:"results"`
} }
if err := json.Unmarshal(oddsBody, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 {
var wg sync.WaitGroup fmt.Printf("❌ Failed odds decode for event_id=%s\nRaw: %s\n", eventID, string(oddsBody))
sem := make(chan struct{}, 5)
for _, eventID := range eventIDs {
if eventID == "" || len(eventID) < 5 {
continue continue
} }
wg.Add(1) result := oddsData.Results[0]
go func(originalID string) { finalID := result.EventID
defer wg.Done() if finalID == "" {
sem <- struct{}{} finalID = result.FI
defer func() { <-sem }()
url := fmt.Sprintf("https://api.b365api.com/v1/bet365/odds?token=%s&event_id=%s", s.token, originalID)
resp, err := http.Get(url)
if err != nil {
fmt.Printf(" Failed HTTP request for event_id=%s: %v\n", originalID, err)
return
} }
defer resp.Body.Close() if finalID == "" {
fmt.Println("⚠️ Skipping event with missing final ID.")
body, err := io.ReadAll(resp.Body) continue
if err != nil {
fmt.Printf(" Failed to read response body for event_id=%s: %v\n", originalID, err)
return
} }
var data StructuredOddsResponse saveOdds := func(sectionName string, section OddsSection) error {
if err := json.Unmarshal(body, &data); err != nil { if len(section.Sp) == 0 {
fmt.Printf(" JSON unmarshal failed for event_id=%s. Response: %s\n", originalID, string(body)) fmt.Printf("⚠️ No odds in section '%s' for event_id=%s\n", sectionName, finalID)
return return nil
} }
if data.Success != 1 || len(data.Results) == 0 { updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64)
fmt.Printf(" API response error or no results for event_id=%s\nBody: %s\n", originalID, string(body)) updatedAt := time.Unix(updatedAtUnix, 0)
return
}
result := data.Results[0]
finalEventID := result.EventID
if finalEventID == "" {
finalEventID = result.FI
}
if finalEventID == "" {
fmt.Printf(" Skipping event_id=%s due to missing both event_id and FI\n", originalID)
return
}
saveOdds := func(sectionName string, section OddsSection) {
for marketType, market := range section.Sp { for marketType, market := range section.Sp {
for _, odd := range market.Odds { if len(market.Odds) == 0 {
val, err := strconv.ParseFloat(odd.Odds, 64) fmt.Printf("⚠️ No odds for marketType=%s in section=%s\n", marketType, sectionName)
if err != nil {
fmt.Printf(" Skipping invalid odds for market=%s, event_id=%s\n", marketType, finalEventID)
continue continue
} }
record := domain.OddsRecord{ marketRecord := domain.Market{
EventID: finalEventID, EventID: finalID,
FI: result.FI,
MarketCategory: sectionName,
MarketType: marketType, MarketType: marketType,
Header: odd.Header, MarketName: market.Name,
Name: odd.Name, MarketID: market.ID,
Handicap: odd.Handicap, UpdatedAt: updatedAt,
OddsValue: val, Odds: market.Odds,
Section: sectionName,
Category: market.ID,
MarketID: odd.ID,
RawEventID: originalID,
} }
fmt.Printf("🟡 Preparing to store: event_id=%s | market=%s | header=%s | name=%s | odds=%.3f\n", s.store.SaveNonLiveOdd(ctx, marketRecord)
finalEventID, marketType, odd.Header, odd.Name, val) fmt.Printf("✅ STORED MARKET: event_id=%s | type=%s | name=%s\n", finalID, marketType, market.Name)
}
return nil
}
err = s.store.SaveNonLiveOdd(ctx, record) if err := saveOdds("asian_lines", result.AsianLines); err != nil {
if err != nil { fmt.Printf("⚠️ Skipping event %s due to asian_lines error: %v\n", finalID, err)
fmt.Printf("❌ DB save error for market=%s, event_id=%s: %v\nRecord: %+v\n", marketType, finalEventID, err, record) continue
} else {
fmt.Printf("✅ Stored odd: event_id=%s | market=%s | odds=%.3f\n", finalEventID, marketType, val)
} }
if err := saveOdds("goals", result.Goals); err != nil {
fmt.Printf("⚠️ Skipping event %s due to goals error: %v\n", finalID, err)
continue
} }
fmt.Printf("✅ Done storing all odds for event_id=%s\n", finalID)
} }
} }
saveOdds("asian_lines", result.AsianLines)
saveOdds("goals", result.Goals)
}(eventID)
}
wg.Wait()
fmt.Println("✅ All non-live odds fetched and stored.") fmt.Println("✅ All non-live odds fetched and stored.")
return nil return nil
} }
// Odds structures
type OddsMarket struct {
ID string `json:"id"`
Name string `json:"name"`
Odds []json.RawMessage `json:"odds"`
}
type OddsSection struct {
UpdatedAt string `json:"updated_at"`
Sp map[string]OddsMarket `json:"sp"`
}
// Helper
func getString(v interface{}) string {
if str, ok := v.(string); ok {
return str
}
return ""
}