Initial commit
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user