diff --git a/classes/CSteamReviews.js b/classes/CSteamReviews.js new file mode 100644 index 0000000..a125a52 --- /dev/null +++ b/classes/CSteamReviews.js @@ -0,0 +1,250 @@ +const Cheerio = require('cheerio'); +const SteamID = require('steamid'); +const StdLib = require('@doctormckay/stdlib'); + +const SteamCommunity = require('../index.js'); +const Helpers = require('../components/helpers.js'); + + +/** + * @typedef Review + * @type {object} + * @property {string} [reviewID] ID of review, used for voting & reporting. Remains `null` if it is your review or you are not logged in as the buttons are not presented then. + * @property {SteamID} steamID SteamID object of the review author + * @property {string} appID AppID of the associated game + * @property {Date} postedDate Date of when the review was posted initially + * @property {Date} [updatedDate] Date of when the review was last updated. Remains `null` if review was never updated + * @property {boolean} recommended True if the author recommends the game, false otherwise. + * @property {boolean} isEarlyAccess True if the review is an early access review + * @property {string} content Text content of the review + * @property {number} [commentsAmount] Amount of comments reported by Steam. Remains `null` if coments are disabled + * @property {Array.<{ index: number, id: string, authorLink: string, postedDate: Date, content: string }>} [comments] Array of the last 10 comments left on this review + * @property {number} recentPlaytimeHours Amount of hours the author played this game for in the last 2 weeks + * @property {number} totalPlaytimeHours Amount of hours the author played this game for in total + * @property {number} [playtimeHoursAtReview] Amount of hours the author played this game for at the point of review. Remains `null` if Steam does not provide this information. + * @property {number} votesHelpful Amount of 'Review is helpful' votes + * @property {number} votesFunny Amount of 'Review is funny' votes + */ + +/** + * Scrape a review's DOM to get all available information + * @param {string | SteamID} userID - SteamID object or steamID64 of the review author + * @param {string} appID - AppID of the associated game + * @param {function(Error, CSteamReview)} [callback] - First argument is null/Error, second is object containing all available information + * @returns {Promise.} Resolves with CSteamReview object + */ +SteamCommunity.prototype.getSteamReview = function(userID, appID, callback) { + if (typeof userID !== 'string' && !Helpers.isSteamID(userID)) { + throw new Error('userID parameter should be a user URL string or a SteamID object'); + } + + if (typeof userID === 'object' && (userID.universe != SteamID.Universe.PUBLIC || userID.type != SteamID.Type.INDIVIDUAL)) { + throw new Error('SteamID must stand for an individual account in the public universe'); + } + + if (typeof userID === 'string') { + userID = new SteamID(userID); + } + + + // Construct object holding all the data we can scrape + let review = { + reviewID: null, + steamID: userID, + appID: appID, + postedDate: null, + updatedDate: null, + recommended: null, + isEarlyAccess: false, + content: null, + commentsAmount: null, + comments: [], + recentPlaytimeHours: null, + totalPlaytimeHours: null, + playtimeHoursAtReview: null, + votesHelpful: 0, + votesFunny: 0 + }; + + + // Get DOM of review + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'GET', + url: `https://steamcommunity.com/profiles/${userID.getSteamID64()}/recommended/${appID}?l=en`, + source: 'steamcommunity', + followRedirect: true // This setting is important: Steam redirects /profiles/ links to /id/ if user has a vanity set + }); + + + try { + + // Load output into cheerio to make parsing easier + let $ = Cheerio.load(result.textBody); + + + // Find reviewID which is needed for upvoting, downvoting, etc. + review.reviewID = $('.review_rate_bar').children('span').attr('id').replace('RecommendationVoteUpBtn', ''); + + // Find postedDate & updatedDate and convert to timestamp + let posted = $('.recommendation_date').text().split('\n'); + + posted.forEach((e) => { + e = e.trim(); + + if (e.startsWith('Posted')) { + review.postedDate = Helpers.decodeSteamTime(e.replace('Posted: ', '')); + } + if (e.startsWith('Updated')) { + review.updatedDate = Helpers.decodeSteamTime(e.replace('Updated: ', '')); + } + }); + + // Find out if user recommended the game or not + review.recommended = $('.ratingSummary').text().trim() == 'Recommended'; + + // Find out if review is an early access review + review.isEarlyAccess = $('.early_access_review').length > 0; + + // Get content + review.content = $('.review_area_content > #ReviewText').find('br').replaceWith('\n').end().text().trim(); // Preserve line breaks in text + + // Get comments data if any exist + let commentThread = $('.commentthread_area').children(); + + if (commentThread.length > 0) { + // Get amount of comments reported by Steam + review.commentsAmount = Number(commentThread.children('.commentthread_count').children('.commentthread_count_label').children().first().text()); + + // Get content and author of each comment + commentThread.children('.commentthread_comments').children().each(async (i, e) => { + let comment = $(e).children('.commentthread_comment_content'); // The whole comment + + let author = comment.children('.commentthread_comment_author'); // The author part of the comment - contains profile link and date + let commentEmoji = comment.children('.commentthread_comment_text').find('img'); // Emojis in the comment text + + review.comments.push({ + index: i, + id: comment.children('.commentthread_comment_text').attr('id').replace('comment_content_', ''), + authorLink: author.children('.commentthread_author_link').attr('href'), + postedDate: Helpers.decodeSteamTime(author.children('.commentthread_comment_timestamp').text()), + content: commentEmoji.replaceWith(commentEmoji.attr('alt')).end().find('br').replaceWith('\n').end().text().trim() // Preserve emojis by using alt text and line breaks in text + }); + }); + } + + // Get recent playtime. Format: recentPlaytime / totalPlaytime (playtimeAtReview) + let playtimeStr = $('.ratingSummaryHeader > .playTime').text().trim().split('/'); + + review.recentPlaytimeHours = Number(playtimeStr[0].trim().split(' ')[0]); + review.totalPlaytimeHours = Number(playtimeStr[1].trim().split(' ')[0]); + + if (playtimeStr[1].includes('at review time')) { // Some reviews don't contain info about playtime at the time of review + review.playtimeHoursAtReview = Number(playtimeStr[1].trim().split('(')[1].split(' ')[0]); + } + + // Get votes + let ratings = $('.ratingBar').find('br').replaceWith('\n').end().text().trim().split('\n'); + + let helpfulStr = ratings.find((e) => e.includes('helpful')); + let funnyStr = ratings.find((e) => e.includes('funny')); + + if (helpfulStr) { + review.votesHelpful = Number(helpfulStr.split(' ')[0]); + } + + if (funnyStr) { + review.votesFunny = Number(funnyStr.split(' ')[0]); + } + + resolve(new CSteamReview(this, review)); + + } catch (err) { + reject(err); + } + }); +}; + + +/** + * Constructor - Creates a new CSteamReview object + * @class + * @param {SteamCommunity} community - Current SteamCommunity instance + * @param {Review} data - Review data collected by the scraper + */ +function CSteamReview(community, data) { + /** + * @type {SteamCommunity} + */ + this._community = community; + + // Clone all the data we received + Object.assign(this, data); +} + + +/** + * Posts a comment to this review + * @param {string} message - Content of the comment to post + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.comment = function(message, callback) { + this._community.postReviewComment(this.steamID.getSteamID64(), this.appID, message, callback); +}; + +/** + * Deletes a comment from this review + * @param {string} gidcomment - ID of the comment to delete + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.deleteComment = function(gidcomment, callback) { + this._community.deleteReviewComment(this.steamID.getSteamID64(), this.appID, gidcomment, callback); +}; + +/** + * Subscribes to this review's comment section + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.subscribe = function(callback) { + this._community.subscribeReviewComments(this.steamID.getSteamID64(), this.appID, callback); +}; + +/** + * Unsubscribes from this review's comment section + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.unsubscribe = function(callback) { + this._community.unsubscribeReviewComments(this.steamID.getSteamID64(), this.appID, callback); +}; + +/** + * Votes on this review as helpful + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.voteHelpful = function(callback) { + this._community.voteReviewHelpful(this.reviewID, callback); +}; + +/** + * Votes on this review as unhelpful + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.voteUnhelpful = function(callback) { + this._community.voteReviewUnhelpful(this.reviewID, callback); +}; + +/** + * Votes on this review as funny + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.voteFunny = function(callback) { + this._community.voteReviewFunny(this.reviewID, callback); +}; + +/** + * Removes funny vote from this review + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamReview.prototype.voteRemoveFunny = function(callback) { + this._community.voteReviewRemoveFunny(this.reviewID, callback); +}; diff --git a/components/reviews.js b/components/reviews.js new file mode 100644 index 0000000..ab55195 --- /dev/null +++ b/components/reviews.js @@ -0,0 +1,244 @@ +const SteamID = require('steamid'); +const StdLib = require('@doctormckay/stdlib'); + +const SteamCommunity = require('../index.js'); +const Helpers = require('../components/helpers.js'); + + +/** + * Posts a comment to a review + * @param {string | SteamID} userID - SteamID object or steamID64 of the review author + * @param {string} appID - AppID of the associated game + * @param {String} message - Content of the comment to post + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.postReviewComment = function(userID, appID, message, callback) { + if (typeof userID == 'string') { + userID = new SteamID(userID); + } + + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let res = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/Recommendation/post/${userID.getSteamID64()}/${appID}/`, + form: { + comment: message, + count: 10, + sessionid: this.getSessionID() + }, + source: 'steamcommunity', + checkCommunityError: true + }); + + if (res.jsonBody && res.jsonBody.success != SteamCommunity.EResult.OK) { + reject(new Error(res.jsonBody.error)); + return; + } + + resolve(); + }); +}; + +/** + * Deletes a comment from a review + * @param {string | SteamID} userID - SteamID object or steamID64 of the review author + * @param {string} appID - AppID of the associated game + * @param {String} message - Content of the comment to post + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.deleteReviewComment = function(userID, appID, cid, callback) { + if (typeof userID == 'string') { + userID = new SteamID(userID); + } + + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let res = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/Recommendation/delete/${userID.getSteamID64()}/${appID}/`, + form: { + gidcomment: cid, + count: 10, + sessionid: this.getSessionID() + }, + source: 'steamcommunity', + checkCommunityError: true + }); + + if (res.jsonBody && res.jsonBody.success != SteamCommunity.EResult.OK) { + reject(new Error(res.jsonBody.error)); + return; + } + + resolve(); + }); +}; + +/** + * Subscribes to a review's comment section + * @param {string | SteamID} userID - SteamID object or steamID64 of the review author + * @param {string} appID - AppID of the associated game + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.subscribeReviewComments = function(userID, appID, callback) { + if (typeof userID == 'string') { + userID = new SteamID(userID); + } + + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/Recommendation/subscribe/${userID.getSteamID64()}/${appID}/`, + form: { + count: 10, + sessionid: this.getSessionID() + }, + source: 'steamcommunity' + }); + + resolve(); + }); +}; + +/** + * Unsubscribes from a review's comment section + * @param {string | SteamID} userID - SteamID object or steamID64 of the review author + * @param {string} appID - AppID of the associated game + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.unsubscribeReviewComments = function(userID, appID, callback) { + if (typeof userID == 'string') { + userID = new SteamID(userID); + } + + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/Recommendation/unsubscribe/${userID.getSteamID64()}/${appID}/`, + form: { + count: 10, + sessionid: this.getSessionID() + }, + source: 'steamcommunity' + }); + + resolve(); + }); +}; + +/** + * Votes on a review as helpful + * @param {string} rid - ID of the review. You can obtain it through `getSteamReview()` + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.voteReviewHelpful = function(rid, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let res = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/userreviews/rate/${rid}`, + form: { + rateup: 'true', + sessionid: this.getSessionID() + }, + source: 'steamcommunity', + checkCommunityError: true + }); + + if (res.jsonBody && res.jsonBody.success != SteamCommunity.EResult.OK) { + reject(Helpers.eresultError(res.jsonBody.success)); + return; + } + + resolve(); + }); +}; + +/** + * Votes on a review as unhelpful + * @param {string} rid - ID of the review. You can obtain it through `getSteamReview()` + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.voteReviewUnhelpful = function(rid, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let res = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/userreviews/rate/${rid}`, + form: { + rateup: 'false', + sessionid: this.getSessionID() + }, + source: 'steamcommunity', + checkCommunityError: true + }); + + if (res.jsonBody && res.jsonBody.success != SteamCommunity.EResult.OK) { + reject(Helpers.eresultError(res.jsonBody.success)); + return; + } + + resolve(); + }); +}; + +/** + * Votes on a review as funny + * @param {string} rid - ID of the review. You can obtain it through `getSteamReview()` + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.voteReviewFunny = function(rid, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let res = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/userreviews/votetag/${rid}`, + form: { + tagid: '1', + rateup: 'true', + sessionid: this.getSessionID() + }, + source: 'steamcommunity', + checkCommunityError: true + }); + + if (res.jsonBody && res.jsonBody.success != SteamCommunity.EResult.OK) { + reject(Helpers.eresultError(res.jsonBody.success)); + return; + } + + resolve(); + }); +}; + +/** + * Removes funny vote from a review + * @param {string} rid - ID of the review. You can obtain it through `getSteamReview()` + * @param {function} [callback] - Takes only an Error object/null as the first argument + * @return Promise Resolves on success, rejects on failure + */ +SteamCommunity.prototype.voteReviewRemoveFunny = function(rid, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let res = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/userreviews/votetag/${rid}`, + form: { + tagid: '1', + rateup: 'false', + sessionid: this.getSessionID() + }, + source: 'steamcommunity', + checkCommunityError: true + }); + + if (res.jsonBody && res.jsonBody.success != SteamCommunity.EResult.OK) { + reject(Helpers.eresultError(res.jsonBody.success)); + return; + } + + resolve(); + }); +}; diff --git a/index.js b/index.js index 4915bb3..04b560a 100644 --- a/index.js +++ b/index.js @@ -487,6 +487,7 @@ SteamCommunity.prototype._resolveVanityURL = async function(url) { require('./components/http.js'); require('./components/profile.js'); +require('./components/reviews.js'); require('./components/market.js'); require('./components/groups.js'); require('./components/users.js'); @@ -498,6 +499,7 @@ require('./components/help.js'); require('./classes/CMarketItem.js'); require('./classes/CMarketSearchResult.js'); require('./classes/CSteamGroup.js'); +require('./classes/CSteamReviews.js'); require('./classes/CSteamSharedFile.js'); require('./classes/CSteamUser.js');