Files
tf2wikipricing/src/content/content.ts
xenticore c8f71b4f47 l10n: update attribution text and style
New text is easier to localize, strings can be found in free dictionaries
2025-04-30 19:15:38 -04:00

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);
})