Initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.claude/settings.local.json
|
||||
|
||||
scripts/rsw/rsw
|
||||
112
BUILD_NOTES.md
Normal file
112
BUILD_NOTES.md
Normal 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
115
SKILL.md
Normal 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
10
scripts/rsw/go.mod
Normal 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
10
scripts/rsw/go.sum
Normal 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=
|
||||
48
scripts/rsw/internal/cmd/game.go
Normal file
48
scripts/rsw/internal/cmd/game.go
Normal 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())
|
||||
}
|
||||
}
|
||||
237
scripts/rsw/internal/cmd/item.go
Normal file
237
scripts/rsw/internal/cmd/item.go
Normal 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)
|
||||
}
|
||||
105
scripts/rsw/internal/cmd/page.go
Normal file
105
scripts/rsw/internal/cmd/page.go
Normal 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)
|
||||
}
|
||||
145
scripts/rsw/internal/cmd/price.go
Normal file
145
scripts/rsw/internal/cmd/price.go
Normal 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)
|
||||
}
|
||||
156
scripts/rsw/internal/cmd/quest.go
Normal file
156
scripts/rsw/internal/cmd/quest.go
Normal 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)
|
||||
}
|
||||
91
scripts/rsw/internal/cmd/root.go
Normal file
91
scripts/rsw/internal/cmd/root.go
Normal 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
|
||||
}
|
||||
60
scripts/rsw/internal/cmd/search.go
Normal file
60
scripts/rsw/internal/cmd/search.go
Normal 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)
|
||||
}
|
||||
144
scripts/rsw/internal/cmd/skill.go
Normal file
144
scripts/rsw/internal/cmd/skill.go
Normal 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)
|
||||
}
|
||||
492
scripts/rsw/internal/extract/infobox.go
Normal file
492
scripts/rsw/internal/extract/infobox.go
Normal 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()
|
||||
}
|
||||
142
scripts/rsw/internal/prices/client.go
Normal file
142
scripts/rsw/internal/prices/client.go
Normal 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)
|
||||
}
|
||||
119
scripts/rsw/internal/prices/mapping.go
Normal file
119
scripts/rsw/internal/prices/mapping.go
Normal 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)
|
||||
}
|
||||
142
scripts/rsw/internal/render/markdown.go
Normal file
142
scripts/rsw/internal/render/markdown.go
Normal 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()
|
||||
}
|
||||
57
scripts/rsw/internal/wiki/client.go
Normal file
57
scripts/rsw/internal/wiki/client.go
Normal 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
|
||||
}
|
||||
135
scripts/rsw/internal/wiki/parse.go
Normal file
135
scripts/rsw/internal/wiki/parse.go
Normal 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
|
||||
}
|
||||
66
scripts/rsw/internal/wiki/search.go
Normal file
66
scripts/rsw/internal/wiki/search.go
Normal 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
15
scripts/rsw/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user