diff --git a/classes/CSteamSharedfile.js b/classes/CSteamSharedfile.js new file mode 100644 index 0000000..889039a --- /dev/null +++ b/classes/CSteamSharedfile.js @@ -0,0 +1,218 @@ +const Cheerio = require('cheerio'); +const SteamID = require('steamid'); +const Helpers = require('../components/helpers.js'); +const SteamCommunity = require('../index.js'); +const ESharedfileType = require('../resources/ESharedfileType.js'); + + +/** + * Scrape a sharedfile's DOM to get all available information + * @param {String} sharedFileId - ID of the sharedfile + * @param {function} callback - First argument is null/Error, second is object containing all available information + */ +SteamCommunity.prototype.getSteamSharedfile = function(sharedFileId, callback) { + + // Construct object holding all the data we can scrape + let sharedfile = { + id: sharedFileId, + type: null, + appID: null, + owner: null, + fileSize: null, + postDate: null, + resolution: null, + uniqueVisitorsCount: null, + favoritesCount: null, + upvoteCount: null, + guideNumRatings: null, + isUpvoted: null, + isDownvoted: null + }; + + + // Get DOM of sharedfile + this.httpRequestGet(`https://steamcommunity.com/sharedfiles/filedetails/?id=${sharedFileId}`, (err, res, body) => { + try { + + /* --------------------- Preprocess output --------------------- */ + + // Load output into cheerio to make parsing easier + let $ = Cheerio.load(body); + + // Dynamically map detailsStatsContainerLeft to detailsStatsContainerRight in an object to make readout easier. It holds size, post date and resolution. + let detailsStatsObj = {}; + let detailsLeft = $(".detailsStatsContainerLeft").children(); + let detailsRight = $(".detailsStatsContainerRight").children(); + + Object.keys(detailsLeft).forEach((e) => { // Dynamically get all details. Don't hardcore so that this also works for guides. + if (isNaN(e)) { + return; // Ignore invalid entries + } + + detailsStatsObj[detailsLeft[e].children[0].data.trim()] = detailsRight[e].children[0].data; + }); + + // Dynamically map stats_table descriptions to values. This holds Unique Visitors and Current Favorites + let statsTableObj = {}; + let statsTable = $(".stats_table").children(); + + Object.keys(statsTable).forEach((e) => { + if (isNaN(e)) { + return; // Ignore invalid entries + } + + // Value description is at index 3, value data at index 1 + statsTableObj[statsTable[e].children[3].children[0].data] = statsTable[e].children[1].children[0].data.replace(/,/g, ""); // Remove commas from 1k+ values + }); + + + /* --------------------- Find and map values --------------------- */ + + // Find appID in share button onclick event + sharedfile.appID = Number($("#ShareItemBtn").attr()["onclick"].replace(`ShowSharePublishedFilePopup( '${sharedFileId}', '`, "").replace("' );", "")); + + + // Find fileSize if not guide + sharedfile.fileSize = detailsStatsObj["File Size"] || null; // TODO: Convert to bytes? It seems like to always be MB but no guarantee + + + // Find postDate and convert to timestamp + let posted = detailsStatsObj["Posted"].trim(); + + sharedfile.postDate = Date.parse(Helpers.decodeSteamTime(posted)); // Pass String into helper and parse the returned String to get a Unix timestamp + + + // Find resolution if artwork or screenshot + sharedfile.resolution = detailsStatsObj["Size"] || null; + + + // Find uniqueVisitorsCount. We can't use ' || null' here as Number("0") casts to false + if (statsTableObj["Unique Visitors"]) { + sharedfile.uniqueVisitorsCount = Number(statsTableObj["Unique Visitors"]); + } + + + // Find favoritesCount. We can't use ' || null' here as Number("0") casts to false + if (statsTableObj["Current Favorites"]) { + sharedfile.favoritesCount = Number(statsTableObj["Current Favorites"]); + } + + + // Find upvoteCount. We can't use ' || null' here as Number("0") casts to false + let upvoteCount = $("#VotesUpCountContainer > #VotesUpCount").text(); + + if (upvoteCount) { + sharedfile.upvoteCount = Number(upvoteCount); + } + + + // Find numRatings if this is a guide as they use a different voting system + let numRatings = $(".ratingSection > .numRatings").text().replace(" ratings", ""); + + sharedfile.guideNumRatings = Number(numRatings) || null; // Set to null if not a guide or if the guide does not have enough ratings to show a value + + + // Determine if this account has already voted on this sharedfile + sharedfile.isUpvoted = String($(".workshopItemControlCtn > #VoteUpBtn")[0].attribs["class"]).includes("toggled"); // Check if upvote btn class contains "toggled" + sharedfile.isDownvoted = String($(".workshopItemControlCtn > #VoteDownBtn")[0].attribs["class"]).includes("toggled"); // Check if downvote btn class contains "toggled" + + + // Determine type by looking at the second breadcrumb. Find the first separator as it has a unique name and go to the next element which holds our value of interest + let breadcrumb = $(".breadcrumbs > .breadcrumb_separator").next().get(0).children[0].data || ""; + + if (breadcrumb.includes("Screenshot")) { + sharedfile.type = ESharedfileType.Screenshot; + } + + if (breadcrumb.includes("Artwork")) { + sharedfile.type = ESharedfileType.Artwork; + } + + if (breadcrumb.includes("Guide")) { + sharedfile.type = ESharedfileType.Guide; + } + + + // Find owner profile link, convert to steamID64 using SteamIdResolver lib and create a SteamID object + let ownerHref = $(".friendBlockLinkOverlay").attr()["href"]; + + Helpers.resolveVanityURL(ownerHref, (err, data) => { // This request takes <1 sec + if (!err) { + sharedfile.owner = new SteamID(data.steamID); + } + + // Make callback when ID was resolved as otherwise owner will always be null + callback(null, new CSteamSharedfile(this, sharedfile)); + }); + + } catch (err) { + callback(err, null); + } + }, "steamcommunity"); +}; + +/** + * Constructor - Creates a new Sharedfile object + * @class + * @param {SteamCommunity} community + * @param {{ id: string, type: ESharedfileType, appID: number, owner: SteamID|null, fileSize: string|null, postDate: number, resolution: string|null, uniqueVisitorsCount: number, favoritesCount: number, upvoteCount: number|null, guideNumRatings: Number|null, isUpvoted: boolean, isDownvoted: boolean }} data + */ +function CSteamSharedfile(community, data) { + /** + * @type {SteamCommunity} + */ + this._community = community; + + // Clone all the data we recieved + Object.assign(this, data); +} + +/** + * Deletes a comment from this sharedfile's comment section + * @param {String} cid - ID of the comment to delete + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedfile.prototype.deleteComment = function(cid, callback) { + this._community.deleteSharedfileComment(this.userID, this.id, cid, callback); +}; + +/** + * Favorites this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedfile.prototype.favorite = function(callback) { + this._community.favoriteSharedfile(this.id, this.appID, callback); +}; + +/** + * Posts a comment to this sharedfile + * @param {String} message - Content of the comment to post + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedfile.prototype.comment = function(message, callback) { + this._community.postSharedfileComment(this.owner, this.id, message, callback); +}; + +/** + * Subscribes to this sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedfile.prototype.subscribe = function(callback) { + this._community.subscribeSharedfileComments(this.owner, this.id, callback); +}; + +/** + * Unfavorites this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedfile.prototype.unfavorite = function(callback) { + this._community.unfavoriteSharedfile(this.id, this.appID, callback); +}; + +/** + * Unsubscribes from this sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamSharedfile.prototype.unsubscribe = function(callback) { + this._community.unsubscribeSharedfileComments(this.owner, this.id, callback); +}; diff --git a/components/helpers.js b/components/helpers.js index d611686..f077790 100644 --- a/components/helpers.js +++ b/components/helpers.js @@ -1,4 +1,6 @@ const EResult = require('../resources/EResult.js'); +const request = require('request'); +const xml2js = require('xml2js'); exports.isSteamID = function(input) { var keys = Object.keys(input); @@ -65,4 +67,42 @@ exports.decodeJwt = function(jwt) { .replace(/_/g, '/'); return JSON.parse(Buffer.from(standardBase64, 'base64').toString('utf8')); -} +}; + +/** + * Resolves a Steam profile URL to get steamID64 and vanityURL + * @param {String} url - Full steamcommunity profile URL or only the vanity part. + * @param {Object} callback - First argument is null/Error, second is object containing vanityURL (String) and steamID (String) + */ +exports.resolveVanityURL = function(url, callback) { + // Precede url param if only the vanity was provided + if (!url.includes("steamcommunity.com")) { + url = "https://steamcommunity.com/id/" + url; + } + + // Make request to get XML data + request(url + "/?xml=1", function(err, response, body) { + if (err) { + callback(err); + return; + } + + // Parse XML data returned from Steam into an object + new xml2js.Parser().parseString(body, (err, parsed) => { + if (err) { + callback(new Error("Couldn't parse XML response")); + return; + } + + if (parsed.response && parsed.response.error) { + callback(new Error("Couldn't find Steam ID")); + return; + } + + let steamID64 = parsed.profile.steamID64; + let vanityURL = parsed.profile.customURL; + + callback(null, {"vanityURL": vanityURL, "steamID": steamID64}); + }); + }); +}; \ No newline at end of file diff --git a/components/inventoryhistory.js b/components/inventoryhistory.js index fd7c522..961d9c1 100644 --- a/components/inventoryhistory.js +++ b/components/inventoryhistory.js @@ -1,5 +1,6 @@ var SteamCommunity = require('../index.js'); var CEconItem = require('../classes/CEconItem.js'); +var Helpers = require('./helpers.js'); var SteamID = require('steamid'); var request = require('request'); var Cheerio = require('cheerio'); @@ -142,7 +143,7 @@ SteamCommunity.prototype.getInventoryHistory = function(options, callback) { } if (options.resolveVanityURLs) { - Async.map(vanityURLs, resolveVanityURL, function(err, results) { + Async.map(vanityURLs, Helpers.resolveVanityURL, function(err, results) { if (err) { callback(err); return; @@ -170,19 +171,3 @@ SteamCommunity.prototype.getInventoryHistory = function(options, callback) { }, "steamcommunity"); }; -function resolveVanityURL(vanityURL, callback) { - request("https://steamcommunity.com/id/" + vanityURL + "/?xml=1", function(err, response, body) { - if (err) { - callback(err); - return; - } - - var match = body.match(/(\d+)<\/steamID64>/); - if (!match || !match[1]) { - callback(new Error("Couldn't find Steam ID")); - return; - } - - callback(null, {"vanityURL": vanityURL, "steamID": match[1]}); - }); -} diff --git a/components/sharedfiles.js b/components/sharedfiles.js new file mode 100644 index 0000000..c051943 --- /dev/null +++ b/components/sharedfiles.js @@ -0,0 +1,157 @@ +var SteamCommunity = require('../index.js'); +var SteamID = require('steamid'); + + +/** + * Deletes a comment from a sharedfile's comment section + * @param {SteamID | String} userID - ID of the user associated to this sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} cid - ID of the comment to delete + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.deleteSharedfileComment = function(userID, sharedFileId, cid, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/delete/${userID.toString()}/${sharedFileId}/`, + "form": { + "gidcomment": cid, + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(null || err); + }, "steamcommunity"); +}; + +/** + * Favorites a sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} appid - ID of the app associated to this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.favoriteSharedfile = function(sharedFileId, appid, callback) { + this.httpRequestPost({ + "uri": "https://steamcommunity.com/sharedfiles/favorite", + "form": { + "id": sharedFileId, + "appid": appid, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(null || err); + }, "steamcommunity"); +}; + +/** + * Posts a comment to a sharedfile + * @param {SteamID | String} userID - ID of the user associated to this sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} message - Content of the comment to post + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.postSharedfileComment = function(userID, sharedFileId, message, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/post/${userID.toString()}/${sharedFileId}/`, + "form": { + "comment": message, + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(null || err); + }, "steamcommunity"); +}; + +/** + * Subscribes to a sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {SteamID | String} userID ID of the user associated to this sharedfile + * @param {String} sharedFileId ID of the sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.subscribeSharedfileComments = function(userID, sharedFileId, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/subscribe/${userID.toString()}/${sharedFileId}/`, + "form": { + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { // eslint-disable-line + if (!callback) { + return; + } + + callback(null || err); + }, "steamcommunity"); +}; + +/** + * Unfavorites a sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {String} appid - ID of the app associated to this sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.unfavoriteSharedfile = function(sharedFileId, appid, callback) { + this.httpRequestPost({ + "uri": "https://steamcommunity.com/sharedfiles/unfavorite", + "form": { + "id": sharedFileId, + "appid": appid, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { + if (!callback) { + return; + } + + callback(null || err); + }, "steamcommunity"); +}; + +/** + * Unsubscribes from a sharedfile's comment section. Note: Checkbox on webpage does not update + * @param {SteamID | String} userID - ID of the user associated to this sharedfile + * @param {String} sharedFileId - ID of the sharedfile + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.unsubscribeSharedfileComments = function(userID, sharedFileId, callback) { + if (typeof userID === "string") { + userID = new SteamID(userID); + } + + this.httpRequestPost({ + "uri": `https://steamcommunity.com/comment/PublishedFile_Public/unsubscribe/${userID.toString()}/${sharedFileId}/`, + "form": { + "count": 10, + "sessionid": this.getSessionID() + } + }, function(err, response, body) { // eslint-disable-line + if (!callback) { + return; + } + + callback(null || err); + }, "steamcommunity"); +}; diff --git a/index.js b/index.js index dc38428..6cd2257 100644 --- a/index.js +++ b/index.js @@ -581,6 +581,7 @@ require('./components/profile.js'); require('./components/market.js'); require('./components/groups.js'); require('./components/users.js'); +require('./components/sharedfiles.js'); require('./components/inventoryhistory.js'); require('./components/webapi.js'); require('./components/twofactor.js'); @@ -589,6 +590,7 @@ require('./components/help.js'); require('./classes/CMarketItem.js'); require('./classes/CMarketSearchResult.js'); require('./classes/CSteamGroup.js'); +require('./classes/CSteamSharedfile.js'); require('./classes/CSteamUser.js'); /** diff --git a/resources/ESharedfileType.js b/resources/ESharedfileType.js new file mode 100644 index 0000000..fc528d5 --- /dev/null +++ b/resources/ESharedfileType.js @@ -0,0 +1,13 @@ +/** + * @enum ESharedfileType + */ +module.exports = { + "Screenshot": 0, + "Artwork": 1, + "Guide": 2, + + // Value-to-name mapping for convenience + "0": "Screenshot", + "1": "Artwork", + "2": "Guide" +}; \ No newline at end of file