Initial commit

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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.claude/settings.local.json
scripts/rsw/rsw

112
BUILD_NOTES.md Normal file
View File

@@ -0,0 +1,112 @@
# rsw Build & Completion Notes
## What's done
All Go source files are written and structurally complete:
```
scripts/rsw/
├── main.go # Entry point
├── go.mod # Module definition (needs go mod tidy)
└── internal/
├── cmd/
│ ├── root.go # Root command, global flags, URL helpers
│ ├── game.go # osrs/rs3 parent commands + factory registration
│ ├── search.go # rsw <game> search <query>
│ ├── page.go # rsw <game> page <title> [--section]
│ ├── item.go # rsw <game> item <name> [--ironman]
│ ├── quest.go # rsw <game> quest <name> [--ironman]
│ ├── skill.go # rsw <game> skill <name> [--level] [--ironman]
│ └── price.go # rsw <game> price <name> [--ironman]
├── wiki/
│ ├── client.go # HTTP client for MediaWiki API
│ ├── search.go # action=query&list=search wrapper
│ └── parse.go # action=parse wrapper (wikitext, sections, HTML)
├── prices/
│ ├── client.go # HTTP client for prices.runescape.wiki API
│ └── mapping.go # Item ID↔name cache with disk persistence
├── render/
│ └── markdown.go # Markdown output builder (headings, tables, KV, GP formatting)
└── extract/
└── infobox.go # Wikitext template parser (handles nesting, wiki links, refs)
```
## What needs to happen next
### 1. `go mod tidy` + `go mod download`
The go.mod declares the cobra dependency but doesn't have a go.sum yet. Run:
```bash
cd scripts/rsw
go mod tidy
```
This will resolve the full dependency tree and create go.sum.
### 2. Compile & smoke test
```bash
go build -o rsw .
./rsw osrs search "dragon scimitar"
./rsw osrs item "abyssal whip"
./rsw osrs quest "Monkey Madness I"
./rsw osrs price "dragon bones"
./rsw rs3 quest "Plague's End"
```
### 3. Known issues to fix during testing
**`strings.Title` is deprecated** — Used in skill.go. Replace with:
```go
import "golang.org/x/text/cases"
import "golang.org/x/text/language"
caser := cases.Title(language.English)
trainingTitle := caser.String(strings.ToLower(skillName)) + " training"
```
Or just capitalize the first letter manually since we're dealing with single words.
**Template name variations** — The RS wiki uses inconsistent casing for templates
(e.g., `Infobox Item` vs `Infobox item`). The extract package does case-insensitive
matching, but you may discover new template names while testing (e.g., RS3 might use
`Infobox object` for some items). Easy to add — just extend the name list in
`findItemInfobox()`.
**Drop table templates** — OSRS uses `DropsLine`, RS3 may use different names.
Test with both games and add any missing template names to `renderDropSources()`.
**Price API for RS3** — The prices.runescape.wiki endpoint for RS3 (`/api/v1/rs/`)
may have different availability than OSRS. The mapping endpoint should work but
test it. RS3 also has the official Jagex GE API as a fallback if needed:
`https://secure.runescape.com/m=itemdb_rs/api/catalogue/detail.json?item=<id>`
### 4. Potential improvements
- **Fuzzy search for item command**: Right now `item` uses wiki search which is
good but could also cross-reference the price mapping cache for exact matches.
- **Structured drop table parsing**: The wikitext drop tables are complex
(conditional drops, noted items, etc.). Current parser handles the basics.
- **Monster lookup command**: `rsw <game> monster <name>` for combat stats,
weaknesses, drop tables. Same pattern as item/quest.
- **Category browsing**: MediaWiki has `action=query&list=categorymembers`
could support `rsw <game> list quests` or `rsw <game> list items --category "Melee weapons"`.
- **Timeseries charting**: The 5m/1h price data could generate ASCII sparklines
for price trends in the terminal.
## Architecture notes
**Factory pattern for cobra commands**: Each subcommand uses a `newXxxCmd()` factory
function registered via `RegisterCommand()`. This is because cobra doesn't support
a command having two parents — we need independent instances for osrs and rs3.
`wireCommands()` is called once at `Execute()` time to create and attach all
command instances.
**Wikitext parser**: The `extract` package implements a lightweight template parser
that handles `{{Template|key=value|...}}` with nesting, wiki links `[[Target|Display]]`,
and common markup stripping. It doesn't handle parser functions (`#if`, `#switch`)
— those are stripped as regular templates. This covers ~80% of useful data extraction
from infoboxes and drop tables.
**Price mapping cache**: Stored at `~/.rsw/cache/mapping.json` with a 24h TTL.
The mapping API returns all items (~4000 for OSRS, ~40000 for RS3) in a single call.
Caching avoids hitting this on every price lookup.

115
SKILL.md Normal file
View File

@@ -0,0 +1,115 @@
---
name: runescape-wiki
description: >
Query the RuneScape Wiki (RS3 and OSRS) for item details, quest requirements,
skill training guides, and Grand Exchange prices. Use this skill whenever the
user asks anything about RuneScape, Old School RuneScape, OSRS, RS3, or mentions
game-specific concepts like quests, skills, items, monsters, the Grand Exchange,
ironman mode, HCIM, or any RuneScape game mechanics. Also trigger when the user
mentions specific in-game items, quest names, skill names, or boss names that are
clearly RuneScape-related. This skill provides a Go CLI tool (rsw) that pulls
live data from the wiki — always prefer it over answering from memory, since wiki
data is authoritative and up-to-date.
---
# RuneScape Wiki CLI Plugin
This skill provides `rsw`, a Go CLI that queries the RuneScape Wiki APIs to get
authoritative, up-to-date game data. It supports both **OSRS** and **RS3**.
## Setup (first use)
The CLI needs to be compiled once. On first use, run:
```bash
cd <skill-dir>/scripts/rsw && go build -o rsw . && chmod +x rsw
```
After that, the binary is at `<skill-dir>/scripts/rsw/rsw`.
If Go is not installed, tell the user they need Go 1.22+ (`go.dev/dl`).
## Commands
The first argument is always the game: `osrs` or `rs3`.
### rsw <game> search <query>
Full-text search across wiki pages. Returns ranked titles with snippets.
Use this to find the right page name before fetching details.
### rsw <game> page <title> [--section <name>]
Fetch a wiki page as cleaned markdown. Use `--section` to get just one section
(e.g., "Drops", "Strategy", "Requirements"). Use `--raw` for raw wikitext.
### rsw <game> item <name> [--ironman]
The workhorse for "where do I find X" questions. Returns:
- Item stats (examine, weight, alch values, quest association)
- Equipment bonuses (if applicable)
- GE price (buy/sell, volume) — or vendor/alch values with `--ironman`
- Drop sources (monster, quantity, rarity)
- Acquisition methods from the wiki page
### rsw <game> quest <name> [--ironman]
Quest details: skill requirements, quest prerequisites, items needed, enemies
to defeat, and rewards. With `--ironman`, adds notes about self-sufficient
item acquisition and HCIM combat safety.
### rsw <game> skill <name> [--level <range>] [--ironman]
Training guide for a skill. Use `--level 50-70` to filter to a specific range.
With `--ironman`, notes which methods work without GE access.
### rsw <game> price <name> [--ironman]
Real-time GE price: instant buy/sell, recent volume, trend data.
With `--ironman`, shows alch values and store prices instead.
## Global Flags
- `--ironman` / `-i` — Shifts output to self-sufficient play: hides GE prices,
shows vendor/alch values, emphasizes drop sources and shop locations.
- `--raw` — Output raw wikitext (for page command).
## How to Answer Questions
When a user asks a RuneScape question, think about which command(s) to combine:
**"Where can I find a dragon scimitar?"**
`rsw osrs item "dragon scimitar"` — shows drop sources and acquisition
→ If it's a quest reward, follow up with `rsw osrs quest "Monkey Madness I"`
**"What are the requirements for Desert Treasure?"**
`rsw osrs quest "Desert Treasure"`
**"How do I train Prayer from 43 to 70 as an ironman?"**
`rsw osrs skill prayer --level 43-70 --ironman`
→ May also want `rsw osrs item "dragon bones" --ironman` for acquisition info
**"What's the current price of an abyssal whip?"**
`rsw osrs price "abyssal whip"`
**"What's a good money maker at 80 combat?"**
`rsw osrs search "money making"` → then `rsw osrs page "Money making guide"`
### Inferring the game
Most players clearly play one game or the other. Look for context clues:
- OSRS-specific: "ironman", "HCIM", "GIM", quest names unique to OSRS
- RS3-specific: "invention", "archaeology", "divination", "EoC", "revolution"
- If ambiguous, ask which game they play
### Combining outputs
Don't just dump raw CLI output. Read it, synthesize an answer, and cite specifics.
If the user asks "how do I get a fire cape", run the search, pull the relevant
page sections, and give them a coherent strategy — not a wall of wikitext.
## API Details (reference)
The CLI hits two API surfaces:
1. **MediaWiki API** (`{game}.runescape.wiki/api.php`) — search, parse, query.
Includes `User-Agent: rsw-cli/1.0` per wiki guidelines.
2. **Real-time Prices API** (`prices.runescape.wiki/api/v1/{game}/`) — latest
prices, mapping (item ID→name), 5m averages, 1h averages.
Item mapping is cached locally at `~/.rsw/cache/mapping.json` (24h TTL).

