520 lines
20 KiB
Go
520 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"math"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"text/template"
|
|
"time"
|
|
|
|
inventorytools "code.mashffxiv.deadbeef.codes/MashPotato/gilgetter/inventorytools"
|
|
)
|
|
|
|
var (
|
|
items map[int]*MarketItem // hash map used to quickly guarantee uniqueness of MarketItems
|
|
itemSlice []MarketItem // rank-ordered slice of the same data in the items map
|
|
nameUpdateQueue chan int // requests sent to this channel use xivapi https://xivapi.com/
|
|
priceUpdateQueue chan int // requests sent to this channel use universalis.app API https://universalis.app/docs/index.html
|
|
updateRequestWg sync.WaitGroup // Used to wait until the queues are empty indicating all items have up to date names and prices
|
|
t *template.Template // Used to cache HTML template parsing, done once at startup
|
|
lastFullPriceUpdateTime time.Time // The last time the prices were updated from Universalis (both start and finish. Start if ongoing, finished when not running)
|
|
startFullPriceUpdateTime time.Time // The last time the prices were started to be updated
|
|
lastSortTime time.Time // The last time the itemSlice was sorted by metric
|
|
lastUniversalisRequestTime time.Time // The last time any request was made to Universalis
|
|
priceUpdateProgress int // an integer from 0-100 indicating percentage of any ongoing Universalis update. If 100, then the update is complete.
|
|
world, datacenter, universalisPriceEndpoint string // filter Universalis to this world name
|
|
priceUpdateErrors map[int]bool // if an item ID exists in here, then there was an error when updating its price. Gets reset after priceUpdateQueue is drained.
|
|
withdrawItemsAhk []byte // contains the processed ahk template of items that need to be withdrawn for crafting or selling
|
|
|
|
retainers []string // contains name of retainers
|
|
craftingCheckoutItemIDs []int // The item IDs which were selected on the crafting checkout page, used for shopping list
|
|
inventories inventorytools.Inventories
|
|
)
|
|
|
|
// Object to represent a crafting material / ingredient in a recipe
|
|
type CraftingMaterial struct {
|
|
ID int `json:"id"`
|
|
Quantity int `json:"quantity"`
|
|
}
|
|
|
|
// Object to represent an item that can be sold on the market
|
|
type MarketItem struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
Job string `json:"job,omitempty"`
|
|
CraftingMetric int `json:"craftingmetric,omitempty"`
|
|
ListingMetric int `json:"listingmetric,omitempty"`
|
|
MaterialCost int `json:"materialcost,omitempty"`
|
|
Velocity int `json:"velocity,omitempty"`
|
|
CraftingMaterials []CraftingMaterial `json:"craftingmaterials,omitempty"`
|
|
MarketBoardListing *UniversalisApiMarketBoardListingResponse `json:"marketboardlisting,omitempty"`
|
|
InStock bool `json:"instock"` // true if item is in character inventory, retainer market listing or retainer bag
|
|
}
|
|
|
|
// JSON object mapping for HTTP response from XIVAPI
|
|
type XivApiSearchResponse struct {
|
|
Pagination struct {
|
|
Page int `json:"Page"`
|
|
PageNext interface{} `json:"PageNext"`
|
|
PagePrev interface{} `json:"PagePrev"`
|
|
PageTotal int `json:"PageTotal"`
|
|
Results int `json:"Results"`
|
|
ResultsPerPage int `json:"ResultsPerPage"`
|
|
ResultsTotal int `json:"ResultsTotal"`
|
|
} `json:"Pagination"`
|
|
Results []struct {
|
|
ID int `json:"ID"`
|
|
Icon string `json:"Icon"`
|
|
Name string `json:"Name"`
|
|
URL string `json:"Url"`
|
|
URLType string `json:"UrlType"`
|
|
Score int `json:"_Score"`
|
|
} `json:"Results"`
|
|
SpeedMs int `json:"SpeedMs"`
|
|
}
|
|
|
|
// JSON object mapping for HTTP response from Universalis
|
|
type UniversalisApiMarketBoardListingResponse struct {
|
|
ItemID int `json:"itemID"`
|
|
WorldID int `json:"worldID"`
|
|
LastUploadTime int64 `json:"lastUploadTime"`
|
|
Listings []struct {
|
|
LastReviewTime int `json:"lastReviewTime"`
|
|
PricePerUnit int `json:"pricePerUnit"`
|
|
Quantity int `json:"quantity"`
|
|
StainID int `json:"stainID"`
|
|
CreatorName string `json:"creatorName"`
|
|
CreatorID string `json:"creatorID"`
|
|
Hq bool `json:"hq"`
|
|
IsCrafted bool `json:"isCrafted"`
|
|
ListingID interface{} `json:"listingID"`
|
|
Materia []interface{} `json:"materia"`
|
|
OnMannequin bool `json:"onMannequin"`
|
|
RetainerCity int `json:"retainerCity"`
|
|
RetainerID string `json:"retainerID"`
|
|
RetainerName string `json:"retainerName"`
|
|
SellerID string `json:"sellerID"`
|
|
Total int `json:"total"`
|
|
} `json:"listings"`
|
|
RecentHistory []struct {
|
|
Hq bool `json:"hq"`
|
|
PricePerUnit int `json:"pricePerUnit"`
|
|
Quantity int `json:"quantity"`
|
|
Timestamp int `json:"timestamp"`
|
|
BuyerName string `json:"buyerName"`
|
|
Total int `json:"total"`
|
|
} `json:"recentHistory"`
|
|
CurrentAveragePrice float64 `json:"currentAveragePrice"`
|
|
CurrentAveragePriceNQ float64 `json:"currentAveragePriceNQ"`
|
|
CurrentAveragePriceHQ float64 `json:"currentAveragePriceHQ"`
|
|
RegularSaleVelocity float64 `json:"regularSaleVelocity"`
|
|
NqSaleVelocity float64 `json:"nqSaleVelocity"`
|
|
HqSaleVelocity float64 `json:"hqSaleVelocity"`
|
|
AveragePrice float64 `json:"averagePrice"`
|
|
AveragePriceNQ float64 `json:"averagePriceNQ"`
|
|
AveragePriceHQ float64 `json:"averagePriceHQ"`
|
|
MinPrice int `json:"minPrice"`
|
|
MinPriceNQ int `json:"minPriceNQ"`
|
|
MinPriceHQ int `json:"minPriceHQ"`
|
|
MaxPrice int `json:"maxPrice"`
|
|
MaxPriceNQ int `json:"maxPriceNQ"`
|
|
MaxPriceHQ int `json:"maxPriceHQ"`
|
|
StackSizeHistogram struct {
|
|
Num1 int `json:"1"`
|
|
} `json:"stackSizeHistogram"`
|
|
StackSizeHistogramNQ struct {
|
|
} `json:"stackSizeHistogramNQ"`
|
|
StackSizeHistogramHQ struct {
|
|
Num1 int `json:"1"`
|
|
} `json:"stackSizeHistogramHQ"`
|
|
WorldName string `json:"worldName"`
|
|
}
|
|
|
|
// Build queues/channels, load crafting recipes, and launch worker goroutines
|
|
func init() {
|
|
log.Println("gilgetter init")
|
|
|
|
// Load application configuration from environment variables
|
|
// Validate that all required environment variables are set
|
|
envVars := make(map[string]string)
|
|
envVars["world"] = os.Getenv("world")
|
|
envVars["datacenter"] = os.Getenv("datacenter")
|
|
for key, value := range envVars {
|
|
if value == "" {
|
|
log.Fatalf("required environment variable '%s' is not set or has empty value", key)
|
|
}
|
|
}
|
|
|
|
retainers = make([]string, 0)
|
|
for i := 0; true; i++ {
|
|
retainer := os.Getenv(fmt.Sprintf("retainer_%d", i))
|
|
if retainer == "" {
|
|
break
|
|
}
|
|
retainers = append(retainers, retainer)
|
|
}
|
|
|
|
world = envVars["world"]
|
|
datacenter = envVars["datacenter"]
|
|
universalisPriceEndpoint = world
|
|
updateRequestWg = sync.WaitGroup{}
|
|
|
|
nameUpdateQueue = make(chan int, 8192)
|
|
priceUpdateQueue = make(chan int, 8192)
|
|
priceUpdateErrors = make(map[int]bool)
|
|
items = make(map[int]*MarketItem)
|
|
|
|
loadRecipes()
|
|
for i := 0; i < 8; i++ {
|
|
go nameUpdateWorker()
|
|
go priceUpdateWorker()
|
|
}
|
|
go reSortWorker()
|
|
|
|
priceUpdateProgress = 100 // must be set to 100 to allow a price update to occur, to prevent concurrent price update requests
|
|
err := fullPriceRefresh()
|
|
if err != nil {
|
|
log.Fatalf("init failed to refresh price data: %v", err)
|
|
}
|
|
lastFullPriceUpdateTime = time.Now()
|
|
|
|
itemSlice = make([]MarketItem, 0)
|
|
|
|
withdrawItemsAhk = make([]byte, 0)
|
|
}
|
|
|
|
// HTTP routing
|
|
func main() {
|
|
// Parse all HTML template files
|
|
var err error
|
|
t, err = template.ParseGlob("./templates/*")
|
|
if err != nil {
|
|
log.Fatalf("couldn't parse HTML templates: %v", err)
|
|
}
|
|
|
|
// Serve public static files such as css, js, images
|
|
http.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.Dir("public"))))
|
|
|
|
// Download .ahk file
|
|
http.HandleFunc("/auto-xiv", autoXivHandler)
|
|
|
|
// Page Handlers
|
|
// Anything that is responsible for the base elements of a viewable web page
|
|
http.HandleFunc("/", homePageHandler)
|
|
http.HandleFunc("/stale-items", staleItemsPageHandler)
|
|
http.HandleFunc("/crafting-checkout", craftingCheckoutPageHandler)
|
|
http.HandleFunc("/inventory", inventoryPageHandler)
|
|
http.HandleFunc("/listings", listingsPageHandler)
|
|
|
|
// API Handlers
|
|
// Anything that is called by the front end but is not a web page
|
|
http.HandleFunc("/full-price-update", fullPriceUpdateHandler)
|
|
http.HandleFunc("/price-update", singlePriceUpdateHandler)
|
|
http.HandleFunc("/status", statusHandler)
|
|
http.HandleFunc("/items-list", itemsListHandler)
|
|
http.HandleFunc("/bugged-metric-items-list", buggedMetricItemsListHandler)
|
|
http.HandleFunc("/stale-items-list", staleItemsListHandler)
|
|
http.HandleFunc("/search-listing-retainer", searchListingRetainerHandler)
|
|
http.HandleFunc("/search-listing-craftedby", searchListingCraftedbyHandler)
|
|
http.HandleFunc("/search-listing-price", searchListingPriceHandler)
|
|
|
|
log.Print("Service listening on :8080")
|
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
}
|
|
|
|
// Processes the nameUpdateQueue which contains item IDs that need to be mapped to names
|
|
// sends request to xivapi
|
|
func nameUpdateWorker() {
|
|
for {
|
|
func() { // anonymous function to allow using defer each loop interation
|
|
id := <-nameUpdateQueue
|
|
updateRequestWg.Add(1)
|
|
defer updateRequestWg.Done()
|
|
|
|
// By this point we know the item name is not found in the cache
|
|
// and this is the first request to populate it
|
|
|
|
// Sleep for between 1-1.1 seconds to avoid rate limits and play nice with the service providers
|
|
timeFuzzing := rand.Intn(500) // between 0 and 100ms
|
|
time.Sleep(time.Millisecond*333 + time.Duration(timeFuzzing)*time.Millisecond)
|
|
|
|
log.Printf("ID: %d - Name Update Request", id)
|
|
|
|
resp, err := http.Get(fmt.Sprintf("https://xivapi.com/search?filters=ID=%d", id))
|
|
if err != nil {
|
|
log.Printf("request to get item ID '%d' name failed: %v", id, err)
|
|
return
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("response to get item ID '%d' name expected status is '%d' but got '%d'", id, http.StatusOK, resp.StatusCode)
|
|
return
|
|
}
|
|
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read xivapi response body for item ID '%d': %v", id, err)
|
|
return
|
|
}
|
|
|
|
xivApiSearchResponse := &XivApiSearchResponse{}
|
|
|
|
err = json.Unmarshal(body, xivApiSearchResponse)
|
|
if err != nil {
|
|
log.Printf("failed to unmarshal xivapi response body for item ID '%d': %v", id, err)
|
|
return
|
|
}
|
|
|
|
for _, xivApiSearchResult := range xivApiSearchResponse.Results {
|
|
if xivApiSearchResult.URLType == "Item" {
|
|
items[id].Name = xivApiSearchResult.Name
|
|
break
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Processes the priceUpdateQueue which contains item IDs that need prices
|
|
// sends request to universalis
|
|
func priceUpdateWorker() {
|
|
for {
|
|
func() { // anonymous function to allow using defer each loop interation
|
|
|
|
id := <-priceUpdateQueue // Block here until we can read something from the price update queue
|
|
|
|
defer updateRequestWg.Done()
|
|
|
|
// Sleep for avoid rate limits and play nice with the service providers
|
|
timeFuzzing := rand.Intn(300) // in ms
|
|
time.Sleep(time.Millisecond*220 + time.Duration(timeFuzzing)*time.Millisecond)
|
|
|
|
// By this point we know the item name is not found in the cache
|
|
// and this is the first request to populate it
|
|
|
|
resp, err := http.Get(fmt.Sprintf("https://universalis.app/api/%s/%d", universalisPriceEndpoint, id))
|
|
if err != nil {
|
|
log.Printf("request to get item ID '%d' price failed: %v", id, err)
|
|
return
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("response to get item ID '%d' price expected status is '%d' but got '%d'", id, http.StatusOK, resp.StatusCode)
|
|
if _, ok := priceUpdateErrors[id]; !ok { // if this item hasn't already had an error
|
|
go func() { // wait 5 seconds requeue the item if it failed, in goroutine so the priceUpdateWorker isn't blocked
|
|
time.Sleep(time.Second * 5)
|
|
updateRequestWg.Add(1)
|
|
priceUpdateQueue <- id
|
|
|
|
}()
|
|
}
|
|
priceUpdateErrors[id] = true // requeue only works one time
|
|
return
|
|
}
|
|
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read universalis response body for item ID '%d': %v", id, err)
|
|
return
|
|
}
|
|
|
|
universalisApiMarketBoardListingResponse := &UniversalisApiMarketBoardListingResponse{}
|
|
|
|
err = json.Unmarshal(body, universalisApiMarketBoardListingResponse)
|
|
if err != nil {
|
|
log.Printf("failed to unmarshal universalis response body for item ID '%d': %v", id, err)
|
|
return
|
|
}
|
|
|
|
items[id].MarketBoardListing = universalisApiMarketBoardListingResponse
|
|
}()
|
|
lastUniversalisRequestTime = time.Now()
|
|
if priceUpdateProgress < 100 { // Only time this is <100 is when a full price update is occurring, we don't want this changed if single items are being updated though
|
|
priceUpdateProgress = (len(items)-len(priceUpdateQueue))*100/(len(items)) - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Will trigger a resort (destroy and recreate) of the itemSlice when conditions are met
|
|
func reSortWorker() {
|
|
for {
|
|
time.Sleep(time.Second * 30)
|
|
if time.Now().Before(lastSortTime.Add(time.Second * 60)) { // if the last sort occurred within 60 seconds, ignore
|
|
continue
|
|
}
|
|
if time.Now().Before(lastUniversalisRequestTime.Add(time.Second * 30)) { // if the last universalis price update occurred within 30 seconds, ignore
|
|
continue // This is because there might be more coming!
|
|
}
|
|
if time.Now().After(lastUniversalisRequestTime.Add(time.Second * 60)) { // If the last universalis request was more than a minute ago, we've already sorted
|
|
continue
|
|
}
|
|
sortItemSlice()
|
|
}
|
|
}
|
|
|
|
// Loads crafting recipe data from the filesystem
|
|
// populates the items map
|
|
func loadRecipes() {
|
|
|
|
// Get all files in the crafting-recipes directory
|
|
files, err := ioutil.ReadDir("crafting-recipes")
|
|
if err != nil {
|
|
log.Fatalf("failed to read crafting-recipes directory: %v", err)
|
|
}
|
|
|
|
// Loop through all files in the crafting-recipes directory
|
|
for _, file := range files {
|
|
if !strings.HasSuffix(file.Name(), ".json") {
|
|
log.Printf("ignoring non-json file '%s'", file.Name())
|
|
continue
|
|
}
|
|
log.Printf("loading file - crafting-recipes/%s", file.Name())
|
|
jsonItems, err := loadJsonFile(fmt.Sprintf("crafting-recipes/%s", file.Name()))
|
|
if err != nil {
|
|
log.Printf("error loading crafting-recipe file '%s': %v", file.Name(), err)
|
|
continue
|
|
}
|
|
|
|
// Check for any new unique item encountered gets queued up for
|
|
// information updates from xivapi and universalis
|
|
for i, item := range *jsonItems {
|
|
if _, found := items[item.ID]; !found { // item is new/unique
|
|
if item.ID == 0 {
|
|
log.Printf("error found market item with ID of 0 at index %d in file %s", i, file.Name())
|
|
continue
|
|
}
|
|
items[item.ID] = &MarketItem{ID: item.ID, Job: item.Job, CraftingMaterials: item.CraftingMaterials}
|
|
items[item.ID].MarketBoardListing = &UniversalisApiMarketBoardListingResponse{}
|
|
|
|
// Queue up any crafting items for information updates as well
|
|
for j, craftingMaterial := range item.CraftingMaterials {
|
|
if _, found := items[craftingMaterial.ID]; !found { // item is new/unique
|
|
if craftingMaterial.ID == 0 {
|
|
log.Printf("error found crafting item with ID of 0 in market item with ID of '%d', crafting item index %d in file %s", item.ID, j, file.Name())
|
|
continue
|
|
}
|
|
items[craftingMaterial.ID] = &MarketItem{ID: craftingMaterial.ID}
|
|
items[craftingMaterial.ID].MarketBoardListing = &UniversalisApiMarketBoardListingResponse{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Loads a JSON file into a slice of market items
|
|
func loadJsonFile(file string) (*[]MarketItem, error) {
|
|
// Load the crafting recipes
|
|
jsonFile, err := os.Open(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open file '%s': %v", file, err)
|
|
}
|
|
defer jsonFile.Close()
|
|
|
|
jsonBytes, err := ioutil.ReadAll(jsonFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read file '%s' to bytes: %v", file, err)
|
|
}
|
|
|
|
jsonItems := make([]MarketItem, 0)
|
|
json.Unmarshal(jsonBytes, &jsonItems)
|
|
|
|
return &jsonItems, nil
|
|
}
|
|
|
|
// Performs a full price refresh of all items from Universalis
|
|
func fullPriceRefresh() error {
|
|
if time.Since(lastFullPriceUpdateTime) < time.Minute*90 {
|
|
return fmt.Errorf("price refresh cannot occur less than 90 minutes apart")
|
|
}
|
|
if priceUpdateProgress != 100 {
|
|
return fmt.Errorf("price refresh is already in progress")
|
|
}
|
|
for _, item := range items {
|
|
if item.Name == "" { // only update names once unless app restarts
|
|
nameUpdateQueue <- item.ID
|
|
}
|
|
priceUpdateQueue <- item.ID
|
|
updateRequestWg.Add(1)
|
|
}
|
|
startFullPriceUpdateTime = time.Now()
|
|
lastFullPriceUpdateTime = time.Now()
|
|
|
|
go func() { //anonymous callback function which runs immediately after all items have their prices updated
|
|
updateRequestWg.Wait() // wait for all items to be updated
|
|
lastFullPriceUpdateTime = time.Now()
|
|
sortItemSlice()
|
|
}()
|
|
|
|
priceUpdateProgress = 0
|
|
return nil
|
|
}
|
|
|
|
// Destroys and recreates the itemSlice using the items map, sorted by weight
|
|
func sortItemSlice() {
|
|
|
|
log.Printf("metric re-sort triggered")
|
|
priceUpdateProgress = 100 // We set this to 100 to ensure any Universalis refresh is in the completed state when we sort the items.
|
|
itemSlice = make([]MarketItem, 0)
|
|
|
|
// Calculate the metric for each item
|
|
for _, item := range items {
|
|
if len(item.CraftingMaterials) < 1 { // If there are no crafting materials for an item (i.e. the item is a crafting material itself), we ignore it.
|
|
continue
|
|
}
|
|
|
|
//item.Metric = int(item.MarketBoardListing.HqSaleVelocity * float64(item.MarketBoardListing.MinPriceHQ))
|
|
|
|
// Subtract cost of crafting materials
|
|
item.MaterialCost = 0
|
|
for _, craftingMaterial := range item.CraftingMaterials {
|
|
|
|
if int(items[craftingMaterial.ID].MarketBoardListing.AveragePrice) > 0 { // if we have historical sales data use it
|
|
item.MaterialCost += (craftingMaterial.Quantity * int(items[craftingMaterial.ID].MarketBoardListing.AveragePrice))
|
|
} else { // Otherwise use the current min prices
|
|
item.MaterialCost += items[craftingMaterial.ID].MarketBoardListing.MinPrice
|
|
}
|
|
}
|
|
|
|
// Calculate the base metric based on sale price and velocity
|
|
if item.MarketBoardListing.MinPriceHQ != 0 {
|
|
// Use HQ by default
|
|
item.Velocity = int(math.Round(item.MarketBoardListing.HqSaleVelocity * 10 / 2.857143)) // 2.857143 is max universalis sale velocity value, so this gives us an "out of 10" rating
|
|
item.CraftingMetric = item.MarketBoardListing.MinPriceHQ
|
|
|
|
item.ListingMetric = item.CraftingMetric*item.Velocity + item.MarketBoardListing.MinPriceHQ
|
|
} else {
|
|
// Item is normal quality only for example, housing items
|
|
item.Velocity = int(math.Round(item.MarketBoardListing.NqSaleVelocity * 10 / 2.857143)) // 2.857143 is max universalis sale velocity value, so this gives us an "out of 10" rating
|
|
item.CraftingMetric = item.MarketBoardListing.MinPriceNQ
|
|
|
|
item.ListingMetric = item.CraftingMetric*item.Velocity + item.MarketBoardListing.MinPriceNQ
|
|
}
|
|
|
|
item.CraftingMetric = (item.CraftingMetric-item.MaterialCost)*item.Velocity - item.MaterialCost*2
|
|
|
|
// Linear scale down outliers
|
|
outlierWeight := 2.0
|
|
if float64(item.MarketBoardListing.MinPriceHQ) > item.MarketBoardListing.AveragePriceHQ { // only scale down when current min price is above average price. Do not scale up ever.
|
|
item.CraftingMetric = item.CraftingMetric - int(outlierWeight*(float64(item.MarketBoardListing.MinPriceHQ)-item.MarketBoardListing.AveragePriceHQ))
|
|
}
|
|
|
|
itemSlice = append(itemSlice, *item)
|
|
}
|
|
|
|
// Sort the item slice by metric
|
|
sort.Slice(itemSlice, func(i, j int) bool {
|
|
return itemSlice[i].CraftingMetric > itemSlice[j].CraftingMetric
|
|
})
|
|
|
|
lastSortTime = time.Now()
|
|
}
|