csgo-cdn
Version:
Retrieves the Steam CDN URLs for CS:GO Item Images
903 lines (706 loc) • 29.5 kB
JavaScript
const Promise = require('bluebird');
const EventEmitter = require('events');
const fs = Promise.promisifyAll(require('fs'));
const vpk = require('vpk');
const vdf = require('simple-vdf');
const hasha = require('hasha');
const winston = require('winston');
const defaultConfig = {
directory: 'data',
updateInterval: 30000,
stickers: true,
patches: true,
graffiti: true,
characters: true,
musicKits: true,
cases: true,
tools: true,
statusIcons: true,
logLevel: 'info'
};
const wears = ['Factory New', 'Minimal Wear', 'Field-Tested', 'Well-Worn', 'Battle-Scarred'];
const neededDirectories = {
stickers: 'resource/flash/econ/stickers',
patches: 'resource/flash/econ/patches',
graffiti: 'resource/flash/econ/stickers/default',
characters: 'resource/flash/econ/characters',
musicKits: 'resource/flash/econ/music_kits',
cases: 'resource/flash/econ/weapon_cases',
tools: 'resource/flash/econ/tools',
statusIcons: 'resource/flash/econ/status_icons',
};
function bytesToMB(bytes) {
return (bytes/1000000).toFixed(2);
}
class CSGOCdn extends EventEmitter {
get ready() {
return this.ready_ || false;
}
get steamReady() {
return !!this.user.steamID;
}
get phase() {
return {
ruby: 'am_ruby_marbleized',
sapphire: 'am_sapphire_marbleized',
blackpearl: 'am_blackpearl_marbleized',
emerald: 'am_emerald_marbleized',
phase1: 'phase1',
phase2: 'phase2',
phase3: 'phase3',
phase4: 'phase4'
}
}
set ready(r) {
const old = this.ready;
this.ready_ = r;
if (r !== old && r) {
this.log.debug('Ready');
this.emit('ready');
}
}
constructor(steamUser, config={}) {
super();
this.config = Object.assign(defaultConfig, config);
this.createDataDirectory();
this.user = Promise.promisifyAll(steamUser, {multiArgs: true});
this.log = winston.createLogger({
level: config.logLevel,
transports: [
new winston.transports.Console({
colorize: true,
format: winston.format.printf((info) => {
return `[csgo-image-cdn] ${info.level}: ${info.message}`;
})
})
]
});
if (!this.steamReady) {
this.log.debug('Steam not ready, waiting for logon');
this.user.once('loggedOn', () => {
this.updateLoop();
});
}
else {
this.updateLoop();
}
}
/**
* Creates the data directory specified in the config if it doesn't exist
*/
createDataDirectory() {
const dir = `./${this.config.directory}`;
if (!fs.existsSync(dir)){
fs.mkdirSync(dir);
}
}
/**
* Runs the update loop at the specified config interval
* @return {Promise<undefined>|void}
*/
updateLoop() {
if (this.config.updateInterval > 0) {
return this.update().then(() => Promise.delay(this.config.updateInterval*1000))
.then(() => this.updateLoop());
}
else {
this.log.info('Auto-updates disabled, checking if required files exist');
// Try to load the resources locally
try {
this.loadResources();
this.loadVPK();
this.ready = true;
} catch(e) {
this.log.warn('Needed CS:GO files not installed');
this.update();
}
}
}
/**
* Returns the product info for CSGO, with its depots and packages
*/
getProductInfo() {
this.log.debug('Obtaining CS:GO product info');
return new Promise((resolve, reject) => {
this.user.getProductInfo([730], [], true, (apps, packages, unknownApps, unknownPackages) => {
resolve([apps, packages, unknownApps, unknownPackages]);
});
});
}
/**
* Returns the latest CSGO manifest ID for the public 731 depot
* @return {*|PromiseLike<*[]>|Promise<*[]>} 731 Depot Manifest ID
*/
getLatestManifestId() {
this.log.debug('Obtaining latest manifest ID');
return this.getProductInfo().then(([apps, packages, unknownApps, unknownPackages]) => {
const csgo = packages['730'].appinfo;
const commonDepot = csgo.depots['731'];
return commonDepot.manifests.public;
});
}
/**
* Retrieves and updates the sticker file directory from Valve
*
* Ensures that only the required VPK files are downloaded and that files with the same SHA1 aren't
* redownloaded
*
* @return {Promise<void>}
*/
async update() {
this.log.info('Checking for CS:GO file updates');
if (!this.steamReady) {
this.log.warn(`Steam not ready, can't check for updates`);
return;
}
const manifestId = await this.getLatestManifestId();
this.log.debug(`Obtained latest manifest ID: ${manifestId}`);
const [manifest] = await this.user.getManifestAsync(730, 731, manifestId);
const manifestFiles = manifest.files;
const dirFile = manifest.files.find((file) => file.filename.endsWith("csgo\\pak01_dir.vpk"));
const itemsGameFile = manifest.files.find((file) => file.filename.endsWith("items_game.txt"));
const itemsGameCDNFile = manifest.files.find((file) => file.filename.endsWith("items_game_cdn.txt"));
const csgoEnglishFile = manifest.files.find((file) => file.filename.endsWith("csgo_english.txt"));
this.log.debug(`Downloading required static files`);
await this.downloadFiles([dirFile, itemsGameFile, itemsGameCDNFile, csgoEnglishFile]);
this.log.debug('Loading static file resources');
this.loadResources();
this.loadVPK();
await this.downloadVPKFiles(this.vpkDir, manifestFiles);
this.ready = true;
}
loadResources() {
this.itemsGame = vdf.parse(fs.readFileSync(`${this.config.directory}/items_game.txt`, 'utf8'))['items_game'];
this.csgoEnglish = vdf.parse(fs.readFileSync(`${this.config.directory}/csgo_english.txt`, 'ucs2'))['lang']['Tokens'];
this.itemsGameCDN = this.parseItemsCDN(fs.readFileSync(`${this.config.directory}/items_game_cdn.txt`, 'utf8'));
this.weaponNameMap = Object.keys(this.csgoEnglish).filter(n => n.startsWith("SFUI_WPNHUD"));
this.csgoEnglishKeys = Object.keys(this.csgoEnglish);
// Ensure paint kit descriptions are lowercase to resolve inconsistencies in the language and items_game file
Object.keys(this.itemsGame.paint_kits).forEach((n) => {
const kit = this.itemsGame.paint_kits[n];
if ('description_tag' in kit) {
kit.description_tag = kit.description_tag.toLowerCase();
}
});
this.invertDictionary(this.csgoEnglish);
}
/**
* Inverts the key mapping of a dictionary recursively while preserving the original keys
*
* Duplicate values with be an array
*
* @param dict Dictionary to invert
*/
invertDictionary(dict) {
dict['inverted'] = {};
for (const prop in dict) {
if (prop === 'inverted' || !dict.hasOwnProperty(prop)) continue;
const val = dict[prop];
if (typeof val === 'object' && !(val instanceof Array)) {
this.invertDictionary(val);
}
else {
if (dict['inverted'][val] === undefined) {
dict['inverted'][val] = [prop];
}
else {
dict['inverted'][val].push(prop);
}
}
}
}
parseItemsCDN(data) {
let lines = data.split('\n');
const items_game_cdn = {};
for (let line of lines) {
let kv = line.split('=');
if (kv[1]) {
items_game_cdn[kv[0]] = kv[1];
}
}
return items_game_cdn;
}
/**
* Downloads the given VPK files from the Steam CDN
* @param files Steam Manifest File Array
* @return {Promise<>} Fulfilled when completed downloading
*/
async downloadFiles(files) {
const promises = [];
for (const file of files) {
let name = file.filename.split('\\');
name = name[name.length-1];
const path = `${this.config.directory}/${name}`;
const isDownloaded = await this.isFileDownloaded(path, file.sha_content);
if (isDownloaded) {
continue;
}
const promise = this.user.downloadFile(730, 731, file, `${this.config.directory}/${name}`);
promises.push(promise);
}
return Promise.all(promises);
}
/**
* Loads the CSGO dir VPK specified in the config
*/
loadVPK() {
this.vpkDir = new vpk(this.config.directory + '/pak01_dir.vpk');
this.vpkDir.load();
this.vpkStickerFiles = this.vpkDir.files.filter((f) => f.startsWith('resource/flash/econ/stickers'));
this.vpkPatchFiles = this.vpkDir.files.filter((f) => f.startsWith('resource/flash/econ/patches'));
}
/**
* Given the CSGO VPK Directory, returns the necessary indices for the chosen options
* @param vpkDir CSGO VPK Directory
* @return {Array} Necessary Sticker VPK Indices
*/
getRequiredVPKFiles(vpkDir) {
const requiredIndices = [];
const neededDirs = Object.keys(neededDirectories).filter((f) => !!this.config[f]).map((f) => neededDirectories[f]);
for (const fileName of vpkDir.files) {
for (const dir of neededDirs) {
if (fileName.startsWith(dir)) {
const archiveIndex = vpkDir.tree[fileName].archiveIndex;
if (!requiredIndices.includes(archiveIndex)) {
requiredIndices.push(archiveIndex);
}
break;
}
}
}
return requiredIndices.sort();
}
/**
* Downloads the required VPK files
* @param vpkDir CSGO VPK Directory
* @param manifestFiles Manifest files
* @return {Promise<void>}
*/
async downloadVPKFiles(vpkDir, manifestFiles) {
this.log.debug('Computing required VPK files for selected packages');
const requiredIndices = this.getRequiredVPKFiles(vpkDir);
this.log.debug(`Required VPK files ${requiredIndices}`);
for (let index in requiredIndices) {
index = parseInt(index);
// pad to 3 zeroes
const archiveIndex = requiredIndices[index];
const paddedIndex = '0'.repeat(3-archiveIndex.toString().length) + archiveIndex;
const fileName = `pak01_${paddedIndex}.vpk`;
const file = manifestFiles.find((f) => f.filename.endsWith(fileName));
const filePath = `${this.config.directory}/${fileName}`;
const isDownloaded = await this.isFileDownloaded(filePath, file.sha_content);
if (isDownloaded) {
this.log.info(`Already downloaded ${filePath}`);
continue;
}
const status = `[${index+1}/${requiredIndices.length}]`;
this.log.info(`${status} Downloading ${fileName} - ${bytesToMB(file.size)} MB`);
await this.user.downloadFile(730, 731, file, filePath, (none, { type, bytesDownloaded, totalSizeBytes }) => {
if (type === 'progress') {
this.log.info(`${status} ${(bytesDownloaded*100/totalSizeBytes).toFixed(2)}% - ${bytesToMB(bytesDownloaded)}/${bytesToMB(totalSizeBytes)} MB`);
}
});
this.log.info(`${status} Downloaded ${fileName}`);
}
}
/**
* Returns whether a file at the given path has the given sha1
* @param path File path
* @param sha1 File SHA1 hash
* @return {Promise<boolean>} Whether the file has the hash
*/
async isFileDownloaded(path, sha1) {
try {
const hash = await hasha.fromFile(path, {algorithm: 'sha1'});
return hash === sha1;
}
catch (e) {
return false;
}
}
/**
* Given a VPK path, returns the CDN URL
* @param path VPK path
* @return {string|void} CDN URL
*/
getPathURL(path) {
const file = this.vpkDir.getFile(path);
if (!file) {
this.log.error(`Failed to retrieve ${path} in VPK, do you have the package category enabled in options?`);
return;
}
const sha1 = hasha(file, {
'algorithm': 'sha1'
});
path = path.replace('resource/flash', 'icons');
path = path.replace('.png', `.${sha1}.png`);
return `https://steamcdn-a.akamaihd.net/apps/730/${path}`;
}
/**
* Returns the item Steam CDN URL for the specified name
*
* Example Sticker Names: cologne2016/nv, cologne2016/fntc_holo, cologne2016/fntc_foil, cluj2015/sig_olofmeister_gold
*
* You can find the sticker names from their relevant "sticker_material" fields in items_game.txt
* items_game.txt can be found in the core game files of CS:GO or as itemsGame here
*
* @param name The item name (the sticker_material field in items_game.txt, or the cdn file format)
* @param large Whether to obtain the "large" CDN version of the item
* @return {string|void} If successful, the HTTPS CDN URL for the item
*/
getStickerURL(name, large=true) {
if (!this.ready) {
return;
}
const fileName = large ? `${name}_large.png` : `${name}.png`;
const path = this.vpkStickerFiles.find((t) => t.endsWith(fileName));
if (path) return this.getPathURL(path);
}
/**
* Returns the item Steam CDN URL for the specified name
*
* Example Patch Names: case01/patch_phoenix, case01/patch_dangerzone, case01/patch_easypeasy, case_skillgroups/patch_goldnova1
*
* You can find the patch names from their relevant "patch_material" fields in items_game.txt
* items_game.txt can be found in the core game files of CS:GO or as itemsGame here
*
* @param name The item name (the patch_material field in items_game.txt, or the cdn file format)
* @param large Whether to obtain the "large" CDN version of the item
* @return {string|void} If successful, the HTTPS CDN URL for the item
*/
getPatchURL(name, large=true) {
if (!this.ready) {
return;
}
const fileName = large ? `${name}_large.png` : `${name}.png`;
const path = this.vpkPatchFiles.find((t) => t.endsWith(fileName));
if (path) return this.getPathURL(path);
}
/**
* Given the specified defindex and paintindex, returns the CDN URL
*
* The item properties can be found in items_game.txt
*
* @param defindex Item Definition Index (weapon type)
* @param paintindex Item Paint Index (skin type)
* @return {string|void} Weapon CDN URL
*/
getWeaponURL(defindex, paintindex) {
if (!this.ready) return;
const paintKits = this.itemsGame.paint_kits;
// Get the skin name
let skinName = '';
if (paintindex in paintKits) {
skinName = paintKits[paintindex].name;
if (skinName === 'default') {
skinName = '';
}
}
// Get the weapon name
let weaponName;
const items = this.itemsGame.items;
if (defindex in items) {
weaponName = items[defindex].name;
}
// Get the image url
const cdnName = `${weaponName}_${skinName}`;
return this.itemsGameCDN[cdnName];
}
/**
* Returns whether the given name is a weapon by checking
* the prefab and whether it is used by one of the sides
* @param marketHashName Item name
* @return {boolean} Whether a weapon
*/
isWeapon(marketHashName) {
const prefabs = this.itemsGame.prefabs;
const items = this.itemsGame.items;
const weaponName = marketHashName.split('|')[0].trim();
const weaponTags = this.csgoEnglish['inverted'][weaponName];
if (!weaponTags) return false;
// For every matching weapon tag...
for (const t of weaponTags) {
const weaponTag = `#${t}`;
const prefab = Object.keys(prefabs).find((n) => {
const fab = prefabs[n];
return fab.item_name === weaponTag;
});
let fab;
if (!prefab) {
// special knives aren't in the prefab (karambits, etc...)
const item = Object.keys(items).find((n) => {
const i = items[n];
return i.item_name === weaponTag;
});
fab = items[item];
}
else {
fab = prefabs[prefab];
}
if (fab && fab.used_by_classes) {
const used = fab.used_by_classes;
// Ensure that the item is used by one of the sides
if (used['terrorists'] || used['counter-terrorists']) {
return true;
}
}
}
return false;
}
/**
* Returns the sticker URL given the market hash name
* @param marketHashName Sticker name
* @return {string|void} Sticker image URL
*/
getStickerNameURL(marketHashName) {
const reg = /Sticker \| (.*)/;
const match = marketHashName.match(reg);
if (!match) return;
const stickerName = match[1];
for (const tag of this.csgoEnglish['inverted'][stickerName] || []) {
const stickerTag = `#${tag}`;
const stickerKits = this.itemsGame.sticker_kits;
const kitIndex = Object.keys(stickerKits).find((n) => {
const k = stickerKits[n];
return k.item_name === stickerTag;
});
const kit = stickerKits[kitIndex];
if (!kit || !kit.sticker_material) continue;
const url = this.getStickerURL(stickerKits[kitIndex].sticker_material, true);
if (url) {
return url;
}
}
}
/**
* Returns the patch URL given the market hash name
* @param marketHashName Patch name
* @return {string|void} Patch image URL
*/
getPatchNameURL(marketHashName) {
const reg = /Patch \| (.*)/;
const match = marketHashName.match(reg);
if (!match) return;
const stickerName = match[1];
for (const tag of this.csgoEnglish['inverted'][stickerName] || []) {
const stickerTag = `#${tag}`;
const stickerKits = this.itemsGame.sticker_kits; // Patches are in the sticker_kits as well
const kitIndex = Object.keys(stickerKits).find((n) => {
const k = stickerKits[n];
return k.item_name === stickerTag;
});
const kit = stickerKits[kitIndex];
if (!kit || !kit.patch_material) continue;
const url = this.getPatchURL(stickerKits[kitIndex].patch_material, true);
if (url) return url;
}
}
/**
* Returns the graffiti URL given the market hash name
* @param marketHashName Graffiti name (optional tint)
* @param large Whether to obtain the "large" CDN version of the item
* @return {string|void} CDN Image URL
*/
getGraffitiNameURL(marketHashName, large=true) {
const reg = /Sealed Graffiti \| ([^(]*)/;
const match = marketHashName.match(reg);
if (!match) return;
const graffitiName = match[1].trim();
for (const tag of this.csgoEnglish['inverted'][graffitiName] || []) {
const stickerTag = `#${tag}`;
const stickerKits = this.itemsGame.sticker_kits;
const kitIndices = Object.keys(stickerKits).filter((n) => {
const k = stickerKits[n];
return k.item_name === stickerTag;
});
// prefer kit indices with "graffiti" in the name
kitIndices.sort((a, b) => {
const index1 = !!stickerKits[a].name && stickerKits[a].name.indexOf('graffiti');
const index2 = !!stickerKits[b].name && stickerKits[b].name.indexOf('graffiti');
if (index1 === index2) {
return 0
} else if (index1 > -1) {
return -1
} else {
return 1
}
});
for (const kitIndex of kitIndices) {
const kit = stickerKits[kitIndex];
if (!kit || !kit.sticker_material) continue;
const url = this.getStickerURL(kit.sticker_material, true);
if (url) {
return url;
}
}
}
}
/**
* Returns the weapon URL given the market hash name
* @param marketHashName Weapon name
* @param {string?} phase Optional Doppler Phase from the phase enum
* @return {string|void} Weapon image URL
*/
getWeaponNameURL(marketHashName, phase) {
const hasWear = wears.findIndex((n) => marketHashName.includes(n)) > -1;
if (hasWear) {
// remove it
marketHashName = marketHashName.replace(/\([^)]*\)$/, '');
}
const match = marketHashName.split('|').map((m) => m.trim());
const weaponName = match[0];
const skinName = match[1];
if (!weaponName) return;
const weaponTags = this.csgoEnglish['inverted'][weaponName] || [];
const prefabs = this.itemsGame.prefabs;
const items = this.itemsGame.items;
// For every matching weapon tag...
for (const t of weaponTags) {
const weaponTag = `#${t}`;
const prefab = Object.keys(prefabs).find((n) => {
const fab = prefabs[n];
return fab.item_name === weaponTag;
});
let weaponClass;
if (!prefab) {
// special knives aren't in the prefab (karambits, etc...)
const item = Object.keys(items).find((n) => {
const i = items[n];
return i.item_name === weaponTag;
});
if (items[item]) {
weaponClass = items[item].name;
}
}
else {
const item = Object.keys(items).find((n) => {
const i = items[n];
return i.prefab === prefab;
});
if (items[item]) {
weaponClass = items[item].name;
}
}
if (!weaponClass) continue;
// Check if this is a vanilla weapon
if (!skinName) {
if (weaponClass && this.itemsGameCDN[weaponClass]) {
return this.itemsGameCDN[weaponClass];
}
else {
continue;
}
}
// For every matching skin name...
for (const key of this.csgoEnglish['inverted'][skinName] || []) {
const skinTag = `#${key.toLowerCase()}`;
const paintKits = this.itemsGame.paint_kits;
const paintindexes = Object.keys(paintKits).filter((n) => {
const kit = paintKits[n];
const isPhase = !phase || kit.name.endsWith(phase);
return isPhase && kit.description_tag === skinTag;
});
// For every matching paint index...
for (const paintindex of paintindexes) {
const paintKit = paintKits[paintindex].name;
const path = (paintKit ? `${weaponClass}_${paintKit}` : weaponClass).toLowerCase();
if (this.itemsGameCDN[path]) {
return this.itemsGameCDN[path];
}
}
}
}
}
/**
* Returns the music kit URL given the market hash name
* @param marketHashName Music kit name
* @return {string|void} Music kit image URL
*/
getMusicKitNameURL(marketHashName) {
const reg = /Music Kit \| (.*)/;
const match = marketHashName.match(reg);
if (!match) return;
const kitName = match[1];
for (const t of this.csgoEnglish['inverted'][kitName] || []) {
const tag = `#${t}`;
const musicDefs = this.itemsGame.music_definitions;
const kitIndex = Object.keys(musicDefs).find((n) => {
const k = musicDefs[n];
return k.loc_name === tag;
});
const kit = musicDefs[kitIndex];
if (!kit || !kit.image_inventory) continue;
const path = `resource/flash/${kit.image_inventory}.png`;
const url = this.getPathURL(path);
if (url) {
return url;
}
}
}
/**
* Retrieves the given item CDN URL given its market_hash_name
*
* Examples: M4A4 | 龍王 (Dragon King) (Field-Tested), Sticker | Robo, AWP | Redline (Field-Tested)
*
* Note: For a weapon, the name MUST have the associated wear
*
* @param marketHashName Item name
* @param {string?} phase Optional Doppler Phase from the phase enum
*/
getItemNameURL(marketHashName, phase) {
marketHashName = marketHashName.trim();
let strippedMarketHashName = marketHashName;
// Weapons and Music Kits can have extra tags we need to ignore
const extraTags = ['★ ', 'StatTrak™ ', 'Souvenir '];
for (const tag of extraTags) {
if (strippedMarketHashName.startsWith(tag)) {
strippedMarketHashName = strippedMarketHashName.replace(tag, '');
}
}
if (this.isWeapon(strippedMarketHashName)) {
return this.getWeaponNameURL(strippedMarketHashName, phase);
}
else if (strippedMarketHashName.startsWith('Music Kit |')) {
return this.getMusicKitNameURL(strippedMarketHashName);
}
else if (marketHashName.startsWith('Sticker |')) {
return this.getStickerNameURL(marketHashName);
}
else if (marketHashName.startsWith('Sealed Graffiti |')) {
return this.getGraffitiNameURL(marketHashName);
}
else if (marketHashName.startsWith('Patch |')) {
return this.getPatchNameURL(marketHashName);
}
else {
// Other in items
for (const t of this.csgoEnglish['inverted'][marketHashName] || []) {
const tag = `#${t.toLowerCase()}`;
const items = this.itemsGame.items;
const prefabs = this.itemsGame.prefabs;
let item = Object.keys(items).find((n) => {
const i = items[n];
return i.item_name && i.item_name.toLowerCase() === tag;
});
let path;
if (!items[item] || !items[item].image_inventory) {
// search the prefabs (ex. CS:GO Case Key)
item = Object.keys(prefabs).find((n) => {
const i = prefabs[n];
return i.item_name && i.item_name.toLowerCase() === tag;
});
if (!prefabs[item] || !prefabs[item].image_inventory) continue;
path = `resource/flash/${prefabs[item].image_inventory}.png`;
}
else {
path = `resource/flash/${items[item].image_inventory}.png`;
}
const url = this.getPathURL(path);
if (url) {
return url;
}
}
}
}
}
module.exports = CSGOCdn;