Item price support
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
78
README.md
Normal file
78
README.md
Normal 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 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 <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
15
scripts/rsw/Makefile
Normal 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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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 + "- ")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
268
scripts/rsw/internal/prices/integration_test.go
Normal file
268
scripts/rsw/internal/prices/integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
125
scripts/rsw/internal/prices/rs3client.go
Normal file
125
scripts/rsw/internal/prices/rs3client.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
114
scripts/rsw/internal/wiki/exchange.go
Normal file
114
scripts/rsw/internal/wiki/exchange.go
Normal 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)
|
||||
}
|
||||
108
scripts/rsw/internal/wiki/exchange_integration_test.go
Normal file
108
scripts/rsw/internal/wiki/exchange_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user