├── .gitignore ├── package.json ├── LICENSE ├── csgo-cdn.d.ts ├── example.js ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | data/ 4 | example.test.js 5 | testList.js 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "csgo-cdn", 3 | "version": "1.2.0", 4 | "description": "Retrieves the Steam CDN URLs for CS:GO Item Images", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "keywords": [ 8 | "steam", 9 | "csgo", 10 | "global offensive", 11 | "csgo", 12 | "stickers", 13 | "cdn", 14 | "images" 15 | ], 16 | "dependencies": { 17 | "bluebird": "^3.5.1", 18 | "hasha": "^3.0.0", 19 | "simple-vdf": "^1.1.0", 20 | "steam-totp": "^2.0.1", 21 | "steam-user": "^4.19.2", 22 | "vpk": "0.2.0", 23 | "winston": "^3.0.0-rc1" 24 | }, 25 | "author": { 26 | "name": "Stepan Fedorko-Bartos", 27 | "email": "Step7750@gmail.com", 28 | "url": "http://stepan.me" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/Step7750/node-csgo-cdn.git" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Stepan Fedorko-Bartos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /csgo-cdn.d.ts: -------------------------------------------------------------------------------- 1 | declare module "csgo-cdn" { 2 | import {EventEmitter} from 'events'; 3 | 4 | type StringToStringObject = { 5 | [key:string]: string 6 | } 7 | 8 | type DeepStringToStringObject = { 9 | [key:string]: string | DeepStringToStringObject 10 | } 11 | 12 | type ItemsEnglishObject = StringToStringObject & { 13 | "inverted": { 14 | [key:string]: Array 15 | } 16 | } 17 | 18 | export enum CsgoCdnLogLevel { 19 | Error = 'error', 20 | Warn = 'warn', 21 | Info = 'info', 22 | Verbose = 'verbose', 23 | Debug = 'debug', 24 | Silly = 'silly' 25 | } 26 | 27 | export enum CsgoCdnSkinPhases { 28 | Ruby = 'am_ruby_marbleized', 29 | Sapphire = 'am_sapphire_marbleized', 30 | Blackpearl = 'am_blackpearl_marbleized', 31 | Emerald = 'am_emerald_marbleized', 32 | Phase1 = 'phase1', 33 | Phase2 = 'phase2', 34 | Phase3 = 'phase3', 35 | Phase4 = 'phase4' 36 | } 37 | 38 | export interface CsgoCdnOptions { 39 | directory: string, // relative data directory for VPK files 40 | updateInterval: number, // seconds between update checks, -1 to disable auto-updates 41 | logLevel: CsgoCdnLogLevel, // logging level, (error, warn, info, verbose, debug, silly) 42 | stickers: boolean, // whether to obtain the vpk for stickers 43 | graffiti: boolean, // whether to obtain the vpk for graffiti 44 | characters: boolean, // whether to obtain the vpk for characters 45 | musicKits: boolean, // whether to obtain the vpk for music kits 46 | cases: boolean, // whether to obtain the vpk for cases 47 | tools: boolean, // whether to obtain the vpk for tools 48 | statusIcons: boolean, // whether to obtain the vpk for status icons 49 | } 50 | 51 | export default class CsgoCdn extends EventEmitter { 52 | public itemsGame: DeepStringToStringObject; 53 | public csgoEnglish: ItemsEnglishObject; 54 | public itemsGameCDN: StringToStringObject; 55 | 56 | constructor(steamUser: any, options?: Partial); 57 | 58 | getItemNameURL(marketHashName: string, phase?: CsgoCdnSkinPhases): string | undefined | null; 59 | getStickerURL(stickerName: string, large?: boolean): string | undefined | null; 60 | getWeaponURL(defindex: number, paintindex: number): string | undefined | null; 61 | 62 | on( event: 'ready', listener: () => void ): this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | const SteamUser = require('steam-user'); 2 | const SteamTotp = require('steam-totp'); 3 | const csgoCDN = require('./index'); 4 | 5 | const cred = { 6 | username: 'USERNAME', 7 | password: 'PASSWORD', 8 | shared_secret: 'SHARED_SECRET', 9 | }; 10 | 11 | const user = new SteamUser({enablePicsCache: true}); 12 | const cdn = new csgoCDN(user, {musicKits: true, cases: true, tools: true, statusIcons: true, logLevel: 'debug'}); 13 | 14 | cdn.on('ready', () => { 15 | console.log(cdn.getStickerURL('cologne2016/astr_gold', false)); 16 | console.log(cdn.getStickerURL('cologne2016/astr_gold', true)); 17 | console.log(cdn.getPatchURL('case01/patch_phoenix', false)); 18 | console.log(cdn.getPatchURL('case01/patch_phoenix', true)); 19 | console.log(cdn.getPatchURL('case01/patch_hydra', true)); 20 | console.log(cdn.getPatchURL('case_skillgroups/patch_supreme', true)); 21 | console.log(cdn.getPatchNameURL('Patch | Phoenix')); 22 | console.log(cdn.getPatchNameURL('Patch | Hydra')); 23 | console.log(cdn.getItemNameURL('Patch | Phoenix')); 24 | console.log(cdn.getItemNameURL('Patch | Sustenance!')); 25 | console.log(cdn.getItemNameURL('M4A4 | 龍王 (Dragon King) (Field-Tested)')); 26 | console.log(cdn.getItemNameURL('AWP | Redline (Field-Tested)')); 27 | console.log(cdn.getItemNameURL('MP7 | Army Recon (Minimal Wear)')); 28 | console.log(cdn.getItemNameURL('Sticker | Robo')); 29 | console.log(cdn.getItemNameURL('Chroma 3 Case Key')); 30 | console.log(cdn.getItemNameURL('Operation Phoenix Weapon Case')); 31 | console.log(cdn.getItemNameURL('Operation Phoenix Pass')); 32 | console.log(cdn.getItemNameURL('Music Kit | Kelly Bailey, Hazardous Environments')); 33 | console.log(cdn.getItemNameURL('StatTrak™ AWP | Redline (Field-Tested)')); 34 | console.log(cdn.getItemNameURL('StatTrak™ Music Kit | Noisia, Sharpened')); 35 | console.log(cdn.getItemNameURL('Sealed Graffiti | X-Axes (Tracer Yellow)')); 36 | console.log(cdn.getItemNameURL('★ Karambit | Gamma Doppler (Factory New)', cdn.phase.phase1)); 37 | console.log(cdn.getItemNameURL('★ Karambit | Gamma Doppler (Factory New)', cdn.phase.emerald)); 38 | console.log(cdn.getItemNameURL('★ Flip Knife | Doppler (Minimal Wear)', cdn.phase.ruby)); 39 | console.log(cdn.getItemNameURL('★ Flip Knife | Doppler (Minimal Wear)', cdn.phase.sapphire)); 40 | console.log(cdn.getItemNameURL('★ Huntsman Knife | Doppler (Factory New)', cdn.phase.blackpearl)); 41 | console.log(cdn.getItemNameURL('AK-47 | Black Laminate (Field-Tested)')); 42 | console.log(cdn.getItemNameURL('Boston 2018 Inferno Souvenir Package')); 43 | console.log(cdn.getItemNameURL('CS:GO Case Key')); 44 | console.log(cdn.getItemNameURL('★ Karambit')); 45 | console.log(cdn.getItemNameURL('AK-47')); 46 | console.log(cdn.getItemNameURL('★ Karambit | Forest DDPAT')); 47 | console.log(cdn.getItemNameURL('AWP | Redline')); 48 | }); 49 | 50 | SteamTotp.getAuthCode(cred.shared_secret, (err, code) => { 51 | if (err) { 52 | throw err; 53 | } 54 | 55 | const loginDetails = { 56 | accountName: cred.username, 57 | password: cred.password, 58 | rememberPassword: true, 59 | twoFactorCode: code, 60 | logonID: 2121, 61 | }; 62 | 63 | console.log('Logging into Steam....'); 64 | 65 | user.logOn(loginDetails); 66 | }); 67 | 68 | user.on('loggedOn', () => { 69 | console.log('Logged onto Steam'); 70 | }); 71 | 72 | user.on('contentServersReady', () => { 73 | console.log('Content servers ready'); 74 | }); 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-csgo-cdn 2 | 3 | Retrieves the Steam CDN URLs for CS:GO Item Images from their `market_hash_name` or properties. 4 | 5 | Can retrieve CDN images for: 6 | * Stickers 7 | * Characters 8 | * Graffiti (without tint) 9 | * Weapons (and doppler phases) 10 | * Music Kits 11 | * Tools (Crate Keys, Cases, Stattrak Swap Tool, etc...) 12 | * Status Icons (Pins, ESports Trophies, Map Contribution Tokens, Service Medals, etc...) 13 | 14 | 15 | ## Table of Contents 16 | * [Why?](https://github.com/Step7750/node-csgo-cdn#why) 17 | * [How?](https://github.com/Step7750/node-csgo-cdn#how) 18 | * [How to Install](https://github.com/Step7750/node-csgo-cdn#how-to-install) 19 | * [Methods](https://github.com/Step7750/node-csgo-cdn#methods) 20 | * [Constructor(client, options)](https://github.com/Step7750/node-csgo-cdn#constructorclient-options) 21 | * [getItemNameURL(marketHashName, phase)](https://github.com/Step7750/node-csgo-cdn#getitemnameurlmarkethashname-phase) 22 | * [getStickerURL(stickerName, large=true)](https://github.com/Step7750/node-csgo-cdn#getstickerurlstickername-largetrue) 23 | * [getWeaponURL(defindex, paintindex)](https://github.com/Step7750/node-csgo-cdn#getweaponurldefindex-paintindex) 24 | * [Properties](https://github.com/Step7750/node-csgo-cdn#properties) 25 | * [itemsGame](https://github.com/Step7750/node-csgo-cdn#itemsgame) 26 | * [csgoEnglish](https://github.com/Step7750/node-csgo-cdn#csgoenglish) 27 | * [itemsGameCDN](https://github.com/Step7750/node-csgo-cdn#itemsgamecdn) 28 | * [phase](https://github.com/Step7750/node-csgo-cdn#phase) 29 | * [Events](https://github.com/Step7750/node-csgo-cdn#events) 30 | * [ready](https://github.com/Step7750/node-csgo-cdn#ready) 31 | 32 | 33 | ## Why? 34 | 35 | Steam hosts all of the CS:GO resource images on their CDN, but unfortunately finding the URL for them was 36 | difficult in the past and would require scraping the market or inventories. 37 | 38 | This library allows you to retrieve the needed CDN URLs given the sticker name, which can save you lots in bandwidth 39 | and prevents you from having to scrape it or host it yourself. 40 | 41 | 42 | ## How? 43 | 44 | Most of the graphical resources for CSGO are stored in [VPK](https://developer.valvesoftware.com/wiki/VPK_File_Format) 45 | files which include the sticker, music kit, tools, and status icon images. 46 | 47 | The root of a VPK contains a `dir` file (`pak01_dir.vpk`) that specifies where files are located over multiple packages. If you look in 48 | the install directory of CS:GO, you'll see `pak01_003.vpk`, `pak01_004.vpk`, etc... where these files are located. 49 | 50 | Thankfully, Valve was kind enough (as of writing this) to include all of the relevant images in a few packages 51 | which are only ~400MB. 52 | 53 | This library, using node-steam-user, checks the manifest for any updates to the public branch of CS:GO, and if so, 54 | downloads only the required VPK packages that contain all relevant images if they have changed from the 55 | content servers. 56 | 57 | When trying to retrieve a CDN image URL for a given item, the library takes the SHA1 hash of the file and the VPK 58 | path that links to it to generate the corresponding URL. 59 | 60 | Example URL: https://steamcdn-a.akamaihd.net/apps/730/icons/econ/stickers/cologne2015/mousesports.3e75da497d9f75fa56f463c22db25f29992561ce.png 61 | 62 | ## How to Install 63 | 64 | ### `npm install csgo-cdn` 65 | 66 | #### See example.js 67 | ```javascript 68 | const SteamUser = require('steam-user'); 69 | const csgoCDN = require('csgo-cdn'); 70 | 71 | const user = new SteamUser(); 72 | const cdn = new csgoCDN(user, {logLevel: 'debug'}); 73 | 74 | cdn.on('ready', () => { 75 | console.log(cdn.getItemNameURL('M4A4 | 龍王 (Dragon King) (Field-Tested)')); 76 | console.log(cdn.getItemNameURL('★ Karambit | Gamma Doppler (Factory New)', cdn.phase.emerald)); 77 | }); 78 | ``` 79 | 80 | ## Methods 81 | 82 | ### Constructor(client, options) 83 | 84 | * `client` - [node-steam-user](https://github.com/DoctorMcKay/node-steam-user) Client **The account MUST own CS:GO** 85 | * `options` - Options 86 | ```javascript 87 | { 88 | directory: 'data', // relative data directory for VPK files 89 | updateInterval: 30000, // seconds between update checks, -1 to disable auto-updates 90 | logLevel: 'info', // logging level, (error, warn, info, verbose, debug, silly) 91 | stickers: true, // whether to obtain the vpk for stickers 92 | patches: true, // whether to obtain the vpk for patches 93 | graffiti: true, // whether to obtain the vpk for graffiti 94 | musicKits: true, // whether to obtain the vpk for music kits 95 | cases: true, // whether to obtain the vpk for cases 96 | tools: true, // whether to obtain the vpk for tools 97 | statusIcons: true, // whether to obtain the vpk for status icons 98 | } 99 | ``` 100 | 101 | ### getItemNameURL(marketHashName, phase) 102 | 103 | * `marketHashName` - The market hash name of an item (ex. "Sticker | Robo" or "AWP | Redline (Field-Tested)") 104 | * `phase` - Optional weapon phase for doppler skins from the `phase` enum property 105 | 106 | **Note: If the item is a weapon, you can omit the wear (ex. `AWP | Redline`)** 107 | 108 | Ensure that you have enabled the relevant VPK downloading for the item category by using the options in the constructor. 109 | 110 | Returns the 'large' version of the image. 111 | 112 | ### getStickerURL(stickerName, large=true) 113 | 114 | * `stickerName` - Name of the sticker path from `items_game.txt` (ex. cluj2015/sig_olofmeister_gold) 115 | * `large` - Whether to obtain the large version of the image 116 | 117 | ### getPatchURL(patchName, large=true) 118 | 119 | * `stickerName` - Name of the patch path from `items_game.txt` (ex. case01/patch_phoenix) 120 | * `large` - Whether to obtain the large version of the image 121 | 122 | 123 | ### getWeaponURL(defindex, paintindex) 124 | 125 | * `defindex` - Definition index of the item (ex. 7 for AK-47) 126 | * `paintindex` - Paint index of the item (ex. 490 for Frontside Misty) 127 | 128 | ## Properties 129 | 130 | ### itemsGame 131 | 132 | Parsed items_game.txt file as a dictionary 133 | 134 | ### csgoEnglish 135 | 136 | Parsed csgo_english file as a dictionary. Also contains all inverted keys, such that the values are also keys themselves 137 | for O(1) retrieval. 138 | 139 | ### itemsGameCDN 140 | 141 | Parsed items_game_cdn.txt file as a dictionary 142 | 143 | ### phase 144 | 145 | Doppler phase enum used to specify the phase of a knife 146 | 147 | ```javascript 148 | cdn.getItemNameURL('★ Karambit | Gamma Doppler (Factory New)', cdn.phase.emerald); 149 | cdn.getItemNameURL('★ Huntsman Knife | Doppler (Factory New)', cdn.phase.blackpearl); 150 | cdn.getItemNameURL('★ Huntsman Knife | Doppler (Factory New)', cdn.phase.phase1); 151 | cdn.getItemNameURL('★ Flip Knife | Doppler (Minimal Wear)', cdn.phase.ruby); 152 | cdn.getItemNameURL('★ Flip Knife | Doppler (Minimal Wear)', cdn.phase.sapphire); 153 | ``` 154 | 155 | ## Events 156 | 157 | ### ready 158 | 159 | Emitted when csgo-cdn is ready, this must be emitted before using the object 160 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const EventEmitter = require('events'); 3 | const fs = Promise.promisifyAll(require('fs')); 4 | const vpk = require('vpk'); 5 | const vdf = require('simple-vdf'); 6 | const hasha = require('hasha'); 7 | const winston = require('winston'); 8 | 9 | const defaultConfig = { 10 | directory: 'data', 11 | updateInterval: 30000, 12 | stickers: true, 13 | patches: true, 14 | graffiti: true, 15 | characters: true, 16 | musicKits: true, 17 | cases: true, 18 | tools: true, 19 | statusIcons: true, 20 | logLevel: 'info' 21 | }; 22 | 23 | const wears = ['Factory New', 'Minimal Wear', 'Field-Tested', 'Well-Worn', 'Battle-Scarred']; 24 | 25 | const neededDirectories = { 26 | stickers: 'resource/flash/econ/stickers', 27 | patches: 'resource/flash/econ/patches', 28 | graffiti: 'resource/flash/econ/stickers/default', 29 | characters: 'resource/flash/econ/characters', 30 | musicKits: 'resource/flash/econ/music_kits', 31 | cases: 'resource/flash/econ/weapon_cases', 32 | tools: 'resource/flash/econ/tools', 33 | statusIcons: 'resource/flash/econ/status_icons', 34 | }; 35 | 36 | function bytesToMB(bytes) { 37 | return (bytes/1000000).toFixed(2); 38 | } 39 | 40 | class CSGOCdn extends EventEmitter { 41 | get ready() { 42 | return this.ready_ || false; 43 | } 44 | 45 | get steamReady() { 46 | return !!this.user.steamID; 47 | } 48 | 49 | get phase() { 50 | return { 51 | ruby: 'am_ruby_marbleized', 52 | sapphire: 'am_sapphire_marbleized', 53 | blackpearl: 'am_blackpearl_marbleized', 54 | emerald: 'am_emerald_marbleized', 55 | phase1: 'phase1', 56 | phase2: 'phase2', 57 | phase3: 'phase3', 58 | phase4: 'phase4' 59 | } 60 | } 61 | 62 | set ready(r) { 63 | const old = this.ready; 64 | this.ready_ = r; 65 | 66 | if (r !== old && r) { 67 | this.log.debug('Ready'); 68 | this.emit('ready'); 69 | } 70 | } 71 | 72 | constructor(steamUser, config={}) { 73 | super(); 74 | 75 | this.config = Object.assign(defaultConfig, config); 76 | 77 | this.createDataDirectory(); 78 | 79 | this.user = Promise.promisifyAll(steamUser, {multiArgs: true}); 80 | 81 | this.log = winston.createLogger({ 82 | level: config.logLevel, 83 | transports: [ 84 | new winston.transports.Console({ 85 | colorize: true, 86 | format: winston.format.printf((info) => { 87 | return `[csgo-image-cdn] ${info.level}: ${info.message}`; 88 | }) 89 | }) 90 | ] 91 | }); 92 | 93 | if (!this.steamReady) { 94 | this.log.debug('Steam not ready, waiting for logon'); 95 | 96 | this.user.once('loggedOn', () => { 97 | this.updateLoop(); 98 | }); 99 | } 100 | else { 101 | this.updateLoop(); 102 | } 103 | } 104 | 105 | /** 106 | * Creates the data directory specified in the config if it doesn't exist 107 | */ 108 | createDataDirectory() { 109 | const dir = `./${this.config.directory}`; 110 | 111 | if (!fs.existsSync(dir)){ 112 | fs.mkdirSync(dir); 113 | } 114 | } 115 | 116 | /** 117 | * Runs the update loop at the specified config interval 118 | * @return {Promise|void} 119 | */ 120 | updateLoop() { 121 | if (this.config.updateInterval > 0) { 122 | return this.update().then(() => Promise.delay(this.config.updateInterval*1000)) 123 | .then(() => this.updateLoop()); 124 | } 125 | else { 126 | this.log.info('Auto-updates disabled, checking if required files exist'); 127 | 128 | // Try to load the resources locally 129 | try { 130 | this.loadResources(); 131 | this.loadVPK(); 132 | this.ready = true; 133 | } catch(e) { 134 | this.log.warn('Needed CS:GO files not installed'); 135 | this.update(); 136 | } 137 | } 138 | } 139 | 140 | /** 141 | * Returns the product info for CSGO, with its depots and packages 142 | */ 143 | getProductInfo() { 144 | this.log.debug('Obtaining CS:GO product info'); 145 | return new Promise((resolve, reject) => { 146 | this.user.getProductInfo([730], [], true, (apps, packages, unknownApps, unknownPackages) => { 147 | resolve([apps, packages, unknownApps, unknownPackages]); 148 | }); 149 | }); 150 | } 151 | 152 | /** 153 | * Returns the latest CSGO manifest ID for the public 731 depot 154 | * @return {*|PromiseLike<*[]>|Promise<*[]>} 731 Depot Manifest ID 155 | */ 156 | getLatestManifestId() { 157 | this.log.debug('Obtaining latest manifest ID'); 158 | return this.getProductInfo().then(([apps, packages, unknownApps, unknownPackages]) => { 159 | const csgo = packages['730'].appinfo; 160 | const commonDepot = csgo.depots['731']; 161 | 162 | return commonDepot.manifests.public; 163 | }); 164 | } 165 | 166 | /** 167 | * Retrieves and updates the sticker file directory from Valve 168 | * 169 | * Ensures that only the required VPK files are downloaded and that files with the same SHA1 aren't 170 | * redownloaded 171 | * 172 | * @return {Promise} 173 | */ 174 | async update() { 175 | this.log.info('Checking for CS:GO file updates'); 176 | 177 | if (!this.steamReady) { 178 | this.log.warn(`Steam not ready, can't check for updates`); 179 | return; 180 | } 181 | 182 | const manifestId = await this.getLatestManifestId(); 183 | 184 | this.log.debug(`Obtained latest manifest ID: ${manifestId.gid}`); 185 | 186 | const [manifest] = await this.user.getManifestAsync(730, 731, manifestId.gid, 'public'); 187 | const manifestFiles = manifest.files; 188 | 189 | const dirFile = manifest.files.find((file) => file.filename.endsWith("csgo\\pak01_dir.vpk")); 190 | const itemsGameFile = manifest.files.find((file) => file.filename.endsWith("items_game.txt")); 191 | const itemsGameCDNFile = manifest.files.find((file) => file.filename.endsWith("items_game_cdn.txt")); 192 | const csgoEnglishFile = manifest.files.find((file) => file.filename.endsWith("csgo_english.txt")); 193 | 194 | this.log.debug(`Downloading required static files`); 195 | 196 | await this.downloadFiles([dirFile, itemsGameFile, itemsGameCDNFile, csgoEnglishFile]); 197 | 198 | this.log.debug('Loading static file resources'); 199 | 200 | this.loadResources(); 201 | this.loadVPK(); 202 | 203 | await this.downloadVPKFiles(this.vpkDir, manifestFiles); 204 | 205 | this.ready = true; 206 | } 207 | 208 | loadResources() { 209 | this.itemsGame = vdf.parse(fs.readFileSync(`${this.config.directory}/items_game.txt`, 'utf8'))['items_game']; 210 | this.csgoEnglish = vdf.parse(fs.readFileSync(`${this.config.directory}/csgo_english.txt`, 'ucs2'))['lang']['Tokens']; 211 | this.itemsGameCDN = this.parseItemsCDN(fs.readFileSync(`${this.config.directory}/items_game_cdn.txt`, 'utf8')); 212 | 213 | this.weaponNameMap = Object.keys(this.csgoEnglish).filter(n => n.startsWith("SFUI_WPNHUD")); 214 | this.csgoEnglishKeys = Object.keys(this.csgoEnglish); 215 | 216 | // Ensure paint kit descriptions are lowercase to resolve inconsistencies in the language and items_game file 217 | Object.keys(this.itemsGame.paint_kits).forEach((n) => { 218 | const kit = this.itemsGame.paint_kits[n]; 219 | 220 | if ('description_tag' in kit) { 221 | kit.description_tag = kit.description_tag.toLowerCase(); 222 | } 223 | }); 224 | 225 | this.invertDictionary(this.csgoEnglish); 226 | } 227 | 228 | /** 229 | * Inverts the key mapping of a dictionary recursively while preserving the original keys 230 | * 231 | * Duplicate values with be an array 232 | * 233 | * @param dict Dictionary to invert 234 | */ 235 | invertDictionary(dict) { 236 | dict['inverted'] = {}; 237 | 238 | for (const prop in dict) { 239 | if (prop === 'inverted' || !dict.hasOwnProperty(prop)) continue; 240 | 241 | const val = dict[prop]; 242 | 243 | if (typeof val === 'object' && !(val instanceof Array)) { 244 | this.invertDictionary(val); 245 | } 246 | else { 247 | if (dict['inverted'][val] === undefined) { 248 | dict['inverted'][val] = [prop]; 249 | } 250 | else { 251 | dict['inverted'][val].push(prop); 252 | } 253 | } 254 | } 255 | } 256 | 257 | parseItemsCDN(data) { 258 | let lines = data.split('\n'); 259 | 260 | const items_game_cdn = {}; 261 | 262 | for (let line of lines) { 263 | let kv = line.split('='); 264 | 265 | if (kv[1]) { 266 | items_game_cdn[kv[0]] = kv[1]; 267 | } 268 | } 269 | 270 | return items_game_cdn; 271 | } 272 | 273 | /** 274 | * Downloads the given VPK files from the Steam CDN 275 | * @param files Steam Manifest File Array 276 | * @return {Promise<>} Fulfilled when completed downloading 277 | */ 278 | async downloadFiles(files) { 279 | const promises = []; 280 | 281 | for (const file of files) { 282 | let name = file.filename.split('\\'); 283 | name = name[name.length-1]; 284 | 285 | const path = `${this.config.directory}/${name}`; 286 | 287 | const isDownloaded = await this.isFileDownloaded(path, file.sha_content); 288 | 289 | if (isDownloaded) { 290 | continue; 291 | } 292 | 293 | const promise = this.user.downloadFile(730, 731, file, `${this.config.directory}/${name}`); 294 | promises.push(promise); 295 | } 296 | 297 | return Promise.all(promises); 298 | } 299 | 300 | /** 301 | * Loads the CSGO dir VPK specified in the config 302 | */ 303 | loadVPK() { 304 | this.vpkDir = new vpk(this.config.directory + '/pak01_dir.vpk'); 305 | this.vpkDir.load(); 306 | 307 | this.vpkStickerFiles = this.vpkDir.files.filter((f) => f.startsWith('resource/flash/econ/stickers')); 308 | this.vpkPatchFiles = this.vpkDir.files.filter((f) => f.startsWith('resource/flash/econ/patches')); 309 | } 310 | 311 | /** 312 | * Given the CSGO VPK Directory, returns the necessary indices for the chosen options 313 | * @param vpkDir CSGO VPK Directory 314 | * @return {Array} Necessary Sticker VPK Indices 315 | */ 316 | getRequiredVPKFiles(vpkDir) { 317 | const requiredIndices = []; 318 | 319 | const neededDirs = Object.keys(neededDirectories).filter((f) => !!this.config[f]).map((f) => neededDirectories[f]); 320 | 321 | for (const fileName of vpkDir.files) { 322 | for (const dir of neededDirs) { 323 | if (fileName.startsWith(dir)) { 324 | const archiveIndex = vpkDir.tree[fileName].archiveIndex; 325 | 326 | if (!requiredIndices.includes(archiveIndex)) { 327 | requiredIndices.push(archiveIndex); 328 | } 329 | 330 | break; 331 | } 332 | } 333 | } 334 | 335 | return requiredIndices.sort(); 336 | } 337 | 338 | /** 339 | * Downloads the required VPK files 340 | * @param vpkDir CSGO VPK Directory 341 | * @param manifestFiles Manifest files 342 | * @return {Promise} 343 | */ 344 | async downloadVPKFiles(vpkDir, manifestFiles) { 345 | this.log.debug('Computing required VPK files for selected packages'); 346 | 347 | const requiredIndices = this.getRequiredVPKFiles(vpkDir); 348 | 349 | this.log.debug(`Required VPK files ${requiredIndices}`); 350 | 351 | for (let index in requiredIndices) { 352 | index = parseInt(index); 353 | 354 | // pad to 3 zeroes 355 | const archiveIndex = requiredIndices[index]; 356 | const paddedIndex = '0'.repeat(3-archiveIndex.toString().length) + archiveIndex; 357 | const fileName = `pak01_${paddedIndex}.vpk`; 358 | 359 | const file = manifestFiles.find((f) => f.filename.endsWith(fileName)); 360 | const filePath = `${this.config.directory}/${fileName}`; 361 | 362 | const isDownloaded = await this.isFileDownloaded(filePath, file.sha_content); 363 | 364 | if (isDownloaded) { 365 | this.log.info(`Already downloaded ${filePath}`); 366 | continue; 367 | } 368 | 369 | const status = `[${index+1}/${requiredIndices.length}]`; 370 | 371 | this.log.info(`${status} Downloading ${fileName} - ${bytesToMB(file.size)} MB`); 372 | 373 | await this.user.downloadFile(730, 731, file, filePath, (none, { type, bytesDownloaded, totalSizeBytes }) => { 374 | if (type === 'progress') { 375 | this.log.info(`${status} ${(bytesDownloaded*100/totalSizeBytes).toFixed(2)}% - ${bytesToMB(bytesDownloaded)}/${bytesToMB(totalSizeBytes)} MB`); 376 | } 377 | }); 378 | 379 | this.log.info(`${status} Downloaded ${fileName}`); 380 | } 381 | } 382 | 383 | /** 384 | * Returns whether a file at the given path has the given sha1 385 | * @param path File path 386 | * @param sha1 File SHA1 hash 387 | * @return {Promise} Whether the file has the hash 388 | */ 389 | async isFileDownloaded(path, sha1) { 390 | try { 391 | const hash = await hasha.fromFile(path, {algorithm: 'sha1'}); 392 | 393 | return hash === sha1; 394 | } 395 | catch (e) { 396 | return false; 397 | } 398 | } 399 | 400 | /** 401 | * Given a VPK path, returns the CDN URL 402 | * @param path VPK path 403 | * @return {string|void} CDN URL 404 | */ 405 | getPathURL(path) { 406 | const file = this.vpkDir.getFile(path); 407 | 408 | if (!file) { 409 | this.log.error(`Failed to retrieve ${path} in VPK, do you have the package category enabled in options?`); 410 | return; 411 | } 412 | 413 | const sha1 = hasha(file, { 414 | 'algorithm': 'sha1' 415 | }); 416 | 417 | path = path.replace('resource/flash', 'icons'); 418 | path = path.replace('.png', `.${sha1}.png`); 419 | 420 | return `https://steamcdn-a.akamaihd.net/apps/730/${path}`; 421 | } 422 | 423 | /** 424 | * Returns the item Steam CDN URL for the specified name 425 | * 426 | * Example Sticker Names: cologne2016/nv, cologne2016/fntc_holo, cologne2016/fntc_foil, cluj2015/sig_olofmeister_gold 427 | * 428 | * You can find the sticker names from their relevant "sticker_material" fields in items_game.txt 429 | * items_game.txt can be found in the core game files of CS:GO or as itemsGame here 430 | * 431 | * @param name The item name (the sticker_material field in items_game.txt, or the cdn file format) 432 | * @param large Whether to obtain the "large" CDN version of the item 433 | * @return {string|void} If successful, the HTTPS CDN URL for the item 434 | */ 435 | getStickerURL(name, large=true) { 436 | if (!this.ready) { 437 | return; 438 | } 439 | 440 | const fileName = large ? `${name}_large.png` : `${name}.png`; 441 | const path = this.vpkStickerFiles.find((t) => t.endsWith(fileName)); 442 | 443 | if (path) return this.getPathURL(path); 444 | } 445 | 446 | /** 447 | * Returns the item Steam CDN URL for the specified name 448 | * 449 | * Example Patch Names: case01/patch_phoenix, case01/patch_dangerzone, case01/patch_easypeasy, case_skillgroups/patch_goldnova1 450 | * 451 | * You can find the patch names from their relevant "patch_material" fields in items_game.txt 452 | * items_game.txt can be found in the core game files of CS:GO or as itemsGame here 453 | * 454 | * @param name The item name (the patch_material field in items_game.txt, or the cdn file format) 455 | * @param large Whether to obtain the "large" CDN version of the item 456 | * @return {string|void} If successful, the HTTPS CDN URL for the item 457 | */ 458 | getPatchURL(name, large=true) { 459 | if (!this.ready) { 460 | return; 461 | } 462 | 463 | const fileName = large ? `${name}_large.png` : `${name}.png`; 464 | const path = this.vpkPatchFiles.find((t) => t.endsWith(fileName)); 465 | 466 | if (path) return this.getPathURL(path); 467 | } 468 | 469 | /** 470 | * Given the specified defindex and paintindex, returns the CDN URL 471 | * 472 | * The item properties can be found in items_game.txt 473 | * 474 | * @param defindex Item Definition Index (weapon type) 475 | * @param paintindex Item Paint Index (skin type) 476 | * @return {string|void} Weapon CDN URL 477 | */ 478 | getWeaponURL(defindex, paintindex) { 479 | if (!this.ready) return; 480 | 481 | const paintKits = this.itemsGame.paint_kits; 482 | 483 | // Get the skin name 484 | let skinName = ''; 485 | 486 | if (paintindex in paintKits) { 487 | skinName = paintKits[paintindex].name; 488 | 489 | if (skinName === 'default') { 490 | skinName = ''; 491 | } 492 | } 493 | 494 | // Get the weapon name 495 | let weaponName; 496 | 497 | const items = this.itemsGame.items; 498 | 499 | if (defindex in items) { 500 | weaponName = items[defindex].name; 501 | } 502 | 503 | // Get the image url 504 | const cdnName = `${weaponName}_${skinName}`; 505 | 506 | return this.itemsGameCDN[cdnName]; 507 | } 508 | 509 | /** 510 | * Returns whether the given name is a weapon by checking 511 | * the prefab and whether it is used by one of the sides 512 | * @param marketHashName Item name 513 | * @return {boolean} Whether a weapon 514 | */ 515 | isWeapon(marketHashName) { 516 | const prefabs = this.itemsGame.prefabs; 517 | const items = this.itemsGame.items; 518 | const weaponName = marketHashName.split('|')[0].trim(); 519 | 520 | const weaponTags = this.csgoEnglish['inverted'][weaponName]; 521 | 522 | if (!weaponTags) return false; 523 | 524 | // For every matching weapon tag... 525 | for (const t of weaponTags) { 526 | const weaponTag = `#${t}`; 527 | 528 | const prefab = Object.keys(prefabs).find((n) => { 529 | const fab = prefabs[n]; 530 | 531 | return fab.item_name === weaponTag; 532 | }); 533 | 534 | let fab; 535 | 536 | if (!prefab) { 537 | // special knives aren't in the prefab (karambits, etc...) 538 | const item = Object.keys(items).find((n) => { 539 | const i = items[n]; 540 | 541 | return i.item_name === weaponTag; 542 | }); 543 | 544 | fab = items[item]; 545 | } 546 | else { 547 | fab = prefabs[prefab]; 548 | } 549 | 550 | if (fab && fab.used_by_classes) { 551 | const used = fab.used_by_classes; 552 | 553 | // Ensure that the item is used by one of the sides 554 | if (used['terrorists'] || used['counter-terrorists']) { 555 | return true; 556 | } 557 | } 558 | } 559 | 560 | return false; 561 | } 562 | 563 | /** 564 | * Returns the sticker URL given the market hash name 565 | * @param marketHashName Sticker name 566 | * @return {string|void} Sticker image URL 567 | */ 568 | getStickerNameURL(marketHashName) { 569 | const reg = /Sticker \| (.*)/; 570 | const match = marketHashName.match(reg); 571 | 572 | if (!match) return; 573 | 574 | const stickerName = match[1]; 575 | 576 | for (const tag of this.csgoEnglish['inverted'][stickerName] || []) { 577 | const stickerTag = `#${tag}`; 578 | 579 | const stickerKits = this.itemsGame.sticker_kits; 580 | 581 | const kitIndex = Object.keys(stickerKits).find((n) => { 582 | const k = stickerKits[n]; 583 | 584 | return k.item_name === stickerTag; 585 | }); 586 | 587 | const kit = stickerKits[kitIndex]; 588 | 589 | if (!kit || !kit.sticker_material) continue; 590 | 591 | const url = this.getStickerURL(stickerKits[kitIndex].sticker_material, true); 592 | 593 | if (url) { 594 | return url; 595 | } 596 | } 597 | } 598 | 599 | /** 600 | * Returns the patch URL given the market hash name 601 | * @param marketHashName Patch name 602 | * @return {string|void} Patch image URL 603 | */ 604 | getPatchNameURL(marketHashName) { 605 | const reg = /Patch \| (.*)/; 606 | const match = marketHashName.match(reg); 607 | 608 | if (!match) return; 609 | 610 | const stickerName = match[1]; 611 | 612 | for (const tag of this.csgoEnglish['inverted'][stickerName] || []) { 613 | const stickerTag = `#${tag}`; 614 | 615 | const stickerKits = this.itemsGame.sticker_kits; // Patches are in the sticker_kits as well 616 | 617 | const kitIndex = Object.keys(stickerKits).find((n) => { 618 | const k = stickerKits[n]; 619 | 620 | return k.item_name === stickerTag; 621 | }); 622 | 623 | const kit = stickerKits[kitIndex]; 624 | 625 | if (!kit || !kit.patch_material) continue; 626 | 627 | const url = this.getPatchURL(stickerKits[kitIndex].patch_material, true); 628 | 629 | if (url) return url; 630 | } 631 | } 632 | 633 | /** 634 | * Returns the graffiti URL given the market hash name 635 | * @param marketHashName Graffiti name (optional tint) 636 | * @param large Whether to obtain the "large" CDN version of the item 637 | * @return {string|void} CDN Image URL 638 | */ 639 | getGraffitiNameURL(marketHashName, large=true) { 640 | const reg = /Sealed Graffiti \| ([^(]*)/; 641 | const match = marketHashName.match(reg); 642 | 643 | if (!match) return; 644 | 645 | const graffitiName = match[1].trim(); 646 | 647 | for (const tag of this.csgoEnglish['inverted'][graffitiName] || []) { 648 | const stickerTag = `#${tag}`; 649 | 650 | const stickerKits = this.itemsGame.sticker_kits; 651 | 652 | const kitIndices = Object.keys(stickerKits).filter((n) => { 653 | const k = stickerKits[n]; 654 | 655 | return k.item_name === stickerTag; 656 | }); 657 | 658 | // prefer kit indices with "graffiti" in the name 659 | kitIndices.sort((a, b) => { 660 | const index1 = !!stickerKits[a].name && stickerKits[a].name.indexOf('graffiti'); 661 | const index2 = !!stickerKits[b].name && stickerKits[b].name.indexOf('graffiti'); 662 | if (index1 === index2) { 663 | return 0 664 | } else if (index1 > -1) { 665 | return -1 666 | } else { 667 | return 1 668 | } 669 | }); 670 | 671 | for (const kitIndex of kitIndices) { 672 | const kit = stickerKits[kitIndex]; 673 | 674 | if (!kit || !kit.sticker_material) continue; 675 | 676 | const url = this.getStickerURL(kit.sticker_material, true); 677 | 678 | if (url) { 679 | return url; 680 | } 681 | } 682 | } 683 | } 684 | 685 | /** 686 | * Returns the weapon URL given the market hash name 687 | * @param marketHashName Weapon name 688 | * @param {string?} phase Optional Doppler Phase from the phase enum 689 | * @return {string|void} Weapon image URL 690 | */ 691 | getWeaponNameURL(marketHashName, phase) { 692 | const hasWear = wears.findIndex((n) => marketHashName.includes(n)) > -1; 693 | 694 | if (hasWear) { 695 | // remove it 696 | marketHashName = marketHashName.replace(/\([^)]*\)$/, ''); 697 | } 698 | 699 | const match = marketHashName.split('|').map((m) => m.trim()); 700 | 701 | const weaponName = match[0]; 702 | const skinName = match[1]; 703 | 704 | if (!weaponName) return; 705 | 706 | const weaponTags = this.csgoEnglish['inverted'][weaponName] || []; 707 | const prefabs = this.itemsGame.prefabs; 708 | const items = this.itemsGame.items; 709 | 710 | // For every matching weapon tag... 711 | for (const t of weaponTags) { 712 | const weaponTag = `#${t}`; 713 | 714 | const prefab = Object.keys(prefabs).find((n) => { 715 | const fab = prefabs[n]; 716 | 717 | return fab.item_name === weaponTag; 718 | }); 719 | 720 | let weaponClass; 721 | 722 | if (!prefab) { 723 | // special knives aren't in the prefab (karambits, etc...) 724 | const item = Object.keys(items).find((n) => { 725 | const i = items[n]; 726 | 727 | return i.item_name === weaponTag; 728 | }); 729 | 730 | if (items[item]) { 731 | weaponClass = items[item].name; 732 | } 733 | } 734 | else { 735 | const item = Object.keys(items).find((n) => { 736 | const i = items[n]; 737 | 738 | return i.prefab === prefab; 739 | }); 740 | 741 | if (items[item]) { 742 | weaponClass = items[item].name; 743 | } 744 | } 745 | 746 | if (!weaponClass) continue; 747 | 748 | // Check if this is a vanilla weapon 749 | if (!skinName) { 750 | if (weaponClass && this.itemsGameCDN[weaponClass]) { 751 | return this.itemsGameCDN[weaponClass]; 752 | } 753 | else { 754 | continue; 755 | } 756 | } 757 | 758 | // For every matching skin name... 759 | for (const key of this.csgoEnglish['inverted'][skinName] || []) { 760 | const skinTag = `#${key.toLowerCase()}`; 761 | 762 | const paintKits = this.itemsGame.paint_kits; 763 | 764 | const paintindexes = Object.keys(paintKits).filter((n) => { 765 | const kit = paintKits[n]; 766 | const isPhase = !phase || kit.name.endsWith(phase); 767 | 768 | return isPhase && kit.description_tag === skinTag; 769 | }); 770 | 771 | // For every matching paint index... 772 | for (const paintindex of paintindexes) { 773 | const paintKit = paintKits[paintindex].name; 774 | 775 | const path = (paintKit ? `${weaponClass}_${paintKit}` : weaponClass).toLowerCase(); 776 | 777 | if (this.itemsGameCDN[path]) { 778 | return this.itemsGameCDN[path]; 779 | } 780 | } 781 | } 782 | } 783 | } 784 | 785 | /** 786 | * Returns the music kit URL given the market hash name 787 | * @param marketHashName Music kit name 788 | * @return {string|void} Music kit image URL 789 | */ 790 | getMusicKitNameURL(marketHashName) { 791 | const reg = /Music Kit \| (.*)/; 792 | const match = marketHashName.match(reg); 793 | 794 | if (!match) return; 795 | 796 | const kitName = match[1]; 797 | 798 | for (const t of this.csgoEnglish['inverted'][kitName] || []) { 799 | const tag = `#${t}`; 800 | 801 | const musicDefs = this.itemsGame.music_definitions; 802 | 803 | const kitIndex = Object.keys(musicDefs).find((n) => { 804 | const k = musicDefs[n]; 805 | 806 | return k.loc_name === tag; 807 | }); 808 | 809 | const kit = musicDefs[kitIndex]; 810 | 811 | if (!kit || !kit.image_inventory) continue; 812 | 813 | const path = `resource/flash/${kit.image_inventory}.png`; 814 | 815 | const url = this.getPathURL(path); 816 | 817 | if (url) { 818 | return url; 819 | } 820 | } 821 | } 822 | 823 | /** 824 | * Retrieves the given item CDN URL given its market_hash_name 825 | * 826 | * Examples: M4A4 | 龍王 (Dragon King) (Field-Tested), Sticker | Robo, AWP | Redline (Field-Tested) 827 | * 828 | * Note: For a weapon, the name MUST have the associated wear 829 | * 830 | * @param marketHashName Item name 831 | * @param {string?} phase Optional Doppler Phase from the phase enum 832 | */ 833 | getItemNameURL(marketHashName, phase) { 834 | marketHashName = marketHashName.trim(); 835 | let strippedMarketHashName = marketHashName; 836 | 837 | // Weapons and Music Kits can have extra tags we need to ignore 838 | const extraTags = ['★ ', 'StatTrak™ ', 'Souvenir ']; 839 | 840 | for (const tag of extraTags) { 841 | if (strippedMarketHashName.startsWith(tag)) { 842 | strippedMarketHashName = strippedMarketHashName.replace(tag, ''); 843 | } 844 | } 845 | 846 | if (this.isWeapon(strippedMarketHashName)) { 847 | return this.getWeaponNameURL(strippedMarketHashName, phase); 848 | } 849 | else if (strippedMarketHashName.startsWith('Music Kit |')) { 850 | return this.getMusicKitNameURL(strippedMarketHashName); 851 | } 852 | else if (marketHashName.startsWith('Sticker |')) { 853 | return this.getStickerNameURL(marketHashName); 854 | } 855 | else if (marketHashName.startsWith('Sealed Graffiti |')) { 856 | return this.getGraffitiNameURL(marketHashName); 857 | } 858 | else if (marketHashName.startsWith('Patch |')) { 859 | return this.getPatchNameURL(marketHashName); 860 | } 861 | else { 862 | // Other in items 863 | for (const t of this.csgoEnglish['inverted'][marketHashName] || []) { 864 | const tag = `#${t.toLowerCase()}`; 865 | const items = this.itemsGame.items; 866 | const prefabs = this.itemsGame.prefabs; 867 | 868 | let item = Object.keys(items).find((n) => { 869 | const i = items[n]; 870 | 871 | return i.item_name && i.item_name.toLowerCase() === tag; 872 | }); 873 | 874 | let path; 875 | 876 | if (!items[item] || !items[item].image_inventory) { 877 | // search the prefabs (ex. CS:GO Case Key) 878 | item = Object.keys(prefabs).find((n) => { 879 | const i = prefabs[n]; 880 | 881 | return i.item_name && i.item_name.toLowerCase() === tag; 882 | }); 883 | 884 | if (!prefabs[item] || !prefabs[item].image_inventory) continue; 885 | 886 | path = `resource/flash/${prefabs[item].image_inventory}.png`; 887 | } 888 | else { 889 | path = `resource/flash/${items[item].image_inventory}.png`; 890 | } 891 | 892 | const url = this.getPathURL(path); 893 | 894 | if (url) { 895 | return url; 896 | } 897 | } 898 | } 899 | } 900 | } 901 | 902 | module.exports = CSGOCdn; 903 | --------------------------------------------------------------------------------