10
scripts/rsw/go.mod Normal file
View File

@@ -0,0 +1,10 @@
module github.com/runescape-wiki/rsw
go 1.22
require github.com/spf13/cobra v1.8.1
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)

10
scripts/rsw/go.sum Normal file
View File

@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,48 @@
package cmd
import (
"github.com/spf13/cobra"
)
// osrsCmd and rs3Cmd act as game-scoped parents.
// All real subcommands are registered under both via factory functions.
var osrsCmd = &cobra.Command{
Use: "osrs",
Short: "Query the Old School RuneScape Wiki",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
game = "osrs"
},
}
var rs3Cmd = &cobra.Command{
Use: "rs3",
Short: "Query the RuneScape 3 Wiki",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
game = "rs3"
},
}
func init() {
rootCmd.AddCommand(osrsCmd)
rootCmd.AddCommand(rs3Cmd)
}
// commandFactories holds functions that create fresh command instances.
// Each subcommand registers a factory at init time.
var commandFactories []func() *cobra.Command
// RegisterCommand adds a command factory. Both osrs and rs3 will get
// independent instances of the command.
func RegisterCommand(factory func() *cobra.Command) {
commandFactories = append(commandFactories, factory)
}
// wireCommands creates and attaches all subcommands to both game parents.
// Called from root.go's init after all subcommand init()s have run.
func wireCommands() {
for _, factory := range commandFactories {
osrsCmd.AddCommand(factory())
rs3Cmd.AddCommand(factory())
}
}

View File

@@ -0,0 +1,237 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"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"
)
func newItemCmd() *cobra.Command {
return &cobra.Command{
Use: "item <name>",
Short: "Look up an item — sources, stats, and price",
Long: `Searches for an item on the wiki and displays its details:
infobox stats, drop sources, acquisition methods, and GE price.
With --ironman, hides GE price and emphasizes self-sufficient acquisition
(drops, shops, crafting).
Examples:
rsw osrs item "abyssal whip"
rsw rs3 item "dragon bones" --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
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
page, err := wikiClient.GetPage(pageTitle)
if err != nil {
return fmt.Errorf("failed to fetch page: %w", err)
}
if Raw() {
fmt.Println(page.Wikitext)
return nil
}
templates := extract.ParseTemplates(page.Wikitext)
md := render.New()
md.H1(pageTitle)
// Extract infobox
infobox := findItemInfobox(templates)
if infobox != nil {
md.H2("Item Details")
md.KV("Examine", infobox.Params["examine"])
md.KV("Members", infobox.Params["members"])
md.KV("Tradeable", infobox.Params["tradeable"])
md.KV("Quest item", infobox.Params["quest"])
md.KV("Weight", infobox.Params["weight"])
md.KV("High Alch", formatAlchValue(infobox.Params["highalch"]))
md.KV("Low Alch", formatAlchValue(infobox.Params["lowalch"]))
md.KV("Destroy", infobox.Params["destroy"])
md.KV("Release", infobox.Params["release"])
md.Newline()
}
// Equipment bonuses
bonuses := extract.FindTemplate(templates, "Infobox Bonuses")
if bonuses != nil {
md.H2("Equipment Bonuses")
headers := []string{"Stat", "Value"}
var rows [][]string
for _, stat := range []string{"astab", "aslash", "acrush", "amagic", "arange",
"dstab", "dslash", "dcrush", "dmagic", "drange",
"str", "rstr", "mdmg", "prayer"} {
if v, ok := bonuses.Params[stat]; ok && v != "" && v != "0" {
rows = append(rows, []string{statName(stat), v})
}
}
if len(rows) > 0 {
md.Table(headers, rows)
}
}
// GE Price vs Ironman
if !Ironman() {
renderGEPrice(md, name, pageTitle)
} else {
md.H2("Ironman Acquisition")
md.P("*GE prices hidden — showing self-sufficient acquisition info.*")
if infobox != nil {
md.KV("High Alch", formatAlchValue(infobox.Params["highalch"]))
md.KV("Low Alch", formatAlchValue(infobox.Params["lowalch"]))
md.KV("Store price", infobox.Params["store"])
}
md.Newline()
}
// Drop sources
renderDropSources(md, templates)
// Acquisition section from the page
renderSourcesSection(md, page, wikiClient, pageTitle)
fmt.Print(md.String())
return nil
},
}
}
func findItemInfobox(templates []extract.Infobox) *extract.Infobox {
for _, name := range []string{"Infobox Item", "Infobox item", "Infobox Bonuses", "Infobox bonuses"} {
if box := extract.FindTemplate(templates, name); box != nil {
return box
}
}
return nil
}
func renderGEPrice(md *render.Builder, name, pageTitle string) {
priceClient := prices.NewClient(GamePriceBaseURL())
mc := prices.NewMappingCache(priceClient)
if err := mc.Load(); err != nil {
return
}
item := mc.LookupByName(name)
if item == nil {
item = mc.LookupByName(pageTitle)
}
if item == nil {
return
}
latest, err := priceClient.GetLatestForItem(item.ID)
if err != nil {
return
}
md.H2("Grand Exchange")
if latest.High != nil {
md.KV("Buy price", render.FormatGP(*latest.High))
}
if latest.Low != nil {
md.KV("Sell price", render.FormatGP(*latest.Low))
}
if item.Limit != nil {
md.KV("Buy limit", render.FormatNumber(*item.Limit))
}
md.Newline()
}
func renderDropSources(md *render.Builder, templates []extract.Infobox) {
drops := extract.FindAllTemplates(templates, "DropsLine")
if len(drops) == 0 {
drops = extract.FindAllTemplates(templates, "DropLine")
}
if len(drops) == 0 {
return
}
md.H2("Drop Sources")
headers := []string{"Monster", "Quantity", "Rarity"}
var rows [][]string
for _, d := range drops {
monster := firstNonEmpty(d.Params["name"], d.Params["Name"], d.Params["monster"], d.Params["Monster"])
qty := firstNonEmpty(d.Params["quantity"], d.Params["Quantity"])
rarity := firstNonEmpty(d.Params["rarity"], d.Params["Rarity"])
if monster != "" {
rows = append(rows, []string{monster, qty, rarity})
}
}
if len(rows) > 0 {
md.Table(headers, rows)
}
}
func renderSourcesSection(md *render.Builder, page *wiki.ParsedPage, client *wiki.Client, title string) {
for _, s := range page.Sections {
lower := strings.ToLower(s.Line)
if lower == "item sources" || lower == "sources" || lower == "obtaining" || lower == "acquisition" {
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 {
sectionPage, err := client.GetPageSection(title, idx)
if err == nil {
plain := extract.ExtractPlainText(sectionPage.Wikitext)
cleaned := strings.TrimSpace(plain)
// Skip if only whitespace/asterisks remain after cleanup
stripped := strings.NewReplacer("*", "", " ", "").Replace(cleaned)
if stripped != "" {
md.H2("Sources")
md.P(cleaned)
}
}
}
return
}
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func formatAlchValue(s string) string {
if s == "" {
return ""
}
return s + " gp"
}
func statName(s string) string {
names := map[string]string{
"astab": "Stab Attack", "aslash": "Slash Attack", "acrush": "Crush Attack",
"amagic": "Magic Attack", "arange": "Ranged Attack",
"dstab": "Stab Defence", "dslash": "Slash Defence", "dcrush": "Crush Defence",
"dmagic": "Magic Defence", "drange": "Ranged Defence",
"str": "Melee Strength", "rstr": "Ranged Strength",
"mdmg": "Magic Damage", "prayer": "Prayer",
}
if n, ok := names[s]; ok {
return n
}
return s
}
func init() {
RegisterCommand(newItemCmd)
}

View File

@@ -0,0 +1,105 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newPageCmd() *cobra.Command {
var pageSection string
cmd := &cobra.Command{
Use: "page <title>",
Short: "Fetch and display a wiki page",
Long: `Fetches a wiki page and renders it as markdown. Optionally filter
to a specific section.
Examples:
rsw osrs page "Dragon scimitar"
rsw rs3 page "Mining" --section "Training"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
title := args[0]
client := wiki.NewClient(GameBaseURL())
page, err := client.GetPage(title)
if err != nil {
return fmt.Errorf("failed to fetch page: %w", err)
}
wikitext := page.Wikitext
if pageSection != "" {
idx := wiki.FindSectionIndex(page.Sections, pageSection)
if idx == -1 {
for _, s := range page.Sections {
if strings.EqualFold(s.Line, pageSection) {
i := 0
fmt.Sscanf(s.Index, "%d", &i)
idx = i
break
}
}
}
if idx == -1 {
return fmt.Errorf("section %q not found. Available sections: %s",
pageSection, listSections(page.Sections))
}
sectionPage, err := client.GetPageSection(title, idx)
if err != nil {
return fmt.Errorf("failed to fetch section: %w", err)
}
wikitext = sectionPage.Wikitext
}
if Raw() {
fmt.Println(wikitext)
return nil
}
md := render.New()
md.H1(page.Title)
if pageSection == "" && len(page.Sections) > 0 {
md.H2("Sections")
for _, s := range page.Sections {
indent := ""
if s.Level == "3" {
indent = " "
} else if s.Level == "4" {
indent = " "
}
md.Line(fmt.Sprintf("%s- %s", indent, s.Line))
}
md.Newline()
md.HR()
}
plain := extract.ExtractPlainText(wikitext)
md.P(plain)
fmt.Print(md.String())
return nil
},
}
cmd.Flags().StringVar(&pageSection, "section", "", "Fetch only the named section")
return cmd
}
func listSections(sections []wiki.Section) string {
names := make([]string, len(sections))
for i, s := range sections {
names[i] = s.Line
}
return strings.Join(names, ", ")
}
func init() {
RegisterCommand(newPageCmd)
}

