From 1ae223a1dc7bd219a8f90e4e44050098ea1243f2 Mon Sep 17 00:00:00 2001 From: Sam Myers Date: Thu, 5 Mar 2026 01:13:19 -0600 Subject: [PATCH] Initial commit --- .gitignore | 3 + BUILD_NOTES.md | 112 ++++++ SKILL.md | 115 ++++++ scripts/rsw/go.mod | 10 + scripts/rsw/go.sum | 10 + scripts/rsw/internal/cmd/game.go | 48 +++ scripts/rsw/internal/cmd/item.go | 237 ++++++++++++ scripts/rsw/internal/cmd/page.go | 105 +++++ scripts/rsw/internal/cmd/price.go | 145 +++++++ scripts/rsw/internal/cmd/quest.go | 156 ++++++++ scripts/rsw/internal/cmd/root.go | 91 +++++ scripts/rsw/internal/cmd/search.go | 60 +++ scripts/rsw/internal/cmd/skill.go | 144 +++++++ scripts/rsw/internal/extract/infobox.go | 492 ++++++++++++++++++++++++ scripts/rsw/internal/prices/client.go | 142 +++++++ scripts/rsw/internal/prices/mapping.go | 119 ++++++ scripts/rsw/internal/render/markdown.go | 142 +++++++ scripts/rsw/internal/wiki/client.go | 57 +++ scripts/rsw/internal/wiki/parse.go | 135 +++++++ scripts/rsw/internal/wiki/search.go | 66 ++++ scripts/rsw/main.go | 15 + 21 files changed, 2404 insertions(+) create mode 100644 .gitignore create mode 100644 BUILD_NOTES.md create mode 100644 SKILL.md create mode 100644 scripts/rsw/go.mod create mode 100644 scripts/rsw/go.sum create mode 100644 scripts/rsw/internal/cmd/game.go create mode 100644 scripts/rsw/internal/cmd/item.go create mode 100644 scripts/rsw/internal/cmd/page.go create mode 100644 scripts/rsw/internal/cmd/price.go create mode 100644 scripts/rsw/internal/cmd/quest.go create mode 100644 scripts/rsw/internal/cmd/root.go create mode 100644 scripts/rsw/internal/cmd/search.go create mode 100644 scripts/rsw/internal/cmd/skill.go create mode 100644 scripts/rsw/internal/extract/infobox.go create mode 100644 scripts/rsw/internal/prices/client.go create mode 100644 scripts/rsw/internal/prices/mapping.go create mode 100644 scripts/rsw/internal/render/markdown.go create mode 100644 scripts/rsw/internal/wiki/client.go create mode 100644 scripts/rsw/internal/wiki/parse.go create mode 100644 scripts/rsw/internal/wiki/search.go create mode 100644 scripts/rsw/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6562d25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.claude/settings.local.json + +scripts/rsw/rsw diff --git a/BUILD_NOTES.md b/BUILD_NOTES.md new file mode 100644 index 0000000..04dc054 --- /dev/null +++ b/BUILD_NOTES.md @@ -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 search + │ ├── page.go # rsw page [--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. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..fdd9316 --- /dev/null +++ b/SKILL.md @@ -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). diff --git a/scripts/rsw/go.mod b/scripts/rsw/go.mod new file mode 100644 index 0000000..05f05b0 --- /dev/null +++ b/scripts/rsw/go.mod @@ -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 +) diff --git a/scripts/rsw/go.sum b/scripts/rsw/go.sum new file mode 100644 index 0000000..912390a --- /dev/null +++ b/scripts/rsw/go.sum @@ -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= diff --git a/scripts/rsw/internal/cmd/game.go b/scripts/rsw/internal/cmd/game.go new file mode 100644 index 0000000..af221cc --- /dev/null +++ b/scripts/rsw/internal/cmd/game.go @@ -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()) + } +} diff --git a/scripts/rsw/internal/cmd/item.go b/scripts/rsw/internal/cmd/item.go new file mode 100644 index 0000000..85531e4 --- /dev/null +++ b/scripts/rsw/internal/cmd/item.go @@ -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) +} diff --git a/scripts/rsw/internal/cmd/page.go b/scripts/rsw/internal/cmd/page.go new file mode 100644 index 0000000..8a19ecb --- /dev/null +++ b/scripts/rsw/internal/cmd/page.go @@ -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) +} diff --git a/scripts/rsw/internal/cmd/price.go b/scripts/rsw/internal/cmd/price.go new file mode 100644 index 0000000..8515899 --- /dev/null +++ b/scripts/rsw/internal/cmd/price.go @@ -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) +} diff --git a/scripts/rsw/internal/cmd/quest.go b/scripts/rsw/internal/cmd/quest.go new file mode 100644 index 0000000..013d6c2 --- /dev/null +++ b/scripts/rsw/internal/cmd/quest.go @@ -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) +} diff --git a/scripts/rsw/internal/cmd/root.go b/scripts/rsw/internal/cmd/root.go new file mode 100644 index 0000000..4904b6d --- /dev/null +++ b/scripts/rsw/internal/cmd/root.go @@ -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 +} diff --git a/scripts/rsw/internal/cmd/search.go b/scripts/rsw/internal/cmd/search.go new file mode 100644 index 0000000..37a10fe --- /dev/null +++ b/scripts/rsw/internal/cmd/search.go @@ -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) +} diff --git a/scripts/rsw/internal/cmd/skill.go b/scripts/rsw/internal/cmd/skill.go new file mode 100644 index 0000000..8f1a55d --- /dev/null +++ b/scripts/rsw/internal/cmd/skill.go @@ -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) +} diff --git a/scripts/rsw/internal/extract/infobox.go b/scripts/rsw/internal/extract/infobox.go new file mode 100644 index 0000000..9566a67 --- /dev/null +++ b/scripts/rsw/internal/extract/infobox.go @@ -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() +} diff --git a/scripts/rsw/internal/prices/client.go b/scripts/rsw/internal/prices/client.go new file mode 100644 index 0000000..faeac98 --- /dev/null +++ b/scripts/rsw/internal/prices/client.go @@ -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) +} diff --git a/scripts/rsw/internal/prices/mapping.go b/scripts/rsw/internal/prices/mapping.go new file mode 100644 index 0000000..b14d989 --- /dev/null +++ b/scripts/rsw/internal/prices/mapping.go @@ -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) +} diff --git a/scripts/rsw/internal/render/markdown.go b/scripts/rsw/internal/render/markdown.go new file mode 100644 index 0000000..886418c --- /dev/null +++ b/scripts/rsw/internal/render/markdown.go @@ -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() +} diff --git a/scripts/rsw/internal/wiki/client.go b/scripts/rsw/internal/wiki/client.go new file mode 100644 index 0000000..e9a32a4 --- /dev/null +++ b/scripts/rsw/internal/wiki/client.go @@ -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 +} diff --git a/scripts/rsw/internal/wiki/parse.go b/scripts/rsw/internal/wiki/parse.go new file mode 100644 index 0000000..2d9d683 --- /dev/null +++ b/scripts/rsw/internal/wiki/parse.go @@ -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 +} diff --git a/scripts/rsw/internal/wiki/search.go b/scripts/rsw/internal/wiki/search.go new file mode 100644 index 0000000..dae3106 --- /dev/null +++ b/scripts/rsw/internal/wiki/search.go @@ -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() +} diff --git a/scripts/rsw/main.go b/scripts/rsw/main.go new file mode 100644 index 0000000..87bb660 --- /dev/null +++ b/scripts/rsw/main.go @@ -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) + } +}