You've already forked tf2wikipricing
Exchange rates are loaded once per page and passed to price row generation, so we don't hit the cache more than once.
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
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 <link rel="canonical" href='...'> 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('%@', '<a rel="nofollow" class="external text" href="https://prices.tf">prices.tf</a>');
|
|
const exchangeRateAttribution = `<a rel="nofollow" class="external text" href="https://www.exchangerate-api.com">Rates By Exchange Rate API</a>.`;
|
|
label.innerHTML = `${updateText}<br>${attributionText}<br>${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);
|
|
}) |