View File

@@ -0,0 +1,145 @@
package cmd
import (
"fmt"
"time"
"github.com/runescape-wiki/rsw/internal/prices"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/spf13/cobra"
)
func newPriceCmd() *cobra.Command {
return &cobra.Command{
Use: "price <item name>",
Short: "Look up Grand Exchange prices for an item",
Long: `Fetches real-time GE price data for an item: current buy/sell prices,
trade volume, and recent trend.
With --ironman, shows alch values and vendor prices instead of GE data.
Examples:
rsw osrs price "abyssal whip"
rsw rs3 price "blue partyhat"
rsw osrs price "dragon bones" --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
priceClient := prices.NewClient(GamePriceBaseURL())
mc := prices.NewMappingCache(priceClient)
if err := mc.Load(); err != nil {
return fmt.Errorf("failed to load item mapping: %w", err)
}
item := mc.LookupByName(name)
if item == nil {
matches := mc.SearchByName(name)
if len(matches) == 0 {
return fmt.Errorf("no item found matching %q", name)
}
if len(matches) > 1 {
md := render.New()
md.H2("Multiple items found")
md.P("Did you mean one of these?")
shown := 0
for _, m := range matches {
md.Bullet(m.Name)
shown++
if shown >= 15 {
md.P(fmt.Sprintf("...and %d more", len(matches)-15))
break
}
}
fmt.Print(md.String())
return nil
}
item = matches[0]
}
md := render.New()
md.H1(item.Name)
md.KV("Examine", item.Examine)
md.KV("Members", fmt.Sprintf("%v", item.Members))
md.KV("Store value", render.FormatGP(item.Value))
if item.HighAlch != nil {
md.KV("High Alch", render.FormatGP(*item.HighAlch))
}
if item.LowAlch != nil {
md.KV("Low Alch", render.FormatGP(*item.LowAlch))
}
md.Newline()
if Ironman() {
md.H2("Ironman Info")
md.P("*GE trading unavailable — showing self-sufficient values.*")
md.KV("Store value", render.FormatGP(item.Value))
if item.HighAlch != nil {
md.KV("High Alch value", render.FormatGP(*item.HighAlch))
}
md.P("Use `rsw item` for drop sources and shop locations.")
} else {
latest, err := priceClient.GetLatestForItem(item.ID)
if err != nil {
md.P("*Price data unavailable.*")
} else {
md.H2("Grand Exchange Price")
if latest.High != nil {
md.KV("Instant buy", render.FormatGP(*latest.High))
if latest.HighTime != nil {
md.KV("Last buy", timeAgo(*latest.HighTime))
}
}
if latest.Low != nil {
md.KV("Instant sell", render.FormatGP(*latest.Low))
if latest.LowTime != nil {
md.KV("Last sell", timeAgo(*latest.LowTime))
}
}
if item.Limit != nil {
md.KV("Buy limit", render.FormatNumber(*item.Limit))
}
md.Newline()
}
hourly, err := priceClient.Get1Hour(item.ID)
if err == nil && len(hourly.Data) > 0 {
recent := hourly.Data[len(hourly.Data)-1]
md.H2("Recent Activity (1h)")
if recent.AvgHighPrice != nil {
md.KV("Avg buy price", render.FormatGP(*recent.AvgHighPrice))
}
md.KV("Buy volume", render.FormatNumber(recent.HighVolume))
if recent.AvgLowPrice != nil {
md.KV("Avg sell price", render.FormatGP(*recent.AvgLowPrice))
}
md.KV("Sell volume", render.FormatNumber(recent.LowVolume))
md.Newline()
}
}
fmt.Print(md.String())
return nil
},
}
}
func timeAgo(unixTime int64) string {
t := time.Unix(unixTime, 0)
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(d.Hours()))
default:
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
}
func init() {
RegisterCommand(newPriceCmd)
}

View File

@@ -0,0 +1,156 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newQuestCmd() *cobra.Command {
return &cobra.Command{
Use: "quest <name>",
Short: "Look up quest requirements, items needed, and rewards",
Long: `Searches for a quest and displays its details: skill requirements,
quest prerequisites, items needed, enemies to defeat, and rewards.
With --ironman, flags items that need self-sufficient acquisition and
notes which combat encounters might be dangerous for HCIM.
Examples:
rsw osrs quest "Monkey Madness I"
rsw rs3 quest "Plague's End" --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
wikiClient := wiki.NewClient(GameBaseURL())
// Try fetching the page directly first (exact title match)
page, err := wikiClient.GetPage(name)
if err != nil {
// Fall back to search
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 quest %q", name)
}
page, err = wikiClient.GetPage(results[0].Title)
if err != nil {
return fmt.Errorf("failed to fetch page: %w", err)
}
}
if Raw() {
fmt.Println(page.Wikitext)
return nil
}
templates := extract.ParseTemplates(page.Wikitext)
md := render.New()
md.H1(page.Title)
// Extract from Infobox Quest
questBox := extract.FindTemplate(templates, "Infobox Quest")
if questBox == nil {
questBox = extract.FindTemplate(templates, "Infobox quest")
}
if questBox != nil {
md.H2("Overview")
md.KV("Members", questBox.Params["members"])
md.KV("Difficulty", questBox.Params["difficulty"])
md.KV("Length", questBox.Params["length"])
md.KV("Series", questBox.Params["series"])
md.KV("Age", questBox.Params["age"])
md.KV("Release", questBox.Params["release"])
md.Newline()
}
// Extract from Quest details template (OSRS stores requirements here)
details := extract.FindTemplate(templates, "Quest details")
if details == nil {
details = extract.FindTemplate(templates, "Quest Details")
}
if details != nil {
renderQuestTemplateField(md, details, "difficulty", "Difficulty")
renderQuestTemplateField(md, details, "length", "Length")
renderQuestTemplateField(md, details, "requirements", "Requirements")
renderQuestTemplateField(md, details, "items", "Items Required")
renderQuestTemplateField(md, details, "recommended", "Recommended")
renderQuestTemplateField(md, details, "kills", "Enemies to Defeat")
}
// Also try section-based extraction as fallback
renderQuestSection(md, page, wikiClient, page.Title, "Requirements",
[]string{"requirements", "skill requirements"})
renderQuestSection(md, page, wikiClient, page.Title, "Items Required",
[]string{"items required", "items needed", "required items"})
renderQuestSection(md, page, wikiClient, page.Title, "Enemies to Defeat",
[]string{"enemies to defeat", "enemies"})
renderQuestSection(md, page, wikiClient, page.Title, "Rewards",
[]string{"rewards"})
if Ironman() {
md.HR()
md.H2("Ironman Notes")
md.P("*Consider the following for self-sufficient play:*")
md.Bullet("All required items must be obtained without the GE")
md.Bullet("Check drop sources and shop availability for each required item")
md.Bullet("Boss/enemy encounters may be dangerous for HCIM — review combat levels and mechanics")
md.Newline()
}
fmt.Print(md.String())
return nil
},
}
}
func renderQuestTemplateField(md *render.Builder, details *extract.Infobox, field, heading string) {
val := details.Params[field]
if val == "" {
return
}
cleaned := extract.CleanWikitext(val)
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
return
}
md.H2(heading)
md.P(cleaned)
}
func renderQuestSection(md *render.Builder, page *wiki.ParsedPage, client *wiki.Client,
title string, heading string, sectionNames []string) {
for _, s := range page.Sections {
lower := strings.ToLower(s.Line)
for _, target := range sectionNames {
if lower == target || strings.Contains(lower, target) {
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 {
sectionPage, err := client.GetPageSection(title, idx)
if err == nil {
plain := extract.ExtractPlainText(sectionPage.Wikitext)
if strings.TrimSpace(plain) != "" {
md.H2(heading)
md.P(plain)
}
}
}
return
}
}
}
}
func init() {
RegisterCommand(newQuestCmd)
}

