You've already forked tf2wikipricing
bump version to 0.3.1
Reviewed-on: http://charon.local/git/xen/tf2wikipricing/pulls/10
This commit is contained in:
@@ -7,6 +7,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- develop
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
@@ -17,10 +18,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4.1.2
|
uses: actions/checkout@v4.1.2
|
||||||
|
- name: Read package version
|
||||||
|
uses: tyankatsu0105/read-package-version-actions@v1
|
||||||
|
id: version
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
- name: Test project
|
- name: Test project
|
||||||
run: bun test
|
run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"'
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: bun run build
|
run: bun run build
|
||||||
- name: Archive production artifacts
|
- name: Archive production artifacts
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Test project
|
- name: Test project
|
||||||
run: bun test
|
run: bun test
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: bun run build
|
run: bun run build --mode production
|
||||||
- name: Archive production artifacts
|
- name: Archive production artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
49
__tests__/formatting.test.ts
Normal file
49
__tests__/formatting.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, test, jest, mock, beforeEach } from "bun:test";
|
||||||
|
import { formatPrice } from '../src/content/utils/formatting';
|
||||||
|
import { $T } from '../src/content/utils/localization';
|
||||||
|
|
||||||
|
mock.module('../src/content/utils/localization', () => ({
|
||||||
|
$T: mock((str) => str)
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../src/content/config', () => ({
|
||||||
|
conversion_ref_usd: 0.05 // Mock conversion rate
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('formatPrice', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats price with keys and metal', () => {
|
||||||
|
expect(formatPrice(2, 10, 50)).toBe('2.2 keys (US$5.50)');
|
||||||
|
expect($T).toHaveBeenCalledWith('%@ keys');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats price with metal only', () => {
|
||||||
|
expect(formatPrice(0, 15.75, 50)).toBe('15.75 ref (US$0.79)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats price with metal only and whole number', () => {
|
||||||
|
expect(formatPrice(0, 3, 50)).toBe('3 ref (US$0.16)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses singular key form', () => {
|
||||||
|
expect(formatPrice(1, 0, 50)).toBe('1 key (US$2.50)');
|
||||||
|
expect($T).toHaveBeenCalledWith('%@ key');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rounds USD up to nearest cent', () => {
|
||||||
|
expect(formatPrice(3, 7.33, 35)).toBe('3.21 keys (US$5.62)'); // (3 * 35 + 7.33) * 0.05 = 5.6165 → 5.62
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles different locale formatting', () => {
|
||||||
|
expect(formatPrice(2, 10, 50, 'de')).toMatch(/2,2 keys \(US\$5,50\)/);
|
||||||
|
expect(formatPrice(0, 15.75, 50, 'de')).toMatch(/15,75 ref \(US\$0,79\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles zero values', () => {
|
||||||
|
expect(formatPrice(0, 0, 50)).toBe('0 ref (US$0.00)');
|
||||||
|
expect(formatPrice(0, 0, 50, 'de')).toMatch(/0 ref \(US\$0,00\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
__tests__/localization.test.ts
Normal file
20
__tests__/localization.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect, jest, beforeEach } from "bun:test";
|
||||||
|
import { extractLocaleFromURL } from '../src/content/utils/localization'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('localization', () => {
|
||||||
|
it('should assume `en` if no locale is specified', async () => {
|
||||||
|
expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Team_Fortress_2')).toBe('en')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract locale shortcode from URL correctly', async () => {
|
||||||
|
expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Phlogistinator/de')).toBe('de')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract locale shortcode with special characters from URL correctly', async () => {
|
||||||
|
expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/%C3%9CberCharge/zh-hans')).toBe('zh-hans')
|
||||||
|
})
|
||||||
|
})
|
||||||
109
__tests__/priceService.test.ts
Normal file
109
__tests__/priceService.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, test, jest, mock, beforeEach } from "bun:test";
|
||||||
|
import { ItemPriceData, fetchPrice, fetchKeyPrice } from '../src/content/priceService'
|
||||||
|
import { PricesResponse, priceUsingPricesTF } from '../src/content/pricing/pricestf'
|
||||||
|
import { getStorageValue, setStorageValue } from '../src/content/storage'
|
||||||
|
import { defindex_key } from "../src/content/config";
|
||||||
|
|
||||||
|
// Mock the storage module
|
||||||
|
mock.module('../src/content/storage', () => ({
|
||||||
|
getStorageValue: jest.fn(),
|
||||||
|
setStorageValue: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the pricing module
|
||||||
|
mock.module('../src/content/pricing/pricestf', () => ({
|
||||||
|
priceUsingPricesTF: jest.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Price Service', () => {
|
||||||
|
const mockToken = 'test-token'
|
||||||
|
const mockDefIndex = 105 // Brigade Helm
|
||||||
|
const mockQuality = 11 // Strange
|
||||||
|
const mockSku = `${mockDefIndex};${mockQuality}`
|
||||||
|
const mockDate = new Date()
|
||||||
|
const mockTtl = 30 * 60 * 1000 // 30 minutes
|
||||||
|
|
||||||
|
const mockPriceResponse: PricesResponse = {
|
||||||
|
keys: 1,
|
||||||
|
metal: 21.33
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockKeyPriceResponse: PricesResponse = {
|
||||||
|
keys: 0,
|
||||||
|
metal: 60.11
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCachedData: ItemPriceData = {
|
||||||
|
sku: mockSku,
|
||||||
|
update: new Date(Date.now() - 15 * 60 * 1000), // 15 minutes ago
|
||||||
|
ttl: mockTtl,
|
||||||
|
keys: 1,
|
||||||
|
metal: 21.11,
|
||||||
|
scmPrice: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetchPrice returns cached data if available and not expired', async () => {
|
||||||
|
(getStorageValue as jest.Mock).mockResolvedValue(mockCachedData)
|
||||||
|
|
||||||
|
const result = await fetchPrice(mockToken, mockDefIndex, mockQuality)
|
||||||
|
|
||||||
|
expect(getStorageValue).toHaveBeenCalledWith(expect.stringContaining(mockSku), null)
|
||||||
|
expect(result).toEqual(mockCachedData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetchPrice fetches new data when cache is expired', async () => {
|
||||||
|
const expiredCache: ItemPriceData = { ...mockCachedData, update: new Date(Date.now() - 2 * mockTtl) };
|
||||||
|
(getStorageValue as jest.Mock).mockResolvedValue(expiredCache);
|
||||||
|
(priceUsingPricesTF as jest.Mock).mockResolvedValue(mockPriceResponse)
|
||||||
|
|
||||||
|
const result = await fetchPrice(mockToken, mockDefIndex, mockQuality)
|
||||||
|
|
||||||
|
expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, mockDefIndex, mockQuality)
|
||||||
|
expect(setStorageValue).toHaveBeenCalled()
|
||||||
|
expect(result.metal).not.toBe(mockCachedData.metal)
|
||||||
|
expect(result.metal).toBe(mockPriceResponse.metal)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetchPrice rejects with 401 when no token provided', async () => {
|
||||||
|
await expect(fetchPrice('', mockDefIndex, mockQuality)).rejects.toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetchPrice handles pricing API errors', async () => {
|
||||||
|
const testError = 500;
|
||||||
|
(priceUsingPricesTF as jest.Mock).mockRejectedValue(testError);
|
||||||
|
(getStorageValue as jest.Mock).mockResolvedValue(null)
|
||||||
|
|
||||||
|
await expect(fetchPrice(mockToken, mockDefIndex, mockQuality)).rejects.toBe(testError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetchKeyPrice uses correct parameters', async () => {
|
||||||
|
(getStorageValue as jest.Mock).mockResolvedValue(null);
|
||||||
|
(priceUsingPricesTF as jest.Mock).mockResolvedValue(mockKeyPriceResponse)
|
||||||
|
|
||||||
|
const result = await fetchKeyPrice(mockToken)
|
||||||
|
|
||||||
|
expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, defindex_key, 6)
|
||||||
|
expect(result.keys).toBe(0) // A key cannot cost a key :P
|
||||||
|
expect(result.metal).toBe(mockKeyPriceResponse.metal)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ItemPriceData.toString() returns formatted string', () => {
|
||||||
|
const data = new ItemPriceData()
|
||||||
|
data.sku = mockSku
|
||||||
|
data.update = mockDate
|
||||||
|
data.ttl = mockTtl
|
||||||
|
data.keys = 1
|
||||||
|
data.metal = 10.66
|
||||||
|
data.scmPrice = 2.68
|
||||||
|
|
||||||
|
const result = data.toString()
|
||||||
|
expect(result).toContain(`Price for ${mockSku}`)
|
||||||
|
expect(result).toContain(`"keys":${data.keys}`)
|
||||||
|
expect(result).toContain(`"metal":${data.metal}`)
|
||||||
|
expect(result).toContain(`"scmPrice":${data.scmPrice}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
33
__tests__/schema.test.ts
Normal file
33
__tests__/schema.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { ItemSchema, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName} from '../src/content/schemaService'
|
||||||
|
|
||||||
|
const mockSchema: ItemSchema = {
|
||||||
|
'21': { name: 'Flame Thrower', tradable: false },
|
||||||
|
'208': { name: 'Flame Thrower', tradable: true },
|
||||||
|
'5021': { name: 'Mann Co. Supply Crate Key', tradable: true },
|
||||||
|
'15141': { name: 'Flame Thrower', tradable: true },
|
||||||
|
'69420': { name: 'Non-Tradable Item', tradable: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Schema Service', () => {
|
||||||
|
|
||||||
|
test('getItemIndexByName returns correct defindex', () => {
|
||||||
|
expect(getItemIndexByName(mockSchema, 'Flame Thrower')).toBe(208)
|
||||||
|
expect(getItemIndexByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(5021)
|
||||||
|
expect(getItemIndexByName(mockSchema, 'Non-Existent Item')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getTradableStatusByDefindex returns correct status', () => {
|
||||||
|
expect(getTradableStatusByDefindex(mockSchema, 208)).toBe(true)
|
||||||
|
expect(getTradableStatusByDefindex(mockSchema, 5021)).toBe(true)
|
||||||
|
expect(getTradableStatusByDefindex(mockSchema, 69420)).toBe(false)
|
||||||
|
expect(() => getTradableStatusByDefindex(mockSchema, 999)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getTradableStatusByName returns correct status', () => {
|
||||||
|
expect(getTradableStatusByName(mockSchema, 'Flame Thrower')).toBe(true)
|
||||||
|
expect(getTradableStatusByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(true)
|
||||||
|
expect(getTradableStatusByName(mockSchema, 'Non-Tradable Item')).toBe(false)
|
||||||
|
expect(getTradableStatusByName(mockSchema, 'Non-Existent Item')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
28
__tests__/url.test.ts
Normal file
28
__tests__/url.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { extractPageTitleFromURL } from '../src/content/utils/url';
|
||||||
|
|
||||||
|
describe('extractPageTitleFromURL', () => {
|
||||||
|
it('extracts simple title from URL', () => {
|
||||||
|
const url = 'https://wiki.teamfortress.com/wiki/Scattergun';
|
||||||
|
expect(extractPageTitleFromURL(url)).toBe('Scattergun');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces underscores with spaces', () => {
|
||||||
|
const url = 'https://wiki.teamfortress.com/wiki/Flame_Thrower';
|
||||||
|
expect(extractPageTitleFromURL(url)).toBe('Flame Thrower');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes URI components', () => {
|
||||||
|
const url = 'https://wiki.teamfortress.com/wiki/Dragon%27s_Fury';
|
||||||
|
expect(extractPageTitleFromURL(url)).toBe("Dragon's Fury");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters', () => {
|
||||||
|
const url = 'https://wiki.teamfortress.com/wiki/Ze_%C3%9Cbermensch';
|
||||||
|
expect(extractPageTitleFromURL(url)).toBe("Ze Übermensch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes language suffix', () => {
|
||||||
|
const url = 'https://wiki.teamfortress.com/wiki/Ze_%C3%9Cbermensch/pt-br';
|
||||||
|
expect(extractPageTitleFromURL(url)).toBe('Ze Übermensch');
|
||||||
|
});
|
||||||
|
});
|
||||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = "./happydom.ts"
|
||||||
3
happydom.ts
Normal file
3
happydom.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||||
|
|
||||||
|
GlobalRegistrator.register();
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "tf2wikipricing",
|
"name": "tf2wikipricing",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"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",
|
||||||
"@types/firefox-webext-browser": "^120.0.4",
|
"@types/firefox-webext-browser": "^120.0.4",
|
||||||
"@types/greasemonkey": "^4.0.7",
|
"@types/greasemonkey": "^4.0.7",
|
||||||
"@types/html": "^1.0.4",
|
"@types/html": "^1.0.4",
|
||||||
|
|||||||
12
src/content/config.ts
Normal file
12
src/content/config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Constants
|
||||||
|
export const storage_lastUpdateTime = 'tf2wikipricing_lastUpdate';
|
||||||
|
export const storage_schema = 'tf2wikipricing_schema';
|
||||||
|
export const storage_version = 'tf2wikipricing_version';
|
||||||
|
export const storage_priceprefix = 'tf2wikipricing_sku_';
|
||||||
|
export const conversion_ref_usd = 0.0265;
|
||||||
|
export const defindex_key = 5021;
|
||||||
|
export const defindex_metal_refined = 5002;
|
||||||
|
export const defindex_metal_reclaimed = 5001;
|
||||||
|
export const defindex_metal_scrap = 5000;
|
||||||
|
|
||||||
|
export * as config from './config';
|
||||||
@@ -1,57 +1,17 @@
|
|||||||
import { getStorageValue, setStorageValue } from './storage'
|
|
||||||
import styleCss from './style.css'
|
import styleCss from './style.css'
|
||||||
|
|
||||||
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
|
import { logDebug, log, logError } from './utils/log'
|
||||||
import './GM_fetch'
|
|
||||||
import { logDebug, log, logError } from './log'
|
|
||||||
import { getPricesToken, priceUsingPricesTF } from './pricing/pricestf'
|
import { getPricesToken, priceUsingPricesTF } from './pricing/pricestf'
|
||||||
const semver = require('semver')
|
|
||||||
// Globals
|
|
||||||
import itemQualities from 'tf2-static-schema/static/qualities.json';
|
import itemQualities from 'tf2-static-schema/static/qualities.json';
|
||||||
var itemSchema: { [key: string]: {name: string, tradable: Boolean}; } | null;
|
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'
|
||||||
|
import { extractPageTitleFromURL } from './utils/url';
|
||||||
|
var itemSchema: ItemSchema | null;
|
||||||
|
|
||||||
declare const __VERSION__: string;
|
var pageLocale: string = 'en'
|
||||||
|
|
||||||
// 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. */
|
/** Exclude these from the pricelist. */
|
||||||
const excludedQualities = new Set([
|
const excludedQualities = new Set([
|
||||||
@@ -59,99 +19,11 @@ const excludedQualities = new Set([
|
|||||||
5, // Unusual
|
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
|
// 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) {
|
function getKeyByValue(object: any, value: string) {
|
||||||
return Object.keys(object).find(key => object[key] === value);
|
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
|
// Main function
|
||||||
async function inject() {
|
async function inject() {
|
||||||
const itemInfobox = findFirstElement('.item-infobox');
|
const itemInfobox = findFirstElement('.item-infobox');
|
||||||
@@ -163,24 +35,10 @@ async function inject() {
|
|||||||
var itemIndex: number | null = null;
|
var itemIndex: number | null = null;
|
||||||
var itemName: string | null = null;
|
var itemName: string | null = null;
|
||||||
|
|
||||||
// Try using buy buttons, if they exist
|
// Find buy buttons
|
||||||
const buyButton = findFirstChildElement('.btn_buynow', itemInfobox);
|
const buyButton = findFirstChildElement('.btn_buynow', itemInfobox);
|
||||||
const marketButton = findFirstChildElement('.btn_buynow_market', 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
|
// Try using item name
|
||||||
const header = findFirstChildElement('.infobox-header', itemInfobox);
|
const header = findFirstChildElement('.infobox-header', itemInfobox);
|
||||||
if (!itemIndex && header) {
|
if (!itemIndex && header) {
|
||||||
@@ -196,7 +54,24 @@ async function inject() {
|
|||||||
const url = document.URL;
|
const url = document.URL;
|
||||||
|
|
||||||
if (itemName && !itemIndex) {
|
if (itemName && !itemIndex) {
|
||||||
itemIndex = getItemIndexByName(itemName)
|
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:', ''));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +88,7 @@ async function inject() {
|
|||||||
|
|
||||||
log(`Starting lookup for ${itemName} (defindex ${itemIndex})`);
|
log(`Starting lookup for ${itemName} (defindex ${itemIndex})`);
|
||||||
|
|
||||||
if(getTradableStatusByDefindex(itemIndex) == false) {
|
if(getTradableStatusByDefindex(itemSchema, itemIndex) == false) {
|
||||||
log(`${itemName} is not tradable, exiting`)
|
log(`${itemName} is not tradable, exiting`)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -329,45 +204,6 @@ async function inject() {
|
|||||||
const qualifiedName = ((quality != 6 ? itemQualities[quality as unknown as keyof typeof itemQualities].toString() : '') + ' ' + itemName).trim()
|
const qualifiedName = ((quality != 6 ? itemQualities[quality as unknown as keyof typeof itemQualities].toString() : '') + ' ' + itemName).trim()
|
||||||
// logDebug(`Fetching price for ${qualifiedName}`)
|
// 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
|
var data: ItemPriceData | null
|
||||||
try {
|
try {
|
||||||
data = await fetchPrice(token, itemIndex, quality, currentTime);
|
data = await fetchPrice(token, itemIndex, quality, currentTime);
|
||||||
@@ -376,24 +212,8 @@ async function inject() {
|
|||||||
log(`${qualifiedName} is unpriced or unavailable, skipping...`)
|
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()
|
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}`
|
const priceRow = createPriceRow(qualityName, data, keyPrice, 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})
|
priceRows.push({quality: quality, row: priceRow})
|
||||||
})
|
})
|
||||||
@@ -428,170 +248,6 @@ async function inject() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function addStyles() {
|
||||||
const head = document.head || document.getElementsByTagName('head')[0],
|
const head = document.head || document.getElementsByTagName('head')[0],
|
||||||
style = document.createElement('style');
|
style = document.createElement('style');
|
||||||
@@ -599,9 +255,11 @@ function addStyles() {
|
|||||||
style.innerHTML = styleCss;
|
style.innerHTML = styleCss;
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareSchema().then(function () {
|
prepareSchema().then(function (schema) {
|
||||||
|
itemSchema = schema;
|
||||||
if (!itemSchema) {
|
if (!itemSchema) {
|
||||||
logError("No item schema ready, exiting.");
|
logError("No item schema ready, exiting.");
|
||||||
|
wipeSchema(); // FIXME: ugly hack. requires additional page reload. if prepareSchema returns null, we should handle it properly
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pageLocale = extractLocaleFromURL(document.URL)
|
pageLocale = extractLocaleFromURL(document.URL)
|
||||||
|
|||||||
81
src/content/priceService.ts
Normal file
81
src/content/priceService.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { defindex_key, storage_priceprefix } from "./config"
|
||||||
|
import { priceUsingPricesTF } from "./pricing/pricestf"
|
||||||
|
import { getStorageValue, setStorageValue } from "./storage"
|
||||||
|
import { logDebug, log } from "./utils/log"
|
||||||
|
|
||||||
|
/** Pricing data for a given TF2 item. */
|
||||||
|
export 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export 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.
|
||||||
|
*/
|
||||||
|
export 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -25,8 +25,17 @@ class PricesResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Price the given item using https://prices.tf
|
* Fetches the current price data for Team Fortress 2 items from prices.tf.
|
||||||
* @return
|
*
|
||||||
|
* This function authenticates with the prices.tf API using the provided token,
|
||||||
|
* and uses it to fetch the latest pricing data for the given item in keys and metal.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const price = await priceUsingPricesTF(token, 105, 11);
|
||||||
|
* console.log("Strange Brigade Helm price: ${price.keys} keys ${price.metal} metal")
|
||||||
|
*
|
||||||
|
* @returns {Promise<PricesResponse>} Object containing 'keys' and 'metal' prices
|
||||||
|
* @throws When authentication fails or API returns non-200 status code
|
||||||
*/
|
*/
|
||||||
async function priceUsingPricesTF(token: string, defIndex: number, quality: number): Promise<PricesResponse> {
|
async function priceUsingPricesTF(token: string, defIndex: number, quality: number): Promise<PricesResponse> {
|
||||||
// prices.tf
|
// prices.tf
|
||||||
@@ -37,7 +46,6 @@ async function priceUsingPricesTF(token: string, defIndex: number, quality: numb
|
|||||||
reject(401)
|
reject(401)
|
||||||
}
|
}
|
||||||
const sku = defIndex + ";" + quality;
|
const sku = defIndex + ";" + quality;
|
||||||
// logDebug(`Making network request to prices.tf for ${sku}`)
|
|
||||||
var response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, {
|
var response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
@@ -64,4 +72,4 @@ async function priceUsingPricesTF(token: string, defIndex: number, quality: numb
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getPricesToken, priceUsingPricesTF }
|
export { getPricesToken, priceUsingPricesTF, PricesResponse }
|
||||||
115
src/content/schemaService.ts
Normal file
115
src/content/schemaService.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { getStorageValue, setStorageValue } from './storage'
|
||||||
|
import { logDebug, log, logError } from './utils/log'
|
||||||
|
import './config'
|
||||||
|
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
|
||||||
|
import './GM_fetch'
|
||||||
|
import { storage_version, storage_schema, storage_lastUpdateTime } from './config'
|
||||||
|
const semver = require('semver')
|
||||||
|
|
||||||
|
export declare const __VERSION__: string;
|
||||||
|
|
||||||
|
function isDateAfterOneDay(date1: Date, date2: Date): boolean {
|
||||||
|
var diff = date2.getTime() - date1.getTime();
|
||||||
|
var diffDays = Math.round(diff / (1000 * 3600 * 24));
|
||||||
|
return diffDays > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ItemSchema { [key: string]: {name: string, tradable: Boolean}; }
|
||||||
|
|
||||||
|
export function getItemIndexByName(schema: ItemSchema, name: string, excludeStock: Boolean = true, excludeDecorated: Boolean = true) {
|
||||||
|
for (const [defindex, value] of Object.entries(schema)) {
|
||||||
|
if (value['name'] == name) {
|
||||||
|
const index = parseInt(defindex)
|
||||||
|
if(excludeStock && index <= 30) continue
|
||||||
|
if(excludeDecorated && (index >= 15000 && index < 16000)) continue
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTradableStatusByDefindex(schema: ItemSchema, defindex: number) {
|
||||||
|
return schema[defindex.toString()].tradable
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTradableStatusByName(schema: ItemSchema, name: string, excludeStock: Boolean = true, excludeDecorated = true,) {
|
||||||
|
for (const [defindex, value] of Object.entries(schema)) {
|
||||||
|
if (value['name'] == name) {
|
||||||
|
const index = parseInt(defindex)
|
||||||
|
if(excludeStock && index <= 30) continue
|
||||||
|
if(excludeDecorated && (index >= 15000 && index < 16000)) continue
|
||||||
|
return value.tradable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wipeSchema(): Promise<void> {
|
||||||
|
await setStorageValue(storage_version, __VERSION__)
|
||||||
|
await setStorageValue(storage_schema, null)
|
||||||
|
await setStorageValue(storage_lastUpdateTime, new Date().toISOString())
|
||||||
|
logDebug(`Schema wiped`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareSchema(): Promise<ItemSchema> {
|
||||||
|
var needsUpdate: Boolean = false
|
||||||
|
var itemSchema: ItemSchema | null = null
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const lastUpdateTime = new Date(update);
|
||||||
|
log(`Item schema updated at ${lastUpdateTime}`);
|
||||||
|
if (itemSchema == null || Object.keys(itemSchema).length === 0 || 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return itemSchema
|
||||||
|
}
|
||||||
33
src/content/uiRenderer.ts
Normal file
33
src/content/uiRenderer.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ItemPriceData } from "./priceService";
|
||||||
|
import { formatPrice } from "./utils/formatting";
|
||||||
|
import { $T } from "./utils/localization";
|
||||||
|
|
||||||
|
export function createPriceRow(qualityName: string, data: ItemPriceData, keyPrice: ItemPriceData, locale: string): HTMLTableRowElement {
|
||||||
|
const priceRow = document.createElement("tr");
|
||||||
|
|
||||||
|
const priceLabel = document.createElement("td");
|
||||||
|
priceLabel.className = "infobox-label";
|
||||||
|
const priceLabelLink = document.createElement("a");
|
||||||
|
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, locale).trim() : $T('Data unavailable')
|
||||||
|
priceLink.innerHTML = priceString // + `<br>$${data.scmPrice}`
|
||||||
|
priceData.appendChild(priceLink);
|
||||||
|
priceRow.appendChild(priceData);
|
||||||
|
return priceRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
9
src/content/utils/dom.ts
Normal file
9
src/content/utils/dom.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function findFirstElement(selector: string): HTMLElement | null {
|
||||||
|
const elements = document.querySelectorAll(selector);
|
||||||
|
return elements.length > 0 ? elements[0] as HTMLElement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFirstChildElement(selector: string, root: Element): HTMLElement | null {
|
||||||
|
const elements = root.querySelectorAll(selector);
|
||||||
|
return elements.length > 0 ? elements[0] as HTMLElement : null;
|
||||||
|
}
|
||||||
28
src/content/utils/formatting.ts
Normal file
28
src/content/utils/formatting.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { conversion_ref_usd } from '../config';
|
||||||
|
import { $T } from './localization'
|
||||||
|
|
||||||
|
function toFixed(num: number, fixed: number) {
|
||||||
|
var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?');
|
||||||
|
return num.toString().match(re)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') {
|
||||||
|
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(locale))
|
||||||
|
} else {
|
||||||
|
output += `${(+toFixed(metal, 2)).toLocaleString(locale)} ref`
|
||||||
|
}
|
||||||
|
const currencyFormatter = new Intl.NumberFormat(locale, {
|
||||||
|
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;
|
||||||
|
}
|
||||||
40
src/content/utils/localization.ts
Normal file
40
src/content/utils/localization.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/content/utils/url.ts
Normal file
8
src/content/utils/url.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export 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('_', ' '));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user