Initial commit
21
.gitea/workflows/ci.yaml
Normal file
@@ -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
|
||||||
175
.gitignore
vendored
Normal file
@@ -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
|
||||||
2
declarations.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare module '*.css';
|
||||||
|
declare module '*.js';
|
||||||
43
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
350
src/content/GM_fetch/index.js
Normal file
@@ -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
|
||||||
|
})();
|
||||||
611
src/content/content.ts
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
import { getStorageValue, setStorageValue } from './storage'
|
||||||
|
import styleCss from './style.css'
|
||||||
|
|
||||||
|
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
|
||||||
|
import './GM_fetch'
|
||||||
|
import { logDebug, log, logError } from './log'
|
||||||
|
import { getPricesToken, priceUsingPricesTF } from './pricing/pricestf'
|
||||||
|
const semver = require('semver')
|
||||||
|
// Globals
|
||||||
|
import itemQualities from 'tf2-static-schema/static/qualities.json';
|
||||||
|
var itemSchema: { [key: string]: {name: string, tradable: Boolean}; } | null;
|
||||||
|
|
||||||
|
declare const __VERSION__: string;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const storage_lastUpdateTime = 'tf2wikipricing_lastUpdate';
|
||||||
|
const storage_schema = 'tf2wikipricing_schema';
|
||||||
|
const storage_version = 'tf2wikipricing_version';
|
||||||
|
const storage_priceprefix = 'tf2wikipricing_sku_';
|
||||||
|
const conversion_ref_usd = 0.0265;
|
||||||
|
const defindex_key = 5021;
|
||||||
|
const defindex_metal_refined = 5002;
|
||||||
|
const defindex_metal_reclaimed = 5001;
|
||||||
|
const defindex_metal_scrap = 5000;
|
||||||
|
|
||||||
|
/** Pricing data for a given TF2 item. */
|
||||||
|
class ItemPriceData {
|
||||||
|
/** Item SKU. */
|
||||||
|
sku: string
|
||||||
|
/** Date updated. */
|
||||||
|
update: Date
|
||||||
|
/** TTL in milliseconds. */
|
||||||
|
ttl: number
|
||||||
|
/** Price in keys. */
|
||||||
|
keys: number
|
||||||
|
/** Price in refined metal. */
|
||||||
|
metal: number
|
||||||
|
/** Steam Community Market price. */
|
||||||
|
scmPrice: number
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `Price for ${this.sku}, fetched ${this.update} (expires ${this.update.getTime() + this.ttl})\n` +
|
||||||
|
JSON.stringify({
|
||||||
|
keys: this.keys,
|
||||||
|
metal: this.metal,
|
||||||
|
scmPrice: this.scmPrice
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SteamMarketSearchResult {
|
||||||
|
name: string
|
||||||
|
sell_price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exclude these from the pricelist. */
|
||||||
|
const excludedQualities = new Set([
|
||||||
|
15, // Decorated
|
||||||
|
5, // Unusual
|
||||||
|
]);
|
||||||
|
|
||||||
|
const localizations: {[lang: string]: any} = {
|
||||||
|
'en': require('../strings/en'), // English
|
||||||
|
'es': require('../strings/es'), // Spanish
|
||||||
|
// 'ja': require('../strings/ja'), // Japanese
|
||||||
|
// 'it': require('../strings/it'), // Italian
|
||||||
|
// 'ar': require('../strings/ar.json') as object, // Arabic
|
||||||
|
// 'cs': require('../strings/cs.json') as object, // Czech
|
||||||
|
// 'da': require('../strings/da.json') as object, // Danish
|
||||||
|
// 'de': require('../strings/de.json') as object, // German
|
||||||
|
// 'fi': require('../strings/fi.json') as object, // Finnish
|
||||||
|
// 'fr': require('../strings/fr.json') as object, // French
|
||||||
|
// 'hu': require('../strings/hu.json') as object, // Hungarian
|
||||||
|
// 'ko': require('../strings/ko.json') as object, // Korean
|
||||||
|
// 'nl': require('../strings/nl.json') as object, // Dutch
|
||||||
|
// 'no': require('../strings/no.json') as object, // Norwegian Bokmål
|
||||||
|
// 'pl': require('../strings/pl.json') as object, // Polish
|
||||||
|
// 'pt': require('../strings/pt.json') as object, // Portuguese
|
||||||
|
// 'pt-BR': require('../strings/pt-BR.json') as object, // Brazilian Portuguese
|
||||||
|
// 'ro': require('../strings/ro.json') as object, // Romanian
|
||||||
|
// 'ru': require('../strings/ru.json') as object, // Russian
|
||||||
|
// 'sv': require('../strings/sv.json') as object, // Swedish
|
||||||
|
// 'tr': require('../strings/tr.json') as object, // Turkish
|
||||||
|
// 'zh-Hans': require('../strings/zh-Hans.json') as object, // Simplified Chinese
|
||||||
|
// 'zh-Hant': require('../strings/zh-Hant.json') as object, // Traditional Chinese
|
||||||
|
}
|
||||||
|
|
||||||
|
function $T(s: string, locale?: Intl.LocalesArgument): string {
|
||||||
|
const code = locale ? locale.toString() : extractLocaleFromURL(document.URL)
|
||||||
|
return localizations.hasOwnProperty(code) ? (localizations[code as unknown as keyof object])[s] || s : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function findFirstElement(selector: string): HTMLElement | null {
|
||||||
|
const elements = document.querySelectorAll(selector);
|
||||||
|
return elements.length > 0 ? elements[0] as HTMLElement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstChildElement(selector: string, root: Element): HTMLElement | null {
|
||||||
|
const elements = root.querySelectorAll(selector);
|
||||||
|
return elements.length > 0 ? elements[0] as HTMLElement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeyByValue(object: any, value: string) {
|
||||||
|
return Object.keys(object).find(key => object[key] === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPageTitleFromURL(url: string): string {
|
||||||
|
var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
|
||||||
|
if (split.indexOf('/') != -1) {
|
||||||
|
// Remove language suffix (/es)
|
||||||
|
split = split.substring(0, split.indexOf('/'));
|
||||||
|
}
|
||||||
|
return decodeURIComponent(split.replaceAll('_', ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLocaleFromURL(url: string): string {
|
||||||
|
var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
|
||||||
|
if (split.indexOf('/') != -1) {
|
||||||
|
// Remove language suffix e.g. `/es`
|
||||||
|
return split.substring(split.indexOf('/') + 1);
|
||||||
|
} else {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateAfterOneDay(date1: Date, date2: Date): boolean {
|
||||||
|
var diff = date2.getTime() - date1.getTime();
|
||||||
|
var diffDays = Math.round(diff / (1000 * 3600 * 24));
|
||||||
|
return diffDays > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemIndexByName(name: string) {
|
||||||
|
for (const [defindex, value] of Object.entries(itemSchema)) {
|
||||||
|
if (value['name'] == name) {
|
||||||
|
return parseInt(defindex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTradableStatusByDefindex(defindex: number) {
|
||||||
|
return itemSchema[defindex.toString()].tradable
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTradableStatusByName(name: string) {
|
||||||
|
for (const [defindex, value] of Object.entries(itemSchema)) {
|
||||||
|
if (value['name'] == name) {
|
||||||
|
return value.tradable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function
|
||||||
|
async function inject() {
|
||||||
|
const itemInfobox = findFirstElement('.item-infobox');
|
||||||
|
if (!itemInfobox) {
|
||||||
|
// Not an item page
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const locale = extractLocaleFromURL(document.URL);
|
||||||
|
var itemIndex: number | null = null;
|
||||||
|
var itemName: string | null = null;
|
||||||
|
|
||||||
|
// Try using buy buttons, if they exist
|
||||||
|
const buyButton = findFirstChildElement('.btn_buynow', itemInfobox);
|
||||||
|
const marketButton = findFirstChildElement('.btn_buynow_market', itemInfobox);
|
||||||
|
|
||||||
|
if(buyButton) {
|
||||||
|
const link = (buyButton.parentElement as HTMLLinkElement);
|
||||||
|
if(link && link.href) {
|
||||||
|
itemIndex = parseInt(link.href.replace('https://store.steampowered.com/buyitem/440/', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!itemIndex && marketButton) {
|
||||||
|
const link = (marketButton.parentElement as HTMLLinkElement);
|
||||||
|
if(link && link.href) {
|
||||||
|
itemIndex = parseInt(link.href.replace('https://steamcommunity.com/market/search/?q=appid:440+prop_def_index:', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try using item name
|
||||||
|
const header = findFirstChildElement('.infobox-header', itemInfobox);
|
||||||
|
if (!itemIndex && header) {
|
||||||
|
// Get <link rel="canonical" href='...'> from document.body
|
||||||
|
const canonical = document.querySelector('link[rel="canonical"]');
|
||||||
|
if (canonical && canonical instanceof HTMLLinkElement) {
|
||||||
|
const url = canonical.href;
|
||||||
|
if (url.indexOf("/wiki/") != -1) {
|
||||||
|
itemName = extractPageTitleFromURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = document.URL;
|
||||||
|
|
||||||
|
if (itemName && !itemIndex) {
|
||||||
|
itemIndex = getItemIndexByName(itemName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemIndex) {
|
||||||
|
// Cannot continue without index
|
||||||
|
logError(itemName ? `Could not find defindex for ${itemName}` : `Could not determine item defindex or name`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!itemName) {
|
||||||
|
// Get name from index
|
||||||
|
itemName = (itemSchema[itemIndex.toString()] as any).name;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Starting lookup for ${itemName} (defindex ${itemIndex})`);
|
||||||
|
|
||||||
|
if(getTradableStatusByDefindex(itemIndex) == false) {
|
||||||
|
log(`${itemName} is not tradable, exiting`)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qualities: number[] = []
|
||||||
|
|
||||||
|
const firstQualityTag = findFirstChildElement('.quality-tag', itemInfobox);
|
||||||
|
|
||||||
|
for (const qualityTag of Array.from(firstQualityTag.parentElement.children)) {
|
||||||
|
const link = findFirstChildElement('a', qualityTag) as HTMLLinkElement;
|
||||||
|
if (!link) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const qualityName = extractPageTitleFromURL(link.href);
|
||||||
|
const quality = parseInt(getKeyByValue(itemQualities, qualityName));
|
||||||
|
if (!quality) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
qualities.push(quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create buttons
|
||||||
|
|
||||||
|
// Item infobox is a table with the following layout:
|
||||||
|
//
|
||||||
|
// th.infobox-header (Item Name)
|
||||||
|
// tr (3D item viewer/2D preview image)
|
||||||
|
// tr (buy buttons if applicable)
|
||||||
|
// ... <- We want to insert our button here.
|
||||||
|
// th.infobox-header (Basic Information)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
var storeButtons: HTMLTableRowElement[] = [];
|
||||||
|
|
||||||
|
// backpack.tf button
|
||||||
|
storeButtons.push(createStoreButton("backpack.tf", new URL(`https://backpack.tf/classifieds?item=${encodeURIComponent(itemName)}`)));
|
||||||
|
|
||||||
|
// mannco.store button
|
||||||
|
storeButtons.push(createStoreButton("mannco.store", new URL(`https://mannco.store/tf2?&search=${encodeURIComponent(itemName)}&page=1`)));
|
||||||
|
|
||||||
|
// marketplace.tf button
|
||||||
|
// Disabled due to requiring login
|
||||||
|
// storeButtons.push(createStoreButton("marketplace.tf", new URL(`https://marketplace.tf/browse/tf2?sterm=${encodeURIComponent(itemName)}`)));
|
||||||
|
|
||||||
|
// stntrading.eu button
|
||||||
|
storeButtons.push(createStoreButton("stntrading.eu", new URL(`https://stntrading.eu/item/tf2/${encodeURIComponent(itemName)}`)));
|
||||||
|
|
||||||
|
const headers = itemInfobox.querySelectorAll("th.infobox-header");
|
||||||
|
storeButtons.reverse().forEach(element => {
|
||||||
|
if (marketButton) {
|
||||||
|
marketButton.closest("tr").insertAdjacentElement('afterend', element);
|
||||||
|
} else if (buyButton) {
|
||||||
|
buyButton.closest("tr").insertAdjacentElement('afterend', element);
|
||||||
|
} else if (headers.length > 2) {
|
||||||
|
headers[1].closest("tr").insertAdjacentElement('beforebegin', element);
|
||||||
|
} else {
|
||||||
|
itemInfobox.children[0].appendChild(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create price infobox
|
||||||
|
const priceInfoboxHeadingRow = document.createElement("tr")
|
||||||
|
const priceInfoboxHeading = document.createElement("th")
|
||||||
|
priceInfoboxHeading.className = "infobox-header"
|
||||||
|
priceInfoboxHeading.colSpan = 2
|
||||||
|
priceInfoboxHeading.innerText = $T("Community Pricing")
|
||||||
|
priceInfoboxHeadingRow.appendChild(priceInfoboxHeading);
|
||||||
|
headers[1].closest("tr").insertAdjacentElement('beforebegin', priceInfoboxHeadingRow);
|
||||||
|
|
||||||
|
// Create progress bar
|
||||||
|
const priceProgressRow = document.createElement("tr")
|
||||||
|
const priceProgressData = document.createElement("td")
|
||||||
|
priceProgressData.colSpan = 2
|
||||||
|
const priceProgress = document.createElement("progress");
|
||||||
|
priceProgress.id = "tf2wikipricing";
|
||||||
|
priceProgress.style.width = "75%"
|
||||||
|
priceProgress.style.marginLeft = "12.5%"
|
||||||
|
priceProgressData.appendChild(priceProgress);
|
||||||
|
priceProgressRow.appendChild(priceProgressData);
|
||||||
|
priceInfoboxHeadingRow.insertAdjacentElement('afterend', priceProgressRow);
|
||||||
|
|
||||||
|
var token: string | null;
|
||||||
|
|
||||||
|
// Steam Community Market
|
||||||
|
// TODO: Change this to lazy-load, so that it doesn't make network requests when we have cached data.
|
||||||
|
// var steamMarketResults = await getSteamResults(itemName)
|
||||||
|
// logDebug(JSON.stringify(steamMarketResults))
|
||||||
|
|
||||||
|
// Fetch prices.tf access token
|
||||||
|
// https://api2.prices.tf/auth/access
|
||||||
|
try {
|
||||||
|
// throw new Error('dont wanna')
|
||||||
|
token = await getPricesToken();
|
||||||
|
} catch (err) {
|
||||||
|
log('Failed to get an access token for prices.tf: ' + err);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateTime: Date | null = null;
|
||||||
|
|
||||||
|
interface PriceRow {
|
||||||
|
quality: number
|
||||||
|
row: HTMLTableRowElement
|
||||||
|
}
|
||||||
|
var priceRows: PriceRow[]= [];
|
||||||
|
|
||||||
|
// Get current key price
|
||||||
|
const keyPrice = await fetchKeyPrice(token);
|
||||||
|
|
||||||
|
var currentTime = new Date()
|
||||||
|
|
||||||
|
const promises = qualities.filter(x => !excludedQualities.has(x)).map(async (quality) => {
|
||||||
|
|
||||||
|
const qualifiedName = ((quality != 6 ? itemQualities[quality as unknown as keyof typeof itemQualities].toString() : '') + ' ' + itemName).trim()
|
||||||
|
// logDebug(`Fetching price for ${qualifiedName}`)
|
||||||
|
|
||||||
|
/*
|
||||||
|
var data: ItemPriceData | null;
|
||||||
|
const cached = await getStorageValue(storage_priceprefix + sku, null)
|
||||||
|
if (cached != null && 'keys' in cached && 'metal' in cached) {
|
||||||
|
data = cached
|
||||||
|
}
|
||||||
|
if (!data || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) {
|
||||||
|
data = new ItemPriceData()
|
||||||
|
data.sku = sku
|
||||||
|
data.update = new Date()
|
||||||
|
data.ttl = (5 * 60 * 1000) // Cache results for 5 minutes
|
||||||
|
|
||||||
|
// logDebug(JSON.stringify(steamMarketResults))
|
||||||
|
// const scmResult = steamMarketResults.find((x: object) => { x['name' as keyof object] === qualifiedName})
|
||||||
|
// logDebug(JSON.stringify(scmResult))
|
||||||
|
// if(scmResult) {
|
||||||
|
// data.scmPrice = scmResult.sell_price / 100
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await priceUsingPricesTF(token, itemIndex, quality)
|
||||||
|
if (response) {
|
||||||
|
data.keys = response.keys
|
||||||
|
data.metal = response.metal
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Received ${error} error while pricing ${sku} using prices.tf`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('keys' in data && 'metal' in data) {
|
||||||
|
await setStorageValue(storage_priceprefix + sku, data)
|
||||||
|
}
|
||||||
|
logDebug(JSON.stringify(data));
|
||||||
|
updateTime = new Date(data.update)
|
||||||
|
} else {
|
||||||
|
logDebug(`Using cached data for ${sku}, expires ${new Date(data.update).getTime() + data.ttl}`);
|
||||||
|
updateTime = new Date(data.update)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
var data: ItemPriceData | null
|
||||||
|
try {
|
||||||
|
data = await fetchPrice(token, itemIndex, quality, currentTime);
|
||||||
|
updateTime = new Date(data.update)
|
||||||
|
} catch {
|
||||||
|
log(`${qualifiedName} is unpriced or unavailable, skipping...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceRow = document.createElement("tr");
|
||||||
|
|
||||||
|
const priceLabel = document.createElement("td");
|
||||||
|
priceLabel.className = "infobox-label";
|
||||||
|
const priceLabelLink = document.createElement("a");
|
||||||
|
const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString()
|
||||||
|
priceLabelLink.href = locale === 'en' ? `https://wiki.teamfortress.com/wiki/${qualityName}` : `https://wiki.teamfortress.com/wiki/${qualityName}/${locale}`
|
||||||
|
priceLabelLink.innerText = $T(qualityName)
|
||||||
|
priceLabel.appendChild(priceLabelLink);
|
||||||
|
priceLabel.innerHTML += ':'
|
||||||
|
priceRow.appendChild(priceLabel);
|
||||||
|
|
||||||
|
const priceData = document.createElement("td");
|
||||||
|
const priceLink = document.createElement("span");
|
||||||
|
const priceString = data ? formatPrice(data.keys, data.metal, keyPrice.metal).trim() : $T('Data unavailable')
|
||||||
|
priceLink.innerHTML = priceString // + `<br>$${data.scmPrice}`
|
||||||
|
priceData.appendChild(priceLink);
|
||||||
|
priceRow.appendChild(priceData);
|
||||||
|
|
||||||
|
priceRows.push({quality: quality, row: priceRow})
|
||||||
|
})
|
||||||
|
Promise.all(promises).then(() => {
|
||||||
|
priceRows.sort((a, b) => {
|
||||||
|
// Sort 6 first always, then numerically
|
||||||
|
if (a.quality === 6) {
|
||||||
|
return -1;
|
||||||
|
} else if (b.quality === 6) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return a.quality == b.quality ? a.quality < b.quality ? -1 : 1 : 0;
|
||||||
|
}
|
||||||
|
}).reverse().forEach((element) => {
|
||||||
|
priceInfoboxHeadingRow.insertAdjacentElement('afterend', element.row);
|
||||||
|
})
|
||||||
|
if(!updateTime) { updateTime = new Date() }
|
||||||
|
|
||||||
|
// Footer row
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
|
||||||
|
const label = document.createElement("td");
|
||||||
|
label.colSpan = 2;
|
||||||
|
label.style.fontSize = "85%";
|
||||||
|
const updateText = $T("Updated %@.", locale).replace('%@', updateTime.toLocaleString(locale, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }))
|
||||||
|
const attributionText = $T("Trade prices sourced from %@. Currency conversions are approximate.", locale).replace('%@', '<a rel="nofollow" class="external text" href="https://prices.tf">prices.tf</a>');
|
||||||
|
label.innerHTML = `${updateText}<br>${attributionText}`;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
priceProgressRow.insertAdjacentElement('afterend', row);
|
||||||
|
priceProgressRow.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStoreButton(storeName: string, url: URL) {
|
||||||
|
const button = document.createElement("tr")
|
||||||
|
var source = `<td colspan="2" class="infobox-data" style="text-align:center"><div class="plainlinks btn_wrapper" style="width:100%"><a rel="nofollow" class="external text" href="{link}" target="_blank"><span class="btn_buynow_addon_${storeName.replaceAll('.', '')}">{title}<span></span></span></a></div></td>`
|
||||||
|
source = source.replace("{link}", url.toString())
|
||||||
|
source = source.replace("{title}", $T("View listings on %@").replace('%@', storeName))
|
||||||
|
button.innerHTML = source
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchKeyPrice(token: string) {
|
||||||
|
return fetchPrice(token, defindex_key, 6, new Date(), 86400000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a price for a given SKU, using cached values if available.
|
||||||
|
* @param token prices.tf access token.
|
||||||
|
* @param update Date retrieved.
|
||||||
|
* @param ttl Time to cache results in milliseconds. 30 minutes by default.
|
||||||
|
*/
|
||||||
|
async function fetchPrice(token: string, defIndex: number, quality: number, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise<ItemPriceData> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const sku = defIndex.toString() + ";" + quality.toString();
|
||||||
|
var data: ItemPriceData | null
|
||||||
|
|
||||||
|
const cached = await getStorageValue(storage_priceprefix + sku, null)
|
||||||
|
if (cached != null && 'keys' in cached && 'metal' in cached) {
|
||||||
|
data = cached
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.sku != sku || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) {
|
||||||
|
logDebug(`Fetching price data for ${sku}`)
|
||||||
|
if(!token) {
|
||||||
|
reject(401)
|
||||||
|
}
|
||||||
|
data = new ItemPriceData()
|
||||||
|
data.sku = sku
|
||||||
|
data.update = update
|
||||||
|
data.ttl = ttl
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await priceUsingPricesTF(token, defIndex, quality)
|
||||||
|
if (response) {
|
||||||
|
data.keys = response.keys
|
||||||
|
data.metal = response.metal
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Received ${error} error while pricing ${sku} using prices.tf`)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('metal' in data && 'keys' in data) {
|
||||||
|
await setStorageValue(storage_priceprefix + sku, data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logDebug(`Using cached price data for ${sku}`)
|
||||||
|
}
|
||||||
|
resolve(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSteamResults(itemName: string) {
|
||||||
|
logDebug(`Making network request to Steam for ${itemName}`)
|
||||||
|
const response = await GM_fetch(`https://steamcommunity.com/market/search/render?appid=440&norender=true&count=10&query=${encodeURIComponent(itemName)}`, {
|
||||||
|
method: 'get',
|
||||||
|
headers: new Headers({
|
||||||
|
'Accept': 'application/json'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (response.status === 200) {
|
||||||
|
const json = await response.json();
|
||||||
|
return json['results']
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFixed(num: number, fixed: number) {
|
||||||
|
var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?');
|
||||||
|
return num.toString().match(re)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageLocale: string = 'en'
|
||||||
|
|
||||||
|
function formatPrice(keys: number, metal: number, keyPrice: number) {
|
||||||
|
const pureMetal = (keys * keyPrice) + metal;
|
||||||
|
const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2)
|
||||||
|
|
||||||
|
var output: string = ''
|
||||||
|
if(keys > 0) {
|
||||||
|
output += (formattedKeys == 1.0 ? $T("%@ key") : $T("%@ keys")).replace('%@', formattedKeys.toLocaleString(pageLocale))
|
||||||
|
} else {
|
||||||
|
output += `${(+toFixed(metal, 2)).toLocaleString(pageLocale)} ref`
|
||||||
|
}
|
||||||
|
const currencyFormatter = new Intl.NumberFormat(pageLocale, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round price up to nearest cent
|
||||||
|
const price = Math.ceil(pureMetal * conversion_ref_usd * 100) / 100
|
||||||
|
output += ` (US$${currencyFormatter.format(price)})`
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareSchema() {
|
||||||
|
var needsUpdate: Boolean = false
|
||||||
|
|
||||||
|
const storedVersion: string | null = await getStorageValue(storage_version, null)
|
||||||
|
if(!storedVersion || !semver.valid(storedVersion)) {
|
||||||
|
log(`Cache is from an unknown version of the extension. Updating for version ${__VERSION__}`);
|
||||||
|
needsUpdate = true
|
||||||
|
} else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) {
|
||||||
|
log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`);
|
||||||
|
needsUpdate = true
|
||||||
|
} else {
|
||||||
|
itemSchema = await getStorageValue(storage_schema, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = await getStorageValue(storage_lastUpdateTime, null)
|
||||||
|
if (update) {
|
||||||
|
log(`Item schema updated at ${new Date(update)}`);
|
||||||
|
const lastUpdateTime = new Date(update);
|
||||||
|
if (!itemSchema || isDateAfterOneDay(lastUpdateTime, new Date())) {
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(needsUpdate) {
|
||||||
|
log("Item Schema out of Date. Rebuilding...");
|
||||||
|
const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json"
|
||||||
|
const response = await GM_fetch(url);
|
||||||
|
if (response.ok) {
|
||||||
|
await setStorageValue(storage_lastUpdateTime, new Date().getTime());
|
||||||
|
|
||||||
|
var cacheItems = {}
|
||||||
|
|
||||||
|
var responseItems: any[] = await response.json()
|
||||||
|
// We want to keep the keys `defindex`, `item_name`, and `attributes`
|
||||||
|
responseItems.forEach((item: any) => {
|
||||||
|
const defindex = item['defindex']
|
||||||
|
const name = item['item_name']
|
||||||
|
var tradable: Boolean = true
|
||||||
|
try {
|
||||||
|
if(item['attributes'] != null) {
|
||||||
|
if(item['attributes'].find((attribute: {}) => (attribute as any)['class'] == "cannot_trade")) {
|
||||||
|
tradable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
logError(error)
|
||||||
|
log(item)
|
||||||
|
}
|
||||||
|
(cacheItems as any)[defindex.toString()] = { "name": name, "tradable": tradable }
|
||||||
|
});
|
||||||
|
|
||||||
|
await setStorageValue(storage_schema, (cacheItems));
|
||||||
|
itemSchema = cacheItems
|
||||||
|
await setStorageValue(storage_version, __VERSION__);
|
||||||
|
logDebug(`Item schema updated at ${new Date()}`)
|
||||||
|
} else {
|
||||||
|
logError("Could not fetch item schema.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStyles() {
|
||||||
|
const head = document.head || document.getElementsByTagName('head')[0],
|
||||||
|
style = document.createElement('style');
|
||||||
|
head.appendChild(style);
|
||||||
|
style.innerHTML = styleCss;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareSchema().then(function () {
|
||||||
|
if (!itemSchema) {
|
||||||
|
logError("No item schema ready, exiting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageLocale = extractLocaleFromURL(document.URL)
|
||||||
|
addStyles();
|
||||||
|
inject();
|
||||||
|
// TODO: Purge expired price data
|
||||||
|
});
|
||||||
20
src/content/log.ts
Normal file
@@ -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 }
|
||||||
67
src/content/pricing/pricestf.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
|
||||||
|
import '../GM_fetch'
|
||||||
|
|
||||||
|
async function getPricesToken(): Promise<string> {
|
||||||
|
return new Promise<any>((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<PricesResponse> {
|
||||||
|
// 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 }
|
||||||
28
src/content/storage.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
declare var __ENV_USERSCRIPT: boolean;
|
||||||
|
declare var __ENV_WEBEXTENSION: boolean;
|
||||||
|
|
||||||
|
function getStorageValue(name: string, defaultValue: string): Promise<any> {
|
||||||
|
if(__ENV_USERSCRIPT) {
|
||||||
|
return GM.getValue(name, defaultValue);
|
||||||
|
} else if(__ENV_WEBEXTENSION) {
|
||||||
|
return browser.storage.local.get(name);
|
||||||
|
} else {
|
||||||
|
return new Promise<any>((resolve) => {
|
||||||
|
resolve(defaultValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStorageValue(name: string, value: any): Promise<any> {
|
||||||
|
if(__ENV_USERSCRIPT) {
|
||||||
|
return GM.setValue(name, value);
|
||||||
|
} else if(__ENV_WEBEXTENSION) {
|
||||||
|
return browser.storage.local.set({name, value});
|
||||||
|
} else {
|
||||||
|
return new Promise<any>((resolve, reject) => {
|
||||||
|
reject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getStorageValue, setStorageValue }
|
||||||
26
src/content/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
BIN
src/icons/icon-48.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
19
src/manifest.json
Executable file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/resources/box-solid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M50.7 58.5L0 160l208 0 0-128L93.7 32C75.5 32 58.9 42.3 50.7 58.5zM240 160l208 0L397.3 58.5C389.1 42.3 372.5 32 354.3 32L240 32l0 128zm208 32L0 192 0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-224z"/></svg>
|
||||||
|
After Width: | Height: | Size: 438 B |
BIN
src/resources/btn_backpacktf.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/resources/btn_generic.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/resources/btn_manncostore.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/resources/btn_stntradingeu.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
1
src/resources/square-steam-brands-solid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M165.6 309.1c18.6 7.7 27.3 28.9 19.6 47.4s-29 27.2-47.6 19.4l-28.5-11.8c5 10.6 13.8 19.4 25.4 24.2c25.2 10.5 54.1-1.4 64.6-26.5c5.1-12.1 5.1-25.5 .1-37.7c-5.1-12.1-14.5-21.6-26.7-26.7c-12.1-5-25-4.8-36.4-.5l29.5 12.2zM448 96c0-35.3-28.7-64-64-64H64C28.7 32 0 60.7 0 96V240.7l116.6 48.1c12-8.2 26.2-12.1 40.7-11.3l55.4-80.2v-1.1c0-48.2 39.3-87.5 87.6-87.5s87.6 39.3 87.6 87.5c0 49.2-40.9 88.7-89.6 87.5l-79 56.3c1.6 38.5-29.1 68.8-65.7 68.8c-31.8 0-58.5-22.7-64.5-52.7L0 319.2V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96zM241.9 196.2a58.4 58.4 0 1 0 116.8 0 58.4 58.4 0 1 0 -116.8 0zm14.6-.1a43.9 43.9 0 1 1 87.8 0 43.9 43.9 0 1 1 -87.8 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 878 B |
24
src/strings/en.js
Normal file
@@ -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"
|
||||||
|
}
|
||||||
24
src/strings/es.js
Normal file
@@ -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"
|
||||||
|
}
|
||||||
16
src/strings/it.js
Normal file
@@ -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"
|
||||||
|
}
|
||||||
24
src/strings/ja.js
Normal file
@@ -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"
|
||||||
|
}
|
||||||
18
src/userscript_header.js
Normal file
@@ -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==
|
||||||
14
tsconfig.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
120
webpack.config.js
Executable file
@@ -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
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
];
|
||||||