887 lines
23 KiB
Go
887 lines
23 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ── Config ──────────────────────────────────────────────────────
|
||
|
||
const (
|
||
wikiAPI = "https://terraria.wiki.gg/api.php"
|
||
wikiImg = "https://terraria.wiki.gg/wiki/Special:FilePath/"
|
||
pageSize = 500
|
||
requestDelay = 400 * time.Millisecond
|
||
iconDelay = 1000 * time.Millisecond
|
||
iconRetryDelay = 5000 * time.Millisecond
|
||
maxRetries = 5
|
||
|
||
dataDir = "data"
|
||
iconsDir = "data/icons"
|
||
stateFile = "data/.fetch-state.json"
|
||
itemsFile = "data/items.json"
|
||
recipesFile = "data/recipes.json"
|
||
dropsFile = "data/drops.json"
|
||
splashesFile = "data/splashes.json"
|
||
)
|
||
|
||
var itemFields = []string{
|
||
"itemid", "name", "internalname", "imagefile", "autoswing", "stack",
|
||
"consumable", "hardmode", "type", "listcat", "tag",
|
||
"damage", "damagetype", "defense", "velocity", "knockback",
|
||
"rare", "buy", "sell", "usetime", "critical", "tooltip",
|
||
"pick", "axe", "hammer", "fishing", "bait", "mana",
|
||
"placeable", "bodyslot", "buffs", "debuffs", "unobtainable",
|
||
}
|
||
|
||
const recipeFields = "result,resultid,resultimage,resulttext,amount,station,ingredients,ings,legacy"
|
||
const dropFields = "nameraw,item,quantity,rate,isfromnpc,normal,expert,master"
|
||
|
||
// ── State ───────────────────────────────────────────────────────
|
||
|
||
type fetchState struct {
|
||
Phase string `json:"phase"`
|
||
ItemsOffset int `json:"itemsOffset"`
|
||
ItemsTotal *int `json:"itemsTotal"`
|
||
RecipesOffset int `json:"recipesOffset"`
|
||
RecipesTotal *int `json:"recipesTotal"`
|
||
DropsOffset int `json:"dropsOffset"`
|
||
DropsTotal *int `json:"dropsTotal"`
|
||
IconsDownloaded int `json:"iconsDownloaded"`
|
||
IconsTotal *int `json:"iconsTotal"`
|
||
IconsFailed []string `json:"iconsFailed"`
|
||
}
|
||
|
||
var state fetchState
|
||
|
||
func defaultState() fetchState {
|
||
return fetchState{
|
||
Phase: "items",
|
||
IconsFailed: []string{},
|
||
}
|
||
}
|
||
|
||
func loadFetchState() {
|
||
data, err := os.ReadFile(stateFile)
|
||
if err != nil {
|
||
state = defaultState()
|
||
return
|
||
}
|
||
if err := json.Unmarshal(data, &state); err != nil {
|
||
state = defaultState()
|
||
}
|
||
if state.IconsFailed == nil {
|
||
state.IconsFailed = []string{}
|
||
}
|
||
// Backward compat: if drops phase was never run and we're past recipes,
|
||
// rewind to "drops" so existing users get drops without --reset.
|
||
if state.DropsTotal == nil && (state.Phase == "icons" || state.Phase == "done") {
|
||
if _, err := os.Stat(dropsFile); os.IsNotExist(err) {
|
||
state.Phase = "drops"
|
||
}
|
||
}
|
||
}
|
||
|
||
func saveFetchState() {
|
||
data, _ := json.Marshal(state)
|
||
if err := os.WriteFile(stateFile, data, 0644); err != nil {
|
||
fmt.Fprintf(os.Stderr, "warning: could not save fetch state: %v\n", err)
|
||
}
|
||
}
|
||
|
||
// ── Helpers ─────────────────────────────────────────────────────
|
||
|
||
func ensureDir(dir string) {
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
fmt.Fprintf(os.Stderr, "warning: could not create directory %s: %v\n", dir, err)
|
||
}
|
||
}
|
||
|
||
func loadJSONFile(file string, v any) error {
|
||
data, err := os.ReadFile(file)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return json.Unmarshal(data, v)
|
||
}
|
||
|
||
func saveJSONFile(file string, v any) {
|
||
data, _ := json.Marshal(v)
|
||
if err := os.WriteFile(file, data, 0644); err != nil {
|
||
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", file, err)
|
||
}
|
||
}
|
||
|
||
func fmtNum(n int) string {
|
||
if n < 1000 {
|
||
return fmt.Sprintf("%d", n)
|
||
}
|
||
return fmt.Sprintf("%d,%03d", n/1000, n%1000)
|
||
}
|
||
|
||
func progressBar(current, total, width int) string {
|
||
if total == 0 {
|
||
return fmt.Sprintf("[%s] 0.0%%", strings.Repeat("░", width))
|
||
}
|
||
pct := float64(current) / float64(total)
|
||
filled := int(pct*float64(width) + 0.5)
|
||
if filled > width {
|
||
filled = width
|
||
}
|
||
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
|
||
return fmt.Sprintf("[%s] %.1f%%", bar, pct*100)
|
||
}
|
||
|
||
// ── HTTP client ─────────────────────────────────────────────────
|
||
|
||
var httpClient = &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 10 {
|
||
return fmt.Errorf("too many redirects")
|
||
}
|
||
req.Header.Set("User-Agent", "TerrariaItemTree/1.0")
|
||
return nil
|
||
},
|
||
}
|
||
|
||
func httpGet(ctx context.Context, rawURL string) ([]byte, int, error) {
|
||
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
req.Header.Set("User-Agent", "TerrariaItemTree/1.0")
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, resp.StatusCode, err
|
||
}
|
||
return body, resp.StatusCode, nil
|
||
}
|
||
|
||
// ── Cargo API ───────────────────────────────────────────────────
|
||
|
||
type cargoResponse struct {
|
||
CargoQuery []struct {
|
||
Title json.RawMessage `json:"title"`
|
||
} `json:"cargoquery"`
|
||
Error *struct {
|
||
Info string `json:"info"`
|
||
} `json:"error"`
|
||
}
|
||
|
||
func cargoQuery(ctx context.Context, params map[string]string) ([]json.RawMessage, error) {
|
||
u, _ := url.Parse(wikiAPI)
|
||
q := u.Query()
|
||
q.Set("action", "cargoquery")
|
||
q.Set("format", "json")
|
||
q.Set("origin", "*")
|
||
for k, v := range params {
|
||
q.Set(k, v)
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
|
||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||
body, status, err := httpGet(ctx, u.String())
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return nil, ctx.Err()
|
||
}
|
||
if attempt == maxRetries {
|
||
return nil, err
|
||
}
|
||
sleepCtx(ctx, requestDelay*time.Duration(attempt))
|
||
continue
|
||
}
|
||
|
||
if status == 429 {
|
||
wait := iconRetryDelay * time.Duration(attempt)
|
||
fmt.Fprintf(os.Stderr, "\n ⏳ Rate limited, waiting %ds (attempt %d/%d)...", int(wait.Seconds()), attempt, maxRetries)
|
||
sleepCtx(ctx, wait)
|
||
continue
|
||
}
|
||
|
||
if status != 200 {
|
||
if attempt == maxRetries {
|
||
return nil, fmt.Errorf("HTTP %d", status)
|
||
}
|
||
sleepCtx(ctx, requestDelay*time.Duration(attempt))
|
||
continue
|
||
}
|
||
|
||
var resp cargoResponse
|
||
if err := json.Unmarshal(body, &resp); err != nil {
|
||
if attempt == maxRetries {
|
||
return nil, fmt.Errorf("JSON parse error: %w", err)
|
||
}
|
||
sleepCtx(ctx, requestDelay*time.Duration(attempt))
|
||
continue
|
||
}
|
||
|
||
if resp.Error != nil {
|
||
return nil, fmt.Errorf("API error: %s", resp.Error.Info)
|
||
}
|
||
|
||
results := make([]json.RawMessage, len(resp.CargoQuery))
|
||
for i, row := range resp.CargoQuery {
|
||
results[i] = row.Title
|
||
}
|
||
return results, nil
|
||
}
|
||
return nil, fmt.Errorf("max retries exceeded")
|
||
}
|
||
|
||
func getCount(ctx context.Context, table string) (int, error) {
|
||
rows, err := cargoQuery(ctx, map[string]string{
|
||
"tables": table,
|
||
"fields": "COUNT(*)=count",
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if len(rows) == 0 {
|
||
return 0, fmt.Errorf("no count returned")
|
||
}
|
||
|
||
var result struct {
|
||
Count string `json:"count"`
|
||
}
|
||
if err := json.Unmarshal(rows[0], &result); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
var n int
|
||
if _, err := fmt.Sscanf(result.Count, "%d", &n); err != nil {
|
||
return 0, fmt.Errorf("parsing count %q: %w", result.Count, err)
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
func sleepCtx(ctx context.Context, d time.Duration) {
|
||
select {
|
||
case <-ctx.Done():
|
||
case <-time.After(d):
|
||
}
|
||
}
|
||
|
||
// ── Phase 1: Fetch all items ────────────────────────────────────
|
||
|
||
func fileHasData(path string) bool {
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
// An empty JSON array "[]" is 2 bytes; anything larger has real data
|
||
return info.Size() > 2
|
||
}
|
||
|
||
func fetchAllItems(ctx context.Context) error {
|
||
fmt.Println("\n📦 Phase 1: Fetching all items...")
|
||
|
||
if fileHasData(itemsFile) && state.ItemsOffset == 0 {
|
||
fmt.Println(" ⏭ data/items.json already exists on disk, skipping")
|
||
state.Phase = "recipes"
|
||
saveFetchState()
|
||
return nil
|
||
}
|
||
|
||
if state.ItemsTotal == nil {
|
||
count, err := getCount(ctx, "Items")
|
||
if err != nil {
|
||
return fmt.Errorf("counting items: %w", err)
|
||
}
|
||
state.ItemsTotal = &count
|
||
saveFetchState()
|
||
}
|
||
fmt.Printf(" Total items in wiki: %s\n", fmtNum(*state.ItemsTotal))
|
||
|
||
var items []json.RawMessage
|
||
if state.ItemsOffset > 0 {
|
||
if data, err := os.ReadFile(itemsFile); err == nil {
|
||
_ = json.Unmarshal(data, &items)
|
||
}
|
||
}
|
||
if items == nil {
|
||
items = []json.RawMessage{}
|
||
}
|
||
fmt.Printf(" Resuming from offset %s (%s items loaded)\n\n", fmtNum(state.ItemsOffset), fmtNum(len(items)))
|
||
|
||
offset := state.ItemsOffset
|
||
for offset < *state.ItemsTotal {
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s %s/%s items", progressBar(offset, *state.ItemsTotal, 30), fmtNum(offset), fmtNum(*state.ItemsTotal))
|
||
|
||
page, err := cargoQuery(ctx, map[string]string{
|
||
"tables": "Items",
|
||
"fields": strings.Join(itemFields, ","),
|
||
"limit": fmt.Sprintf("%d", pageSize),
|
||
"offset": fmt.Sprintf("%d", offset),
|
||
"order_by": "itemid",
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("fetching items at offset %d: %w", offset, err)
|
||
}
|
||
|
||
if len(page) == 0 {
|
||
break
|
||
}
|
||
items = append(items, page...)
|
||
offset += len(page)
|
||
state.ItemsOffset = offset
|
||
|
||
saveJSONFile(itemsFile, items)
|
||
saveFetchState()
|
||
|
||
sleepCtx(ctx, requestDelay)
|
||
}
|
||
|
||
saveJSONFile(itemsFile, items)
|
||
state.ItemsOffset = offset
|
||
state.Phase = "recipes"
|
||
saveFetchState()
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s %s items fetched ✓\n", progressBar(1, 1, 30), fmtNum(len(items)))
|
||
return nil
|
||
}
|
||
|
||
// ── Phase 2: Fetch all recipes ──────────────────────────────────
|
||
|
||
func fetchAllRecipes(ctx context.Context) error {
|
||
fmt.Println("\n📜 Phase 2: Fetching all recipes...")
|
||
|
||
if fileHasData(recipesFile) && state.RecipesOffset == 0 {
|
||
fmt.Println(" ⏭ data/recipes.json already exists on disk, skipping")
|
||
state.Phase = "drops"
|
||
saveFetchState()
|
||
return nil
|
||
}
|
||
|
||
if state.RecipesTotal == nil {
|
||
count, err := getCount(ctx, "Recipes")
|
||
if err != nil {
|
||
return fmt.Errorf("counting recipes: %w", err)
|
||
}
|
||
state.RecipesTotal = &count
|
||
saveFetchState()
|
||
}
|
||
fmt.Printf(" Total recipes in wiki: %s\n", fmtNum(*state.RecipesTotal))
|
||
|
||
var recipes []json.RawMessage
|
||
if state.RecipesOffset > 0 {
|
||
if data, err := os.ReadFile(recipesFile); err == nil {
|
||
_ = json.Unmarshal(data, &recipes)
|
||
}
|
||
}
|
||
if recipes == nil {
|
||
recipes = []json.RawMessage{}
|
||
}
|
||
fmt.Printf(" Resuming from offset %s (%s recipes loaded)\n\n", fmtNum(state.RecipesOffset), fmtNum(len(recipes)))
|
||
|
||
offset := state.RecipesOffset
|
||
for offset < *state.RecipesTotal {
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s %s/%s recipes", progressBar(offset, *state.RecipesTotal, 30), fmtNum(offset), fmtNum(*state.RecipesTotal))
|
||
|
||
page, err := cargoQuery(ctx, map[string]string{
|
||
"tables": "Recipes",
|
||
"fields": recipeFields,
|
||
"limit": fmt.Sprintf("%d", pageSize),
|
||
"offset": fmt.Sprintf("%d", offset),
|
||
"order_by": "resultid",
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("fetching recipes at offset %d: %w", offset, err)
|
||
}
|
||
|
||
if len(page) == 0 {
|
||
break
|
||
}
|
||
recipes = append(recipes, page...)
|
||
offset += len(page)
|
||
state.RecipesOffset = offset
|
||
|
||
saveJSONFile(recipesFile, recipes)
|
||
saveFetchState()
|
||
|
||
sleepCtx(ctx, requestDelay)
|
||
}
|
||
|
||
saveJSONFile(recipesFile, recipes)
|
||
state.RecipesOffset = offset
|
||
state.Phase = "drops"
|
||
saveFetchState()
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s %s recipes fetched ✓\n", progressBar(1, 1, 30), fmtNum(len(recipes)))
|
||
return nil
|
||
}
|
||
|
||
// ── Phase 3: Fetch all drops ────────────────────────────────────
|
||
|
||
func fetchAllDrops(ctx context.Context) error {
|
||
fmt.Println("\n💀 Phase 3: Fetching all drops...")
|
||
|
||
if fileHasData(dropsFile) && state.DropsOffset == 0 {
|
||
fmt.Println(" ⏭ data/drops.json already exists on disk, skipping")
|
||
state.Phase = "icons"
|
||
saveFetchState()
|
||
return nil
|
||
}
|
||
|
||
if state.DropsTotal == nil {
|
||
count, err := getCount(ctx, "Drops")
|
||
if err != nil {
|
||
return fmt.Errorf("counting drops: %w", err)
|
||
}
|
||
state.DropsTotal = &count
|
||
saveFetchState()
|
||
}
|
||
fmt.Printf(" Total drops in wiki: %s\n", fmtNum(*state.DropsTotal))
|
||
|
||
var drops []json.RawMessage
|
||
if state.DropsOffset > 0 {
|
||
if data, err := os.ReadFile(dropsFile); err == nil {
|
||
_ = json.Unmarshal(data, &drops)
|
||
}
|
||
}
|
||
if drops == nil {
|
||
drops = []json.RawMessage{}
|
||
}
|
||
fmt.Printf(" Resuming from offset %s (%s drops loaded)\n\n", fmtNum(state.DropsOffset), fmtNum(len(drops)))
|
||
|
||
offset := state.DropsOffset
|
||
for offset < *state.DropsTotal {
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s %s/%s drops", progressBar(offset, *state.DropsTotal, 30), fmtNum(offset), fmtNum(*state.DropsTotal))
|
||
|
||
page, err := cargoQuery(ctx, map[string]string{
|
||
"tables": "Drops",
|
||
"fields": dropFields,
|
||
"limit": fmt.Sprintf("%d", pageSize),
|
||
"offset": fmt.Sprintf("%d", offset),
|
||
"order_by": "item,nameraw",
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("fetching drops at offset %d: %w", offset, err)
|
||
}
|
||
|
||
if len(page) == 0 {
|
||
break
|
||
}
|
||
drops = append(drops, page...)
|
||
offset += len(page)
|
||
state.DropsOffset = offset
|
||
|
||
saveJSONFile(dropsFile, drops)
|
||
saveFetchState()
|
||
|
||
sleepCtx(ctx, requestDelay)
|
||
}
|
||
|
||
saveJSONFile(dropsFile, drops)
|
||
state.DropsOffset = offset
|
||
state.Phase = "icons"
|
||
saveFetchState()
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s %s drops fetched ✓\n", progressBar(1, 1, 30), fmtNum(len(drops)))
|
||
return nil
|
||
}
|
||
|
||
// ── Phase 4: Download all icons ─────────────────────────────────
|
||
|
||
func fetcherSanitizeImagefile(name string) string {
|
||
if idx := strings.Index(name, " / "); idx != -1 {
|
||
return strings.TrimSpace(name[:idx])
|
||
}
|
||
return name
|
||
}
|
||
|
||
func downloadIcon(ctx context.Context, filename string) string {
|
||
dest := filepath.Join(iconsDir, filename)
|
||
|
||
// Already downloaded — skip
|
||
if info, err := os.Stat(dest); err == nil && info.Size() > 0 {
|
||
return "skip"
|
||
}
|
||
|
||
wikiFilename := strings.ReplaceAll(filename, "_", " ")
|
||
iconURL := wikiImg + url.PathEscape(wikiFilename)
|
||
|
||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||
if ctx.Err() != nil {
|
||
return "fail"
|
||
}
|
||
|
||
body, status, err := httpGet(ctx, iconURL)
|
||
if err != nil {
|
||
if attempt == maxRetries {
|
||
return "fail"
|
||
}
|
||
sleepCtx(ctx, iconDelay*time.Duration(attempt))
|
||
continue
|
||
}
|
||
|
||
if status == 429 {
|
||
wait := iconRetryDelay * time.Duration(attempt)
|
||
fmt.Fprintf(os.Stderr, "\n ⏳ Rate limited, waiting %ds...", int(wait.Seconds()))
|
||
sleepCtx(ctx, wait)
|
||
continue
|
||
}
|
||
|
||
if status != 200 {
|
||
if attempt == maxRetries {
|
||
return "fail"
|
||
}
|
||
sleepCtx(ctx, iconDelay*time.Duration(attempt))
|
||
continue
|
||
}
|
||
|
||
if err := os.WriteFile(dest, body, 0644); err != nil {
|
||
return "fail"
|
||
}
|
||
return "ok"
|
||
}
|
||
return "fail"
|
||
}
|
||
|
||
func fetchAllIcons(ctx context.Context) error {
|
||
fmt.Println("\n🖼️ Phase 4: Downloading item icons...")
|
||
|
||
// Load items to get icon list
|
||
var items []struct {
|
||
ImageFile string `json:"imagefile"`
|
||
}
|
||
if err := loadJSONFile(itemsFile, &items); err != nil {
|
||
return fmt.Errorf("loading items for icons: %w", err)
|
||
}
|
||
|
||
// Collect unique icon filenames
|
||
iconSet := make(map[string]bool)
|
||
for _, item := range items {
|
||
f := fetcherSanitizeImagefile(item.ImageFile)
|
||
if f != "" {
|
||
iconSet[f] = true
|
||
}
|
||
}
|
||
iconList := make([]string, 0, len(iconSet))
|
||
for f := range iconSet {
|
||
iconList = append(iconList, f)
|
||
}
|
||
sort.Strings(iconList)
|
||
|
||
state.IconsTotal = intPtr(len(iconList))
|
||
fmt.Printf(" Total unique icons: %s\n", fmtNum(len(iconList)))
|
||
|
||
downloaded := 0
|
||
skipped := 0
|
||
var failed []string
|
||
|
||
for i, filename := range iconList {
|
||
if ctx.Err() != nil {
|
||
break
|
||
}
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s %s/%s icons", progressBar(i, len(iconList), 30), fmtNum(i), fmtNum(len(iconList)))
|
||
|
||
result := downloadIcon(ctx, filename)
|
||
switch result {
|
||
case "skip":
|
||
skipped++
|
||
case "ok":
|
||
downloaded++
|
||
sleepCtx(ctx, iconDelay)
|
||
default:
|
||
failed = append(failed, filename)
|
||
}
|
||
|
||
if (i+1)%50 == 0 {
|
||
state.IconsDownloaded = downloaded + skipped
|
||
state.IconsFailed = failed
|
||
if state.IconsFailed == nil {
|
||
state.IconsFailed = []string{}
|
||
}
|
||
saveFetchState()
|
||
}
|
||
}
|
||
|
||
state.IconsDownloaded = downloaded + skipped
|
||
state.IconsFailed = failed
|
||
if state.IconsFailed == nil {
|
||
state.IconsFailed = []string{}
|
||
}
|
||
state.Phase = "done"
|
||
saveFetchState()
|
||
|
||
fmt.Fprintf(os.Stderr, "\r %s Icons done ✓\n", progressBar(1, 1, 30))
|
||
fmt.Printf(" ✓ Downloaded: %s\n", fmtNum(downloaded))
|
||
fmt.Printf(" ⏭ Skipped (cached): %s\n", fmtNum(skipped))
|
||
if len(failed) > 0 {
|
||
fmt.Printf(" ✗ Failed: %d\n", len(failed))
|
||
_ = os.WriteFile(filepath.Join(dataDir, "icons-failed.txt"), []byte(strings.Join(failed, "\n")), 0644)
|
||
fmt.Println(" (see data/icons-failed.txt)")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func intPtr(n int) *int {
|
||
return &n
|
||
}
|
||
|
||
// ── Splash texts ────────────────────────────────────────────────
|
||
|
||
func generateSplashes() {
|
||
splashes := []string{
|
||
"Dig Peon, Dig!",
|
||
"Epic Dirt",
|
||
"Adaman-TIGHT!",
|
||
"Sand is Overpowered",
|
||
"The Return of the Guide",
|
||
"A Bunnies Tale",
|
||
"Dr. Bones and The Temple of Blood Moon",
|
||
"Slimeassic Park",
|
||
"The Grass is Greener on This Side",
|
||
"Small Blocks, Not for Children Under the Age of 5",
|
||
"Digger T' Blocks",
|
||
"There is No Cow Layer",
|
||
"Suspicious Looking Eyeballs",
|
||
"Purple Grass!",
|
||
"No one Dug Behind!",
|
||
"The Water Fall Of Content!",
|
||
"Earthbound",
|
||
"Dig Dug Ain't Got Nuthin on Me",
|
||
"Ore's Well That Ends Well",
|
||
"Judgement Clay",
|
||
"Terrestrial Trouble",
|
||
"(Not Responding)",
|
||
"Red Dev Redemption",
|
||
"Rise of the Slimes",
|
||
"Now with more things to kill you!",
|
||
"Rumors of the Guides' death were greatly exaggerated",
|
||
"I Pity the Tools...",
|
||
"A spelunker says 'What'?",
|
||
"May the blocks be with you",
|
||
"Better than life",
|
||
"Terraria: Terraria:",
|
||
"Now in 1D",
|
||
"Coming soon to a computer near you",
|
||
"Dividing by zero",
|
||
"Now with SOUND",
|
||
"Press alt-f4",
|
||
"You sand bro?",
|
||
"A good day to dig hard",
|
||
"Can You Re-Dig-It?",
|
||
"I don't know that-- aaaaa!",
|
||
"What's that purple spiked thing?",
|
||
"I wanna be the guide",
|
||
"Cthulhu is mad... and is missing an eye!",
|
||
"NOT THE BEES!!!",
|
||
"Legend of Maxx",
|
||
"Cult of Cenx",
|
||
"Electric Boogaloo",
|
||
"Also try Minecraft!",
|
||
"Also try Breath of the Wild!",
|
||
"I just wanna know where the gold at?",
|
||
"Now with more ducks!",
|
||
"1 + 1 = 10",
|
||
"Infinite Plantera",
|
||
"Also try Stardew Valley!",
|
||
"Also try Core Keeper!",
|
||
"Also try Project Zomboid!",
|
||
"Now with microtransactions!",
|
||
"Built on Blockchain Technology",
|
||
"Now with even less Ocram!",
|
||
"Otherworld",
|
||
"Touch Grass Simulator",
|
||
"Don't dig up!",
|
||
"For the worthy!",
|
||
"Now with even more Ocram!",
|
||
"Shut Up and Dig Gaiden!",
|
||
"Also try Don't Starve!",
|
||
"The Final Update",
|
||
"Bigger and Boulder",
|
||
"The Revenge of Moon Lord",
|
||
"Treraira",
|
||
"Also try tModLoader!",
|
||
"Super Terraria Kart",
|
||
"It's Scragglin' Time",
|
||
"Dig, fight, explore, build on that thang!",
|
||
}
|
||
saveJSONFile(splashesFile, splashes)
|
||
fmt.Printf(" ✨ Generated %s with %d entries\n", splashesFile, len(splashes))
|
||
}
|
||
|
||
// ── Main fetcher entry point ────────────────────────────────────
|
||
|
||
var validPhases = map[string]bool{
|
||
"items": true, "recipes": true, "drops": true, "icons": true,
|
||
}
|
||
|
||
func runFetcher(args []string) {
|
||
fmt.Println("╔══════════════════════════════════════════╗")
|
||
fmt.Println("║ Terraria Wiki Full Data Fetcher ║")
|
||
fmt.Println("║ Safe to Ctrl+C and resume anytime ║")
|
||
fmt.Println("╚══════════════════════════════════════════╝")
|
||
|
||
// Parse flags
|
||
reset := false
|
||
only := ""
|
||
for _, arg := range args {
|
||
if arg == "--reset" {
|
||
reset = true
|
||
} else if strings.HasPrefix(arg, "--only=") {
|
||
only = strings.TrimPrefix(arg, "--only=")
|
||
if !validPhases[only] {
|
||
fmt.Fprintf(os.Stderr, "❌ Unknown phase %q. Valid phases: items, recipes, drops, icons\n", only)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
}
|
||
|
||
if reset {
|
||
fmt.Println("\n🗑️ Resetting all state...")
|
||
_ = os.Remove(stateFile)
|
||
_ = os.Remove(itemsFile)
|
||
_ = os.Remove(recipesFile)
|
||
_ = os.Remove(dropsFile)
|
||
}
|
||
|
||
ensureDir(dataDir)
|
||
ensureDir(iconsDir)
|
||
|
||
generateSplashes()
|
||
|
||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||
defer cancel()
|
||
|
||
start := time.Now()
|
||
|
||
// --only mode: run a single phase independently
|
||
if only != "" {
|
||
fmt.Printf("\n⏩ Running only: %s\n", only)
|
||
var err error
|
||
switch only {
|
||
case "items":
|
||
state = fetchState{Phase: "items", IconsFailed: []string{}}
|
||
err = fetchAllItems(ctx)
|
||
case "recipes":
|
||
state = fetchState{Phase: "recipes", IconsFailed: []string{}}
|
||
err = fetchAllRecipes(ctx)
|
||
case "drops":
|
||
state = fetchState{Phase: "drops", IconsFailed: []string{}}
|
||
err = fetchAllDrops(ctx)
|
||
case "icons":
|
||
state = fetchState{Phase: "icons", IconsFailed: []string{}}
|
||
err = fetchAllIcons(ctx)
|
||
}
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
fmt.Println("\n\n⚠️ Interrupted!")
|
||
os.Exit(0)
|
||
}
|
||
fmt.Fprintf(os.Stderr, "\n\n❌ Fatal error: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("\n✅ %s done in %.1fs\n", only, time.Since(start).Seconds())
|
||
return
|
||
}
|
||
|
||
// Normal mode: resume from saved state
|
||
loadFetchState()
|
||
|
||
fmt.Printf("\n⏩ Current phase: %s\n", state.Phase)
|
||
|
||
var err error
|
||
|
||
if state.Phase == "items" {
|
||
err = fetchAllItems(ctx)
|
||
if err != nil {
|
||
goto done
|
||
}
|
||
}
|
||
|
||
if state.Phase == "recipes" {
|
||
err = fetchAllRecipes(ctx)
|
||
if err != nil {
|
||
goto done
|
||
}
|
||
}
|
||
|
||
if state.Phase == "drops" {
|
||
err = fetchAllDrops(ctx)
|
||
if err != nil {
|
||
goto done
|
||
}
|
||
}
|
||
|
||
if state.Phase == "icons" {
|
||
err = fetchAllIcons(ctx)
|
||
if err != nil {
|
||
goto done
|
||
}
|
||
}
|
||
|
||
done:
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
fmt.Println("\n\n⚠️ Interrupted! State saved. Run again to resume.")
|
||
saveFetchState()
|
||
os.Exit(0)
|
||
}
|
||
fmt.Fprintf(os.Stderr, "\n\n❌ Fatal error: %v\n", err)
|
||
saveFetchState()
|
||
os.Exit(1)
|
||
}
|
||
|
||
elapsed := time.Since(start).Seconds()
|
||
|
||
if state.Phase == "done" {
|
||
var items []json.RawMessage
|
||
_ = loadJSONFile(itemsFile, &items)
|
||
var recipes []json.RawMessage
|
||
_ = loadJSONFile(recipesFile, &recipes)
|
||
var drops []json.RawMessage
|
||
_ = loadJSONFile(dropsFile, &drops)
|
||
|
||
iconCount := 0
|
||
if entries, err := os.ReadDir(iconsDir); err == nil {
|
||
for _, e := range entries {
|
||
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||
iconCount++
|
||
}
|
||
}
|
||
}
|
||
|
||
fmt.Println("\n══════════════════════════════════════════")
|
||
fmt.Printf("✅ All done in %.1fs!\n", elapsed)
|
||
fmt.Printf(" 📦 %s items → data/items.json\n", fmtNum(len(items)))
|
||
fmt.Printf(" 📜 %s recipes → data/recipes.json\n", fmtNum(len(recipes)))
|
||
fmt.Printf(" 💀 %s drops → data/drops.json\n", fmtNum(len(drops)))
|
||
fmt.Printf(" 🖼️ %s icons → data/icons/\n", fmtNum(iconCount))
|
||
fmt.Println("══════════════════════════════════════════")
|
||
}
|
||
}
|