Files
tf2wikipricing/src/content/content.ts
xenticore 12de4a7148 refactor: moved components to separate modules
`content.ts` is now half less than the size, exported functions can be unit tested
2025-03-24 15:39:28 -04:00

274 lines
9.2 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, 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'
var itemSchema: ItemSchema | null;
var pageLocale: 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);
}
function extractPageTitleFromURL(url: string): string {
var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
if (split.indexOf('/') != -1) {
// Remove language suffix (/es)
split = split.substring(0, split.indexOf('/'));
}
return decodeURIComponent(split.replaceAll('_', ' '));
}
// Main function
async function inject() {
const itemInfobox = findFirstElement('.item-infobox');
if (!itemInfobox) {
// Not an item page
return;
}
const locale = extractLocaleFromURL(document.URL);
var itemIndex: number | null = null;
var itemName: string | null = null;
// Try using buy buttons, if they exist
const buyButton = findFirstChildElement('.btn_buynow', itemInfobox);
const marketButton = findFirstChildElement('.btn_buynow_market', itemInfobox);
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:', ''));
}
}
// 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)
}
}
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;
interface PriceRow {
quality: number
row: HTMLTableRowElement
}
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, locale)
priceRows.push({quality: quality, row: priceRow})
})
Promise.all(promises).then(() => {
priceRows.sort((a, b) => {
// Sort 6 first always, then numerically
if (a.quality === 6) {
return -1;
} else if (b.quality === 6) {
return 1;
} else {
return a.quality == b.quality ? a.quality < b.quality ? -1 : 1 : 0;
}
}).reverse().forEach((element) => {
priceInfoboxHeadingRow.insertAdjacentElement('afterend', element.row);
})
if(!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>');
label.innerHTML = `${updateText}<br>${attributionText}`;
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(function (schema) {
itemSchema = schema;
if (!itemSchema) {
logError("No item schema ready, exiting.");
wipeSchema(); // FIXME: ugly hack. requires additional page reload. if prepareSchema returns null, we should handle it properly
return;
}
pageLocale = extractLocaleFromURL(document.URL)
addStyles();
inject();
// TODO: Purge expired price data
});