import styleCss from './style.css'
import { logDebug, log, logError } from './utils/log'
import { getPricesToken, priceUsingPricesTF } from './pricing/pricestf'
import itemQualities from 'tf2-static-schema/static/qualities.json';
import { getItemIndexByName, getTradableStatusByDefindex, ItemSchema, ItemSlot, prepareSchema, wipeSchema } from './schemaService'
import { $T, extractLocaleFromURL } from './utils/localization'
import { fetchPrice, fetchKeyPrice, ItemPriceData } from './priceService'
import { createPriceRow, createStoreButton } from './uiRenderer'
import { findFirstElement, findFirstChildElement } from './utils/dom'
import { extractPageTitleFromURL } from './utils/url';
import { ExchangeRates, prepareExchangeRates } from './exchangeRateService';
var itemSchema: ItemSchema | null;
var exchangeRates: ExchangeRates | null;
var locale: string = 'en'
/** Exclude these from the pricelist. */
const excludedQualities = new Set([
15, // Decorated
5, // Unusual
]);
// Helper functions
function getKeyByValue(object: any, value: string) {
return Object.keys(object).find(key => object[key] === value);
}
// Main function
async function inject() {
const itemInfobox = findFirstElement('.item-infobox');
if (!itemInfobox) {
// Not an item page
return;
}
var itemIndex: number | null = null;
var itemName: string | null = null;
// Find buy buttons
const buyButton = findFirstChildElement('.btn_buynow', itemInfobox);
const marketButton = findFirstChildElement('.btn_buynow_market', itemInfobox);
// Try using item name
const header = findFirstChildElement('.infobox-header', itemInfobox);
if (!itemIndex && header) {
// Get from document.body
const canonical = document.querySelector('link[rel="canonical"]');
if (canonical && canonical instanceof HTMLLinkElement) {
const url = canonical.href;
if (url.indexOf("/wiki/") != -1) {
itemName = extractPageTitleFromURL(url);
}
}
const url = document.URL;
if (itemName && !itemIndex) {
itemIndex = getItemIndexByName(itemSchema, itemName)
}
}
// Try using buy buttons, if they exist
if(!itemIndex) {
if(buyButton) {
const link = (buyButton.parentElement as HTMLLinkElement);
if(link && link.href) {
itemIndex = parseInt(link.href.replace('https://store.steampowered.com/buyitem/440/', ''));
}
}
if(!itemIndex && marketButton) {
const link = (marketButton.parentElement as HTMLLinkElement);
if(link && link.href) {
itemIndex = parseInt(link.href.replace('https://steamcommunity.com/market/search/?q=appid:440+prop_def_index:', ''));
}
}
}
if (!itemIndex) {
// Cannot continue without index
logError(itemName ? `Could not find defindex for ${itemName}` : `Could not determine item defindex or name`);
return;
}
if(!itemName) {
// Get name from index
itemName = (itemSchema[itemIndex.toString()] as any).name;
}
log(`Starting lookup for ${itemName} (defindex ${itemIndex})`);
if(getTradableStatusByDefindex(itemSchema, itemIndex) == false) {
log(`${itemName} is not tradable, exiting`)
return;
}
var qualities: number[] = []
const firstQualityTag = findFirstChildElement('.quality-tag', itemInfobox);
for (const qualityTag of Array.from(firstQualityTag.parentElement.children)) {
const link = findFirstChildElement('a', qualityTag) as HTMLLinkElement;
if (!link) {
continue;
}
const qualityName = extractPageTitleFromURL(link.href);
const quality = parseInt(getKeyByValue(itemQualities, qualityName));
if (!quality) {
continue;
}
qualities.push(quality);
}
/// Create buttons
// Item infobox is a table with the following layout:
//
// th.infobox-header (Item Name)
// tr (3D item viewer/2D preview image)
// tr (buy buttons if applicable)
// ... <- We want to insert our button here.
// th.infobox-header (Basic Information)
// ...
var storeButtons: HTMLTableRowElement[] = [];
// backpack.tf button
storeButtons.push(createStoreButton("backpack.tf", new URL(`https://backpack.tf/classifieds?item=${encodeURIComponent(itemName)}`)));
// mannco.store button
storeButtons.push(createStoreButton("mannco.store", new URL(`https://mannco.store/tf2?&search=${encodeURIComponent(itemName)}&page=1`)));
// marketplace.tf button
// Disabled due to requiring login
// storeButtons.push(createStoreButton("marketplace.tf", new URL(`https://marketplace.tf/browse/tf2?sterm=${encodeURIComponent(itemName)}`)));
// stntrading.eu button
storeButtons.push(createStoreButton("stntrading.eu", new URL(`https://stntrading.eu/item/tf2/${encodeURIComponent(itemName)}`)));
const headers = itemInfobox.querySelectorAll("th.infobox-header");
storeButtons.reverse().forEach(element => {
if (marketButton) {
marketButton.closest("tr").insertAdjacentElement('afterend', element);
} else if (buyButton) {
buyButton.closest("tr").insertAdjacentElement('afterend', element);
} else if (headers.length > 2) {
headers[1].closest("tr").insertAdjacentElement('beforebegin', element);
} else {
itemInfobox.children[0].appendChild(element);
}
});
/// Create price infobox
const priceInfoboxHeadingRow = document.createElement("tr")
const priceInfoboxHeading = document.createElement("th")
priceInfoboxHeading.className = "infobox-header"
priceInfoboxHeading.colSpan = 2
priceInfoboxHeading.innerText = $T("Community Pricing")
priceInfoboxHeadingRow.appendChild(priceInfoboxHeading);
headers[1].closest("tr").insertAdjacentElement('beforebegin', priceInfoboxHeadingRow);
// Create progress bar
const priceProgressRow = document.createElement("tr")
const priceProgressData = document.createElement("td")
priceProgressData.colSpan = 2
const priceProgress = document.createElement("progress");
priceProgress.id = "tf2wikipricing";
priceProgress.style.width = "75%"
priceProgress.style.marginLeft = "12.5%"
priceProgressData.appendChild(priceProgress);
priceProgressRow.appendChild(priceProgressData);
priceInfoboxHeadingRow.insertAdjacentElement('afterend', priceProgressRow);
var token: string | null;
// Steam Community Market
// TODO: Change this to lazy-load, so that it doesn't make network requests when we have cached data.
// var steamMarketResults = await getSteamResults(itemName)
// logDebug(JSON.stringify(steamMarketResults))
// Fetch prices.tf access token
// https://api2.prices.tf/auth/access
try {
// throw new Error('dont wanna')
token = await getPricesToken();
} catch (err) {
log('Failed to get an access token for prices.tf: ' + err);
}
var updateTime: Date | null = null;
enum PriceRowCategory {
None,
Festive,
Botkiller,
KillstreakKit
}
interface PriceRow {
order: number
row: HTMLTableRowElement
category: PriceRowCategory
}
var priceRows: PriceRow[]= [];
// Get current key price
const keyPrice = await fetchKeyPrice(token);
var currentTime = new Date()
const promises = qualities.filter(x => !excludedQualities.has(x)).map(async (quality) => {
const qualifiedName = ((quality != 6 ? itemQualities[quality as unknown as keyof typeof itemQualities].toString() : '') + ' ' + itemName).trim()
// logDebug(`Fetching price for ${qualifiedName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, itemIndex + ";" + quality, currentTime);
updateTime = new Date(data.update)
} catch {
log(`${qualifiedName} is unpriced or unavailable, skipping...`)
}
const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString()
const priceRow = createPriceRow(qualityName, data, keyPrice, exchangeRates, locale)
priceRows.push({order: quality == 6 ? -1 : quality, row: priceRow, category: PriceRowCategory.None})
})
// Check item schema for Australium variant of current defindex
if(itemSchema[itemIndex].hasAustraliumVariant) {
promises.push(new Promise(async (resolve) => {
logDebug(`Fetching price for Australium ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemIndex};11;australium`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`Australium ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Australium"), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons")
priceRows.push({order: 99, row: priceRow, category: PriceRowCategory.None})
resolve()
return
}))
}
var festiveHeadingRow: HTMLTableRowElement | null
// Check item schema for Festive variant of current defindex
if(itemSchema[itemIndex].festiveVariant != null) {
/// Create subheading
festiveHeadingRow = document.createElement("tr")
const festiveHeading = document.createElement("th")
festiveHeading.className = "infobox-subheader"
festiveHeading.colSpan = 2
festiveHeading.innerText = $T("Festive")
festiveHeading.style.fontSize = '1em';
festiveHeading.style.backgroundColor = '#F5C087';
festiveHeadingRow.style.display = 'none';
festiveHeadingRow.appendChild(festiveHeading);
promises.push(new Promise(async (resolve) => {
logDebug(`Fetching price for Festive ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`Festive ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Unique"), data, keyPrice, exchangeRates, locale)
priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive})
resolve()
return
}))
promises.push(new Promise(async (resolve) => {
logDebug(`Fetching price for Strange Festive ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Strange"), data, keyPrice, exchangeRates, locale)
priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive})
resolve()
return
}))
}
var killstreakKitHeadingRow: HTMLTableRowElement | null
// Check for Killstreak Kits
if(itemSchema[itemIndex].slot == ItemSlot.Primary ||
itemSchema[itemIndex].slot == ItemSlot.Secondary ||
itemSchema[itemIndex].slot == ItemSlot.Melee)
{
/// Create subheading
killstreakKitHeadingRow = document.createElement("tr")
const heading = document.createElement("th")
heading.className = "infobox-subheader"
heading.colSpan = 2
heading.innerText = $T("Killstreak Kit")
heading.style.fontSize = '1em';
heading.style.backgroundColor = '#F5C087';
killstreakKitHeadingRow.style.display = 'none';
killstreakKitHeadingRow.appendChild(heading);
[1,2,3].map((tier) => {
promises.push(new Promise(async (resolve) => {
logDebug(`Fetching price for ${itemName} Killstreak Kit Tier ${tier}`)
var data: ItemPriceData | null
try {
var kitIndex: number
switch (tier) {
default:
case 1: kitIndex = 6527; break;
case 2: kitIndex = 6523; break;
case 3: kitIndex = 6526; break;
}
data = await fetchPrice(token, `${kitIndex};6;uncraftable;kt-${tier};td-${itemIndex}`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`${itemName} Killstreak Kit Tier ${tier} is unpriced or unavailable, skipping...`)
resolve()
return
}
const priceRow = createPriceRow($T(`kt-${tier}`), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Killstreak_Kit")
priceRows.push({order: tier, row: priceRow, category: PriceRowCategory.KillstreakKit})
resolve()
return
}))
})
}
// Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II
const botkillerOrder = [
"Silver",
"Gold",
"Rust",
"Blood",
"Carbonado",
"Diamond",
"Silver Mk.II",
"Gold Mk.II",
]
var botKillerHeadingRow: HTMLTableRowElement | null
if(itemSchema[itemIndex].botkillerVariants != null && itemSchema[itemIndex].botkillerVariants.length > 0) {
/// Create subheading
botKillerHeadingRow = document.createElement("tr")
const festiveHeading = document.createElement("th")
festiveHeading.className = "infobox-subheader"
festiveHeading.colSpan = 2
festiveHeading.innerText = $T("Botkiller")
festiveHeading.style.fontSize = '1em';
festiveHeading.style.backgroundColor = '#F5C087';
botKillerHeadingRow.style.display = 'none';
botKillerHeadingRow.appendChild(festiveHeading);
itemSchema[itemIndex].botkillerVariants.map((variantIndex) => {
const itemName = itemSchema[variantIndex].name
// FIXME: variantName should match wiki display name
const variantName = itemName.includes('Mk.II') ? itemName.split(' ')[0] + ' Mk.II' : itemName.split(' ')[0]
promises.push(new Promise(async (resolve) => {
logDebug(`Fetching price for ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${variantIndex};11`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T(variantName), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons")
// FIXME: order should be by release
// Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II
priceRows.push({order: botkillerOrder.indexOf(variantName), row: priceRow, category: PriceRowCategory.Botkiller})
resolve()
return
}))
})
}
if(killstreakKitHeadingRow) priceInfoboxHeadingRow.insertAdjacentElement('afterend', killstreakKitHeadingRow);
if(botKillerHeadingRow) priceInfoboxHeadingRow.insertAdjacentElement('afterend', botKillerHeadingRow);
if(festiveHeadingRow) priceInfoboxHeadingRow.insertAdjacentElement('afterend', festiveHeadingRow);
Promise.all(promises).then(() => {
priceRows.sort((a, b) => {
if (a.category != b.category) {
return a.category - b.category;
}
return a.order - b.order;
}).reverse().forEach((element) => {
switch(element.category) {
case PriceRowCategory.None:
priceInfoboxHeadingRow.insertAdjacentElement('afterend', element.row);
break;
case PriceRowCategory.Festive:
festiveHeadingRow.insertAdjacentElement('afterend', element.row);
festiveHeadingRow.style.display = 'revert';
break;
case PriceRowCategory.Botkiller:
botKillerHeadingRow.insertAdjacentElement('afterend', element.row);
botKillerHeadingRow.style.display = 'revert';
break;
case PriceRowCategory.KillstreakKit:
killstreakKitHeadingRow.insertAdjacentElement('afterend', element.row);
killstreakKitHeadingRow.style.display = 'revert';
}
})
if(!updateTime || !(updateTime instanceof Date) || isNaN(+updateTime)) updateTime = new Date()
// Footer row
const row = document.createElement("tr");
const label = document.createElement("td");
label.colSpan = 2;
label.style.fontSize = "85%";
const updateText = $T("Updated %@.", locale).replace('%@', updateTime.toLocaleString(locale, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }))
const attributionText = $T("Trade prices sourced from %@. Currency conversions are approximate.", locale).replace('%@', 'prices.tf');
const exchangeRateAttribution = `Rates By Exchange Rate API.`;
label.innerHTML = `${updateText}
${attributionText}
${exchangeRateAttribution}`;
row.appendChild(label);
priceProgressRow.insertAdjacentElement('afterend', row);
priceProgressRow.remove()
})
}
function addStyles() {
const head = document.head || document.getElementsByTagName('head')[0],
style = document.createElement('style');
head.appendChild(style);
style.innerHTML = styleCss;
}
prepareSchema()
.then(schema => {
itemSchema = schema;
if (!itemSchema) {
wipeSchema(); // FIXME: ugly hack. requires additional page reload. if prepareSchema returns null, we should handle it properly
throw new Error("No item schema ready");
}
})
.then(prepareExchangeRates)
.then(rates => exchangeRates = rates)
.then(() => {
locale = extractLocaleFromURL(document.URL)
addStyles();
inject();
// TODO: Purge expired price data
})
.catch((error) => {
logError(error);
})