Initial commit

This commit is contained in:
2026-03-05 01:13:19 -06:00
commit 1ae223a1dc
21 changed files with 2404 additions and 0 deletions

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

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