Evil
This commit is contained in:
@@ -6,11 +6,13 @@ Small Go scraper for the Outward Fandom wiki.
|
||||
|
||||
```text
|
||||
.
|
||||
├── cmd/outward-web/main.go # web UI entrypoint
|
||||
├── cmd/scrappr/main.go # binary entrypoint
|
||||
├── internal/app # bootstrapping and output writing
|
||||
├── internal/logx # colored emoji logger
|
||||
├── internal/model # dataset models
|
||||
├── internal/scraper # crawl flow, parsing, queueing, retries
|
||||
├── internal/webui # embedded web server + static UI
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── outward_data.json # generated output
|
||||
@@ -22,6 +24,10 @@ Small Go scraper for the Outward Fandom wiki.
|
||||
go run ./cmd/scrappr
|
||||
```
|
||||
|
||||
```bash
|
||||
go run ./cmd/outward-web
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
- Crawls item and crafting pages from `outward.fandom.com`
|
||||
@@ -32,6 +38,7 @@ go run ./cmd/scrappr
|
||||
- Stores legacy and portable infobox fields, primary item image URLs, recipes, effects, and raw content tables for later processing
|
||||
- Saves resumable checkpoints into `.cache/scrape-state.json` on a timer, during progress milestones, and on `Ctrl+C`
|
||||
- Writes a stable, sorted JSON dataset to `outward_data.json`
|
||||
- Serves a local craft-planner UI backed by recipes from `outward_data.json`
|
||||
|
||||
## Tuning
|
||||
|
||||
|
||||
26
cmd/outward-web/main.go
Normal file
26
cmd/outward-web/main.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"scrappr/internal/logx"
|
||||
"scrappr/internal/webui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := envOrDefault("SCRAPPR_ADDR", ":8080")
|
||||
dataPath := envOrDefault("SCRAPPR_DATA", "outward_data.json")
|
||||
|
||||
if err := webui.Run(addr, dataPath); err != nil {
|
||||
logx.Eventf("error", "fatal: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -452,14 +452,26 @@ func (s *Scraper) parseImageURL(doc *goquery.Document) string {
|
||||
|
||||
func (s *Scraper) normalizeImageURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch {
|
||||
case raw == "":
|
||||
if raw == "" {
|
||||
return ""
|
||||
case strings.HasPrefix(raw, "//"):
|
||||
return "https:" + raw
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
|
||||
if strings.HasPrefix(raw, "//") {
|
||||
raw = "https:" + raw
|
||||
}
|
||||
|
||||
query := ""
|
||||
if idx := strings.Index(raw, "?"); idx >= 0 {
|
||||
query = raw[idx:]
|
||||
raw = raw[:idx]
|
||||
}
|
||||
|
||||
const scaledMarker = "/revision/latest/scale-to-width-down/"
|
||||
if idx := strings.Index(raw, scaledMarker); idx >= 0 {
|
||||
raw = raw[:idx] + "/revision/latest"
|
||||
}
|
||||
|
||||
return raw + query
|
||||
}
|
||||
|
||||
func (s *Scraper) parseContentTables(doc *goquery.Document) []model.Table {
|
||||
|
||||
227
internal/webui/server.go
Normal file
227
internal/webui/server.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"scrappr/internal/logx"
|
||||
"scrappr/internal/model"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
type Catalog struct {
|
||||
ItemNames []string `json:"item_names"`
|
||||
Items []CatalogItem `json:"items"`
|
||||
Craftables []CraftableEntry `json:"craftables"`
|
||||
}
|
||||
|
||||
type CatalogItem struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
}
|
||||
|
||||
type CraftableEntry struct {
|
||||
CatalogItem
|
||||
Recipes []model.Recipe `json:"recipes"`
|
||||
}
|
||||
|
||||
func Run(addr, dataPath string) error {
|
||||
catalog, err := loadCatalog(dataPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
staticFS, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", serveStaticFile(staticFS, "index.html", "text/html; charset=utf-8"))
|
||||
mux.HandleFunc("/styles.css", serveStaticFile(staticFS, "styles.css", "text/css; charset=utf-8"))
|
||||
mux.HandleFunc("/app.js", serveStaticFile(staticFS, "app.js", "application/javascript; charset=utf-8"))
|
||||
mux.HandleFunc("/api/catalog", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
setNoCache(w)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(catalog); err != nil {
|
||||
logx.Eventf("error", "catalog encode failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
logx.Eventf(
|
||||
"start",
|
||||
"web UI listening on %s using %s (%d craftable items)",
|
||||
displayAddr(addr),
|
||||
dataPath,
|
||||
len(catalog.Craftables),
|
||||
)
|
||||
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
func loadCatalog(dataPath string) (Catalog, error) {
|
||||
file, err := os.Open(dataPath)
|
||||
if err != nil {
|
||||
return Catalog{}, fmt.Errorf("open dataset %s: %w", dataPath, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var dataset model.Dataset
|
||||
if err := json.NewDecoder(file).Decode(&dataset); err != nil {
|
||||
return Catalog{}, fmt.Errorf("decode dataset %s: %w", dataPath, err)
|
||||
}
|
||||
|
||||
return buildCatalog(dataset), nil
|
||||
}
|
||||
|
||||
func buildCatalog(dataset model.Dataset) Catalog {
|
||||
itemNames := make([]string, 0, len(dataset.Items))
|
||||
items := make([]CatalogItem, 0, len(dataset.Items))
|
||||
craftableByName := map[string]*CraftableEntry{}
|
||||
itemByName := map[string]CatalogItem{}
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, item := range dataset.Items {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
itemNames = append(itemNames, name)
|
||||
}
|
||||
|
||||
catalogItem := CatalogItem{
|
||||
Name: item.Name,
|
||||
URL: item.URL,
|
||||
ImageURL: item.ImageURL,
|
||||
Categories: item.Categories,
|
||||
ItemType: item.Infobox["Type"],
|
||||
}
|
||||
|
||||
items = append(items, catalogItem)
|
||||
itemByName[strings.ToLower(strings.TrimSpace(item.Name))] = catalogItem
|
||||
}
|
||||
|
||||
for _, item := range dataset.Items {
|
||||
for _, recipe := range item.Recipes {
|
||||
resultKey := strings.ToLower(strings.TrimSpace(recipe.Result))
|
||||
if resultKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := craftableByName[resultKey]
|
||||
if entry == nil {
|
||||
base := itemByName[resultKey]
|
||||
if strings.TrimSpace(base.Name) == "" {
|
||||
base = CatalogItem{
|
||||
Name: recipe.Result,
|
||||
URL: item.URL,
|
||||
ImageURL: item.ImageURL,
|
||||
}
|
||||
}
|
||||
|
||||
entry = &CraftableEntry{
|
||||
CatalogItem: base,
|
||||
}
|
||||
craftableByName[resultKey] = entry
|
||||
}
|
||||
|
||||
if !hasRecipe(entry.Recipes, recipe) {
|
||||
entry.Recipes = append(entry.Recipes, recipe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(itemNames)
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||
})
|
||||
|
||||
craftables := make([]CraftableEntry, 0, len(craftableByName))
|
||||
for _, entry := range craftableByName {
|
||||
craftables = append(craftables, *entry)
|
||||
}
|
||||
sort.Slice(craftables, func(i, j int) bool {
|
||||
return strings.ToLower(craftables[i].Name) < strings.ToLower(craftables[j].Name)
|
||||
})
|
||||
|
||||
return Catalog{
|
||||
ItemNames: itemNames,
|
||||
Items: items,
|
||||
Craftables: craftables,
|
||||
}
|
||||
}
|
||||
|
||||
func hasRecipe(existing []model.Recipe, target model.Recipe) bool {
|
||||
for _, recipe := range existing {
|
||||
if recipe.Result != target.Result || recipe.ResultCount != target.ResultCount || recipe.Station != target.Station {
|
||||
continue
|
||||
}
|
||||
if len(recipe.Ingredients) != len(target.Ingredients) {
|
||||
continue
|
||||
}
|
||||
|
||||
match := true
|
||||
for i := range recipe.Ingredients {
|
||||
if recipe.Ingredients[i] != target.Ingredients[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func displayAddr(addr string) string {
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
return "http://localhost" + addr
|
||||
}
|
||||
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
|
||||
return addr
|
||||
}
|
||||
return "http://" + addr
|
||||
}
|
||||
|
||||
func serveStaticFile(staticFS fs.FS, name, contentType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && "/"+name != r.URL.Path {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := fs.ReadFile(staticFS, name)
|
||||
if err != nil {
|
||||
http.Error(w, "asset missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
setNoCache(w)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
func setNoCache(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
435
internal/webui/static/app.js
Normal file
435
internal/webui/static/app.js
Normal file
@@ -0,0 +1,435 @@
|
||||
const state = {
|
||||
catalog: null,
|
||||
itemByKey: new Map(),
|
||||
};
|
||||
|
||||
const inventoryInput = document.querySelector("#inventory-input");
|
||||
const quickItemInput = document.querySelector("#quick-item");
|
||||
const quickQtyInput = document.querySelector("#quick-qty");
|
||||
const itemList = document.querySelector("#item-list");
|
||||
const summaryText = document.querySelector("#summary-text");
|
||||
const resultsRoot = document.querySelector("#results");
|
||||
const chipsRoot = document.querySelector("#inventory-chips");
|
||||
|
||||
document.querySelector("#analyze-btn").addEventListener("click", render);
|
||||
document.querySelector("#sample-btn").addEventListener("click", () => {
|
||||
inventoryInput.value = [
|
||||
"Water",
|
||||
"2x Ableroot",
|
||||
"2x Oil",
|
||||
"2x Gravel Beetle",
|
||||
"Linen Cloth",
|
||||
"Seaweed",
|
||||
"Salt",
|
||||
].join("\n");
|
||||
render();
|
||||
});
|
||||
document.querySelector("#clear-btn").addEventListener("click", () => {
|
||||
inventoryInput.value = "";
|
||||
quickItemInput.value = "";
|
||||
quickQtyInput.value = "1";
|
||||
render();
|
||||
});
|
||||
document.querySelector("#add-item").addEventListener("click", addQuickItem);
|
||||
quickItemInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addQuickItem();
|
||||
}
|
||||
});
|
||||
inventoryInput.addEventListener("input", renderInventoryPreview);
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
summaryText.textContent = "Failed to load crafting catalog.";
|
||||
resultsRoot.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/catalog");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Catalog request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
state.catalog = await response.json();
|
||||
state.itemByKey = buildItemIndex(state.catalog.items || []);
|
||||
hydrateItemList(state.catalog.item_names || []);
|
||||
summaryText.textContent = `Loaded ${state.catalog.item_names.length} known items and ${state.catalog.craftables.length} craftable entries.`;
|
||||
render();
|
||||
}
|
||||
|
||||
function buildItemIndex(items) {
|
||||
const index = new Map();
|
||||
for (const item of items) {
|
||||
index.set(normalizeName(item.name), item);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function hydrateItemList(names) {
|
||||
itemList.innerHTML = names.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
|
||||
}
|
||||
|
||||
function addQuickItem() {
|
||||
const name = quickItemInput.value.trim();
|
||||
const qty = Math.max(1, Number.parseInt(quickQtyInput.value || "1", 10) || 1);
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const line = qty > 1 ? `${qty}x ${name}` : name;
|
||||
inventoryInput.value = inventoryInput.value.trim()
|
||||
? `${inventoryInput.value.trim()}\n${line}`
|
||||
: line;
|
||||
|
||||
quickItemInput.value = "";
|
||||
quickQtyInput.value = "1";
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderInventoryPreview();
|
||||
|
||||
if (!state.catalog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inventory = parseInventory(inventoryInput.value);
|
||||
const craftable = findCraftableRecipes(state.catalog.craftables, inventory);
|
||||
|
||||
if (!craftable.length) {
|
||||
summaryText.textContent = `No craftable recipes found from ${inventory.totalKinds} inventory item types.`;
|
||||
resultsRoot.innerHTML = `
|
||||
<div class="empty-state">
|
||||
Add more ingredients or load the sample inventory to see matching recipes.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
summaryText.textContent = `${craftable.length} recipe matches from ${inventory.totalKinds} inventory item types.`;
|
||||
resultsRoot.innerHTML = craftable.map(renderRecipeCard).join("");
|
||||
wireResultImages();
|
||||
}
|
||||
|
||||
function renderInventoryPreview() {
|
||||
const inventory = parseInventory(inventoryInput.value);
|
||||
const entries = Object.values(inventory.byKey).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (!entries.length) {
|
||||
chipsRoot.innerHTML = `<div class="chip">No inventory entered yet</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
chipsRoot.innerHTML = entries
|
||||
.map((entry) => `<div class="chip">${escapeHtml(entry.display)}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function parseInventory(raw) {
|
||||
const byKey = {};
|
||||
const lines = raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseStackText(line);
|
||||
if (!parsed.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizeName(parsed.name);
|
||||
if (!byKey[key]) {
|
||||
byKey[key] = {
|
||||
name: parsed.name,
|
||||
count: 0,
|
||||
display: "",
|
||||
};
|
||||
}
|
||||
|
||||
byKey[key].count += parsed.count;
|
||||
byKey[key].display = `${byKey[key].count}x ${byKey[key].name}`;
|
||||
}
|
||||
|
||||
return {
|
||||
byKey,
|
||||
totalKinds: Object.keys(byKey).length,
|
||||
};
|
||||
}
|
||||
|
||||
function findCraftableRecipes(items, inventory) {
|
||||
const matches = [];
|
||||
|
||||
for (const item of items) {
|
||||
for (const recipe of item.recipes || []) {
|
||||
if (normalizeName(recipe.result || "") !== normalizeName(item.name || "")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const requirements = collapseRequirements(recipe.ingredients || []);
|
||||
|
||||
if (!requirements.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let maxCrafts = Number.POSITIVE_INFINITY;
|
||||
let canCraft = true;
|
||||
|
||||
for (const requirement of requirements) {
|
||||
const have = availableCountForIngredient(requirement.name, inventory);
|
||||
if (have < requirement.count) {
|
||||
canCraft = false;
|
||||
break;
|
||||
}
|
||||
maxCrafts = Math.min(maxCrafts, Math.floor(have / requirement.count));
|
||||
}
|
||||
|
||||
if (!canCraft) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
item,
|
||||
recipe,
|
||||
maxCrafts: Number.isFinite(maxCrafts) ? maxCrafts : 0,
|
||||
resultYield: parseResultYield(recipe.result_count),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches.sort((left, right) => {
|
||||
if (right.maxCrafts !== left.maxCrafts) {
|
||||
return right.maxCrafts - left.maxCrafts;
|
||||
}
|
||||
return left.item.name.localeCompare(right.item.name);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecipeCard(entry) {
|
||||
const initials = escapeHtml(entry.item.name.slice(0, 2).toUpperCase());
|
||||
const ingredients = (entry.recipe.ingredients || [])
|
||||
.map((ingredient) => `<span class="ingredient-pill">${escapeHtml(ingredient)}</span>`)
|
||||
.join("");
|
||||
|
||||
const canonicalImageUrl = normalizeImageUrl(entry.item.image_url || "");
|
||||
const image = canonicalImageUrl
|
||||
? `<img src="${escapeHtml(canonicalImageUrl)}" data-fallback-src="${escapeHtml(fallbackImageUrl(canonicalImageUrl))}" alt="${escapeHtml(entry.item.name)}">`
|
||||
: `<div class="result-fallback">${initials}</div>`;
|
||||
|
||||
const station = entry.recipe.station ? `Station: ${escapeHtml(entry.recipe.station)}` : "Station: Any";
|
||||
const yieldLabel = entry.resultYield > 1 ? `${entry.resultYield}x per craft` : "1x per craft";
|
||||
|
||||
return `
|
||||
<article class="result-card">
|
||||
${image}
|
||||
<div>
|
||||
<div class="result-top">
|
||||
<div>
|
||||
<h3 class="result-name">
|
||||
<a href="${escapeHtml(entry.item.url)}" target="_blank" rel="noreferrer">${escapeHtml(entry.item.name)}</a>
|
||||
</h3>
|
||||
<p class="meta">${station} · ${yieldLabel}</p>
|
||||
</div>
|
||||
<div class="recipe-badge">${entry.maxCrafts} craft${entry.maxCrafts === 1 ? "" : "s"} possible</div>
|
||||
</div>
|
||||
<div class="ingredients">${ingredients}</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function wireResultImages() {
|
||||
const images = resultsRoot.querySelectorAll("img[data-fallback-src]");
|
||||
for (const image of images) {
|
||||
image.onerror = () => {
|
||||
const fallback = image.dataset.fallbackSrc || "";
|
||||
if (!fallback || image.dataset.failedOnce === "1") {
|
||||
image.onerror = null;
|
||||
image.replaceWith(createFallbackNode(image.alt || "?"));
|
||||
return;
|
||||
}
|
||||
|
||||
image.dataset.failedOnce = "1";
|
||||
image.src = fallback;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackNode(label) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "result-fallback";
|
||||
node.textContent = String(label).slice(0, 2).toUpperCase();
|
||||
return node;
|
||||
}
|
||||
|
||||
function parseStackText(raw) {
|
||||
const cleaned = raw.replace(/×/g, "x").trim();
|
||||
if (!cleaned) {
|
||||
return { name: "", count: 0 };
|
||||
}
|
||||
|
||||
const prefixed = cleaned.match(/^(\d+)\s*x\s+(.+)$/i);
|
||||
if (prefixed) {
|
||||
return {
|
||||
count: Number.parseInt(prefixed[1], 10),
|
||||
name: prefixed[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
count: 1,
|
||||
name: cleaned,
|
||||
};
|
||||
}
|
||||
|
||||
function parseResultYield(raw) {
|
||||
const parsed = parseStackText(raw || "1x Result");
|
||||
return parsed.count || 1;
|
||||
}
|
||||
|
||||
function normalizeImageUrl(raw) {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [base, query = ""] = value.split("?");
|
||||
const canonicalBase = base.replace(/\/revision\/latest\/scale-to-width-down\/\d+$/, "/revision/latest");
|
||||
return query ? `${canonicalBase}?${query}` : canonicalBase;
|
||||
}
|
||||
|
||||
function fallbackImageUrl(raw) {
|
||||
const value = normalizeImageUrl(raw);
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [base] = value.split("?");
|
||||
return base.replace(/\/revision\/latest$/, "");
|
||||
}
|
||||
|
||||
function collapseRequirements(ingredients) {
|
||||
const counts = new Map();
|
||||
|
||||
for (const ingredient of ingredients) {
|
||||
const parsed = parseStackText(ingredient);
|
||||
if (!parsed.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizeName(parsed.name);
|
||||
const existing = counts.get(key) || {
|
||||
name: parsed.name,
|
||||
count: 0,
|
||||
};
|
||||
|
||||
existing.count += parsed.count;
|
||||
counts.set(key, existing);
|
||||
}
|
||||
|
||||
return Array.from(counts.values());
|
||||
}
|
||||
|
||||
function availableCountForIngredient(ingredientName, inventory) {
|
||||
const requirementKey = normalizeName(ingredientName);
|
||||
|
||||
if (requirementKey === "water") {
|
||||
return countInventory(inventory, ["water", "clean water"]);
|
||||
}
|
||||
|
||||
if (requirementKey === "meat") {
|
||||
return countMatchingInventory(inventory, (item) => isMeatLike(item));
|
||||
}
|
||||
|
||||
if (requirementKey === "vegetable") {
|
||||
return countMatchingInventory(inventory, (item) => isVegetableLike(item));
|
||||
}
|
||||
|
||||
if (requirementKey === "ration ingredient") {
|
||||
return countMatchingInventory(inventory, (item) => isRationIngredient(item));
|
||||
}
|
||||
|
||||
return inventory.byKey[requirementKey]?.count || 0;
|
||||
}
|
||||
|
||||
function countInventory(inventory, keys) {
|
||||
return keys.reduce((sum, key) => sum + (inventory.byKey[key]?.count || 0), 0);
|
||||
}
|
||||
|
||||
function countMatchingInventory(inventory, predicate) {
|
||||
let total = 0;
|
||||
|
||||
for (const [key, entry] of Object.entries(inventory.byKey)) {
|
||||
if (!predicate(resolveInventoryItem(key, entry.name))) {
|
||||
continue;
|
||||
}
|
||||
total += entry.count;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function resolveInventoryItem(key, fallbackName) {
|
||||
const item = state.itemByKey.get(key);
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
name: fallbackName,
|
||||
categories: [],
|
||||
item_type: "",
|
||||
};
|
||||
}
|
||||
|
||||
function isMeatLike(item) {
|
||||
const name = normalizeName(item.name);
|
||||
return name.includes("meat") || name.includes("jerky");
|
||||
}
|
||||
|
||||
function isVegetableLike(item) {
|
||||
const name = normalizeName(item.name);
|
||||
const categories = normalizeList(item.categories || []);
|
||||
const itemType = normalizeName(item.item_type || "");
|
||||
const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food";
|
||||
|
||||
if (!foodLike) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.includes("salt") || name.includes("water") || name.includes("meat") || name.includes("fish") || name.includes("egg")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRationIngredient(item) {
|
||||
const name = normalizeName(item.name);
|
||||
const categories = normalizeList(item.categories || []);
|
||||
const itemType = normalizeName(item.item_type || "");
|
||||
const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food";
|
||||
|
||||
if (!foodLike) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !name.includes("salt") && !name.includes("water");
|
||||
}
|
||||
|
||||
function normalizeList(values) {
|
||||
return values.map((value) => normalizeName(value));
|
||||
}
|
||||
|
||||
function normalizeName(value) {
|
||||
return value.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
60
internal/webui/static/index.html
Normal file
60
internal/webui/static/index.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Outward Craft Planner</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Outward Craft Planner</p>
|
||||
<h1>Tell me what is in your bag, and I will tell you what you can craft.</h1>
|
||||
<p class="hero-copy">
|
||||
One item per line. Use <code>2x Linen Cloth</code> if you have more than one.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<aside class="panel input-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Inventory</h2>
|
||||
<p>Quick add or paste a full list.</p>
|
||||
</div>
|
||||
|
||||
<div class="quick-add">
|
||||
<input id="quick-item" list="item-list" type="text" placeholder="Search item name">
|
||||
<input id="quick-qty" type="number" min="1" value="1">
|
||||
<button id="add-item" type="button">Add</button>
|
||||
<datalist id="item-list"></datalist>
|
||||
</div>
|
||||
|
||||
<textarea id="inventory-input" spellcheck="false" placeholder="Water 2x Linen Cloth Gravel Beetle Oil"></textarea>
|
||||
|
||||
<div class="actions">
|
||||
<button id="analyze-btn" class="primary" type="button">Analyze Crafting</button>
|
||||
<button id="sample-btn" type="button">Load Sample</button>
|
||||
<button id="clear-btn" type="button">Clear</button>
|
||||
</div>
|
||||
|
||||
<div class="inventory-preview">
|
||||
<div class="mini-title">Parsed inventory</div>
|
||||
<div id="inventory-chips" class="chips"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="panel output-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Craftable Items</h2>
|
||||
<p id="summary-text">Loading dataset…</p>
|
||||
</div>
|
||||
|
||||
<div id="results" class="results"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
311
internal/webui/static/styles.css
Normal file
311
internal/webui/static/styles.css
Normal file
@@ -0,0 +1,311 @@
|
||||
:root {
|
||||
--bg: #f3ecdf;
|
||||
--bg-strong: #e6d8bd;
|
||||
--panel: rgba(255, 251, 244, 0.9);
|
||||
--panel-border: rgba(74, 52, 24, 0.14);
|
||||
--ink: #2f2417;
|
||||
--muted: #6c5a43;
|
||||
--accent: #9b4d20;
|
||||
--accent-strong: #7a3912;
|
||||
--accent-soft: #ecd4b6;
|
||||
--success: #2f7a42;
|
||||
--shadow: 0 24px 60px rgba(56, 38, 16, 0.12);
|
||||
--radius: 22px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(193, 121, 52, 0.18), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(80, 128, 91, 0.18), transparent 24%),
|
||||
linear-gradient(180deg, #f8f3e8 0%, var(--bg) 46%, #ece0c9 100%);
|
||||
font-family: "Trebuchet MS", "Segoe UI Variable Text", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
code,
|
||||
textarea,
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 24px 4px 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
max-width: 840px;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
font-size: clamp(2rem, 4.8vw, 3.8rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 760px;
|
||||
margin: 16px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.03rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
backdrop-filter: blur(12px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.input-panel,
|
||||
.output-panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.panel-head p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.quick-add {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 88px 96px;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(72, 48, 19, 0.14);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: var(--ink);
|
||||
padding: 14px 16px;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 260px;
|
||||
margin-top: 14px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: rgba(155, 77, 32, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(155, 77, 32, 0.12);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 13px 16px;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
background: rgba(92, 70, 33, 0.08);
|
||||
transition: transform 160ms ease, background 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(92, 70, 33, 0.14);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: #fff7ef;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.inventory-preview {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(72, 48, 19, 0.1);
|
||||
}
|
||||
|
||||
.mini-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255, 250, 241, 0.92), rgba(247, 239, 225, 0.92));
|
||||
border: 1px solid rgba(72, 48, 19, 0.1);
|
||||
}
|
||||
|
||||
.result-card img,
|
||||
.result-fallback {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 18px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, #d6b58a, #a36b31);
|
||||
}
|
||||
|
||||
.result-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff7ef;
|
||||
font-weight: 800;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.result-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.result-name a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.result-name a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.recipe-badge {
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(47, 122, 66, 0.12);
|
||||
color: var(--success);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ingredients {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.ingredient-pill {
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(90, 62, 25, 0.08);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px 20px;
|
||||
border-radius: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: rgba(250, 246, 237, 0.72);
|
||||
border: 1px dashed rgba(72, 48, 19, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
width: min(100vw - 18px, 1180px);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.input-panel,
|
||||
.output-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.quick-add {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
318913
outward_data.json
318913
outward_data.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user