You've already forked tf2wikipricing
274 lines
9.2 KiB
TypeScript
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
|
|
}); |