diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a045d5d..6a43716 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,7 +13,7 @@ "name": "wiki", "source": "./plugins/rsw", "description": "RuneScape Wiki CLI", - "version": "1.0.0", + "version": "1.0.1", "author": { "name": "Sam Myers" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..9db485a --- /dev/null +++ b/README.md @@ -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 50–70."* +- *"I'm an ironman — how do I get dragon bones?"* + +## Commands + +All commands take the game (`osrs` or `rs3`) as the first argument. + +### `item ` +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 ` +Look up quest requirements, items needed, enemies to defeat, and rewards. + +``` +rsw osrs quest "Monkey Madness I" +rsw rs3 quest "Plague's End" +``` + +### `skill ` +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 ` +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) | diff --git a/scripts/rsw/Makefile b/scripts/rsw/Makefile new file mode 100644 index 0000000..7a4f27b --- /dev/null +++ b/scripts/rsw/Makefile @@ -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) diff --git a/scripts/rsw/internal/cmd/item.go b/scripts/rsw/internal/cmd/item.go index 85531e4..30791a5 100644 --- a/scripts/rsw/internal/cmd/item.go +++ b/scripts/rsw/internal/cmd/item.go @@ -122,8 +122,13 @@ func findItemInfobox(templates []extract.Infobox) *extract.Infobox { } func renderGEPrice(md *render.Builder, name, pageTitle string) { + if Game() == "rs3" { + renderRS3GEPrice(md, pageTitle) + return + } + priceClient := prices.NewClient(GamePriceBaseURL()) - mc := prices.NewMappingCache(priceClient) + mc := prices.NewMappingCache(priceClient, Game()) if err := mc.Load(); err != nil { return } @@ -152,6 +157,32 @@ func renderGEPrice(md *render.Builder, name, pageTitle string) { 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) { drops := extract.FindAllTemplates(templates, "DropsLine") if len(drops) == 0 { @@ -182,7 +213,7 @@ func renderSourcesSection(md *render.Builder, page *wiki.ParsedPage, client *wik lower := strings.ToLower(s.Line) if lower == "item sources" || lower == "sources" || lower == "obtaining" || lower == "acquisition" { idx := 0 - fmt.Sscanf(s.Index, "%d", &idx) + _, _ = fmt.Sscanf(s.Index, "%d", &idx) if idx > 0 { sectionPage, err := client.GetPageSection(title, idx) if err == nil { diff --git a/scripts/rsw/internal/cmd/price.go b/scripts/rsw/internal/cmd/price.go index 8515899..53682a9 100644 --- a/scripts/rsw/internal/cmd/price.go +++ b/scripts/rsw/internal/cmd/price.go @@ -6,6 +6,7 @@ import ( "github.com/runescape-wiki/rsw/internal/prices" "github.com/runescape-wiki/rsw/internal/render" + "github.com/runescape-wiki/rsw/internal/wiki" "github.com/spf13/cobra" ) @@ -25,8 +26,13 @@ Examples: Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] + + if Game() == "rs3" { + return runRS3Price(name) + } + priceClient := prices.NewClient(GamePriceBaseURL()) - mc := prices.NewMappingCache(priceClient) + mc := prices.NewMappingCache(priceClient, Game()) if err := mc.Load(); err != nil { return fmt.Errorf("failed to load item mapping: %w", err) @@ -104,17 +110,16 @@ Examples: } hourly, err := priceClient.Get1Hour(item.ID) - if err == nil && len(hourly.Data) > 0 { - recent := hourly.Data[len(hourly.Data)-1] + if err == nil { md.H2("Recent Activity (1h)") - if recent.AvgHighPrice != nil { - md.KV("Avg buy price", render.FormatGP(*recent.AvgHighPrice)) + if hourly.AvgHighPrice != nil { + md.KV("Avg buy price", render.FormatGP(*hourly.AvgHighPrice)) } - md.KV("Buy volume", render.FormatNumber(recent.HighVolume)) - if recent.AvgLowPrice != nil { - md.KV("Avg sell price", render.FormatGP(*recent.AvgLowPrice)) + md.KV("Buy volume", render.FormatNumber(hourly.HighVolume)) + if hourly.AvgLowPrice != nil { + 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() } } @@ -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 { t := time.Unix(unixTime, 0) d := time.Since(t) diff --git a/scripts/rsw/internal/cmd/quest.go b/scripts/rsw/internal/cmd/quest.go index 013d6c2..a9d43da 100644 --- a/scripts/rsw/internal/cmd/quest.go +++ b/scripts/rsw/internal/cmd/quest.go @@ -134,7 +134,7 @@ func renderQuestSection(md *render.Builder, page *wiki.ParsedPage, client *wiki. for _, target := range sectionNames { if lower == target || strings.Contains(lower, target) { idx := 0 - fmt.Sscanf(s.Index, "%d", &idx) + _, _ = fmt.Sscanf(s.Index, "%d", &idx) if idx > 0 { sectionPage, err := client.GetPageSection(title, idx) if err == nil { diff --git a/scripts/rsw/internal/cmd/root.go b/scripts/rsw/internal/cmd/root.go index 4904b6d..719624f 100644 --- a/scripts/rsw/internal/cmd/root.go +++ b/scripts/rsw/internal/cmd/root.go @@ -64,12 +64,11 @@ func GameBaseURL() string { } // GamePriceBaseURL returns the real-time price API base URL. +// Only valid for OSRS; RS3 uses a separate client (prices.RS3Client). func GamePriceBaseURL() string { switch game { case "osrs": return "https://prices.runescape.wiki/api/v1/osrs" - case "rs3": - return "https://prices.runescape.wiki/api/v1/rs" default: return "" } diff --git a/scripts/rsw/internal/htmlconv/htmlconv.go b/scripts/rsw/internal/htmlconv/htmlconv.go index b435da9..f03c5db 100644 --- a/scripts/rsw/internal/htmlconv/htmlconv.go +++ b/scripts/rsw/internal/htmlconv/htmlconv.go @@ -381,7 +381,7 @@ func (w *walker) renderListItem(n *html.Node) { ctx := &w.listCtx[len(w.listCtx)-1] if ctx.ordered { ctx.index++ - w.sb.WriteString(fmt.Sprintf("%s%d. ", indent, ctx.index)) + fmt.Fprintf(&w.sb, "%s%d. ", indent, ctx.index) } else { w.sb.WriteString(indent + "- ") } diff --git a/scripts/rsw/internal/prices/client.go b/scripts/rsw/internal/prices/client.go index faeac98..c36497c 100644 --- a/scripts/rsw/internal/prices/client.go +++ b/scripts/rsw/internal/prices/client.go @@ -96,31 +96,40 @@ type VolumeEntry struct { } // TimeseriesResponse wraps the 5m/1h API responses. +// The API returns data as a map keyed by item ID string. type TimeseriesResponse struct { - Data []VolumeEntry `json:"data"` + Data map[string]VolumeEntry `json:"data"` } // 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) var resp TimeseriesResponse if err := c.getJSON(url, &resp); err != nil { 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. -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) var resp TimeseriesResponse if err := c.getJSON(url, &resp); err != nil { 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) if err != nil { return fmt.Errorf("creating request: %w", err) @@ -131,7 +140,7 @@ func (c *Client) getJSON(url string, dest interface{}) error { if err != nil { return fmt.Errorf("executing request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) diff --git a/scripts/rsw/internal/prices/integration_test.go b/scripts/rsw/internal/prices/integration_test.go new file mode 100644 index 0000000..351dd1f --- /dev/null +++ b/scripts/rsw/internal/prices/integration_test.go @@ -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) + } +} diff --git a/scripts/rsw/internal/prices/mapping.go b/scripts/rsw/internal/prices/mapping.go index b14d989..22ce051 100644 --- a/scripts/rsw/internal/prices/mapping.go +++ b/scripts/rsw/internal/prices/mapping.go @@ -18,16 +18,19 @@ type MappingCache struct { byID map[int]*MappingItem byName map[string]*MappingItem cacheDir string + game string } // 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() return &MappingCache{ client: client, byID: make(map[int]*MappingItem), byName: make(map[string]*MappingItem), cacheDir: filepath.Join(home, ".rsw", "cache"), + game: game, } } @@ -81,7 +84,8 @@ func (mc *MappingCache) index() { } 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 { diff --git a/scripts/rsw/internal/prices/rs3client.go b/scripts/rsw/internal/prices/rs3client.go new file mode 100644 index 0000000..08b2575 --- /dev/null +++ b/scripts/rsw/internal/prices/rs3client.go @@ -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 +} diff --git a/scripts/rsw/internal/wiki/client.go b/scripts/rsw/internal/wiki/client.go index e9a32a4..ef6ea4c 100644 --- a/scripts/rsw/internal/wiki/client.go +++ b/scripts/rsw/internal/wiki/client.go @@ -42,7 +42,7 @@ func (c *Client) get(params url.Values, dest interface{}) error { if err != nil { return fmt.Errorf("executing request: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) diff --git a/scripts/rsw/internal/wiki/exchange.go b/scripts/rsw/internal/wiki/exchange.go new file mode 100644 index 0000000..bb02e3e --- /dev/null +++ b/scripts/rsw/internal/wiki/exchange.go @@ -0,0 +1,114 @@ +package wiki + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +// RS3ExchangeItem holds metadata parsed from a Module:Exchange/ 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/ 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) +} diff --git a/scripts/rsw/internal/wiki/exchange_integration_test.go b/scripts/rsw/internal/wiki/exchange_integration_test.go new file mode 100644 index 0000000..3b0743c --- /dev/null +++ b/scripts/rsw/internal/wiki/exchange_integration_test.go @@ -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") + } +}