View File

@@ -0,0 +1,91 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
ironman bool
raw bool
game string
)
var rootCmd = &cobra.Command{
Use: "rsw <game> <command>",
Short: "RuneScape Wiki CLI — query the RS3 and OSRS wikis from your terminal",
Long: `rsw is a command-line tool for querying the RuneScape Wiki.
It supports both RS3 (runescape.wiki) and Old School RuneScape (oldschool.runescape.wiki).
The first argument must be the game: "osrs" or "rs3".
Examples:
rsw osrs item "dragon scimitar" --ironman
rsw rs3 quest "Plague's End"
rsw osrs skill mining --level 50-70 --ironman
rsw rs3 price "blue partyhat"`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if game != "osrs" && game != "rs3" {
return fmt.Errorf("game must be 'osrs' or 'rs3', got %q", game)
}
return nil
},
}
func Execute() error {
wireCommands()
return rootCmd.Execute()
}
func init() {
// Game is set by the wrapper commands, not directly by the user.
// See game.go for how osrs/rs3 subcommands inject this.
rootCmd.PersistentFlags().BoolVarP(&ironman, "ironman", "i", false, "Ironman mode: emphasize self-sufficient acquisition, hide GE prices")
rootCmd.PersistentFlags().BoolVar(&raw, "raw", false, "Output raw wikitext instead of rendered markdown")
// Suppress the default completion command
rootCmd.CompletionOptions.DisableDefaultCmd = true
}
// GameBaseURL returns the MediaWiki API base URL for the selected game.
func GameBaseURL() string {
switch game {
case "osrs":
return "https://oldschool.runescape.wiki/api.php"
case "rs3":
return "https://runescape.wiki/api.php"
default:
fmt.Fprintf(os.Stderr, "invalid game %q\n", game)
os.Exit(1)
return ""
}
}
// GamePriceBaseURL returns the real-time price API base URL.
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 ""
}
}
// Game returns the current game selection.
func Game() string {
return game
}
// Ironman returns whether ironman mode is active.
func Ironman() bool {
return ironman
}
// Raw returns whether raw output mode is active.
func Raw() bool {
return raw
}

View File

