commit 568b2eaabdc21f621a5d64635aff7faf83731cce Author: xenticore Date: Fri Mar 21 13:53:45 2025 -0400 Initial commit diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..79eed0a --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,21 @@ +name: CI + +on: [push, pull_request] + +jobs: + build: + runs-on: debian-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4.1.2 + - name: Install dependencies + run: bun install + - name: Build project + run: bun run build + - name: Archive production artifacts + uses: actions/upload-artifact@v3 + with: + name: tf2wikipricing.user.js + path: | + dist/userscript/tf2wikipricing.user.js \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..6acbca9 Binary files /dev/null and b/bun.lockb differ diff --git a/declarations.d.ts b/declarations.d.ts new file mode 100644 index 0000000..61b3ddb --- /dev/null +++ b/declarations.d.ts @@ -0,0 +1,2 @@ +declare module '*.css'; +declare module '*.js'; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4788d59 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "tf2wikipricing", + "version": "0.3.0-beta", + "description": "Adds item pricing to the Team Fortress 2 wiki", + "devDependencies": { + "@types/firefox-webext-browser": "^120.0.4", + "@types/greasemonkey": "^4.0.7", + "@types/html": "^1.0.4", + "browserify-fs": "^1.0.0", + "buffer": "^6.0.3", + "copy-webpack-plugin": "^12.0.2", + "path-browserify": "^1.0.1", + "raw-loader": "^4.0.2", + "tf2-static-schema": "^1.74.0", + "ts-loader": "^9.5.1", + "webpack": "^5.94.0", + "webpack-cli": "^5.1.4" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "eslintConfig": { + "env": { + "browser": true, + "webextensions": true + } + }, + "license": "unlicensed", + "scripts": { + "build": "webpack --config webpack.config.js" + }, + "dependencies": { + "@types/chrome": "^0.0.270", + "base64-inline-loader": "^2.0.1", + "css-loader": "^7.1.2", + "css-to-string-loader": "^0.1.3", + "extract-loader": "^5.1.0", + "jsonc-loader": "^0.1.1", + "style-loader": "^4.0.0", + "to-string-loader": "^1.2.0", + "url-loader": "^4.1.1" + } +} diff --git a/src/content/GM_fetch/index.js b/src/content/GM_fetch/index.js new file mode 100644 index 0000000..e2d3de4 --- /dev/null +++ b/src/content/GM_fetch/index.js @@ -0,0 +1,350 @@ +(function() { + 'use strict'; + + var Promise = window.Bluebird || window.Promise; + + if (self.GM_fetch) { + return + } + + function normalizeName(name) { + if (typeof name !== 'string') { + name = name.toString(); + } + if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) { + throw new TypeError('Invalid character in header field name') + } + return name.toLowerCase() + } + + function normalizeValue(value) { + if (typeof value !== 'string') { + value = value.toString(); + } + return value + } + + function Headers(headers) { + this.map = {} + + if (headers instanceof Headers) { + headers.forEach(function(value, name) { + this.append(name, value) + }, this) + + } else if (headers) { + Object.getOwnPropertyNames(headers).forEach(function(name) { + this.append(name, headers[name]) + }, this) + } + } + + Headers.prototype.append = function(name, value) { + name = normalizeName(name) + value = normalizeValue(value) + var list = this.map[name] + if (!list) { + list = [] + this.map[name] = list + } + list.push(value) + } + + Headers.prototype['delete'] = function(name) { + delete this.map[normalizeName(name)] + } + + Headers.prototype.get = function(name) { + var values = this.map[normalizeName(name)] + return values ? values[0] : null + } + + Headers.prototype.getAll = function(name) { + return this.map[normalizeName(name)] || [] + } + + Headers.prototype.has = function(name) { + return this.map.hasOwnProperty(normalizeName(name)) + } + + Headers.prototype.set = function(name, value) { + this.map[normalizeName(name)] = [normalizeValue(value)] + } + + Headers.prototype.forEach = function(callback, thisArg) { + Object.getOwnPropertyNames(this.map).forEach(function(name) { + this.map[name].forEach(function(value) { + callback.call(thisArg, value, name, this) + }, this) + }, this) + } + + function consumed(body) { + if (body.bodyUsed) { + return Promise.reject(new TypeError('Already read')) + } + body.bodyUsed = true + } + + function fileReaderReady(reader) { + return new Promise(function(resolve, reject) { + reader.onload = function() { + resolve(reader.result) + } + reader.onerror = function() { + reject(reader.error) + } + }) + } + + function readBlobAsArrayBuffer(blob) { + var reader = new FileReader() + reader.readAsArrayBuffer(blob) + return fileReaderReady(reader) + } + + function readBlobAsText(blob) { + var reader = new FileReader() + reader.readAsText(blob) + return fileReaderReady(reader) + } + + var support = { + blob: 'FileReader' in self && 'Blob' in self && (function() { + try { + new Blob(); + return true + } catch(e) { + return false + } + })(), + formData: 'FormData' in self + } + + function Body() { + this.bodyUsed = false + + + this._initBody = function(body) { + this._bodyInit = body + if (typeof body === 'string') { + this._bodyText = body + } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { + this._bodyBlob = body + } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { + this._bodyFormData = body + } else if (!body) { + this._bodyText = '' + } else { + throw new Error('unsupported BodyInit type') + } + } + + if (support.blob) { + this.blob = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return Promise.resolve(this._bodyBlob) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as blob') + } else { + return Promise.resolve(new Blob([this._bodyText])) + } + } + + this.arrayBuffer = function() { + return this.blob().then(readBlobAsArrayBuffer) + } + + this.text = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return readBlobAsText(this._bodyBlob) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as text') + } else { + return Promise.resolve(this._bodyText) + } + } + } else { + this.text = function() { + var rejected = consumed(this) + return rejected ? rejected : Promise.resolve(this._bodyText) + } + } + + if (support.formData) { + this.formData = function() { + return this.text().then(decode) + } + } + + this.json = function() { + return this.text().then(JSON.parse) + } + + return this + } + + // HTTP methods whose capitalization should be normalized + var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] + + function normalizeMethod(method) { + var upcased = method.toUpperCase() + return (methods.indexOf(upcased) > -1) ? upcased : method + } + + function Request(url, options) { + options = options || {} + this.url = url + + this.credentials = options.credentials || 'omit' + this.headers = new Headers(options.headers) + this.method = normalizeMethod(options.method || 'GET') + this.mode = options.mode || null + this.referrer = null + + if ((this.method === 'GET' || this.method === 'HEAD') && options.body) { + throw new TypeError('Body not allowed for GET or HEAD requests') + } + this._initBody(options.body) + } + + function decode(body) { + var form = new FormData() + body.trim().split('&').forEach(function(bytes) { + if (bytes) { + var split = bytes.split('=') + var name = split.shift().replace(/\+/g, ' ') + var value = split.join('=').replace(/\+/g, ' ') + form.append(decodeURIComponent(name), decodeURIComponent(value)) + } + }) + return form + } + + function headers(responseHeaders) { + var head = new Headers() + var pairs = responseHeaders.trim().split('\n') + pairs.forEach(function(header) { + var split = header.trim().split(':') + var key = split.shift().trim() + var value = split.join(':').trim() + head.append(key, value) + }) + return head + } + + Body.call(Request.prototype) + + function Response(bodyInit, options) { + if (!options) { + options = {} + } + + this._initBody(bodyInit) + this.type = 'default' + this.url = null + this.status = options.status + this.ok = this.status >= 200 && this.status < 300 + this.statusText = options.statusText + this.headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers) + this.url = options.url || '' + } + + Body.call(Response.prototype) + + self.Headers = Headers; + self.Request = Request; + self.Response = Response; + + self.GM_fetch = function(input, init) { + // TODO: Request constructor should accept input, init + var request + if (Request.prototype.isPrototypeOf(input) && !init) { + request = input + } else { + request = new Request(input, init) + } + + return new Promise(function(resolve, reject) { + var xhr_details = {}; + var _parsedRespHeaders; + + function responseURL(finalUrl, rawRespHeaders, respHeaders) { + if (finalUrl) { + return finalUrl; + } + + // Avoid security warnings on getResponseHeader when not allowed by CORS + if (/^X-Request-URL:/m.test(rawRespHeaders)) { + return respHeaders.get('X-Request-URL') + } + + return; + } + + xhr_details.method = request.method; + + xhr_details.url = request.url; + + xhr_details.synchronous = false; + + xhr_details.onload = function(resp) { + var status = resp.status + if (status < 100 || status > 599) { + reject(new TypeError('Network request failed')) + return + } + + var rawRespHeaders = resp.responseHeaders; + _parsedRespHeaders = headers(rawRespHeaders); + + var options = { + status: status, + statusText: resp.statusText, + headers: _parsedRespHeaders, + url: responseURL(resp.finalUrl, rawRespHeaders, _parsedRespHeaders) + } + var body = resp.responseText; + resolve(new Response(body, options)) + } + + xhr_details.onerror = function() { + reject(new TypeError('Network request failed')) + } + + xhr_details.headers = {}; + request.headers.forEach(function(value, name) { + xhr_details.headers[name] = value; + }); + + if(typeof request._bodyInit !== 'undefined') { + xhr_details.data = request._bodyInit; + } + + GM_xmlhttpRequest(xhr_details); + + /* + // need to see if there's any way of doing this + if (request.credentials === 'include') { + xhr.withCredentials = true + } + + GM_xmlhttpRequest has a responseType param, but this didn't seem to work, at least in TamperMonkey + if ('responseType' in xhr && support.blob) { + xhr.responseType = 'blob' + } + */ + }) + } + self.GM_fetch.polyfill = true +})(); \ No newline at end of file diff --git a/src/content/content.ts b/src/content/content.ts new file mode 100644 index 0000000..aa983e7 --- /dev/null +++ b/src/content/content.ts @@ -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 +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 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 // + `
$${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('%@', 'prices.tf'); + label.innerHTML = `${updateText}
${attributionText}`; + row.appendChild(label); + + priceProgressRow.insertAdjacentElement('afterend', row); + priceProgressRow.remove() + }) +} + +function createStoreButton(storeName: string, url: URL) { + const button = document.createElement("tr") + var source = `` + 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 { + 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 +}); \ No newline at end of file diff --git a/src/content/log.ts b/src/content/log.ts new file mode 100644 index 0000000..23ababe --- /dev/null +++ b/src/content/log.ts @@ -0,0 +1,20 @@ +declare var __PRODUCTION: boolean; +declare var __EXTENSION_NAME: string; +const logHeader = `[${__EXTENSION_NAME}] `; + +/** `console.debug` with header; automatically NO-OP on production build */ +function logDebug(message?: any, ...optionalParams: any[]): void { + if(process.env.NODE_ENV !== 'production') console.debug(logHeader + message, optionalParams); +} + +/** `console.log` with header */ +function log(message?: any, ...optionalParams: any[]): void { + console.log(logHeader + message, optionalParams) +} + +/** `console.error` with header */ +function logError(message?: any, ...optionalParams: any[]): void { + console.error(logHeader + message, optionalParams) +} + +export { log, logDebug, logError } \ No newline at end of file diff --git a/src/content/pricing/pricestf.ts b/src/content/pricing/pricestf.ts new file mode 100644 index 0000000..4f83674 --- /dev/null +++ b/src/content/pricing/pricestf.ts @@ -0,0 +1,67 @@ +declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise +import '../GM_fetch' + +async function getPricesToken(): Promise { + return new Promise((resolve, reject) => { + GM_fetch('https://api2.prices.tf/auth/access', { + method: 'post', + headers: new Headers({ + 'Accept': 'application/json' + }) + }) + .then((response) => { + if (response.status != 200) { + reject(response.status) + } + return response.json() + }) + .then((responseData) => resolve(responseData['accessToken'])) + }) +} + +class PricesResponse { + keys: number + metal: number +} + +/** + * Price the given item using https://prices.tf + * @return + */ +async function priceUsingPricesTF(token: string, defIndex: number, quality: number): Promise { + // prices.tf + // https://api2.prices.tf/prices/${sku} + // Authorization: Bearer ${token} + return new Promise(async (resolve, reject) => { + if (!token) { + reject(401) + } + 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)}`, { + method: 'get', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}` + }) + }) + if (response.status === 404 && quality === 6) { + // Try uncraftable variant + response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku + ';uncraftable')}`, { + method: 'get', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}` + }) + }) + } + if (response.status === 200) { + const json = await response.json() + resolve({ keys: json['sellKeys'], metal: json['sellHalfScrap'] / 18.0 }) + } else { + reject(response.status) + } + }) +} + +export { getPricesToken, priceUsingPricesTF } \ No newline at end of file diff --git a/src/content/storage.ts b/src/content/storage.ts new file mode 100644 index 0000000..d14fea8 --- /dev/null +++ b/src/content/storage.ts @@ -0,0 +1,28 @@ +declare var __ENV_USERSCRIPT: boolean; +declare var __ENV_WEBEXTENSION: boolean; + +function getStorageValue(name: string, defaultValue: string): Promise { + if(__ENV_USERSCRIPT) { + return GM.getValue(name, defaultValue); + } else if(__ENV_WEBEXTENSION) { + return browser.storage.local.get(name); + } else { + return new Promise((resolve) => { + resolve(defaultValue); + }); + } +} + +function setStorageValue(name: string, value: any): Promise { + if(__ENV_USERSCRIPT) { + return GM.setValue(name, value); + } else if(__ENV_WEBEXTENSION) { + return browser.storage.local.set({name, value}); + } else { + return new Promise((resolve, reject) => { + reject(); + }); + } +} + +export { getStorageValue, setStorageValue } diff --git a/src/content/style.css b/src/content/style.css new file mode 100644 index 0000000..c86c29d --- /dev/null +++ b/src/content/style.css @@ -0,0 +1,26 @@ +[class^="btn_buynow_addon_"] { + padding: 6px 4px 4px 28px; + background-position: right 0; + position: relative; + display: block; + left: -1px; + height: 14px; +} +[class^="btn_buynow_addon_"]:hover { + background-position: 0 -24px; +} +[class^="btn_buynow_addon_"], [class^="btn_buynow_addon_"] span { + background: url('../resources/btn_generic.png') no-repeat; + color: #FFF; + line-height: 100%; + font-size: 90%; +} +.btn_buynow_addon_backpacktf, .btn_buynow_addon_backpacktf span { + background: url('../resources/btn_backpacktf.png') no-repeat; +} +.btn_buynow_addon_manncostore, .btn_buynow_addon_manncostore span { + background: url('../resources/btn_manncostore.png') no-repeat; +} +.btn_buynow_addon_stntradingeu, .btn_buynow_addon_stntradingeu span { + background: url('../resources/btn_stntradingeu.png') no-repeat; +} \ No newline at end of file diff --git a/src/icons/icon-48.png b/src/icons/icon-48.png new file mode 100644 index 0000000..425ceb8 Binary files /dev/null and b/src/icons/icon-48.png differ diff --git a/src/icons/icon-96.png b/src/icons/icon-96.png new file mode 100644 index 0000000..1f4fc6e Binary files /dev/null and b/src/icons/icon-96.png differ diff --git a/src/manifest.json b/src/manifest.json new file mode 100755 index 0000000..09c8e80 --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,19 @@ +{ + "name": EXTENSION_NAME, + "description": EXTENSION_DESCRIPTION, + "author": EXTENSION_AUTHOR, + "manifest_version": 3, + "version": EXTENSION_VERSION, + "content_scripts": [ + { + "matches": ["*://wiki.teamfortress.com/wiki/*"], + "run_at": "document_start", + "all_frames": true, + "js": ["content/content.js"] + } + ], + "icons": { + "48": "icons/icon-48.png", + "96": "icons/icon-96.png" + } +} diff --git a/src/resources/box-solid.svg b/src/resources/box-solid.svg new file mode 100644 index 0000000..520df8c --- /dev/null +++ b/src/resources/box-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/resources/btn_backpacktf.png b/src/resources/btn_backpacktf.png new file mode 100644 index 0000000..29a20ed Binary files /dev/null and b/src/resources/btn_backpacktf.png differ diff --git a/src/resources/btn_generic.png b/src/resources/btn_generic.png new file mode 100644 index 0000000..c23441d Binary files /dev/null and b/src/resources/btn_generic.png differ diff --git a/src/resources/btn_manncostore.png b/src/resources/btn_manncostore.png new file mode 100644 index 0000000..3da0428 Binary files /dev/null and b/src/resources/btn_manncostore.png differ diff --git a/src/resources/btn_stntradingeu.png b/src/resources/btn_stntradingeu.png new file mode 100644 index 0000000..362885e Binary files /dev/null and b/src/resources/btn_stntradingeu.png differ diff --git a/src/resources/square-steam-brands-solid.svg b/src/resources/square-steam-brands-solid.svg new file mode 100644 index 0000000..768683d --- /dev/null +++ b/src/resources/square-steam-brands-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/strings/en.js b/src/strings/en.js new file mode 100644 index 0000000..36dd116 --- /dev/null +++ b/src/strings/en.js @@ -0,0 +1,24 @@ +module.exports = { + // Generic button text, %@ is always a URL (eg. backpack.tf) + "View listings on %@": "View listings on %@", + + // Itembox header + "Community Pricing": "Community Pricing", + // Itembox footer + "Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot + "Trade prices sourced from %@. Currency conversions are approximate.": "Trade prices sourced from %@. Currency conversions are approximate.", // %@ is always a URL, (eg. prices.tf) + + // Price strings + "Data unavailable": "Data unavailable", // sourced from AppleGlot + "%@ key": "%@ key", + "%@ keys": "%@ keys", + + // Item quality names, all sourced from TF2 wiki + "Normal": "Normal", + "Genuine": "Genuine", + "Vintage": "Vintage", + "Unique": "Unique", + "Strange": "Strange", + "Collector's": "Collector's", + "Haunted": "Haunted" +} \ No newline at end of file diff --git a/src/strings/es.js b/src/strings/es.js new file mode 100644 index 0000000..788f147 --- /dev/null +++ b/src/strings/es.js @@ -0,0 +1,24 @@ +module.exports = { + // Generic button text, %@ is always a URL (eg. backpack.tf) + "View listings on %@": "Ver ofertas en %@", + + // Itembox header + "Community Pricing": "Precios de la comunidad", + // Itembox footer + "Updated %@.": "Actualizado %@.", // %@ is a date string, sourced from AppleGlot + "Trade prices sourced from %@. Currency conversions are approximate.": "Precios comerciales obtenidos de %@. Las conversiones de divisas son aproximadas.", // %@ is always a URL, (eg. prices.tf) + + // Price strings + "Data unavailable": "Datos no disponibles", // sourced from AppleGlot + "%@ key": "%@ llave", + "%@ keys": "%@ llaves", + + // Item quality names, all sourced from TF2 wiki + "Normal": "de Calidad Normal", + "Genuine": "de Calidad Genuina", + "Vintage": "de Calidad Clásica", + "Unique": "de Calidad Única", + "Strange": "de Calidad Rara", + "Collector's": "de Coleccionista", + "Haunted": "de Calidad Embrujada" +} \ No newline at end of file diff --git a/src/strings/it.js b/src/strings/it.js new file mode 100644 index 0000000..d4a3463 --- /dev/null +++ b/src/strings/it.js @@ -0,0 +1,16 @@ +module.exports = { + "View listings on %@": "Voir les offres sur %@", + "Community Pricing": "Community Pricing", + "Updated %@": "Updated %@", + "From %@": "From %@", + "Data unavailable": "Data unavailable", + "%@ key": "%@ key", + "%@ keys": "%@ keys", + "Normal": "Normale", + "Genuine": "Autentico", + "Vintage": "Vintage", + "Unique": "Unico", + "Strange": "Strano", + "Collector's": "Da collezione", + "Haunted": "Stregato" +} \ No newline at end of file diff --git a/src/strings/ja.js b/src/strings/ja.js new file mode 100644 index 0000000..43315d1 --- /dev/null +++ b/src/strings/ja.js @@ -0,0 +1,24 @@ +module.exports = { + // Generic button text, %@ is always a URL (eg. backpack.tf) + "View listings on %@": "%@で検索結果を見る", + + // Itembox header + "Community Pricing": "Community Pricing", + // Itembox footer + "Updated %@.": "アップデート: %@。", // %@ is a date string, sourced from AppleGlot + "Trade prices sourced from %@. Currency conversions are approximate.": "Trade prices sourced from %@. Currency conversions are approximate.", // %@ is always a URL, (eg. prices.tf) + + // Price strings + "Data unavailable": "データがありません", // sourced from AppleGlot + "%@ key": "%@ key", + "%@ keys": "%@ keys", + + // Item quality names, all sourced from TF2 wiki + "Normal": "ノーマル", + "Genuine": "ジェニュイン", + "Vintage": "ビンテージ", + "Unique": "専用", + "Strange": "ストレンジ", + "Collector's": "Collector's", + "Haunted": "Haunted" +} \ No newline at end of file diff --git a/src/userscript_header.js b/src/userscript_header.js new file mode 100644 index 0000000..f8f443a --- /dev/null +++ b/src/userscript_header.js @@ -0,0 +1,18 @@ +// ==UserScript== +// @name EXTENSION_NAME +// @description EXTENSION_DESCRIPTION +// @version EXTENSION_VERSION +// @match *://wiki.teamfortress.com/wiki/* +// @run-at document-start +// @inject-into content +// @connect steamcommunity.com +// @domain steamcommunity.com +// @connect prices.tf +// @domain prices.tf +// @grant GM.setValue +// @grant GM_setValue +// @grant GM.getValue +// @grant GM_getValue +// @grant GM.xmlhttpRequest +// @grant GM_xmlhttpRequest +// ==/UserScript== \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..92c656e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "outDir": "./dist/", + "noImplicitAny": true, + "module": "es6", + "target": "es6", + "jsx": "react", + "lib": ["dom", "es2021"], + "allowJs": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100755 index 0000000..5e87d44 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,120 @@ +var path = require('path'); +var CopyPlugin = require('copy-webpack-plugin'); +var webpack = require('webpack'); +var fs = require('fs'); +var package = require('./package.json'); + +function allReplace(str, obj, quote = true) { + for (const x in obj) { + str = str.replace(new RegExp(x, 'g'), quote ? `"${obj[x]}"` : obj[x]); + } + return str; +}; + +const defines = { + EXTENSION_NAME: package.name, + __EXTENSION_NAME: JSON.stringify(package.name), + EXTENSION_AUTHOR: package.author, + EXTENSION_DESCRIPTION: package.description, + EXTENSION_VERSION: package.version, + __VERSION__: JSON.stringify(package.version), +} + +module.exports = [ + /* + // WebExtension + { + entry: { + content: './src/content/content.ts' + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.(png|jpg|gif|svg)$/i, + use: [ + { + loader: 'url-loader', + options: { + limit: true, + }, + }, + ], + }, + ], + }, + optimization: { + minimize: true + }, + output: { + path: path.resolve(__dirname, "dist/extension"), + filename: "[name]/[name].js" + }, + resolve: { + extensions: [".ts", ".tsx", ".js", ".json", ".css"] + }, + plugins: [ + new webpack.DefinePlugin({__ENV_WEBEXTENSION: true, __ENV_USERSCRIPT: false}), + new CopyPlugin({ patterns: [ + { from: './src/manifest.json', to: 'manifest.json', + transform(content, absoluteFrom) { + return allReplace(content.toString(), defines) + }, + } + ]}), + new CopyPlugin({ patterns: [ + { from: './src/icons', to: 'icons/[file]'}, + ]}), + ], + }, + */ + // Userscript + { + entry: { + content: './src/content/content.ts' + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.css$/i, + use: ['style-loader', 'css-loader'] + }, + { + test: /\.(jpe?g|png|ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/, + type: 'asset/inline' + }, + ], + }, + optimization: { + minimize: true, + }, + devtool: false, + output: { + path: path.resolve(__dirname, 'dist/userscript'), + filename: `${package.name}.user.js` + }, + resolve: { + extensions: [".ts", ".tsx", ".js", ".json", ".css"] + }, + plugins: [ + new webpack.DefinePlugin({ ...defines, __ENV_USERSCRIPT: true, __ENV_WEBEXTENSION: false }), + new webpack.BannerPlugin({ + raw: true, + banner: () => { + const header = fs.readFileSync(path.resolve(__dirname, 'src/userscript_header.js'), 'utf8') + return allReplace(header, defines, false) + }, + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT + }), + ], + } +];