Initial commit
This commit is contained in:
142
scripts/rsw/internal/prices/client.go
Normal file
142
scripts/rsw/internal/prices/client.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package prices
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const userAgent = "rsw-cli/1.0 (RuneScape Wiki CLI tool; https://github.com/runescape-wiki/rsw)"
|
||||
|
||||
// Client wraps HTTP requests to the real-time prices API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a price API client.
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LatestPrice represents the current GE price for an item.
|
||||
type LatestPrice struct {
|
||||
High *int `json:"high"`
|
||||
HighTime *int64 `json:"highTime"`
|
||||
Low *int `json:"low"`
|
||||
LowTime *int64 `json:"lowTime"`
|
||||
}
|
||||
|
||||
// LatestResponse maps item IDs to their latest prices.
|
||||
type LatestResponse struct {
|
||||
Data map[string]LatestPrice `json:"data"`
|
||||
}
|
||||
|
||||
// MappingItem maps an item ID to metadata.
|
||||
type MappingItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Examine string `json:"examine"`
|
||||
Members bool `json:"members"`
|
||||
HighAlch *int `json:"highalch"`
|
||||
LowAlch *int `json:"lowalch"`
|
||||
Limit *int `json:"limit"`
|
||||
Value int `json:"value"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// GetLatest fetches the latest prices for all items.
|
||||
func (c *Client) GetLatest() (*LatestResponse, error) {
|
||||
url := fmt.Sprintf("%s/latest", c.baseURL)
|
||||
var resp LatestResponse
|
||||
if err := c.getJSON(url, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetLatestForItem fetches the latest price for a specific item by ID.
|
||||
func (c *Client) GetLatestForItem(itemID int) (*LatestPrice, error) {
|
||||
url := fmt.Sprintf("%s/latest?id=%d", c.baseURL, itemID)
|
||||
var resp LatestResponse
|
||||
if err := c.getJSON(url, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idStr := fmt.Sprintf("%d", itemID)
|
||||
if p, ok := resp.Data[idStr]; ok {
|
||||
return &p, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no price data for item %d", itemID)
|
||||
}
|
||||
|
||||
// GetMapping fetches the item ID → metadata mapping.
|
||||
func (c *Client) GetMapping() ([]MappingItem, error) {
|
||||
url := fmt.Sprintf("%s/mapping", c.baseURL)
|
||||
var resp []MappingItem
|
||||
if err := c.getJSON(url, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// VolumeEntry represents trade volume data for an item.
|
||||
type VolumeEntry struct {
|
||||
AvgHighPrice *int `json:"avgHighPrice"`
|
||||
HighVolume int `json:"highPriceVolume"`
|
||||
AvgLowPrice *int `json:"avgLowPrice"`
|
||||
LowVolume int `json:"lowPriceVolume"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// TimeseriesResponse wraps the 5m/1h API responses.
|
||||
type TimeseriesResponse struct {
|
||||
Data []VolumeEntry `json:"data"`
|
||||
}
|
||||
|
||||
// Get5Min fetches 5-minute average data for an item.
|
||||
func (c *Client) Get5Min(itemID int) (*TimeseriesResponse, error) {
|
||||
url := fmt.Sprintf("%s/5m?id=%d", c.baseURL, itemID)
|
||||
var resp TimeseriesResponse
|
||||
if err := c.getJSON(url, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Get1Hour fetches 1-hour average data for an item.
|
||||
func (c *Client) Get1Hour(itemID int) (*TimeseriesResponse, error) {
|
||||
url := fmt.Sprintf("%s/1h?id=%d", c.baseURL, itemID)
|
||||
var resp TimeseriesResponse
|
||||
if err := c.getJSON(url, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(url string, dest interface{}) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("executing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(dest)
|
||||
}
|
||||
119
scripts/rsw/internal/prices/mapping.go
Normal file
119
scripts/rsw/internal/prices/mapping.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package prices
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cacheTTL = 24 * time.Hour
|
||||
|
||||
// MappingCache provides a local cache of the item ID ↔ name mapping.
|
||||
type MappingCache struct {
|
||||
client *Client
|
||||
items []MappingItem
|
||||
byID map[int]*MappingItem
|
||||
byName map[string]*MappingItem
|
||||
cacheDir string
|
||||
}
|
||||
|
||||
// NewMappingCache creates a mapping cache backed by the given client.
|
||||
func NewMappingCache(client *Client) *MappingCache {
|
||||
home, _ := os.UserHomeDir()
|
||||
return &MappingCache{
|
||||
client: client,
|
||||
byID: make(map[int]*MappingItem),
|
||||
byName: make(map[string]*MappingItem),
|
||||
cacheDir: filepath.Join(home, ".rsw", "cache"),
|
||||
}
|
||||
}
|
||||
|
||||
// Load populates the cache, reading from disk if fresh or fetching from API.
|
||||
func (mc *MappingCache) Load() error {
|
||||
// Try disk cache first
|
||||
if mc.loadFromDisk() {
|
||||
return nil
|
||||
}
|
||||
|
||||
items, err := mc.client.GetMapping()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching mapping: %w", err)
|
||||
}
|
||||
|
||||
mc.items = items
|
||||
mc.index()
|
||||
mc.saveToDisk()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupByName finds an item by exact name (case-insensitive).
|
||||
func (mc *MappingCache) LookupByName(name string) *MappingItem {
|
||||
return mc.byName[strings.ToLower(name)]
|
||||
}
|
||||
|
||||
// SearchByName finds items whose name contains the query (case-insensitive).
|
||||
func (mc *MappingCache) SearchByName(query string) []*MappingItem {
|
||||
query = strings.ToLower(query)
|
||||
var results []*MappingItem
|
||||
for i := range mc.items {
|
||||
if strings.Contains(strings.ToLower(mc.items[i].Name), query) {
|
||||
results = append(results, &mc.items[i])
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// LookupByID finds an item by its ID.
|
||||
func (mc *MappingCache) LookupByID(id int) *MappingItem {
|
||||
return mc.byID[id]
|
||||
}
|
||||
|
||||
func (mc *MappingCache) index() {
|
||||
mc.byID = make(map[int]*MappingItem, len(mc.items))
|
||||
mc.byName = make(map[string]*MappingItem, len(mc.items))
|
||||
for i := range mc.items {
|
||||
mc.byID[mc.items[i].ID] = &mc.items[i]
|
||||
mc.byName[strings.ToLower(mc.items[i].Name)] = &mc.items[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MappingCache) cachePath() string {
|
||||
return filepath.Join(mc.cacheDir, "mapping.json")
|
||||
}
|
||||
|
||||
func (mc *MappingCache) loadFromDisk() bool {
|
||||
path := mc.cachePath()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if time.Since(info.ModTime()) > cacheTTL {
|
||||
return false
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var items []MappingItem
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
mc.items = items
|
||||
mc.index()
|
||||
return true
|
||||
}
|
||||
|
||||
func (mc *MappingCache) saveToDisk() {
|
||||
_ = os.MkdirAll(mc.cacheDir, 0755)
|
||||
data, err := json.Marshal(mc.items)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(mc.cachePath(), data, 0644)
|
||||
}
|
||||
Reference in New Issue
Block a user