Add proper error handling for price parsing, date parsing, and HTTP status codes in CheapShark, Epic, and ITAD integrations. Fix Epic dedup IDs to distinguish current vs upcoming offers. Bump CheapShark fetch page size from 10 to 60. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
5.9 KiB
Go
246 lines
5.9 KiB
Go
package deals
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ITADDeal struct {
|
|
GameID string
|
|
Slug string
|
|
Title string
|
|
Type string
|
|
Discount int
|
|
Price float64
|
|
Regular float64
|
|
Currency string
|
|
ShopName string
|
|
ShopID int
|
|
URL string
|
|
Flag string // "H" = historical low, "N" = new low
|
|
Timestamp time.Time
|
|
Expiry *time.Time
|
|
DedupID string
|
|
IsHistLow bool
|
|
}
|
|
|
|
type itadResponse struct {
|
|
List []itadEntry `json:"list"`
|
|
}
|
|
|
|
type itadEntry struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
Deal struct {
|
|
Shop struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"shop"`
|
|
Price struct {
|
|
Amount float64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
} `json:"price"`
|
|
Regular struct {
|
|
Amount float64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
} `json:"regular"`
|
|
Cut int `json:"cut"`
|
|
URL string `json:"url"`
|
|
Flag string `json:"flag"`
|
|
Timestamp string `json:"timestamp"`
|
|
Expiry *string `json:"expiry"`
|
|
} `json:"deal"`
|
|
}
|
|
|
|
// FetchITADDeals fetches deals from ITAD API v2.
|
|
func FetchITADDeals(apiKey string, limit int) ([]ITADDeal, error) {
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
|
|
url := fmt.Sprintf(
|
|
"https://api.isthereanydeal.com/deals/v2?key=%s&sort=-cut&limit=%d&nondeals=false",
|
|
apiKey, limit,
|
|
)
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("itad request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("itad returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var result itadResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("itad decode failed: %w", err)
|
|
}
|
|
|
|
var deals []ITADDeal
|
|
for _, e := range result.List {
|
|
// Only include games and DLC
|
|
t := strings.ToLower(e.Type)
|
|
if t != "game" && t != "dlc" {
|
|
continue
|
|
}
|
|
|
|
deal := ITADDeal{
|
|
GameID: e.ID,
|
|
Slug: e.Slug,
|
|
Title: e.Title,
|
|
Type: e.Type,
|
|
Discount: e.Deal.Cut,
|
|
Price: e.Deal.Price.Amount,
|
|
Regular: e.Deal.Regular.Amount,
|
|
Currency: e.Deal.Price.Currency,
|
|
ShopName: e.Deal.Shop.Name,
|
|
ShopID: e.Deal.Shop.ID,
|
|
URL: e.Deal.URL,
|
|
Flag: e.Deal.Flag,
|
|
IsHistLow: e.Deal.Flag == "H" || e.Deal.Flag == "N",
|
|
DedupID: fmt.Sprintf("itad-%s-%d-%d", e.ID, e.Deal.Shop.ID, e.Deal.Cut),
|
|
}
|
|
|
|
if ts, err := time.Parse(time.RFC3339, e.Deal.Timestamp); err == nil {
|
|
deal.Timestamp = ts
|
|
}
|
|
if e.Deal.Expiry != nil {
|
|
if exp, err := time.Parse(time.RFC3339, *e.Deal.Expiry); err == nil {
|
|
deal.Expiry = &exp
|
|
}
|
|
}
|
|
|
|
deals = append(deals, deal)
|
|
}
|
|
|
|
return deals, nil
|
|
}
|
|
|
|
// FilterITADDeals filters ITAD deals by discount and price thresholds.
|
|
func FilterITADDeals(deals []ITADDeal, minDiscount int, maxPrice float64) []ITADDeal {
|
|
var filtered []ITADDeal
|
|
for _, d := range deals {
|
|
if d.Discount < minDiscount {
|
|
slog.Debug("itad: skipping (low discount)", "title", d.Title, "discount", d.Discount)
|
|
continue
|
|
}
|
|
if d.Price > maxPrice {
|
|
slog.Debug("itad: skipping (too expensive)", "title", d.Title, "price", d.Price)
|
|
continue
|
|
}
|
|
filtered = append(filtered, d)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// LookupHistoricalLows checks if games are at their historical low price via ITAD.
|
|
// Takes a map of steamAppID -> deal index, returns map of steamAppID -> isHistoricalLow.
|
|
func LookupHistoricalLows(apiKey string, steamAppIDs []string) (map[string]bool, error) {
|
|
result := make(map[string]bool)
|
|
if apiKey == "" || len(steamAppIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
// Step 1: Look up ITAD game IDs from Steam app IDs
|
|
itadIDs := make(map[string]string) // steamAppID -> itadID
|
|
for _, appID := range steamAppIDs {
|
|
if appID == "" || appID == "0" {
|
|
continue
|
|
}
|
|
url := fmt.Sprintf(
|
|
"https://api.isthereanydeal.com/games/lookup/v1?key=%s&appid=%s",
|
|
apiKey, appID,
|
|
)
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
slog.Warn("itad lookup failed", "appid", appID, "error", err)
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
slog.Warn("itad lookup returned non-200", "appid", appID, "status", resp.StatusCode)
|
|
resp.Body.Close()
|
|
continue
|
|
}
|
|
|
|
var lookup struct {
|
|
Found bool `json:"found"`
|
|
Game struct {
|
|
ID string `json:"id"`
|
|
} `json:"game"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&lookup); err != nil {
|
|
resp.Body.Close()
|
|
continue
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if lookup.Found {
|
|
itadIDs[appID] = lookup.Game.ID
|
|
}
|
|
}
|
|
|
|
if len(itadIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
// Step 2: Get price overview for all looked-up games
|
|
var gameIDs []string
|
|
itadToSteam := make(map[string]string)
|
|
for steamID, itadID := range itadIDs {
|
|
gameIDs = append(gameIDs, itadID)
|
|
itadToSteam[itadID] = steamID
|
|
}
|
|
|
|
body, err := json.Marshal(gameIDs)
|
|
if err != nil {
|
|
return result, fmt.Errorf("itad overview marshal failed: %w", err)
|
|
}
|
|
url := fmt.Sprintf("https://api.isthereanydeal.com/games/overview/v2?key=%s", apiKey)
|
|
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
return result, fmt.Errorf("itad overview request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return result, fmt.Errorf("itad overview returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var overview struct {
|
|
Prices []struct {
|
|
ID string `json:"id"`
|
|
Current struct {
|
|
Price struct {
|
|
Amount float64 `json:"amount"`
|
|
} `json:"price"`
|
|
} `json:"current"`
|
|
Lowest struct {
|
|
Price struct {
|
|
Amount float64 `json:"amount"`
|
|
} `json:"price"`
|
|
} `json:"lowest"`
|
|
} `json:"prices"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil {
|
|
return result, fmt.Errorf("itad overview decode failed: %w", err)
|
|
}
|
|
|
|
for _, p := range overview.Prices {
|
|
if steamID, ok := itadToSteam[p.ID]; ok {
|
|
result[steamID] = p.Current.Price.Amount <= p.Lowest.Price.Amount
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|