bump version to 0.5.0

- Adds festive & botkiller pricing
- Script retries if API is hit too frequently
This commit is contained in:
Xen
2025-04-07 17:23:29 -04:00
7 changed files with 452 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, mock } from "bun:test"; import { describe, expect, test, mock } from "bun:test";
import { ItemSchema, ItemSlot, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName, prepareSchema } from '../src/content/schemaService' import { ItemSchema, ItemSlot, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName, linkBotkillerVariants, linkFestiveVariants, prepareSchema } from '../src/content/schemaService'
// Mock the storage and log functions // Mock the storage and log functions
mock.module('../src/content/storage', () => ({ mock.module('../src/content/storage', () => ({
@@ -19,35 +19,72 @@ const mockSchema: ItemSchema = {
slot: ItemSlot.Primary, slot: ItemSlot.Primary,
tradable: false, tradable: false,
hasAustraliumVariant: false, hasAustraliumVariant: false,
canKillstreakify: true canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
}, },
'208': { '208': {
name: 'Flame Thrower', name: 'Flame Thrower',
slot: ItemSlot.Primary, slot: ItemSlot.Primary,
tradable: true, tradable: true,
hasAustraliumVariant: true, hasAustraliumVariant: true,
canKillstreakify: true canKillstreakify: true,
festiveVariant: 659,
botkillerVariants: [798, 807]
},
'659': {
name: 'Festive Flame Thrower',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
},
'798': {
name: 'Silver Botkiller Flame Thrower Mk.I',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
},
'807': {
name: 'Gold Botkiller Flame Thrower Mk.I',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
}, },
'5021': { '5021': {
name: 'Mann Co. Supply Crate Key', name: 'Mann Co. Supply Crate Key',
slot: ItemSlot.Tool, slot: ItemSlot.Tool,
tradable: true, tradable: true,
hasAustraliumVariant: false, hasAustraliumVariant: false,
canKillstreakify: false canKillstreakify: false,
festiveVariant: null,
botkillerVariants: null
}, },
'15141': { '15141': {
name: 'Flame Thrower', name: 'Flame Thrower',
slot: ItemSlot.Primary, slot: ItemSlot.Primary,
tradable: true, tradable: true,
hasAustraliumVariant: false, hasAustraliumVariant: false,
canKillstreakify: true canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
}, },
'69420': { '69420': {
name: 'Non-Tradable Item', name: 'Non-Tradable Item',
slot: ItemSlot.Misc, slot: ItemSlot.Misc,
tradable: false, tradable: false,
hasAustraliumVariant: false, hasAustraliumVariant: false,
canKillstreakify: false canKillstreakify: false,
festiveVariant: null,
botkillerVariants: null
} }
} }
@@ -117,4 +154,187 @@ describe('Schema Service', () => {
} }
}); });
}); });
describe('linkFestiveVariants', () => {
test('should link festive variants to their original counterparts', () => {
const testSchema = {
'21': { // Stock (should be ignored)
name: 'Flame Thrower',
slot: ItemSlot.Primary,
tradable: false,
hasAustraliumVariant: false,
canKillstreakify: false,
festiveVariant: null,
botkillerVariants: null
},
'208': { // Original Flame Thrower
name: 'Flame Thrower',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: true,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
},
'659': { // Festive Flame Thrower (should be detected)
name: 'Festive Flame Thrower',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
},
'15141': { // Decorated (should be ignored)
name: 'Flame Thrower',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
}
};
const mockResponseItems = [
{ item_class: 'tf_weapon_flamethrower', defindex: 21, item_name: 'Flame Thrower' }, // Incorrect; stock
{ item_class: 'tf_weapon_flamethrower', defindex: 208, item_name: 'Flame Thrower' }, // Original
{ item_class: 'tf_weapon_flamethrower', defindex: 659, item_name: 'Festive Flame Thrower' }, // Festive
{ item_class: 'tf_weapon_flamethrower', defindex: 15141, item_name: 'Flame Thrower' }, // Incorrect; decorated
];
linkFestiveVariants(mockResponseItems, testSchema)
expect(testSchema['21'].festiveVariant).toBeNull()
expect(testSchema['208'].festiveVariant).toBe(659)
expect(testSchema['659'].festiveVariant).toBeNull();
expect(testSchema['15141'].festiveVariant).toBeNull()
})
test('should not link if no festive variant exists', () => {
const testSchema = {
'163': {
name: 'Crit-a-Cola',
slot: ItemSlot.Secondary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: false,
festiveVariant: null,
botkillerVariants: null
}
};
const mockResponseItems = [
{ item_class: 'tf_weapon_lunchbox_drink', defindex: 163, item_name: 'Crit-a-Cola' }
];
linkFestiveVariants(mockResponseItems, testSchema);
expect(testSchema['163'].festiveVariant).toBeNull();
});
});
describe('linkBotkillerVariants', () => {
test('should link botkiller variants to their original counterparts', () => {
const testSchema = {
'21': { // Original Rocket Launcher
name: 'Flame Thrower',
slot: ItemSlot.Primary,
tradable: false,
hasAustraliumVariant: false,
canKillstreakify: false,
festiveVariant: null,
botkillerVariants: null
},
'208': { // Original Flame Thrower
name: 'Flame Thrower',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: true,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
},
'798': {
name: 'Silver Botkiller Flame Thrower Mk. I',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
},
'807': {
name: 'Gold Botkiller Flame Thrower Mk. I',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
},
'15141': { // Decorated (should be ignored)
name: 'Flame Thrower',
slot: ItemSlot.Primary,
tradable: true,
hasAustraliumVariant: false,
canKillstreakify: true,
festiveVariant: null,
botkillerVariants: null
}
};
const mockResponseItems = [
{ item_class: 'tf_weapon_flamethrower', defindex: 21, item_name: 'Flame Thrower', item_type_name: 'Flame Thrower'
}, // Incorrect; stock
{
item_class: 'tf_weapon_flamethrower',
defindex: 208,
item_name: 'Flame Thrower',
item_type_name: 'Flame Thrower'
},
{
item_class: 'tf_weapon_flamethrower',
defindex: 798,
item_name: 'Silver Botkiller Flame Thrower Mk.I',
item_type_name: 'Flame Thrower'
},
{
item_class: 'tf_weapon_flamethrower',
defindex: 807,
item_name: 'Gold Botkiller Flame Thrower Mk.I',
item_type_name: 'Flame Thrower'
},
{ item_class: 'tf_weapon_flamethrower', defindex: 15141, item_name: 'Flame Thrower', item_type_name: 'Flame Thrower' }, // Incorrect; decorated
];
linkBotkillerVariants(mockResponseItems, testSchema);
expect(testSchema['21'].botkillerVariants).toBeNull()
expect(testSchema['208'].botkillerVariants).toEqual([798, 807]);
expect(testSchema['798'].botkillerVariants).toBeNull();
expect(testSchema['807'].botkillerVariants).toBeNull();
expect(testSchema['15141'].botkillerVariants).toBeNull()
});
test('should not link if no botkiller variants exist', () => {
const testSchema = {
'163': { // Crit-a-Cola
name: 'Crit-a-Cola',
slot: ItemSlot.Secondary,
tradable: true,
hasAustraliumVariant: false,
festiveVariant: null,
canKillstreakify: false,
botkillerVariants: null,
}
};
const mockResponseItems = [
{
item_class: 'tf_weapon_lunchbox_drink',
defindex: 163,
item_name: 'Crit-a-Cola',
item_type_name: 'Lunch Box',
}
];
linkBotkillerVariants(mockResponseItems, testSchema);
expect(testSchema['163'].botkillerVariants).toBeNull();
});
})
}) })

