Item price support

This commit is contained in:
2026-03-05 22:45:55 -06:00
parent 4282c2a770
commit d920a1e62d
15 changed files with 834 additions and 26 deletions

View File

@@ -13,7 +13,7 @@
"name": "wiki", "name": "wiki",
"source": "./plugins/rsw", "source": "./plugins/rsw",
"description": "RuneScape Wiki CLI", "description": "RuneScape Wiki CLI",
"version": "1.0.0", "version": "1.0.1",
"author": { "author": {
"name": "Sam Myers" "name": "Sam Myers"
} }

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# RuneScape Wiki — Claude Code Plugin
Query the RuneScape Wiki directly from Claude Code. Look up items, quests, skill training guides, and live Grand Exchange prices for both **OSRS** and **RS3**, without leaving your terminal.
## Installation
1. Add the marketplace:
```
plugin marketplace add https://git.samlab.cloud/sam/claude-plugin-runescape.git
```
2. Install the plugin:
```
/plugins
```
Search for `wiki` and install **runescape-wiki**.
> **Requires Go** to be installed. The plugin binary is built automatically on first use.
## What it does
Once installed, Claude can use the RuneScape Wiki tools when you ask questions like:
- *"How do I get an abyssal whip in OSRS?"*
- *"What are the requirements for Plague's End in RS3?"*
- *"What's the current GE price of a blue partyhat?"*
- *"Show me a mining training guide for levels 5070."*
- *"I'm an ironman — how do I get dragon bones?"*
## Commands
All commands take the game (`osrs` or `rs3`) as the first argument.
### `item <name>`
Look up an item's stats, equipment bonuses, drop sources, and Grand Exchange price.
```
rsw osrs item "abyssal whip"
rsw rs3 item "dragon bones" --ironman
```
### `quest <name>`
Look up quest requirements, items needed, enemies to defeat, and rewards.
```
rsw osrs quest "Monkey Madness I"
rsw rs3 quest "Plague's End"
```
### `skill <name>`
Fetch a skill training guide with section-by-section methods. Filter to a level range with `--level`.
```
rsw osrs skill mining
rsw rs3 skill prayer --level 50-70
```
### `price <name>`
Get live Grand Exchange prices including instant buy/sell, buy limit, and recent 1-hour trade volume.
```
rsw osrs price "abyssal whip"
rsw rs3 price "blue partyhat"
```
## Flags
| Flag | Description |
|------|-------------|
| `--ironman` / `-i` | Ironman mode: hides GE prices, emphasizes drops, shops, and self-sufficient methods |
| `--raw` | Output raw wikitext instead of rendered markdown |
## Supported games
| Argument | Wiki |
|----------|------|
| `osrs` | [oldschool.runescape.wiki](https://oldschool.runescape.wiki) |
| `rs3` | [runescape.wiki](https://runescape.wiki) |

15
scripts/rsw/Makefile Normal file
View File

@@ -0,0 +1,15 @@
.PHONY: build test lint clean
BIN := rsw
build:
go build -o $(BIN) .
test:
go test ./... -count=1
lint:
golangci-lint run ./...
clean:
rm -f $(BIN)

View File

@@ -122,8 +122,13 @@ func findItemInfobox(templates []extract.Infobox) *extract.Infobox {
} }
func renderGEPrice(md *render.Builder, name, pageTitle string) { func renderGEPrice(md *render.Builder, name, pageTitle string) {
if Game() == "rs3" {
renderRS3GEPrice(md, pageTitle)
return
}
priceClient := prices.NewClient(GamePriceBaseURL()) priceClient := prices.NewClient(GamePriceBaseURL())
mc := prices.NewMappingCache(priceClient) mc := prices.NewMappingCache(priceClient, Game())
if err := mc.Load(); err != nil { if err := mc.Load(); err != nil {
return return
} }
@@ -152,6 +157,32 @@ func renderGEPrice(md *render.Builder, name, pageTitle string) {
md.Newline() md.Newline()
} }
func renderRS3GEPrice(md *render.Builder, pageTitle string) {
wikiClient := wiki.NewClient(GameBaseURL())
exchangeItem, err := wikiClient.GetExchangeModule(pageTitle)
if err != nil || exchangeItem == nil {
return
}
rs3Client := prices.NewRS3Client()
detail, err := rs3Client.GetDetail(exchangeItem.ItemID)
if err != nil {
return
}
md.H2("Grand Exchange")
md.KV("Current price", detail.CurrentPrice+" gp")
if detail.TodayPrice != 0 {
md.KV("Today's change", render.FormatGP(detail.TodayPrice))
} else {
md.KV("Today's change", "0 gp")
}
if exchangeItem.Limit > 0 {
md.KV("Buy limit", render.FormatNumber(exchangeItem.Limit))
}
md.Newline()
}
func renderDropSources(md *render.Builder, templates []extract.Infobox) { func renderDropSources(md *render.Builder, templates []extract.Infobox) {
drops := extract.FindAllTemplates(templates, "DropsLine") drops := extract.FindAllTemplates(templates, "DropsLine")
if len(drops) == 0 { if len(drops) == 0 {
@@ -182,7 +213,7 @@ func renderSourcesSection(md *render.Builder, page *wiki.ParsedPage, client *wik
lower := strings.ToLower(s.Line) lower := strings.ToLower(s.Line)
if lower == "item sources" || lower == "sources" || lower == "obtaining" || lower == "acquisition" { if lower == "item sources" || lower == "sources" || lower == "obtaining" || lower == "acquisition" {
idx := 0 idx := 0
fmt.Sscanf(s.Index, "%d", &idx) _, _ = fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 { if idx > 0 {
sectionPage, err := client.GetPageSection(title, idx) sectionPage, err := client.GetPageSection(title, idx)
if err == nil { if err == nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/runescape-wiki/rsw/internal/prices" "github.com/runescape-wiki/rsw/internal/prices"
"github.com/runescape-wiki/rsw/internal/render" "github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -25,8 +26,13 @@ Examples:
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
name := args[0] name := args[0]
if Game() == "rs3" {
return runRS3Price(name)
}
priceClient := prices.NewClient(GamePriceBaseURL()) priceClient := prices.NewClient(GamePriceBaseURL())
mc := prices.NewMappingCache(priceClient) mc := prices.NewMappingCache(priceClient, Game())
if err := mc.Load(); err != nil { if err := mc.Load(); err != nil {
return fmt.Errorf("failed to load item mapping: %w", err) return fmt.Errorf("failed to load item mapping: %w", err)
@@ -104,17 +110,16 @@ Examples:
} }
hourly, err := priceClient.Get1Hour(item.ID) hourly, err := priceClient.Get1Hour(item.ID)
if err == nil && len(hourly.Data) > 0 { if err == nil {
recent := hourly.Data[len(hourly.Data)-1]
md.H2("Recent Activity (1h)") md.H2("Recent Activity (1h)")
if recent.AvgHighPrice != nil { if hourly.AvgHighPrice != nil {
md.KV("Avg buy price", render.FormatGP(*recent.AvgHighPrice)) md.KV("Avg buy price", render.FormatGP(*hourly.AvgHighPrice))
} }
md.KV("Buy volume", render.FormatNumber(recent.HighVolume)) md.KV("Buy volume", render.FormatNumber(hourly.HighVolume))
if recent.AvgLowPrice != nil { if hourly.AvgLowPrice != nil {
md.KV("Avg sell price", render.FormatGP(*recent.AvgLowPrice)) md.KV("Avg sell price", render.FormatGP(*hourly.AvgLowPrice))
} }
md.KV("Sell volume", render.FormatNumber(recent.LowVolume)) md.KV("Sell volume", render.FormatNumber(hourly.LowVolume))
md.Newline() md.Newline()
} }
} }
@@ -125,6 +130,58 @@ Examples:
} }
} }
func runRS3Price(name string) error {
wikiClient := wiki.NewClient(GameBaseURL())
results, err := wikiClient.Search(name, 5)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(results) == 0 {
return fmt.Errorf("no wiki page found for %q", name)
}
pageTitle := results[0].Title
exchangeItem, err := wikiClient.GetExchangeModule(pageTitle)
if err != nil {
return fmt.Errorf("failed to look up exchange data: %w", err)
}
if exchangeItem == nil {
return fmt.Errorf("%q is not tradeable on the Grand Exchange", pageTitle)
}
rs3Client := prices.NewRS3Client()
detail, err := rs3Client.GetDetail(exchangeItem.ItemID)
if err != nil {
return fmt.Errorf("failed to fetch RS3 price data: %w", err)
}
md := render.New()
md.H1(detail.Name)
md.KV("Examine", exchangeItem.Examine)
md.KV("Members", fmt.Sprintf("%v", exchangeItem.Members))
if exchangeItem.Limit > 0 {
md.KV("Buy limit", render.FormatNumber(exchangeItem.Limit))
}
md.KV("Store value", render.FormatGP(exchangeItem.Value))
md.Newline()
md.H2("Grand Exchange Price")
md.KV("Current price", detail.CurrentPrice+" gp")
if detail.TodayPrice != 0 {
md.KV("Today's change", render.FormatGP(detail.TodayPrice))
} else {
md.KV("Today's change", "0 gp")
}
md.KV("30-day change", fmt.Sprintf("%s (%s)", detail.Day30Change, detail.Day30Trend))
md.KV("90-day change", fmt.Sprintf("%s (%s)", detail.Day90Change, detail.Day90Trend))
md.KV("180-day change", fmt.Sprintf("%s (%s)", detail.Day180Change, detail.Day180Trend))
md.Newline()
fmt.Print(md.String())
return nil
}
func timeAgo(unixTime int64) string { func timeAgo(unixTime int64) string {
t := time.Unix(unixTime, 0) t := time.Unix(unixTime, 0)
d := time.Since(t) d := time.Since(t)

View File

@@ -134,7 +134,7 @@ func renderQuestSection(md *render.Builder, page *wiki.ParsedPage, client *wiki.
for _, target := range sectionNames { for _, target := range sectionNames {
if lower == target || strings.Contains(lower, target) { if lower == target || strings.Contains(lower, target) {
idx := 0 idx := 0
fmt.Sscanf(s.Index, "%d", &idx) _, _ = fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 { if idx > 0 {
sectionPage, err := client.GetPageSection(title, idx) sectionPage, err := client.GetPageSection(title, idx)
if err == nil { if err == nil {

View File

@@ -64,12 +64,11 @@ func GameBaseURL() string {
} }
// GamePriceBaseURL returns the real-time price API base URL. // GamePriceBaseURL returns the real-time price API base URL.
// Only valid for OSRS; RS3 uses a separate client (prices.RS3Client).
func GamePriceBaseURL() string { func GamePriceBaseURL() string {
switch game { switch game {
case "osrs": case "osrs":
return "https://prices.runescape.wiki/api/v1/osrs" return "https://prices.runescape.wiki/api/v1/osrs"
case "rs3":
return "https://prices.runescape.wiki/api/v1/rs"
default: default:
return "" return ""
} }

View File

@@ -381,7 +381,7 @@ func (w *walker) renderListItem(n *html.Node) {
ctx := &w.listCtx[len(w.listCtx)-1] ctx := &w.listCtx[len(w.listCtx)-1]
if ctx.ordered { if ctx.ordered {
ctx.index++ ctx.index++
w.sb.WriteString(fmt.Sprintf("%s%d. ", indent, ctx.index)) fmt.Fprintf(&w.sb, "%s%d. ", indent, ctx.index)
} else { } else {
w.sb.WriteString(indent + "- ") w.sb.WriteString(indent + "- ")
} }

View File

@@ -96,31 +96,40 @@ type VolumeEntry struct {
} }
// TimeseriesResponse wraps the 5m/1h API responses. // TimeseriesResponse wraps the 5m/1h API responses.
// The API returns data as a map keyed by item ID string.
type TimeseriesResponse struct { type TimeseriesResponse struct {
Data []VolumeEntry `json:"data"` Data map[string]VolumeEntry `json:"data"`
} }
// Get5Min fetches 5-minute average data for an item. // Get5Min fetches 5-minute average data for an item.
func (c *Client) Get5Min(itemID int) (*TimeseriesResponse, error) { func (c *Client) Get5Min(itemID int) (*VolumeEntry, error) {
url := fmt.Sprintf("%s/5m?id=%d", c.baseURL, itemID) url := fmt.Sprintf("%s/5m?id=%d", c.baseURL, itemID)
var resp TimeseriesResponse var resp TimeseriesResponse
if err := c.getJSON(url, &resp); err != nil { if err := c.getJSON(url, &resp); err != nil {
return nil, err return nil, err
} }
return &resp, nil idStr := fmt.Sprintf("%d", itemID)
if entry, ok := resp.Data[idStr]; ok {
return &entry, nil
}
return nil, fmt.Errorf("no 5m data for item %d", itemID)
} }
// Get1Hour fetches 1-hour average data for an item. // Get1Hour fetches 1-hour average data for an item.
func (c *Client) Get1Hour(itemID int) (*TimeseriesResponse, error) { func (c *Client) Get1Hour(itemID int) (*VolumeEntry, error) {
url := fmt.Sprintf("%s/1h?id=%d", c.baseURL, itemID) url := fmt.Sprintf("%s/1h?id=%d", c.baseURL, itemID)
var resp TimeseriesResponse var resp TimeseriesResponse
if err := c.getJSON(url, &resp); err != nil { if err := c.getJSON(url, &resp); err != nil {
return nil, err return nil, err
} }
return &resp, nil idStr := fmt.Sprintf("%d", itemID)
if entry, ok := resp.Data[idStr]; ok {
return &entry, nil
}
return nil, fmt.Errorf("no 1h data for item %d", itemID)
} }
func (c *Client) getJSON(url string, dest interface{}) error { func (c *Client) getJSON(url string, dest any) error {
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return fmt.Errorf("creating request: %w", err) return fmt.Errorf("creating request: %w", err)
@@ -131,7 +140,7 @@ func (c *Client) getJSON(url string, dest interface{}) error {
if err != nil { if err != nil {
return fmt.Errorf("executing request: %w", err) return fmt.Errorf("executing request: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)

View File

@@ -0,0 +1,268 @@
package prices_test
import (
"strings"
"testing"
"github.com/runescape-wiki/rsw/internal/prices"
)
const osrsPriceBaseURL = "https://prices.runescape.wiki/api/v1/osrs"
func osrsClient() *prices.Client {
return prices.NewClient(osrsPriceBaseURL)
}
// --- OSRS Mapping Tests ---
func TestOSRS_GetMapping_ReturnsItems(t *testing.T) {
items, err := osrsClient().GetMapping()
if err != nil {
t.Fatalf("GetMapping failed: %v", err)
}
if len(items) < 100 {
t.Fatalf("expected at least 100 items, got %d", len(items))
}
}
func TestOSRS_GetMapping_ContainsAbyssalWhip(t *testing.T) {
items, err := osrsClient().GetMapping()
if err != nil {
t.Fatalf("GetMapping failed: %v", err)
}
found := false
for _, item := range items {
if strings.EqualFold(item.Name, "abyssal whip") {
found = true
if item.ID != 4151 {
t.Errorf("expected Abyssal whip ID to be 4151, got %d", item.ID)
}
if !item.Members {
t.Error("expected Abyssal whip to be members-only")
}
break
}
}
if !found {
t.Error("expected to find 'Abyssal whip' in mapping")
}
}
// --- OSRS MappingCache Tests ---
func TestOSRS_MappingCache_LookupByName(t *testing.T) {
mc := prices.NewMappingCache(osrsClient(), "osrs")
if err := mc.Load(); err != nil {
t.Fatalf("Load failed: %v", err)
}
item := mc.LookupByName("Abyssal whip")
if item == nil {
t.Fatal("expected to find Abyssal whip")
}
if item.ID != 4151 {
t.Errorf("expected ID 4151, got %d", item.ID)
}
}
func TestOSRS_MappingCache_LookupByName_CaseInsensitive(t *testing.T) {
mc := prices.NewMappingCache(osrsClient(), "osrs")
if err := mc.Load(); err != nil {
t.Fatalf("Load failed: %v", err)
}
item := mc.LookupByName("ABYSSAL WHIP")
if item == nil {
t.Fatal("expected case-insensitive lookup to find Abyssal whip")
}
}
func TestOSRS_MappingCache_SearchByName(t *testing.T) {
mc := prices.NewMappingCache(osrsClient(), "osrs")
if err := mc.Load(); err != nil {
t.Fatalf("Load failed: %v", err)
}
results := mc.SearchByName("dragon")
if len(results) == 0 {
t.Fatal("expected to find items containing 'dragon'")
}
for _, item := range results {
if !strings.Contains(strings.ToLower(item.Name), "dragon") {
t.Errorf("result %q does not contain 'dragon'", item.Name)
}
}
}
func TestOSRS_MappingCache_LookupByID(t *testing.T) {
mc := prices.NewMappingCache(osrsClient(), "osrs")
if err := mc.Load(); err != nil {
t.Fatalf("Load failed: %v", err)
}
item := mc.LookupByID(4151)
if item == nil {
t.Fatal("expected to find item 4151")
}
if !strings.EqualFold(item.Name, "abyssal whip") {
t.Errorf("expected 'Abyssal whip', got %q", item.Name)
}
}
// --- OSRS Latest Price Tests ---
func TestOSRS_GetLatestForItem_AbyssalWhip(t *testing.T) {
latest, err := osrsClient().GetLatestForItem(4151)
if err != nil {
t.Fatalf("GetLatestForItem failed: %v", err)
}
if latest.High == nil && latest.Low == nil {
t.Error("expected at least one of High or Low to be non-nil")
}
if latest.High != nil && *latest.High <= 0 {
t.Errorf("expected positive high price, got %d", *latest.High)
}
if latest.Low != nil && *latest.Low <= 0 {
t.Errorf("expected positive low price, got %d", *latest.Low)
}
}
func TestOSRS_GetLatestForItem_Nonexistent(t *testing.T) {
_, err := osrsClient().GetLatestForItem(999999999)
if err == nil {
t.Error("expected error for nonexistent item ID")
}
}
// --- OSRS Timeseries Tests ---
func TestOSRS_Get1Hour_AbyssalWhip(t *testing.T) {
entry, err := osrsClient().Get1Hour(4151)
if err != nil {
t.Fatalf("Get1Hour failed: %v", err)
}
if entry.AvgHighPrice == nil {
t.Error("expected AvgHighPrice to be non-nil for Abyssal whip")
} else if *entry.AvgHighPrice <= 0 {
t.Errorf("expected positive AvgHighPrice, got %d", *entry.AvgHighPrice)
}
if entry.AvgLowPrice == nil {
t.Error("expected AvgLowPrice to be non-nil for Abyssal whip")
} else if *entry.AvgLowPrice <= 0 {
t.Errorf("expected positive AvgLowPrice, got %d", *entry.AvgLowPrice)
}
if entry.HighVolume <= 0 {
t.Errorf("expected positive buy volume, got %d", entry.HighVolume)
}
if entry.LowVolume <= 0 {
t.Errorf("expected positive sell volume, got %d", entry.LowVolume)
}
}
func TestOSRS_Get5Min_AbyssalWhip(t *testing.T) {
entry, err := osrsClient().Get5Min(4151)
if err != nil {
t.Fatalf("Get5Min failed: %v", err)
}
if entry.AvgHighPrice == nil {
t.Error("expected AvgHighPrice to be non-nil for Abyssal whip")
} else if *entry.AvgHighPrice <= 0 {
t.Errorf("expected positive AvgHighPrice, got %d", *entry.AvgHighPrice)
}
if entry.AvgLowPrice == nil {
t.Error("expected AvgLowPrice to be non-nil for Abyssal whip")
} else if *entry.AvgLowPrice <= 0 {
t.Errorf("expected positive AvgLowPrice, got %d", *entry.AvgLowPrice)
}
}
// --- RS3 Client Tests ---
func TestRS3_GetDetail_AbyssalWhip(t *testing.T) {
c := prices.NewRS3Client()
detail, err := c.GetDetail(4151)
if err != nil {
t.Fatalf("GetDetail failed: %v", err)
}
if detail.Name != "Abyssal whip" {
t.Errorf("expected name 'Abyssal whip', got %q", detail.Name)
}
if detail.CurrentPrice == "" {
t.Error("expected non-empty current price")
}
if detail.ID != 4151 {
t.Errorf("expected ID 4151, got %d", detail.ID)
}
}
func TestRS3_GetDetail_BluePartyhat(t *testing.T) {
c := prices.NewRS3Client()
detail, err := c.GetDetail(1042)
if err != nil {
t.Fatalf("GetDetail failed: %v", err)
}
if !strings.Contains(strings.ToLower(detail.Name), "partyhat") &&
!strings.Contains(strings.ToLower(detail.Name), "party hat") {
t.Errorf("expected name to contain 'partyhat', got %q", detail.Name)
}
if detail.CurrentPrice == "" {
t.Error("expected non-empty current price")
}
}
func TestRS3_GetDetail_HasTrends(t *testing.T) {
c := prices.NewRS3Client()
detail, err := c.GetDetail(4151)
if err != nil {
t.Fatalf("GetDetail failed: %v", err)
}
if detail.Day30Change == "" {
t.Error("expected non-empty 30-day change")
}
if detail.Day90Change == "" {
t.Error("expected non-empty 90-day change")
}
if detail.Day180Change == "" {
t.Error("expected non-empty 180-day change")
}
validTrends := map[string]bool{"positive": true, "negative": true, "neutral": true}
if !validTrends[detail.Day30Trend] {
t.Errorf("unexpected 30-day trend %q", detail.Day30Trend)
}
if !validTrends[detail.Day90Trend] {
t.Errorf("unexpected 90-day trend %q", detail.Day90Trend)
}
}
func TestRS3_GetDetail_Nonexistent(t *testing.T) {
c := prices.NewRS3Client()
_, err := c.GetDetail(999999999)
if err == nil {
t.Error("expected error for nonexistent RS3 item ID")
}
}
// --- RS3 Exchange Module Tests (via wiki client) ---
// These are in the wiki package test file, but we verify the RS3 price
// client integrates correctly with real item IDs from exchange modules.
func TestRS3_GetDetail_Members_Field(t *testing.T) {
c := prices.NewRS3Client()
detail, err := c.GetDetail(4151)
if err != nil {
t.Fatalf("GetDetail failed: %v", err)
}
if detail.Members != "true" {
t.Errorf("expected Abyssal whip members='true', got %q", detail.Members)
}
}

View File

@@ -18,16 +18,19 @@ type MappingCache struct {
byID map[int]*MappingItem byID map[int]*MappingItem
byName map[string]*MappingItem byName map[string]*MappingItem
cacheDir string cacheDir string
game string
} }
// NewMappingCache creates a mapping cache backed by the given client. // NewMappingCache creates a mapping cache backed by the given client.
func NewMappingCache(client *Client) *MappingCache { // The game parameter scopes the cache file (e.g. "osrs" or "rs3").
func NewMappingCache(client *Client, game string) *MappingCache {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return &MappingCache{ return &MappingCache{
client: client, client: client,
byID: make(map[int]*MappingItem), byID: make(map[int]*MappingItem),
byName: make(map[string]*MappingItem), byName: make(map[string]*MappingItem),
cacheDir: filepath.Join(home, ".rsw", "cache"), cacheDir: filepath.Join(home, ".rsw", "cache"),
game: game,
} }
} }
@@ -81,7 +84,8 @@ func (mc *MappingCache) index() {
} }
func (mc *MappingCache) cachePath() string { func (mc *MappingCache) cachePath() string {
return filepath.Join(mc.cacheDir, "mapping.json") filename := mc.game + "_mapping.json"
return filepath.Join(mc.cacheDir, filename)
} }
func (mc *MappingCache) loadFromDisk() bool { func (mc *MappingCache) loadFromDisk() bool {

View File

@@ -0,0 +1,125 @@
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
}

View File

@@ -42,7 +42,7 @@ func (c *Client) get(params url.Values, dest interface{}) error {
if err != nil { if err != nil {
return fmt.Errorf("executing request: %w", err) return fmt.Errorf("executing request: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)

View File

@@ -0,0 +1,114 @@
package wiki
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
// RS3ExchangeItem holds metadata parsed from a Module:Exchange/<name> page.
type RS3ExchangeItem struct {
ItemID int
Name string
Value int
Limit int
Members bool
Category string
Examine string
}
// revisionResponse matches the JSON from action=query&prop=revisions&rvslots=main.
type revisionResponse struct {
Query struct {
Pages map[string]struct {
PageID int `json:"pageid"`
Title string `json:"title"`
Revisions []struct {
Slots struct {
Main struct {
Content string `json:"*"`
} `json:"main"`
} `json:"slots"`
} `json:"revisions"`
} `json:"pages"`
} `json:"query"`
}
// GetExchangeModule fetches Module:Exchange/<name> and parses item metadata.
// Returns nil, nil if the module page does not exist (item not tradeable).
func (c *Client) GetExchangeModule(itemName string) (*RS3ExchangeItem, error) {
title := "Module:Exchange/" + itemName
params := url.Values{
"action": {"query"},
"titles": {title},
"prop": {"revisions"},
"rvprop": {"content"},
"rvslots": {"main"},
}
var resp revisionResponse
if err := c.get(params, &resp); err != nil {
return nil, fmt.Errorf("fetching exchange module: %w", err)
}
for id, page := range resp.Query.Pages {
// Page ID -1 means the page doesn't exist
if id == "-1" {
return nil, nil
}
if len(page.Revisions) == 0 {
return nil, nil
}
content := page.Revisions[0].Slots.Main.Content
return parseLuaModule(content), nil
}
return nil, nil
}
var (
reItemID = regexp.MustCompile(`itemId\s*=\s*(\d+)`)
reItem = regexp.MustCompile(`item\s*=\s*'((?:[^'\\]|\\.)*)'`)
reValue = regexp.MustCompile(`value\s*=\s*(\d+)`)
reLimit = regexp.MustCompile(`limit\s*=\s*(\d+)`)
reMembers = regexp.MustCompile(`members\s*=\s*(true|false)`)
reCategory = regexp.MustCompile(`category\s*=\s*'((?:[^'\\]|\\.)*)'`)
reExamine = regexp.MustCompile(`examine\s*=\s*'((?:[^'\\]|\\.)*)'`)
)
func parseLuaModule(content string) *RS3ExchangeItem {
item := &RS3ExchangeItem{}
if m := reItemID.FindStringSubmatch(content); m != nil {
item.ItemID, _ = strconv.Atoi(m[1])
}
if m := reItem.FindStringSubmatch(content); m != nil {
item.Name = unescapeLua(m[1])
}
if m := reValue.FindStringSubmatch(content); m != nil {
item.Value, _ = strconv.Atoi(m[1])
}
if m := reLimit.FindStringSubmatch(content); m != nil {
item.Limit, _ = strconv.Atoi(m[1])
}
if m := reMembers.FindStringSubmatch(content); m != nil {
item.Members = strings.ToLower(m[1]) == "true"
}
if m := reCategory.FindStringSubmatch(content); m != nil {
item.Category = unescapeLua(m[1])
}
if m := reExamine.FindStringSubmatch(content); m != nil {
item.Examine = unescapeLua(m[1])
}
return item
}
// unescapeLua handles Lua string escape sequences in single-quoted strings.
func unescapeLua(s string) string {
return strings.NewReplacer(`\'`, `'`, `\\`, `\`).Replace(s)
}

View File

@@ -0,0 +1,108 @@
package wiki_test
import (
"testing"
)
// --- RS3 Exchange Module Tests ---
func TestRS3_GetExchangeModule_AbyssalWhip(t *testing.T) {
item, err := rs3Client().GetExchangeModule("Abyssal whip")
if err != nil {
t.Fatalf("GetExchangeModule failed: %v", err)
}
if item == nil {
t.Fatal("expected non-nil exchange item for Abyssal whip")
}
if item.ItemID != 4151 {
t.Errorf("expected item ID 4151, got %d", item.ItemID)
}
if item.Name != "Abyssal whip" {
t.Errorf("expected name 'Abyssal whip', got %q", item.Name)
}
if !item.Members {
t.Error("expected Abyssal whip to be members-only")
}
if item.Limit <= 0 {
t.Errorf("expected positive buy limit, got %d", item.Limit)
}
if item.Value <= 0 {
t.Errorf("expected positive store value, got %d", item.Value)
}
if item.Examine == "" {
t.Error("expected non-empty examine text")
}
}
func TestRS3_GetExchangeModule_BluePartyhat(t *testing.T) {
item, err := rs3Client().GetExchangeModule("Blue partyhat")
if err != nil {
t.Fatalf("GetExchangeModule failed: %v", err)
}
if item == nil {
t.Fatal("expected non-nil exchange item for Blue partyhat")
}
if item.ItemID != 1042 {
t.Errorf("expected item ID 1042, got %d", item.ItemID)
}
if item.Members {
t.Error("expected Blue partyhat to not be members-only")
}
if item.Limit <= 0 {
t.Errorf("expected positive buy limit, got %d", item.Limit)
}
}
func TestRS3_GetExchangeModule_DragonBones(t *testing.T) {
item, err := rs3Client().GetExchangeModule("Dragon bones")
if err != nil {
t.Fatalf("GetExchangeModule failed: %v", err)
}
if item == nil {
t.Fatal("expected non-nil exchange item for Dragon bones")
}
if item.ItemID <= 0 {
t.Error("expected positive item ID")
}
if item.Name != "Dragon bones" {
t.Errorf("expected name 'Dragon bones', got %q", item.Name)
}
}
func TestRS3_GetExchangeModule_NonTradeableItem(t *testing.T) {
// "Quest point cape" is not tradeable, so no exchange module exists
item, err := rs3Client().GetExchangeModule("Quest point cape")
if err != nil {
t.Fatalf("GetExchangeModule failed: %v", err)
}
if item != nil {
t.Error("expected nil for non-tradeable item")
}
}
func TestRS3_GetExchangeModule_NonExistentItem(t *testing.T) {
item, err := rs3Client().GetExchangeModule("Completely Fake Item That Does Not Exist")
if err != nil {
t.Fatalf("GetExchangeModule failed: %v", err)
}
if item != nil {
t.Error("expected nil for non-existent item")
}
}
func TestRS3_GetExchangeModule_HasCategory(t *testing.T) {
item, err := rs3Client().GetExchangeModule("Abyssal whip")
if err != nil {
t.Fatalf("GetExchangeModule failed: %v", err)
}
if item == nil {
t.Fatal("expected non-nil exchange item")
}
if item.Category == "" {
t.Error("expected non-empty category")
}
}