All checks were successful
Build Frontend / Build Frontend (push) Successful in 24s
580 lines
16 KiB
JavaScript
580 lines
16 KiB
JavaScript
import axios from "axios";
|
|
import Item from "../models/Item.js";
|
|
import MarketPrice from "../models/MarketPrice.js";
|
|
|
|
/**
|
|
* Pricing Service
|
|
* Fetches and updates item prices from SteamAPIs.com
|
|
*/
|
|
|
|
class PricingService {
|
|
constructor() {
|
|
this.apiKey = process.env.STEAM_APIS_KEY || process.env.STEAM_API_KEY;
|
|
this.baseUrl = "https://api.steamapis.com";
|
|
this.appIds = {
|
|
cs2: 730,
|
|
rust: 252490,
|
|
};
|
|
this.lastUpdate = {};
|
|
this.updateInterval = 60 * 60 * 1000; // 1 hour in milliseconds
|
|
}
|
|
|
|
/**
|
|
* Detect phase from item name and description
|
|
* @param {string} name - Item name
|
|
* @param {string} description - Item description
|
|
* @returns {string|null} - Phase name or null
|
|
*/
|
|
detectPhase(name, description = "") {
|
|
const combinedText = `${name} ${description}`.toLowerCase();
|
|
|
|
// Doppler phases
|
|
if (combinedText.includes("ruby")) return "Ruby";
|
|
if (combinedText.includes("sapphire")) return "Sapphire";
|
|
if (combinedText.includes("black pearl")) return "Black Pearl";
|
|
if (combinedText.includes("emerald")) return "Emerald";
|
|
if (combinedText.includes("phase 1")) return "Phase 1";
|
|
if (combinedText.includes("phase 2")) return "Phase 2";
|
|
if (combinedText.includes("phase 3")) return "Phase 3";
|
|
if (combinedText.includes("phase 4")) return "Phase 4";
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fetch market prices for a specific game
|
|
* @param {string} game - Game identifier ('cs2' or 'rust')
|
|
* @returns {Promise<Object>} - Map of item names to prices
|
|
*/
|
|
async fetchMarketPrices(game = "cs2") {
|
|
if (!this.apiKey) {
|
|
throw new Error("Steam API key not configured");
|
|
}
|
|
|
|
const appId = this.appIds[game];
|
|
if (!appId) {
|
|
throw new Error(`Invalid game: ${game}`);
|
|
}
|
|
|
|
try {
|
|
console.log(`📊 Fetching ${game.toUpperCase()} market prices...`);
|
|
|
|
const response = await axios.get(
|
|
`${this.baseUrl}/market/items/${appId}`,
|
|
{
|
|
params: {
|
|
api_key: this.apiKey,
|
|
},
|
|
timeout: 30000, // 30 second timeout
|
|
}
|
|
);
|
|
|
|
if (!response.data || !response.data.data) {
|
|
console.warn(`⚠️ No price data returned for ${game}`);
|
|
return {};
|
|
}
|
|
|
|
const priceMap = {};
|
|
const items = response.data.data;
|
|
|
|
// Process each item - API returns array with numeric indices
|
|
Object.keys(items).forEach((index) => {
|
|
const itemData = items[index];
|
|
|
|
if (itemData && itemData.prices) {
|
|
// Get item name from market_hash_name or market_name
|
|
const itemName = itemData.market_hash_name || itemData.market_name;
|
|
|
|
if (!itemName) {
|
|
return; // Skip items without names
|
|
}
|
|
|
|
// Get the most recent price - try different price fields
|
|
const price =
|
|
itemData.prices.safe ||
|
|
itemData.prices.median ||
|
|
itemData.prices.mean ||
|
|
itemData.prices.avg ||
|
|
itemData.prices.latest;
|
|
|
|
if (price && price > 0) {
|
|
priceMap[itemName] = {
|
|
price: price,
|
|
currency: "USD",
|
|
timestamp: new Date(),
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(
|
|
`✅ Fetched ${
|
|
Object.keys(priceMap).length
|
|
} prices for ${game.toUpperCase()}`
|
|
);
|
|
this.lastUpdate[game] = new Date();
|
|
|
|
return priceMap;
|
|
} catch (error) {
|
|
console.error(
|
|
`❌ Error fetching market prices for ${game}:`,
|
|
error.message
|
|
);
|
|
|
|
if (error.response?.status === 401) {
|
|
throw new Error("Steam API authentication failed. Check your API key.");
|
|
}
|
|
|
|
if (error.response?.status === 429) {
|
|
throw new Error("Steam API rate limit exceeded. Try again later.");
|
|
}
|
|
|
|
throw new Error(`Failed to fetch market prices: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update database prices for all items of a specific game
|
|
* @param {string} game - Game identifier ('cs2' or 'rust')
|
|
* @returns {Promise<Object>} - Update statistics
|
|
*/
|
|
async updateDatabasePrices(game = "cs2") {
|
|
try {
|
|
console.log(`🔄 Updating database prices for ${game.toUpperCase()}...`);
|
|
|
|
// Fetch latest market prices
|
|
const priceMap = await this.fetchMarketPrices(game);
|
|
|
|
if (Object.keys(priceMap).length === 0) {
|
|
console.warn(`⚠️ No prices fetched for ${game}`);
|
|
return {
|
|
success: false,
|
|
game,
|
|
updated: 0,
|
|
notFound: 0,
|
|
errors: 0,
|
|
};
|
|
}
|
|
|
|
// Get all active items for this game
|
|
const items = await Item.find({
|
|
game,
|
|
status: "active",
|
|
});
|
|
|
|
let updated = 0;
|
|
let notFound = 0;
|
|
let errors = 0;
|
|
|
|
// Helper function to build full item name with wear
|
|
const buildFullName = (item) => {
|
|
const wearMap = {
|
|
fn: "Factory New",
|
|
mw: "Minimal Wear",
|
|
ft: "Field-Tested",
|
|
ww: "Well-Worn",
|
|
bs: "Battle-Scarred",
|
|
};
|
|
|
|
let fullName = item.name;
|
|
|
|
// Add StatTrak prefix if applicable
|
|
if (item.statTrak && !fullName.includes("StatTrak")) {
|
|
fullName = `StatTrak™ ${fullName}`;
|
|
}
|
|
|
|
// Add Souvenir prefix if applicable
|
|
if (item.souvenir && !fullName.includes("Souvenir")) {
|
|
fullName = `Souvenir ${fullName}`;
|
|
}
|
|
|
|
// Add wear condition suffix if applicable
|
|
if (item.wear && wearMap[item.wear]) {
|
|
fullName = `${fullName} (${wearMap[item.wear]})`;
|
|
}
|
|
|
|
return fullName;
|
|
};
|
|
|
|
// Update each item
|
|
for (const item of items) {
|
|
try {
|
|
// Try exact name match first
|
|
let priceData = priceMap[item.name];
|
|
|
|
// If not found, try with full name (including wear)
|
|
if (!priceData && item.wear) {
|
|
const fullName = buildFullName(item);
|
|
priceData = priceMap[fullName];
|
|
}
|
|
|
|
// If still not found, try partial matching
|
|
if (!priceData) {
|
|
const baseName = item.name
|
|
.replace(/^StatTrak™\s+/, "")
|
|
.replace(/^Souvenir\s+/, "");
|
|
|
|
// Try to find by partial match
|
|
const matchingKey = Object.keys(priceMap).find((key) => {
|
|
const apiBaseName = key
|
|
.replace(/^StatTrak™\s+/, "")
|
|
.replace(/^Souvenir\s+/, "")
|
|
.replace(/\s*\([^)]+\)\s*$/, ""); // Remove wear condition
|
|
|
|
return apiBaseName === baseName;
|
|
});
|
|
|
|
if (matchingKey) {
|
|
priceData = priceMap[matchingKey];
|
|
}
|
|
}
|
|
|
|
if (priceData) {
|
|
// Update market price
|
|
item.marketPrice = priceData.price;
|
|
item.priceUpdatedAt = new Date();
|
|
await item.save();
|
|
updated++;
|
|
} else {
|
|
notFound++;
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error updating item ${item.name}:`, err.message);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
const stats = {
|
|
success: true,
|
|
game,
|
|
total: items.length,
|
|
updated,
|
|
notFound,
|
|
errors,
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
console.log(`✅ Price update complete for ${game.toUpperCase()}:`);
|
|
console.log(` - Total items: ${stats.total}`);
|
|
console.log(` - Updated: ${stats.updated}`);
|
|
console.log(` - Not found: ${stats.notFound}`);
|
|
console.log(` - Errors: ${stats.errors}`);
|
|
|
|
return stats;
|
|
} catch (error) {
|
|
console.error(
|
|
`❌ Failed to update database prices for ${game}:`,
|
|
error.message
|
|
);
|
|
return {
|
|
success: false,
|
|
game,
|
|
error: error.message,
|
|
timestamp: new Date(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update MarketPrice reference database for a specific game
|
|
* Fetches all items from Steam market and updates the reference database
|
|
* @param {string} game - Game identifier ('cs2' or 'rust')
|
|
* @returns {Promise<Object>} - Update statistics
|
|
*/
|
|
async updateMarketPriceDatabase(game) {
|
|
console.log(
|
|
`\n🔄 Updating MarketPrice reference database for ${game.toUpperCase()}...`
|
|
);
|
|
|
|
try {
|
|
const appId = this.appIds[game];
|
|
if (!appId) {
|
|
throw new Error(`Invalid game: ${game}`);
|
|
}
|
|
|
|
if (!this.apiKey) {
|
|
throw new Error("Steam API key not configured");
|
|
}
|
|
|
|
// Fetch all market items from Steam API
|
|
console.log(`📡 Fetching market data from Steam API...`);
|
|
const response = await axios.get(
|
|
`${this.baseUrl}/market/items/${appId}`,
|
|
{
|
|
params: { api_key: this.apiKey },
|
|
timeout: 60000,
|
|
}
|
|
);
|
|
|
|
if (!response.data || !response.data.data) {
|
|
throw new Error("No data returned from Steam API");
|
|
}
|
|
|
|
const items = response.data.data;
|
|
const itemCount = Object.keys(items).length;
|
|
console.log(`✅ Received ${itemCount} items from API`);
|
|
|
|
let inserted = 0;
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
let errors = 0;
|
|
|
|
// Process items in batches
|
|
const bulkOps = [];
|
|
|
|
for (const item of Object.values(items)) {
|
|
try {
|
|
// Get the best available price
|
|
const price =
|
|
item.prices?.safe ||
|
|
item.prices?.median ||
|
|
item.prices?.mean ||
|
|
item.prices?.avg ||
|
|
item.prices?.latest;
|
|
|
|
if (!price || price <= 0) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const marketHashName = item.market_hash_name || item.market_name;
|
|
const marketName = item.market_name || item.market_hash_name;
|
|
|
|
if (!marketHashName || !marketName) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Determine which price type was used
|
|
let priceType = "safe";
|
|
if (item.prices?.safe) priceType = "safe";
|
|
else if (item.prices?.median) priceType = "median";
|
|
else if (item.prices?.mean) priceType = "mean";
|
|
else if (item.prices?.avg) priceType = "avg";
|
|
else if (item.prices?.latest) priceType = "latest";
|
|
|
|
bulkOps.push({
|
|
updateOne: {
|
|
filter: { marketHashName: marketHashName },
|
|
update: {
|
|
$set: {
|
|
name: marketName,
|
|
game: game,
|
|
appId: appId,
|
|
marketHashName: marketHashName,
|
|
price: price,
|
|
priceType: priceType,
|
|
image: item.image || null,
|
|
borderColor: item.border_color || null,
|
|
nameId: item.nameID || null,
|
|
lastUpdated: new Date(),
|
|
},
|
|
},
|
|
upsert: true,
|
|
},
|
|
});
|
|
|
|
// Execute in batches of 1000
|
|
if (bulkOps.length >= 1000) {
|
|
const result = await MarketPrice.bulkWrite(bulkOps);
|
|
inserted += result.upsertedCount;
|
|
updated += result.modifiedCount;
|
|
console.log(
|
|
` 📦 Batch: ${inserted} inserted, ${updated} updated`
|
|
);
|
|
bulkOps.length = 0;
|
|
}
|
|
} catch (err) {
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
// Execute remaining items
|
|
if (bulkOps.length > 0) {
|
|
const result = await MarketPrice.bulkWrite(bulkOps);
|
|
inserted += result.upsertedCount;
|
|
updated += result.modifiedCount;
|
|
}
|
|
|
|
console.log(`✅ MarketPrice update complete for ${game.toUpperCase()}:`);
|
|
console.log(` 📥 Inserted: ${inserted}`);
|
|
console.log(` 🔄 Updated: ${updated}`);
|
|
console.log(` ⏭️ Skipped: ${skipped}`);
|
|
if (errors > 0) {
|
|
console.log(` ❌ Errors: ${errors}`);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
game,
|
|
total: itemCount,
|
|
inserted,
|
|
updated,
|
|
skipped,
|
|
errors,
|
|
};
|
|
} catch (error) {
|
|
console.error(
|
|
`❌ Error updating MarketPrice for ${game}:`,
|
|
error.message
|
|
);
|
|
return {
|
|
success: false,
|
|
game,
|
|
error: error.message,
|
|
total: 0,
|
|
inserted: 0,
|
|
updated: 0,
|
|
skipped: 0,
|
|
errors: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update prices for all games (both Item and MarketPrice databases)
|
|
* @returns {Promise<Object>} - Combined update statistics
|
|
*/
|
|
async updateAllPrices() {
|
|
console.log("🔄 Starting price update for all games...");
|
|
|
|
const results = {
|
|
marketPrices: {
|
|
cs2: await this.updateMarketPriceDatabase("cs2"),
|
|
rust: await this.updateMarketPriceDatabase("rust"),
|
|
},
|
|
itemPrices: {
|
|
cs2: await this.updateDatabasePrices("cs2"),
|
|
rust: await this.updateDatabasePrices("rust"),
|
|
},
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
console.log("✅ All price updates complete!");
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get estimated price for an item based on characteristics
|
|
* @param {Object} itemData - Item data with name, wear, phase, etc.
|
|
* @returns {Promise<number>} - Estimated price in USD
|
|
*/
|
|
async estimatePrice(itemData) {
|
|
const { name, wear, phase, statTrak, souvenir } = itemData;
|
|
|
|
// Try to find in database first
|
|
const dbItem = await Item.findOne({
|
|
name,
|
|
status: "active",
|
|
}).sort({ marketPrice: -1 });
|
|
|
|
let basePrice = null;
|
|
|
|
if (dbItem && dbItem.marketPrice) {
|
|
// Use market price from database
|
|
basePrice = dbItem.marketPrice;
|
|
} else {
|
|
// No hardcoded fallback - return null if no market data
|
|
console.warn(`⚠️ No market price available for: ${name}`);
|
|
return null;
|
|
}
|
|
|
|
// Apply wear multiplier
|
|
if (wear) {
|
|
const wearMultipliers = {
|
|
fn: 1.0,
|
|
mw: 0.85,
|
|
ft: 0.7,
|
|
ww: 0.55,
|
|
bs: 0.4,
|
|
};
|
|
basePrice *= wearMultipliers[wear] || 1.0;
|
|
}
|
|
|
|
// Apply phase multiplier for Doppler knives
|
|
if (phase) {
|
|
const phaseMultipliers = {
|
|
Ruby: 3.5,
|
|
Sapphire: 3.8,
|
|
"Black Pearl": 2.5,
|
|
Emerald: 4.0,
|
|
"Phase 2": 1.3,
|
|
"Phase 4": 1.2,
|
|
"Phase 1": 1.0,
|
|
"Phase 3": 0.95,
|
|
};
|
|
basePrice *= phaseMultipliers[phase] || 1.0;
|
|
}
|
|
|
|
// Apply StatTrak multiplier
|
|
if (statTrak) {
|
|
basePrice *= 1.5;
|
|
}
|
|
|
|
// Apply Souvenir multiplier
|
|
if (souvenir) {
|
|
basePrice *= 1.3;
|
|
}
|
|
|
|
return parseFloat(basePrice.toFixed(2));
|
|
}
|
|
|
|
/**
|
|
* Estimate price from item name (fallback method)
|
|
* @param {string} name - Item name
|
|
* @returns {number|null} - Estimated base price or null if no data
|
|
*/
|
|
estimatePriceFromName(name) {
|
|
// No hardcoded prices - return null to indicate no market data available
|
|
console.warn(`⚠️ No market price data available for: ${name}`);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Schedule automatic price updates
|
|
* @param {number} intervalMs - Update interval in milliseconds (default: 1 hour)
|
|
*/
|
|
scheduleUpdates(intervalMs = this.updateInterval) {
|
|
console.log(
|
|
`⏰ Scheduling automatic price updates every ${
|
|
intervalMs / 60000
|
|
} minutes`
|
|
);
|
|
|
|
// Run on interval (initial update is handled on startup)
|
|
setInterval(() => {
|
|
this.updateAllPrices().catch((error) => {
|
|
console.error("Scheduled price update failed:", error.message);
|
|
});
|
|
}, intervalMs);
|
|
}
|
|
|
|
/**
|
|
* Check if prices need updating
|
|
* @param {string} game - Game identifier
|
|
* @returns {boolean} - True if update needed
|
|
*/
|
|
needsUpdate(game) {
|
|
if (!this.lastUpdate[game]) {
|
|
return true;
|
|
}
|
|
|
|
const timeSinceUpdate = Date.now() - this.lastUpdate[game].getTime();
|
|
return timeSinceUpdate >= this.updateInterval;
|
|
}
|
|
|
|
/**
|
|
* Get last update timestamp for a game
|
|
* @param {string} game - Game identifier
|
|
* @returns {Date|null} - Last update timestamp
|
|
*/
|
|
getLastUpdate(game) {
|
|
return this.lastUpdate[game] || null;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
const pricingService = new PricingService();
|
|
export default pricingService;
|