View File

@@ -1,6 +1,6 @@
{ {
"name": "tf2wikipricing", "name": "tf2wikipricing",
"version": "0.4.1", "version": "0.5.0",
"description": "Adds item pricing to the Team Fortress 2 wiki", "description": "Adds item pricing to the Team Fortress 2 wiki",
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "^17.4.4", "@happy-dom/global-registrator": "^17.4.4",

View File

@@ -187,9 +187,16 @@ async function inject() {
var updateTime: Date | null = null; var updateTime: Date | null = null;
enum PriceRowCategory {
None,
Festive,
Botkiller
}
interface PriceRow { interface PriceRow {
quality: number order: number
row: HTMLTableRowElement row: HTMLTableRowElement
category: PriceRowCategory
} }
var priceRows: PriceRow[]= []; var priceRows: PriceRow[]= [];
@@ -214,7 +221,7 @@ async function inject() {
const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString() const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString()
const priceRow = createPriceRow(qualityName, data, keyPrice, locale) const priceRow = createPriceRow(qualityName, data, keyPrice, locale)
priceRows.push({quality: quality, row: priceRow}) priceRows.push({order: quality == 6 ? -1 : quality, row: priceRow, category: PriceRowCategory.None})
}) })
// Check item schema for Australium variant of current defindex // Check item schema for Australium variant of current defindex
@@ -231,24 +238,128 @@ async function inject() {
const priceRow = createPriceRow($T("Australium"), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons") const priceRow = createPriceRow($T("Australium"), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons")
priceRows.push({quality: 99, row: priceRow}) priceRows.push({order: 99, row: priceRow, category: PriceRowCategory.None})
resolve() resolve()
return return
})) }))
} }
var 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.appendChild(festiveHeading);
promises.push(new Promise(async (resolve) => {
logDebug(`Fetching price for Festive ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`Festive ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Unique"), data, keyPrice, locale)
priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive})
resolve()
return
}))
promises.push(new Promise(async (resolve) => {
logDebug(`Fetching price for Strange Festive ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Strange"), data, keyPrice, locale)
priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive})
resolve()
return
}))
}
// 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",
]
var 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.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(new Promise(async (resolve) => {
logDebug(`Fetching price for ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${variantIndex};11`, currentTime);
updateTime = new Date(data.update)
} catch {
log(`${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T(variantName), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons")
// FIXME: order should be by release
// Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II
priceRows.push({order: botkillerOrder.indexOf(variantName), row: priceRow, category: PriceRowCategory.Botkiller})
resolve()
return
}))
})
}
if(botKillerHeadingRow) priceInfoboxHeadingRow.insertAdjacentElement('afterend', botKillerHeadingRow);
if(festiveHeadingRow) priceInfoboxHeadingRow.insertAdjacentElement('afterend', festiveHeadingRow);
Promise.all(promises).then(() => { Promise.all(promises).then(() => {
priceRows.sort((a, b) => { priceRows.sort((a, b) => {
// Sort 6 first always, then numerically if (a.category != b.category) {
if (a.quality === 6) { return a.category - b.category;
return -1;
} else if (b.quality === 6) {
return 1;
} else {
return a.quality == b.quality ? a.quality < b.quality ? -1 : 1 : 0;
} }
return a.order - b.order;
}).reverse().forEach((element) => { }).reverse().forEach((element) => {
priceInfoboxHeadingRow.insertAdjacentElement('afterend', element.row); switch(element.category) {
case PriceRowCategory.None:
priceInfoboxHeadingRow.insertAdjacentElement('afterend', element.row);
break;
case PriceRowCategory.Festive:
festiveHeadingRow.insertAdjacentElement('afterend', element.row);
break;
case PriceRowCategory.Botkiller:
botKillerHeadingRow.insertAdjacentElement('afterend', element.row);
break;
}
}) })
if(!updateTime || !(updateTime instanceof Date) || isNaN(+updateTime)) updateTime = new Date() if(!updateTime || !(updateTime instanceof Date) || isNaN(+updateTime)) updateTime = new Date()

View File

@@ -1,5 +1,6 @@
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
import '../GM_fetch' import '../GM_fetch'
import { logDebug } from '../utils/log'
async function getPricesToken(): Promise<string> { async function getPricesToken(): Promise<string> {
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
@@ -37,7 +38,7 @@ class PricesResponse {
* @returns {Promise<PricesResponse>} Object containing 'keys' and 'metal' prices * @returns {Promise<PricesResponse>} Object containing 'keys' and 'metal' prices
* @throws When authentication fails or API returns non-200 status code * @throws When authentication fails or API returns non-200 status code
*/ */
async function priceUsingPricesTF(token: string, sku: string): Promise<PricesResponse> { async function priceUsingPricesTF(token: string, sku: string, retries: number = 3): Promise<PricesResponse> {
// prices.tf // prices.tf
// https://api2.prices.tf/prices/${sku} // https://api2.prices.tf/prices/${sku}
// Authorization: Bearer ${token} // Authorization: Bearer ${token}
@@ -65,11 +66,37 @@ async function priceUsingPricesTF(token: string, sku: string): Promise<PricesRes
}) })
} }
} }
if (response.status === 200) { switch (response.status) {
const json = await response.json() case 200:
resolve({ keys: json['sellKeys'], metal: json['sellHalfScrap'] / 18.0 }) const json = await response.json()
} else { resolve({ keys: json['sellKeys'], metal: json['sellHalfScrap'] / 18.0 })
reject(response.status) break;
case 404:
reject("Not found in prices.tf")
break;
case 429:
case 503:
// Happens if we send too many requests in a short period of time
// Retry after a few seconds
if(retries > 0) {
logDebug(`Cloudflare rate limit exceeded, trying again after 2 seconds, ${retries} retries left`)
await new Promise(resolve => setTimeout(resolve, 2000));
try {
const retryResult = await priceUsingPricesTF(token, sku, retries - 1);
resolve(retryResult);
} catch (err) {
reject(err);
}
} else {
reject("Cloudflare rate limit exceeded, stopping")
}
break;
default:
// Something went wrong
logDebug(`Received ${response.status} error while pricing ${sku}`)
logDebug(`${JSON.stringify(response.headers)}`)
reject("Unknown error")
break;
} }
}) })
} }

