You've already forked tf2wikipricing
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
import styleCss from './style.css'
|
|
|
|
import { logDebug, log, logError } from './utils/log'
|
|
import { getPricesToken } 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';
|
|
let itemSchema: ItemSchema | null;
|
|
let exchangeRates: ExchangeRates | null;
|
|
|
|
let locale: string = 'en'
|
|
|
|
/** Exclude these from the pricelist. */
|
|
const excludedQualities = new Set([
|
|
15, // Decorated
|
|
5, // Unusual
|
|
]);
|
|
|
|
// Helper functions
|
|
function getKeyByValue<K extends string | number | symbol, V>(obj: Record<K, V>, value: V): K | undefined {
|
|
for (const [key, val] of Object.entries(obj)) {
|
|
if (val === value) {
|
|
return key as unknown as K;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// Main function
|
|
async function inject() {
|
|
const itemInfobox = findFirstElement('.item-infobox');
|
|
if (!itemInfobox) {
|
|
// Not an item page
|
|
return;
|
|
}
|
|
let itemIndex: number | null = null;
|
|
let 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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Prioritize name from item schema
|
|
if(itemName !== itemSchema[itemIndex.toString()].name) {
|
|
itemName = (itemSchema[itemIndex.toString()]).name
|
|
}
|
|
|
|
log(`Starting lookup for ${itemName} (defindex ${itemIndex})`);
|
|
|
|
if(getTradableStatusByDefindex(itemSchema, itemIndex) == false) {
|
|
log(`${itemName} is not tradable, exiting`)
|
|
return;
|
|
}
|
|
|
|
const 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)
|
|
// ...
|
|
|
|
const 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);
|
|
|
|
let 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);
|
|
}
|
|
|
|
let updateTime: Date | null = null;
|
|
|
|
enum PriceRowCategory {
|
|
None,
|
|
Festive,
|
|
Botkiller,
|
|
KillstreakKit
|
|
}
|
|
|
|
interface PriceRow {
|
|
order: number
|
|
row: HTMLTableRowElement
|
|
category: PriceRowCategory
|
|
}
|
|
const priceRows: PriceRow[]= [];
|
|
|
|
// Get current key price
|
|
const keyPrice = await fetchKeyPrice(token);
|
|
|
|
const 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}`)
|
|
|
|
let 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(fetchPrice(token, `${itemIndex};11;australium`, currentTime).then(data => {
|
|
updateTime = new Date(data.update)
|
|
log(`Saving price for Australium ${itemName}`)
|
|
|
|
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})
|
|
})
|
|
.catch((error) => {
|
|
logError(error)
|
|
log(`Australium ${itemName} is unpriced or unavailable, skipping...`)
|
|
}))
|
|
}
|
|
|
|
let 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(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime).then(data => {
|
|
updateTime = new Date(data.update)
|
|
log(`updateTime price for Festive ${itemName}`)
|
|
|
|
const priceRow = createPriceRow($T("Unique"), data, keyPrice, exchangeRates, locale)
|
|
|
|
priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive})
|
|
})
|
|
.catch((error) => {
|
|
logError(error)
|
|
log(`Festive ${itemName} is unpriced or unavailable, skipping...`)
|
|
}))
|
|
promises.push(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime).then(data => {
|
|
updateTime = new Date(data.update)
|
|
log(`Saving price for Strange Festive ${itemName}`)
|
|
|
|
const priceRow = createPriceRow($T("Strange"), data, keyPrice, exchangeRates, locale)
|
|
|
|
priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive})
|
|
})
|
|
.catch((error) => {
|
|
logError(error)
|
|
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
|
|
}))
|
|
}
|
|
|
|
let 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) => {
|
|
let kitIndex: number
|
|
switch (tier) {
|
|
default:
|
|
case 1: kitIndex = 6527; break;
|
|
case 2: kitIndex = 6523; break;
|
|
case 3: kitIndex = 6526; break;
|
|
}
|
|
promises.push(fetchPrice(token, `${kitIndex};6;uncraftable;kt-${tier};td-${itemIndex}`, currentTime).then(data => {
|
|
updateTime = new Date(data.update)
|
|
logDebug(`Saving price for ${itemName} Killstreak Kit Tier ${tier}`)
|
|
|
|
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})
|
|
})
|
|
.catch((error) => {
|
|
logError(`Failed to fetch price for ${itemName} Killstreak Kit Tier ${tier}`, error)
|
|
}))
|
|
})
|
|
}
|
|
|
|
// 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",
|
|
]
|
|
let 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(fetchPrice(token, `${variantIndex};11`, currentTime).then(data => {
|
|
logDebug(`Saving price for ${itemName}`)
|
|
updateTime = new Date(data.update)
|
|
|
|
const priceRow = createPriceRow($T(variantName), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons")
|
|
|
|
priceRows.push({order: botkillerOrder.indexOf(variantName), row: priceRow, category: PriceRowCategory.Botkiller})
|
|
})
|
|
.catch((error) => {
|
|
logError(error)
|
|
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
|
|
}))
|
|
})
|
|
}
|
|
|
|
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%";
|
|
label.style.textAlign = "center";
|
|
const updateText = $T("Updated %@.", locale).replace('%@', updateTime.toLocaleString(locale, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }))
|
|
const attributionHeader = $T("Acknowledgements");
|
|
const pricesAttribution = `<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><b>${attributionHeader}</b><br>${pricesAttribution}<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);
|
|
}) |