package prices import ( "encoding/json" "fmt" "io" "net/http" "time" ) const rs3BaseURL = "https://secure.runescape.com/m=itemdb_rs" // RS3Client wraps HTTP requests to the Jagex RS3 Grand Exchange API. type RS3Client struct { httpClient *http.Client } // NewRS3Client creates an RS3 price API client. func NewRS3Client() *RS3Client { return &RS3Client{ httpClient: &http.Client{ Timeout: 15 * time.Second, }, } } // RS3ItemDetail holds current price and trend data for an RS3 item. type RS3ItemDetail struct { ID int Name string Description string Members string CurrentPrice string // pre-formatted, e.g. "91.6b" CurrentTrend string TodayPrice int TodayTrend string Day30Change string Day30Trend string Day90Change string Day90Trend string Day180Change string Day180Trend string } // rs3DetailResponse matches the JSON shape from the Jagex catalogue API. type rs3DetailResponse struct { Item struct { ID int `json:"id"` Name string `json:"name"` Description string `json:"description"` Type string `json:"type"` Members string `json:"members"` Current struct { Trend string `json:"trend"` Price any `json:"price"` // string or int } `json:"current"` Today struct { Trend string `json:"trend"` Price any `json:"price"` // int or string } `json:"today"` Day30 struct { Trend string `json:"trend"` Change string `json:"change"` } `json:"day30"` Day90 struct { Trend string `json:"trend"` Change string `json:"change"` } `json:"day90"` Day180 struct { Trend string `json:"trend"` Change string `json:"change"` } `json:"day180"` } `json:"item"` } // GetDetail fetches current price and trend data for an RS3 item by ID. func (c *RS3Client) GetDetail(itemID int) (*RS3ItemDetail, error) { url := fmt.Sprintf("%s/api/catalogue/detail.json?item=%d", rs3BaseURL, itemID) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("User-Agent", userAgent) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("executing request: %w", err) } defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("RS3 API returned status %d: %s", resp.StatusCode, string(body)) } var raw rs3DetailResponse if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } detail := &RS3ItemDetail{ ID: raw.Item.ID, Name: raw.Item.Name, Description: raw.Item.Description, Members: raw.Item.Members, CurrentPrice: fmt.Sprintf("%v", raw.Item.Current.Price), CurrentTrend: raw.Item.Current.Trend, TodayTrend: raw.Item.Today.Trend, Day30Change: raw.Item.Day30.Change, Day30Trend: raw.Item.Day30.Trend, Day90Change: raw.Item.Day90.Change, Day90Trend: raw.Item.Day90.Trend, Day180Change: raw.Item.Day180.Change, Day180Trend: raw.Item.Day180.Trend, } // today.price can be int or string switch v := raw.Item.Today.Price.(type) { case float64: detail.TodayPrice = int(v) } return detail, nil }