View File

@@ -38,6 +38,8 @@ export class ItemSchema {
slot: ItemSlot, slot: ItemSlot,
tradable: Boolean, tradable: Boolean,
hasAustraliumVariant: Boolean, hasAustraliumVariant: Boolean,
festiveVariant: number | null
botkillerVariants: Array<number> | null
canKillstreakify: Boolean canKillstreakify: Boolean
}; };
} }
@@ -70,6 +72,45 @@ export function getTradableStatusByName(schema: ItemSchema, name: string, exclud
return true return true
} }
export function linkFestiveVariants(items: Array<{item_class: string, defindex: number, item_name: string}>, schema: ItemSchema): void {
if(!schema) return
items.filter(item =>
item.item_class != null &&
item.item_class.startsWith('tf_weapon') &&
item.item_name.startsWith('Festive ')
).forEach(festive => {
const originalName = festive.item_name.slice(8); // "Festive " is 8 chars
const original = items.find(item => item.item_name === originalName && item.defindex > 30 && (item.defindex < 15000 || item.defindex >= 16000));
if (original) {
if(schema[original.defindex]) {
schema[original.defindex].festiveVariant = festive.defindex;
}
}
});
}
export function linkBotkillerVariants(items: Array<{item_class: string, defindex: number, item_name: string, item_type_name: string}>, schema: ItemSchema): void {
if(!schema) return
items.filter(item =>
item.item_class != null &&
item.item_class.startsWith('tf_weapon') &&
item.item_name.includes('Botkiller')
).forEach(botkiller => {
const originalName = botkiller.item_type_name
const original = items.find(item => item.item_name === originalName && item.defindex > 30 && (item.defindex < 15000 || item.defindex >= 16000));
if (original) {
if(schema[original.defindex]) {
if(schema[original.defindex].botkillerVariants == null) {
// init array
schema[original.defindex].botkillerVariants = new Array<number>()
}
schema[original.defindex].botkillerVariants.push(botkiller.defindex)
}
}
});
}
export async function wipeSchema(): Promise<void> { export async function wipeSchema(): Promise<void> {
await setStorageValue(storage_version, __VERSION__) await setStorageValue(storage_version, __VERSION__)
await setStorageValue(storage_schema, null) await setStorageValue(storage_schema, null)
@@ -149,6 +190,9 @@ export async function prepareSchema(): Promise<ItemSchema> {
} }
}); });
linkFestiveVariants(responseItems, cacheItems)
linkBotkillerVariants(responseItems, cacheItems)
await setStorageValue(storage_schema, (cacheItems)); await setStorageValue(storage_schema, (cacheItems));
itemSchema = cacheItems itemSchema = cacheItems
await setStorageValue(storage_version, __VERSION__); await setStorageValue(storage_version, __VERSION__);

View File

@@ -22,5 +22,17 @@ module.exports = {
"Strange": "Strange", "Strange": "Strange",
"Collector's": "Collector's", "Collector's": "Collector's",
"Haunted": "Haunted", "Haunted": "Haunted",
"Australium": "Australium" "Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
} }

View File

@@ -22,5 +22,17 @@ module.exports = {
"Strange": "de Calidad Rara", "Strange": "de Calidad Rara",
"Collector's": "de Coleccionista", "Collector's": "de Coleccionista",
"Haunted": "de Calidad Embrujada", "Haunted": "de Calidad Embrujada",
"Australium": "de Australium" "Australium": "de Australium",
"Festive": "Festiva",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Matabots",
"Silver": "Plata",
"Gold": "Oro",
"Rust": "Oxidado",
"Blood": "Sangriento",
"Carbonado": "Carbonado",
"Diamond": "Diamante",
"Silver Mk.II": "Plata Mk.II",
"Gold Mk.II": "Oro Mk.II",
} }