@@ -0,0 +1,60 @@
package cmd
import (
"fmt"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newSearchCmd() *cobra.Command {
var searchLimit int
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search the wiki for pages matching a query",
Long: `Search performs a full-text search across the wiki and returns
matching page titles with short snippets.
Examples:
rsw osrs search "dragon scimitar"
rsw rs3 search "mining training"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
client := wiki.NewClient(GameBaseURL())
results, err := client.Search(query, searchLimit)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(results) == 0 {
fmt.Println("No results found.")
return nil
}
md := render.New()
md.H2(fmt.Sprintf("Search results for \"%s\"", query))
for i, r := range results {
md.NumberedItem(i+1, fmt.Sprintf("**%s**", r.Title))
if r.Snippet != "" {
md.Line(fmt.Sprintf(" %s", r.Snippet))
}
}
md.Newline()
fmt.Print(md.String())
return nil
},
}
cmd.Flags().IntVar(&searchLimit, "limit", 10, "Maximum number of results")
return cmd
}
func init() {
RegisterCommand(newSearchCmd)
}

View File

@@ -0,0 +1,144 @@
package cmd
import (
"fmt"
"strings"
"github.com/runescape-wiki/rsw/internal/extract"
"github.com/runescape-wiki/rsw/internal/render"
"github.com/runescape-wiki/rsw/internal/wiki"
"github.com/spf13/cobra"
)
func newSkillCmd() *cobra.Command {
var levelRange string
cmd := &cobra.Command{
Use: "skill <name>",
Short: "Look up skill training methods",
Long: `Fetches training guide information for a skill. Optionally filter
to a specific level range.
With --ironman, emphasizes training methods viable without GE access.
Examples:
rsw osrs skill mining
rsw rs3 skill "prayer" --level 50-70
rsw osrs skill slayer --ironman`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
skillName := args[0]
wikiClient := wiki.NewClient(GameBaseURL())
trainingTitle := capitalizeFirst(strings.ToLower(skillName)) + " training"
page, err := wikiClient.GetPage(trainingTitle)
if err != nil {
page, err = wikiClient.GetPage(capitalizeFirst(strings.ToLower(skillName)))
if err != nil {
return fmt.Errorf("failed to fetch skill page: %w", err)
}
}
if Raw() {
fmt.Println(page.Wikitext)
return nil
}
md := render.New()
md.H1(fmt.Sprintf("%s Training Guide", page.Title))
if Ironman() {
md.P("*Showing methods suitable for ironman accounts (no GE access).*")
}
if len(page.Sections) > 0 {
md.H2("Contents")
for _, s := range page.Sections {
if s.Level == "2" {
md.Bullet(s.Line)
}
}
md.Newline()
}
if levelRange != "" {
found := false
for _, s := range page.Sections {
if strings.Contains(strings.ToLower(s.Line), strings.ToLower(levelRange)) ||
sectionMatchesLevelRange(s.Line, levelRange) {
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx > 0 {
sectionPage, err := wikiClient.GetPageSection(page.Title, idx)
if err == nil {
plain := extract.ExtractPlainText(sectionPage.Wikitext)
if strings.TrimSpace(plain) != "" {
md.H2(s.Line)
md.P(plain)
found = true
}
}
}
}
}
if !found {
md.P(fmt.Sprintf("*No section found matching level range %q. Showing full guide.*", levelRange))
renderFullGuide(md, page, wikiClient)
}
} else {
renderFullGuide(md, page, wikiClient)
}
fmt.Print(md.String())
return nil
},
}
cmd.Flags().StringVar(&levelRange, "level", "", "Filter to a level range (e.g., '50-70')")
return cmd
}
func renderFullGuide(md *render.Builder, page *wiki.ParsedPage, client *wiki.Client) {
for _, s := range page.Sections {
if s.Level != "2" {
continue
}
idx := 0
fmt.Sscanf(s.Index, "%d", &idx)
if idx <= 0 {
continue
}
sectionPage, err := client.GetPageSection(page.Title, idx)
if err != nil {
continue
}
plain := extract.ExtractPlainText(sectionPage.Wikitext)
if strings.TrimSpace(plain) != "" {
md.H2(s.Line)
md.P(plain)
}
}
}
func sectionMatchesLevelRange(heading, levelRange string) bool {
h := strings.ToLower(heading)
h = strings.ReplaceAll(h, "", "-")
h = strings.ReplaceAll(h, "—", "-")
h = strings.ReplaceAll(h, " ", "")
lr := strings.ToLower(levelRange)
lr = strings.ReplaceAll(lr, " ", "")
return strings.Contains(h, lr)
}
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func init() {
RegisterCommand(newSkillCmd)
}

View File

@@ -0,0 +1,492 @@
package extract
import (
"strconv"
"strings"
"unicode"
)
// Infobox represents a parsed wiki infobox (or any template) as key-value pairs.
type Infobox struct {
TemplateName string
Params map[string]string
}
// ParseTemplates extracts all top-level templates from wikitext.
// It handles nested templates (e.g., {{formatnum:{{GEPrice|...}}}}) by tracking brace depth.
func ParseTemplates(wikitext string) []Infobox {
var results []Infobox
i := 0
for i < len(wikitext)-1 {
if wikitext[i] == '{' && wikitext[i+1] == '{' {
end := findTemplateEnd(wikitext, i)
if end == -1 {
i++
continue
}
inner := wikitext[i+2 : end]
if box := parseTemplate(inner); box != nil {
results = append(results, *box)
}
i = end + 2
} else {
i++
}
}
return results
}
// FindTemplate searches parsed templates for one matching the given name (case-insensitive).
func FindTemplate(templates []Infobox, name string) *Infobox {
target := strings.ToLower(strings.TrimSpace(name))
for i, t := range templates {
if strings.ToLower(strings.TrimSpace(t.TemplateName)) == target {
return &templates[i]
}
}
return nil
}
// FindAllTemplates returns all templates matching the given name.
func FindAllTemplates(templates []Infobox, name string) []Infobox {
target := strings.ToLower(strings.TrimSpace(name))
var matches []Infobox
for _, t := range templates {
if strings.ToLower(strings.TrimSpace(t.TemplateName)) == target {
matches = append(matches, t)
}
}
return matches
}
// findTemplateEnd finds the index of the closing '}}' for a template starting at pos.
func findTemplateEnd(s string, pos int) int {
depth := 0
i := pos
for i < len(s)-1 {
if s[i] == '{' && s[i+1] == '{' {
depth++
i += 2
} else if s[i] == '}' && s[i+1] == '}' {
depth--
if depth == 0 {
return i
}
i += 2
} else {
i++
}
}
return -1
}
// parseTemplate parses the inner content of a {{...}} template.
func parseTemplate(inner string) *Infobox {
// Split on '|' but respect nested templates
parts := splitOnPipes(inner)
if len(parts) == 0 {
return nil
}
name := strings.TrimSpace(parts[0])
// Skip parser functions like #if, #switch, etc.
if strings.HasPrefix(name, "#") {
return nil
}
params := make(map[string]string)
positional := 1
for _, part := range parts[1:] {
eqIdx := strings.Index(part, "=")
if eqIdx > 0 {
key := strings.TrimSpace(part[:eqIdx])
val := strings.TrimSpace(part[eqIdx+1:])
// Clean up common wikitext artifacts
val = cleanValue(val)
if key != "" {
params[key] = val
}
} else {
// Positional parameter
params[strconv.Itoa(positional)] = strings.TrimSpace(part)
positional++
}
}
return &Infobox{
TemplateName: name,
Params: params,
}
}
// splitOnPipes splits a string on '|' while respecting nested {{...}} and [[...]].
func splitOnPipes(s string) []string {
var parts []string
var current strings.Builder
braceDepth := 0
bracketDepth := 0
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case ch == '{' && i+1 < len(s) && s[i+1] == '{':
braceDepth++
current.WriteByte('{')
current.WriteByte('{')
i++
case ch == '}' && i+1 < len(s) && s[i+1] == '}':
braceDepth--
current.WriteByte('}')
current.WriteByte('}')
i++
case ch == '[' && i+1 < len(s) && s[i+1] == '[':
bracketDepth++
current.WriteByte('[')
current.WriteByte('[')
i++
case ch == ']' && i+1 < len(s) && s[i+1] == ']':
bracketDepth--
current.WriteByte(']')
current.WriteByte(']')
i++
case ch == '|' && braceDepth == 0 && bracketDepth == 0:
parts = append(parts, current.String())
current.Reset()
default:
current.WriteByte(ch)
}
}
parts = append(parts, current.String())
return parts
}
// cleanValue strips common wikitext formatting from a value.
func cleanValue(s string) string {
// Remove [[ ]] wiki links, keeping the display text
s = cleanWikiLinks(s)
// Remove '' and ''' (bold/italic)
s = strings.ReplaceAll(s, "'''", "")
s = strings.ReplaceAll(s, "''", "")
// Trim whitespace
s = strings.TrimSpace(s)
return s
}
// cleanWikiLinks converts [[Target|Display]] to Display, and [[Target]] to Target.
func cleanWikiLinks(s string) string {
var b strings.Builder
i := 0
for i < len(s) {
if i+1 < len(s) && s[i] == '[' && s[i+1] == '[' {
end := strings.Index(s[i:], "]]")
if end == -1 {
b.WriteByte(s[i])
i++
continue
}
inner := s[i+2 : i+end]
if pipeIdx := strings.Index(inner, "|"); pipeIdx >= 0 {
b.WriteString(inner[pipeIdx+1:])
} else {
b.WriteString(inner)
}
i = i + end + 2
} else {
b.WriteByte(s[i])
i++
}
}
return b.String()
}
// CleanWikitext strips templates, wiki links, and HTML but preserves line structure
// and converts wiki list markers (* items) to readable bullet points.
func CleanWikitext(s string) string {
s = expandKnownTemplates(s)
s = removeTemplates(s)
s = cleanWikiLinks(s)
s = strings.ReplaceAll(s, "'''", "")
s = strings.ReplaceAll(s, "''", "")
s = removeWikiTables(s)
s = stripHTMLTags(s)
s = removeRefs(s)
s = removeSectionHeadings(s)
s = removeFileAndCategoryLines(s)
s = removeFileRefs(s)
// Process line by line to preserve list structure
lines := strings.Split(s, "\n")
var out []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Convert wiki list markers to markdown bullets
if strings.HasPrefix(trimmed, "***") {
out = append(out, " - "+strings.TrimSpace(trimmed[3:]))
} else if strings.HasPrefix(trimmed, "**") {
out = append(out, " - "+strings.TrimSpace(trimmed[2:]))
} else if strings.HasPrefix(trimmed, "*") {
out = append(out, "- "+strings.TrimSpace(trimmed[1:]))
} else {
out = append(out, trimmed)
}
}
return strings.Join(out, "\n")
}
// ExtractPlainText strips all wikitext markup to produce plain text.
func ExtractPlainText(wikitext string) string {
s := wikitext
// Remove templates (simplified — just removes {{ ... }} at depth 0)
s = removeTemplates(s)
s = cleanWikiLinks(s)
s = strings.ReplaceAll(s, "'''", "")
s = strings.ReplaceAll(s, "''", "")
// Remove wiki tables {| ... |}
s = removeWikiTables(s)
// Remove section headings (== Foo ==, === Bar ===, etc.)
s = removeSectionHeadings(s)
// Remove HTML tags
s = stripHTMLTags(s)
// Remove references
s = removeRefs(s)
// Remove file/image links and category lines
s = removeFileAndCategoryLines(s)
// Remove leftover File: references
s = removeFileRefs(s)
// Collapse whitespace
s = collapseWhitespace(s)
return strings.TrimSpace(s)
}
// expandKnownTemplates replaces well-known templates with plain text equivalents
// before the generic template removal pass. This preserves useful data from
// templates like {{Skillreq|Mining|75}} → "75 Mining".
func expandKnownTemplates(s string) string {
var b strings.Builder
i := 0
for i < len(s)-1 {
if s[i] == '{' && s[i+1] == '{' {
end := findTemplateEnd(s, i)
if end == -1 {
b.WriteByte(s[i])
i++
continue
}
inner := s[i+2 : end]
if expanded, ok := tryExpandTemplate(inner); ok {
b.WriteString(expanded)
} else {
// Leave it for removeTemplates to handle
b.WriteString(s[i : end+2])
}
i = end + 2
} else {
b.WriteByte(s[i])
i++
}
}
if i < len(s) {
b.WriteByte(s[i])
}
return b.String()
}
func tryExpandTemplate(inner string) (string, bool) {
parts := splitOnPipes(inner)
if len(parts) == 0 {
return "", false
}
name := strings.TrimSpace(parts[0])
lower := strings.ToLower(name)
switch lower {
case "skillreq", "scp":
// {{Skillreq|Skill|Level}} or {{SCP|Level|Skill}} → "Level Skill"
if len(parts) >= 3 {
if lower == "scp" {
return strings.TrimSpace(parts[1]) + " " + strings.TrimSpace(parts[2]), true
}
return strings.TrimSpace(parts[2]) + " " + strings.TrimSpace(parts[1]), true
}
if len(parts) == 2 {
return strings.TrimSpace(parts[1]), true
}
case "fairycode":
if len(parts) >= 2 {
return strings.TrimSpace(parts[1]), true
}
case "coins", "coins detail":
if len(parts) >= 2 {
return strings.TrimSpace(parts[1]) + " coins", true
}
}
return "", false
}
func removeTemplates(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if i+1 < len(s) && s[i] == '{' && s[i+1] == '{' {
depth++
i++
} else if i+1 < len(s) && s[i] == '}' && s[i+1] == '}' {
depth--
if depth < 0 {
depth = 0
}
i++
} else if depth == 0 {
b.WriteByte(s[i])
}
}
return b.String()
}
func stripHTMLTags(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
} else if r == '>' {
inTag = false
} else if !inTag {
b.WriteRune(r)
}
}
return b.String()
}
func removeRefs(s string) string {
// Remove <ref>...</ref> and <ref ... />
for {
start := strings.Index(s, "<ref")
if start == -1 {
break
}
// Self-closing?
selfClose := strings.Index(s[start:], "/>")
endTag := strings.Index(s[start:], "</ref>")
if selfClose != -1 && (endTag == -1 || selfClose < endTag) {
s = s[:start] + s[start+selfClose+2:]
} else if endTag != -1 {
s = s[:start] + s[start+endTag+6:]
} else {
break
}
}
return s
}
func removeWikiTables(s string) string {
var b strings.Builder
depth := 0
lines := strings.Split(s, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "{|") {
depth++
continue
}
if strings.HasPrefix(trimmed, "|}") {
if depth > 0 {
depth--
}
continue
}
if depth == 0 {
b.WriteString(line)
b.WriteByte('\n')
}
}
return b.String()
}
func removeFileAndCategoryLines(s string) string {
var b strings.Builder
lines := strings.Split(s, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
// Skip lines that are file/image embeds or category tags
if strings.HasPrefix(lower, "[[file:") || strings.HasPrefix(lower, "[[image:") ||
strings.HasPrefix(lower, "[[category:") || strings.HasPrefix(lower, "category:") ||
strings.HasPrefix(lower, "thumb|") || lower == "thumb" ||
(strings.Contains(lower, "px|") && (strings.Contains(lower, "thumb") || strings.Contains(lower, "right") || strings.Contains(lower, "left"))) {
continue
}
// Remove inline [[Category:...]] references
for strings.Contains(line, "[[Category:") {
start := strings.Index(line, "[[Category:")
end := strings.Index(line[start:], "]]")
if end == -1 {
break
}
line = line[:start] + line[start+end+2:]
}
b.WriteString(line)
b.WriteByte('\n')
}
return b.String()
}
func removeSectionHeadings(s string) string {
var b strings.Builder
lines := strings.Split(s, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "==") && strings.HasSuffix(trimmed, "==") {
continue
}
b.WriteString(line)
b.WriteByte('\n')
}
return b.String()
}
func removeFileRefs(s string) string {
var b strings.Builder
lines := strings.Split(s, "\n")
for _, line := range lines {
// Remove inline File: references (leftover after wiki link cleaning)
cleaned := line
for {
lower := strings.ToLower(cleaned)
idx := strings.Index(lower, "file:")
if idx == -1 {
break
}
// Find the end of this reference — usually ends at whitespace or next sentence
end := idx + 5
for end < len(cleaned) && cleaned[end] != ' ' && cleaned[end] != '\n' {
end++
}
cleaned = cleaned[:idx] + cleaned[end:]
}
b.WriteString(cleaned)
b.WriteByte('\n')
}
return b.String()
}
func collapseWhitespace(s string) string {
var b strings.Builder
prevSpace := false
for _, r := range s {
if unicode.IsSpace(r) {
if !prevSpace {
b.WriteRune(' ')
}
prevSpace = true
} else {
b.WriteRune(r)
prevSpace = false
}
}
return b.String()
}

View File

@@ -0,0 +1,142 @@
package prices
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const userAgent = "rsw-cli/1.0 (RuneScape Wiki CLI tool; https://github.com/runescape-wiki/rsw)"
// Client wraps HTTP requests to the real-time prices API.
type Client struct {
baseURL string
httpClient *http.Client
}
// NewClient creates a price API client.
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
// LatestPrice represents the current GE price for an item.
type LatestPrice struct {
High *int `json:"high"`
HighTime *int64 `json:"highTime"`
Low *int `json:"low"`
LowTime *int64 `json:"lowTime"`
}
// LatestResponse maps item IDs to their latest prices.
type LatestResponse struct {
Data map[string]LatestPrice `json:"data"`
}
// MappingItem maps an item ID to metadata.
type MappingItem struct {
ID int `json:"id"`
Name string `json:"name"`
Examine string `json:"examine"`
Members bool `json:"members"`
HighAlch *int `json:"highalch"`
LowAlch *int `json:"lowalch"`
Limit *int `json:"limit"`
Value int `json:"value"`
Icon string `json:"icon"`
}
// GetLatest fetches the latest prices for all items.
func (c *Client) GetLatest() (*LatestResponse, error) {
url := fmt.Sprintf("%s/latest", c.baseURL)
var resp LatestResponse
if err := c.getJSON(url, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetLatestForItem fetches the latest price for a specific item by ID.
func (c *Client) GetLatestForItem(itemID int) (*LatestPrice, error) {
url := fmt.Sprintf("%s/latest?id=%d", c.baseURL, itemID)
var resp LatestResponse
if err := c.getJSON(url, &resp); err != nil {
return nil, err
}
idStr := fmt.Sprintf("%d", itemID)
if p, ok := resp.Data[idStr]; ok {
return &p, nil
}
return nil, fmt.Errorf("no price data for item %d", itemID)
}
// GetMapping fetches the item ID → metadata mapping.
func (c *Client) GetMapping() ([]MappingItem, error) {
url := fmt.Sprintf("%s/mapping", c.baseURL)
var resp []MappingItem
if err := c.getJSON(url, &resp); err != nil {
return nil, err
}
return resp, nil
}
// VolumeEntry represents trade volume data for an item.
type VolumeEntry struct {
AvgHighPrice *int `json:"avgHighPrice"`
HighVolume int `json:"highPriceVolume"`
AvgLowPrice *int `json:"avgLowPrice"`
LowVolume int `json:"lowPriceVolume"`
Timestamp int64 `json:"timestamp"`
}
// TimeseriesResponse wraps the 5m/1h API responses.
type TimeseriesResponse struct {
Data []VolumeEntry `json:"data"`
}
// Get5Min fetches 5-minute average data for an item.
func (c *Client) Get5Min(itemID int) (*TimeseriesResponse, error) {
url := fmt.Sprintf("%s/5m?id=%d", c.baseURL, itemID)
var resp TimeseriesResponse
if err := c.getJSON(url, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Get1Hour fetches 1-hour average data for an item.
func (c *Client) Get1Hour(itemID int) (*TimeseriesResponse, error) {
url := fmt.Sprintf("%s/1h?id=%d", c.baseURL, itemID)
var resp TimeseriesResponse
if err := c.getJSON(url, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func (c *Client) getJSON(url string, dest interface{}) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", userAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
return json.NewDecoder(resp.Body).Decode(dest)
}

View File

@@ -0,0 +1,119 @@
package prices
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const cacheTTL = 24 * time.Hour
// MappingCache provides a local cache of the item ID ↔ name mapping.
type MappingCache struct {
client *Client
items []MappingItem
byID map[int]*MappingItem
byName map[string]*MappingItem
cacheDir string
}
// NewMappingCache creates a mapping cache backed by the given client.
func NewMappingCache(client *Client) *MappingCache {
home, _ := os.UserHomeDir()
return &MappingCache{
client: client,
byID: make(map[int]*MappingItem),
byName: make(map[string]*MappingItem),
cacheDir: filepath.Join(home, ".rsw", "cache"),
}
}
// Load populates the cache, reading from disk if fresh or fetching from API.
func (mc *MappingCache) Load() error {
// Try disk cache first
if mc.loadFromDisk() {
return nil
}
items, err := mc.client.GetMapping()
if err != nil {
return fmt.Errorf("fetching mapping: %w", err)
}
mc.items = items
mc.index()
mc.saveToDisk()
return nil
}
// LookupByName finds an item by exact name (case-insensitive).
func (mc *MappingCache) LookupByName(name string) *MappingItem {
return mc.byName[strings.ToLower(name)]
}
// SearchByName finds items whose name contains the query (case-insensitive).
func (mc *MappingCache) SearchByName(query string) []*MappingItem {
query = strings.ToLower(query)
var results []*MappingItem
for i := range mc.items {
if strings.Contains(strings.ToLower(mc.items[i].Name), query) {
results = append(results, &mc.items[i])
}
}
return results
}
// LookupByID finds an item by its ID.
func (mc *MappingCache) LookupByID(id int) *MappingItem {
return mc.byID[id]
}
func (mc *MappingCache) index() {
mc.byID = make(map[int]*MappingItem, len(mc.items))
mc.byName = make(map[string]*MappingItem, len(mc.items))
for i := range mc.items {
mc.byID[mc.items[i].ID] = &mc.items[i]
mc.byName[strings.ToLower(mc.items[i].Name)] = &mc.items[i]
}
}
func (mc *MappingCache) cachePath() string {
return filepath.Join(mc.cacheDir, "mapping.json")
}
func (mc *MappingCache) loadFromDisk() bool {
path := mc.cachePath()
info, err := os.Stat(path)
if err != nil {
return false
}
if time.Since(info.ModTime()) > cacheTTL {
return false
}
data, err := os.ReadFile(path)
if err != nil {
return false
}
var items []MappingItem
if err := json.Unmarshal(data, &items); err != nil {
return false
}
mc.items = items
mc.index()
return true
}
func (mc *MappingCache) saveToDisk() {
_ = os.MkdirAll(mc.cacheDir, 0755)
data, err := json.Marshal(mc.items)
if err != nil {
return
}
_ = os.WriteFile(mc.cachePath(), data, 0644)
}

View File

@@ -0,0 +1,142 @@
package render
import (
"fmt"
"strings"
)
// Builder accumulates markdown output.
type Builder struct {
sb strings.Builder
}
// New creates a new markdown builder.
func New() *Builder {
return &Builder{}
}
// H1 writes a level-1 heading.
func (b *Builder) H1(text string) {
fmt.Fprintf(&b.sb, "# %s\n\n", text)
}
// H2 writes a level-2 heading.
func (b *Builder) H2(text string) {
fmt.Fprintf(&b.sb, "## %s\n\n", text)
}
// H3 writes a level-3 heading.
func (b *Builder) H3(text string) {
fmt.Fprintf(&b.sb, "### %s\n\n", text)
}
// P writes a paragraph.
func (b *Builder) P(text string) {
fmt.Fprintf(&b.sb, "%s\n\n", text)
}
// Bold writes bold text inline (no newline).
func (b *Builder) Bold(text string) {
fmt.Fprintf(&b.sb, "**%s**", text)
}
// KV writes a key: value line.
func (b *Builder) KV(key, value string) {
if value == "" {
return
}
fmt.Fprintf(&b.sb, "- **%s:** %s\n", key, value)
}
// Bullet writes a bullet point.
func (b *Builder) Bullet(text string) {
fmt.Fprintf(&b.sb, "- %s\n", text)
}
// NumberedItem writes a numbered list item.
func (b *Builder) NumberedItem(n int, text string) {
fmt.Fprintf(&b.sb, "%d. %s\n", n, text)
}
// Table writes a markdown table.
func (b *Builder) Table(headers []string, rows [][]string) {
if len(headers) == 0 {
return
}
// Header row
fmt.Fprintf(&b.sb, "| %s |\n", strings.Join(headers, " | "))
// Separator
seps := make([]string, len(headers))
for i := range seps {
seps[i] = "---"
}
fmt.Fprintf(&b.sb, "| %s |\n", strings.Join(seps, " | "))
// Data rows
for _, row := range rows {
// Pad row to match header length
for len(row) < len(headers) {
row = append(row, "")
}
fmt.Fprintf(&b.sb, "| %s |\n", strings.Join(row[:len(headers)], " | "))
}
b.sb.WriteString("\n")
}
// Line writes a raw line.
func (b *Builder) Line(text string) {
fmt.Fprintf(&b.sb, "%s\n", text)
}
// Newline writes an empty line.
func (b *Builder) Newline() {
b.sb.WriteString("\n")
}
// HR writes a horizontal rule.
func (b *Builder) HR() {
b.sb.WriteString("---\n\n")
}
// String returns the accumulated markdown.
func (b *Builder) String() string {
return b.sb.String()
}
// FormatGP formats a coin amount with commas (e.g., 1,234,567 gp).
func FormatGP(amount int) string {
if amount < 0 {
return fmt.Sprintf("-%s gp", addCommas(-amount))
}
return fmt.Sprintf("%s gp", addCommas(amount))
}
// FormatNumber formats a number with commas.
func FormatNumber(n int) string {
return addCommas(n)
}
func addCommas(n int) string {
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var result strings.Builder
remainder := len(s) % 3
if remainder > 0 {
result.WriteString(s[:remainder])
if len(s) > remainder {
result.WriteString(",")
}
}
for i := remainder; i < len(s); i += 3 {
if i > remainder {
result.WriteString(",")
}
result.WriteString(s[i : i+3])
}
return result.String()
}

View File

@@ -0,0 +1,57 @@
package wiki
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const userAgent = "rsw-cli/1.0 (RuneScape Wiki CLI tool; https://github.com/runescape-wiki/rsw)"
// Client wraps HTTP requests to the MediaWiki API.
type Client struct {
baseURL string
httpClient *http.Client
}
// NewClient creates a wiki API client for the given base URL.
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
// get performs a GET request with the given parameters and decodes JSON into dest.
func (c *Client) get(params url.Values, dest interface{}) error {
params.Set("format", "json")
reqURL := fmt.Sprintf("%s?%s", c.baseURL, params.Encode())
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", userAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
return nil
}

View File

@@ -0,0 +1,135 @@
package wiki
import (
"fmt"
"net/url"
"strconv"
)
// Section represents a section of a wiki page.
type Section struct {
Index string `json:"index"`
Level string `json:"level"`
Line string `json:"line"`
Number string `json:"number"`
Anchor string `json:"anchor"`
}
// ParsedPage contains the result of parsing a wiki page.
type ParsedPage struct {
Title string `json:"title"`
PageID int `json:"pageid"`
Wikitext string // Raw wikitext of the page or section
HTML string // Rendered HTML
Sections []Section // Table of contents
}
// parseResponse wraps the API response for action=parse.
type parseResponse struct {
Parse struct {
Title string `json:"title"`
PageID int `json:"pageid"`
Wikitext struct {
Content string `json:"*"`
} `json:"wikitext"`
Text struct {
Content string `json:"*"`
} `json:"text"`
Sections []Section `json:"sections"`
} `json:"parse"`
Error *struct {
Code string `json:"code"`
Info string `json:"info"`
} `json:"error"`
}
// GetPage fetches the full wikitext and section list for a page.
func (c *Client) GetPage(title string) (*ParsedPage, error) {
params := url.Values{
"action": {"parse"},
"page": {title},
"prop": {"wikitext|sections"},
"redirects": {"1"},
}
var resp parseResponse
if err := c.get(params, &resp); err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("wiki API error: %s — %s", resp.Error.Code, resp.Error.Info)
}
return &ParsedPage{
Title: resp.Parse.Title,
PageID: resp.Parse.PageID,
Wikitext: resp.Parse.Wikitext.Content,
Sections: resp.Parse.Sections,
}, nil
}
// GetPageSection fetches the wikitext for a specific section of a page.
func (c *Client) GetPageSection(title string, sectionIndex int) (*ParsedPage, error) {
params := url.Values{
"action": {"parse"},
"page": {title},
"prop": {"wikitext"},
"section": {strconv.Itoa(sectionIndex)},
}
var resp parseResponse
if err := c.get(params, &resp); err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("wiki API error: %s — %s", resp.Error.Code, resp.Error.Info)
}
return &ParsedPage{
Title: resp.Parse.Title,
PageID: resp.Parse.PageID,
Wikitext: resp.Parse.Wikitext.Content,
}, nil
}
// GetPageHTML fetches the rendered HTML for a page.
func (c *Client) GetPageHTML(title string) (*ParsedPage, error) {
params := url.Values{
"action": {"parse"},
"page": {title},
"prop": {"text|sections"},
}
var resp parseResponse
if err := c.get(params, &resp); err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("wiki API error: %s — %s", resp.Error.Code, resp.Error.Info)
}
return &ParsedPage{
Title: resp.Parse.Title,
PageID: resp.Parse.PageID,
HTML: resp.Parse.Text.Content,
Sections: resp.Parse.Sections,
}, nil
}
// FindSectionIndex searches the section list for a section matching the given name.
// Returns -1 if not found.
func FindSectionIndex(sections []Section, name string) int {
for _, s := range sections {
if s.Line == name {
idx, err := strconv.Atoi(s.Index)
if err != nil {
continue
}
return idx
}
}
return -1
}

View File

@@ -0,0 +1,66 @@
package wiki
import (
"net/url"
"strconv"
"strings"
)
// SearchResult represents a single search hit from the wiki.
type SearchResult struct {
Title string `json:"title"`
Snippet string `json:"snippet"`
PageID int `json:"pageid"`
Size int `json:"size"`
}
// searchResponse is the raw API response shape for action=query&list=search.
type searchResponse struct {
Query struct {
Search []SearchResult `json:"search"`
} `json:"query"`
}
// Search performs a full-text search across wiki pages.
func (c *Client) Search(query string, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 10
}
params := url.Values{
"action": {"query"},
"list": {"search"},
"srsearch": {query},
"srlimit": {strconv.Itoa(limit)},
"srprop": {"snippet|size"},
}
var resp searchResponse
if err := c.get(params, &resp); err != nil {
return nil, err
}
// Strip HTML tags from snippets (the API returns <span class="searchmatch">...</span>)
for i := range resp.Query.Search {
resp.Query.Search[i].Snippet = stripHTML(resp.Query.Search[i].Snippet)
}
return resp.Query.Search, nil
}
// stripHTML removes HTML tags from a string. Lightweight, no external dep.
func stripHTML(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return b.String()
}

15
scripts/rsw/main.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"github.com/runescape-wiki/rsw/internal/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}