Initial commit

This commit is contained in:
xenticore
2025-03-21 13:53:45 -04:00
commit 568b2eaabd
28 changed files with 1604 additions and 0 deletions

611
src/content/content.ts Normal file
View File

@@ -0,0 +1,611 @@
import { getStorageValue, setStorageValue } from './storage'
import styleCss from './style.css'
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
import './GM_fetch'
import { logDebug, log, logError } from './log'
import { getPricesToken, priceUsingPricesTF } from './pricing/pricestf'
const semver = require('semver')
// Globals
import itemQualities from 'tf2-static-schema/static/qualities.json';
var itemSchema: { [key: string]: {name: string, tradable: Boolean}; } | null;
declare const __VERSION__: string;
// Constants
const storage_lastUpdateTime = 'tf2wikipricing_lastUpdate';
const storage_schema = 'tf2wikipricing_schema';
const storage_version = 'tf2wikipricing_version';
const storage_priceprefix = 'tf2wikipricing_sku_';
const conversion_ref_usd = 0.0265;
const defindex_key = 5021;
const defindex_metal_refined = 5002;
const defindex_metal_reclaimed = 5001;
const defindex_metal_scrap = 5000;
/** Pricing data for a given TF2 item. */
class ItemPriceData {
/** Item SKU. */
sku: string
/** Date updated. */
update: Date
/** TTL in milliseconds. */
ttl: number
/** Price in keys. */
keys: number
/** Price in refined metal. */
metal: number
/** Steam Community Market price. */
scmPrice: number
toString(): string {
return `Price for ${this.sku}, fetched ${this.update} (expires ${this.update.getTime() + this.ttl})\n` +
JSON.stringify({
keys: this.keys,
metal: this.metal,
scmPrice: this.scmPrice
})
}
}
class SteamMarketSearchResult {
name: string
sell_price: number
}
/** Exclude these from the pricelist. */
const excludedQualities = new Set([
15, // Decorated
5, // Unusual
]);
const localizations: {[lang: string]: any} = {
'en': require('../strings/en'), // English
'es': require('../strings/es'), // Spanish
// 'ja': require('../strings/ja'), // Japanese
// 'it': require('../strings/it'), // Italian
// 'ar': require('../strings/ar.json') as object, // Arabic
// 'cs': require('../strings/cs.json') as object, // Czech
// 'da': require('../strings/da.json') as object, // Danish
// 'de': require('../strings/de.json') as object, // German
// 'fi': require('../strings/fi.json') as object, // Finnish
// 'fr': require('../strings/fr.json') as object, // French
// 'hu': require('../strings/hu.json') as object, // Hungarian
// 'ko': require('../strings/ko.json') as object, // Korean
// 'nl': require('../strings/nl.json') as object, // Dutch
// 'no': require('../strings/no.json') as object, // Norwegian Bokmål
// 'pl': require('../strings/pl.json') as object, // Polish
// 'pt': require('../strings/pt.json') as object, // Portuguese
// 'pt-BR': require('../strings/pt-BR.json') as object, // Brazilian Portuguese
// 'ro': require('../strings/ro.json') as object, // Romanian
// 'ru': require('../strings/ru.json') as object, // Russian
// 'sv': require('../strings/sv.json') as object, // Swedish
// 'tr': require('../strings/tr.json') as object, // Turkish
// 'zh-Hans': require('../strings/zh-Hans.json') as object, // Simplified Chinese
// 'zh-Hant': require('../strings/zh-Hant.json') as object, // Traditional Chinese
}
function $T(s: string, locale?: Intl.LocalesArgument): string {
const code = locale ? locale.toString() : extractLocaleFromURL(document.URL)
return localizations.hasOwnProperty(code) ? (localizations[code as unknown as keyof object])[s] || s : s;
}
// Helper functions
function findFirstElement(selector: string): HTMLElement | null {
const elements = document.querySelectorAll(selector);
return elements.length > 0 ? elements[0] as HTMLElement : null;
}
function findFirstChildElement(selector: string, root: Element): HTMLElement | null {
const elements = root.querySelectorAll(selector);
return elements.length > 0 ? elements[0] as HTMLElement : null;
}
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('_', ' '));
}
function extractLocaleFromURL(url: string): string {
var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
if (split.indexOf('/') != -1) {
// Remove language suffix e.g. `/es`
return split.substring(split.indexOf('/') + 1);
} else {
return 'en';
}
}
function isDateAfterOneDay(date1: Date, date2: Date): boolean {
var diff = date2.getTime() - date1.getTime();
var diffDays = Math.round(diff / (1000 * 3600 * 24));
return diffDays > 1;
}
function getItemIndexByName(name: string) {
for (const [defindex, value] of Object.entries(itemSchema)) {
if (value['name'] == name) {
return parseInt(defindex)
}
}
return null
}
function getTradableStatusByDefindex(defindex: number) {
return itemSchema[defindex.toString()].tradable
}
function getTradableStatusByName(name: string) {
for (const [defindex, value] of Object.entries(itemSchema)) {
if (value['name'] == name) {
return value.tradable
}
}
return true
}
// 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(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(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;
const cached = await getStorageValue(storage_priceprefix + sku, null)
if (cached != null && 'keys' in cached && 'metal' in cached) {
data = cached
}
if (!data || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) {
data = new ItemPriceData()
data.sku = sku
data.update = new Date()
data.ttl = (5 * 60 * 1000) // Cache results for 5 minutes
// logDebug(JSON.stringify(steamMarketResults))
// const scmResult = steamMarketResults.find((x: object) => { x['name' as keyof object] === qualifiedName})
// logDebug(JSON.stringify(scmResult))
// if(scmResult) {
// data.scmPrice = scmResult.sell_price / 100
// }
try {
const response = await priceUsingPricesTF(token, itemIndex, quality)
if (response) {
data.keys = response.keys
data.metal = response.metal
}
} catch (error) {
log(`Received ${error} error while pricing ${sku} using prices.tf`)
}
if ('keys' in data && 'metal' in data) {
await setStorageValue(storage_priceprefix + sku, data)
}
logDebug(JSON.stringify(data));
updateTime = new Date(data.update)
} else {
logDebug(`Using cached data for ${sku}, expires ${new Date(data.update).getTime() + data.ttl}`);
updateTime = new Date(data.update)
}
*/
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 priceRow = document.createElement("tr");
const priceLabel = document.createElement("td");
priceLabel.className = "infobox-label";
const priceLabelLink = document.createElement("a");
const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString()
priceLabelLink.href = locale === 'en' ? `https://wiki.teamfortress.com/wiki/${qualityName}` : `https://wiki.teamfortress.com/wiki/${qualityName}/${locale}`
priceLabelLink.innerText = $T(qualityName)
priceLabel.appendChild(priceLabelLink);
priceLabel.innerHTML += ':'
priceRow.appendChild(priceLabel);
const priceData = document.createElement("td");
const priceLink = document.createElement("span");
const priceString = data ? formatPrice(data.keys, data.metal, keyPrice.metal).trim() : $T('Data unavailable')
priceLink.innerHTML = priceString // + `<br>$${data.scmPrice}`
priceData.appendChild(priceLink);
priceRow.appendChild(priceData);
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 createStoreButton(storeName: string, url: URL) {
const button = document.createElement("tr")
var source = `<td colspan="2" class="infobox-data" style="text-align:center"><div class="plainlinks btn_wrapper" style="width:100%"><a rel="nofollow" class="external text" href="{link}" target="_blank"><span class="btn_buynow_addon_${storeName.replaceAll('.', '')}">{title}<span></span></span></a></div></td>`
source = source.replace("{link}", url.toString())
source = source.replace("{title}", $T("View listings on %@").replace('%@', storeName))
button.innerHTML = source
return button
}
async function fetchKeyPrice(token: string) {
return fetchPrice(token, defindex_key, 6, new Date(), 86400000)
}
/**
* Fetch a price for a given SKU, using cached values if available.
* @param token prices.tf access token.
* @param update Date retrieved.
* @param ttl Time to cache results in milliseconds. 30 minutes by default.
*/
async function fetchPrice(token: string, defIndex: number, quality: number, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise<ItemPriceData> {
return new Promise(async (resolve, reject) => {
const sku = defIndex.toString() + ";" + quality.toString();
var data: ItemPriceData | null
const cached = await getStorageValue(storage_priceprefix + sku, null)
if (cached != null && 'keys' in cached && 'metal' in cached) {
data = cached
}
if (!data || data.sku != sku || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) {
logDebug(`Fetching price data for ${sku}`)
if(!token) {
reject(401)
}
data = new ItemPriceData()
data.sku = sku
data.update = update
data.ttl = ttl
try {
const response = await priceUsingPricesTF(token, defIndex, quality)
if (response) {
data.keys = response.keys
data.metal = response.metal
}
} catch (error) {
log(`Received ${error} error while pricing ${sku} using prices.tf`)
reject(error)
}
if ('metal' in data && 'keys' in data) {
await setStorageValue(storage_priceprefix + sku, data)
}
} else {
logDebug(`Using cached price data for ${sku}`)
}
resolve(data)
})
}
async function getSteamResults(itemName: string) {
logDebug(`Making network request to Steam for ${itemName}`)
const response = await GM_fetch(`https://steamcommunity.com/market/search/render?appid=440&norender=true&count=10&query=${encodeURIComponent(itemName)}`, {
method: 'get',
headers: new Headers({
'Accept': 'application/json'
})
})
if (response.status === 200) {
const json = await response.json();
return json['results']
}
return []
}
function toFixed(num: number, fixed: number) {
var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?');
return num.toString().match(re)[0];
}
var pageLocale: string = 'en'
function formatPrice(keys: number, metal: number, keyPrice: number) {
const pureMetal = (keys * keyPrice) + metal;
const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2)
var output: string = ''
if(keys > 0) {
output += (formattedKeys == 1.0 ? $T("%@ key") : $T("%@ keys")).replace('%@', formattedKeys.toLocaleString(pageLocale))
} else {
output += `${(+toFixed(metal, 2)).toLocaleString(pageLocale)} ref`
}
const currencyFormatter = new Intl.NumberFormat(pageLocale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Round price up to nearest cent
const price = Math.ceil(pureMetal * conversion_ref_usd * 100) / 100
output += ` (US$${currencyFormatter.format(price)})`
return output;
}
async function prepareSchema() {
var needsUpdate: Boolean = false
const storedVersion: string | null = await getStorageValue(storage_version, null)
if(!storedVersion || !semver.valid(storedVersion)) {
log(`Cache is from an unknown version of the extension. Updating for version ${__VERSION__}`);
needsUpdate = true
} else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) {
log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`);
needsUpdate = true
} else {
itemSchema = await getStorageValue(storage_schema, null);
}
const update = await getStorageValue(storage_lastUpdateTime, null)
if (update) {
log(`Item schema updated at ${new Date(update)}`);
const lastUpdateTime = new Date(update);
if (!itemSchema || isDateAfterOneDay(lastUpdateTime, new Date())) {
needsUpdate = true
}
}
if(needsUpdate) {
log("Item Schema out of Date. Rebuilding...");
const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json"
const response = await GM_fetch(url);
if (response.ok) {
await setStorageValue(storage_lastUpdateTime, new Date().getTime());
var cacheItems = {}
var responseItems: any[] = await response.json()
// We want to keep the keys `defindex`, `item_name`, and `attributes`
responseItems.forEach((item: any) => {
const defindex = item['defindex']
const name = item['item_name']
var tradable: Boolean = true
try {
if(item['attributes'] != null) {
if(item['attributes'].find((attribute: {}) => (attribute as any)['class'] == "cannot_trade")) {
tradable = false
}
}
} catch(error) {
logError(error)
log(item)
}
(cacheItems as any)[defindex.toString()] = { "name": name, "tradable": tradable }
});
await setStorageValue(storage_schema, (cacheItems));
itemSchema = cacheItems
await setStorageValue(storage_version, __VERSION__);
logDebug(`Item schema updated at ${new Date()}`)
} else {
logError("Could not fetch item schema.");
}
}
}
function addStyles() {
const head = document.head || document.getElementsByTagName('head')[0],
style = document.createElement('style');
head.appendChild(style);
style.innerHTML = styleCss;
}
prepareSchema().then(function () {
if (!itemSchema) {
logError("No item schema ready, exiting.");
return;
}
pageLocale = extractLocaleFromURL(document.URL)
addStyles();
inject();
// TODO: Purge expired price data
});