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(obj: Record, 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 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 = `prices.tf`; const exchangeRateAttribution = `Rates By Exchange Rate API`; label.innerHTML = `${updateText}
${attributionHeader}
${pricesAttribution}
${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); })