Initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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