diff --git a/classes/CSteamDiscussion.js b/classes/CSteamDiscussion.js new file mode 100644 index 0000000..b6706b2 --- /dev/null +++ b/classes/CSteamDiscussion.js @@ -0,0 +1,221 @@ +const Cheerio = require('cheerio'); +const SteamID = require('steamid'); +const StdLib = require('@doctormckay/stdlib'); + +const SteamCommunity = require('../index.js'); +const Helpers = require('../components/helpers.js'); + +const EDiscussionType = require('../resources/EDiscussionType.js'); + + +/** + * Scrape a discussion's DOM to get all available information + * @param {string} url - SteamCommunity url pointing to the discussion to fetch + * @param {function} callback - First argument is null/Error, second is object containing all available information + */ +SteamCommunity.prototype.getSteamDiscussion = function(url, callback) { + // Construct object holding all the data we can scrape + let discussion = { + id: null, + type: null, + appID: null, + forumID: null, + gidforum: null, // This is some id used as parameter 2 in post requests + topicOwner: null, // This is some id used as parameter 1 in post requests + author: null, + postedDate: null, + title: null, + content: null, + commentsAmount: null, // I originally wanted to fetch all comments by default but that would have been a lot of potentially unused data + answerCommentIndex: null, + accountCanComment: null // Is this account allowed to comment on this discussion? + }; + + // Get DOM of discussion + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'GET', + url: url + '?l=en', + source: 'steamcommunity' + }); + + + try { + + /* --------------------- Preprocess output --------------------- */ + + // Load output into cheerio to make parsing easier + let $ = Cheerio.load(result.textBody); + + // Get breadcrumbs once. Depending on the type of discussion, it either uses "forum" or "group" breadcrumbs + let breadcrumbs = $('.forum_breadcrumbs').children(); + + if (breadcrumbs.length == 0) breadcrumbs = $('.group_breadcrumbs').children(); + + // Steam redirects us to the forum page if the discussion does not exist which we can detect by missing breadcrumbs + if (!breadcrumbs[0]) { + reject(new Error('Discussion not found')); + return; + } + + /* --------------------- Find and map values --------------------- */ + + // Determine type from URL as some checks will deviate, depending on the type + if (url.includes('steamcommunity.com/discussions/forum')) discussion.type = EDiscussionType.Forum; + if (/steamcommunity.com\/app\/.+\/discussions/g.test(url)) discussion.type = EDiscussionType.App; + if (/steamcommunity.com\/groups\/.+\/discussions/g.test(url)) discussion.type = EDiscussionType.Group; + if (/steamcommunity.com\/app\/.+\/eventcomments/g.test(url)) discussion.type = EDiscussionType.Eventcomments; + + + // Get appID from breadcrumbs if this discussion is associated to one + if (discussion.type == EDiscussionType.App) { + let appIdHref = breadcrumbs[0].attribs.href.split('/'); + + discussion.appID = appIdHref[appIdHref.length - 1]; + } + + + // Get forumID from breadcrumbs - Ignore for type Eventcomments as it doesn't have multiple forums + if (discussion.type != EDiscussionType.Eventcomments) { + let forumIdHref; + + if (discussion.type == EDiscussionType.Group) { // Groups have an extra breadcrumb so we need to shift by 2 + forumIdHref = breadcrumbs[4].attribs.href.split('/'); + } else { + forumIdHref = breadcrumbs[2].attribs.href.split('/'); + } + + discussion.forumID = forumIdHref[forumIdHref.length - 2]; + } + + + // Get id, gidforum and topicOwner. The first is used in the URL itself, the other two only in post requests + let gids = $('.forum_paging > .forum_paging_controls').attr('id').split('_'); + + discussion.id = gids[4]; + discussion.gidforum = gids[3]; + discussion.topicOwner = gids[2]; + + + // Find postedDate and convert to timestamp + let posted = $('.topicstats > .topicstats_label:contains("Date Posted:")').next().text(); + + discussion.postedDate = Helpers.decodeSteamTime(posted.trim()); + + + // Find commentsAmount + discussion.commentsAmount = Number($('.topicstats > .topicstats_label:contains("Posts:")').next().text()); + + + // Get discussion title & content + discussion.title = $('.forum_op > .topic').text().trim(); + discussion.content = $('.forum_op > .content').text().trim(); + + + // Find comment marked as answer + let hasAnswer = $('.commentthread_answer_bar'); + + if (hasAnswer.length != 0) { + let answerPermLink = hasAnswer.next().children('.forum_comment_permlink').text().trim(); + + // Convert comment id to number, remove hashtag and subtract by 1 to make it an index + discussion.answerCommentIndex = Number(answerPermLink.replace('#', '')) - 1; + } + + + // Check if this account is allowed to comment on this discussion + let cannotReplyReason = $('.topic_cannotreply_reason'); + + discussion.accountCanComment = cannotReplyReason.length == 0; + + + // Find author and convert to SteamID object - Ignore for type Eventcomments as they are posted by the "game", not by an Individual + if (discussion.type != EDiscussionType.Eventcomments) { + let authorLink = $('.authorline > .forum_op_author').attr('href'); + + Helpers.resolveVanityURL(authorLink, (err, data) => { // This request takes <1 sec + if (err) { + reject(err); + return; + } + + discussion.author = new SteamID(data.steamID); + + // Resolve when ID was resolved as otherwise owner will always be null + resolve(new CSteamDiscussion(this, discussion)); + }); + } else { + resolve(new CSteamDiscussion(this, discussion)); + } + + } catch (err) { + reject(err); + } + }); +}; + + +/** + * Constructor - Creates a new Discussion object + * @class + * @param {SteamCommunity} community + * @param {{ id: string, type: EDiscussionType, appID: string, forumID: string, gidforum: string, topicOwner: string, author: SteamID, postedDate: Object, title: string, content: string, commentsAmount: number, answerCommentIndex: number, accountCanComment: boolean }} data + */ +function CSteamDiscussion(community, data) { + /** + * @type {SteamCommunity} + */ + this._community = community; + + // Clone all the data we received + Object.assign(this, data); +} + + +/** + * Scrapes a range of comments from this discussion + * @param {number} startIndex - Index (0 based) of the first comment to fetch + * @param {number} endIndex - Index (0 based) of the last comment to fetch + * @param {function} callback - First argument is null/Error, second is array containing the requested comments + */ +CSteamDiscussion.prototype.getComments = function(startIndex, endIndex, callback) { + this._community.getDiscussionComments(`https://steamcommunity.com/app/${this.appID}/discussions/${this.forumID}/${this.id}`, startIndex, endIndex, callback); +}; + + +/** + * Posts a comment to this discussion's comment section + * @param {String} message - Content of the comment to post + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamDiscussion.prototype.comment = function(message, callback) { + this._community.postDiscussionComment(this.topicOwner, this.gidforum, this.id, message, callback); +}; + + +/** + * Delete a comment from this discussion's comment section + * @param {String} gidcomment - ID of the comment to delete + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamDiscussion.prototype.deleteComment = function(gidcomment, callback) { + this._community.deleteDiscussionComment(this.topicOwner, this.gidforum, this.id, gidcomment, callback); +}; + + +/** + * Subscribes to this discussion's comment section + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamDiscussion.prototype.subscribe = function(callback) { + this._community.subscribeDiscussionComments(this.topicOwner, this.gidforum, this.id, callback); +}; + + +/** + * Unsubscribes from this discussion's comment section + * @param {function} callback - Takes only an Error object/null as the first argument + */ +CSteamDiscussion.prototype.unsubscribe = function(callback) { + this._community.unsubscribeDiscussionComments(this.topicOwner, this.gidforum, this.id, callback); +}; diff --git a/components/discussions.js b/components/discussions.js new file mode 100644 index 0000000..0263805 --- /dev/null +++ b/components/discussions.js @@ -0,0 +1,296 @@ +const Cheerio = require('cheerio'); +const StdLib = require('@doctormckay/stdlib'); + +const SteamCommunity = require('../index.js'); +const Helpers = require('./helpers.js'); + + +/** + * Scrapes a range of comments from a Steam discussion + * @param {url} url - SteamCommunity url pointing to the discussion to fetch + * @param {number} startIndex - Index (0 based) of the first comment to fetch + * @param {number} endIndex - Index (0 based) of the last comment to fetch + * @param {function} callback - First argument is null/Error, second is array containing the requested comments + */ +SteamCommunity.prototype.getDiscussionComments = function(url, startIndex, endIndex, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'GET', + url: url + '?l=en', + source: 'steamcommunity' + }); + + + // Load output into cheerio to make parsing easier + let $ = Cheerio.load(result.textBody); + + let paging = $('.forum_paging > .forum_paging_summary').children(); + + /** + * Stores every loaded page inside a Cheerio instance + * @type {{[key: number]: cheerio.Root}} + */ + let pages = { + 0: $ + }; + + + // Determine amount of comments per page and total. Update endIndex if null to get all comments + let commentsPerPage = Number(paging[4].children[0].data); + let totalComments = Number(paging[5].children[0].data); + + if (endIndex == null || endIndex > totalComments - 1) { // Make sure to check against null as the index 0 would cast to false + endIndex = totalComments - 1; + } + + + // Save all pages that need to be fetched in order to get the requested comments + let firstPage = Math.trunc(startIndex / commentsPerPage); // Index of the first page that needs to be fetched + let lastPage = Math.trunc(endIndex / commentsPerPage); + let promises = []; + + for (let i = firstPage; i <= lastPage; i++) { + if (i == 0) continue; // First page is already in pages object + + promises.push(new Promise((resolve) => { + setTimeout(async () => { // Delay fetching a bit to reduce the risk of Steam blocking us + + let commentsPage = await this.httpRequest({ + method: 'GET', + url: url + '?l=en&ctp=' + (i + 1), + source: 'steamcommunity' + }); + + try { + pages[i] = Cheerio.load(commentsPage.textBody); + resolve(); + } catch (err) { + return reject('Failed to load comments page: ' + err); + } + + }, 250 * i); + })); + } + + await Promise.all(promises); // Wait for all pages to be fetched + + + // Fill comments with content of all comments + let comments = []; + + for (let i = startIndex; i <= endIndex; i++) { + let $ = pages[Math.trunc(i / commentsPerPage)]; + + let thisComment = $(`.forum_comment_permlink:contains("#${i + 1}")`).parent(); + let thisCommentID = thisComment.attr('id').replace('comment_', ''); + + // Note: '>' inside the cheerio selectors didn't work here + let authorContainer = thisComment.children('.commentthread_comment_content').children('.commentthread_comment_author').children('.commentthread_author_link'); + let commentContainer = thisComment.children('.commentthread_comment_content').children(`#comment_content_${thisCommentID}`); + + + // Prepare comment text by finding all existing blockquotes, formatting them and adding them infront each other. Afterwards handle the text itself + let commentText = ''; + let blockQuoteSelector = '.bb_blockquote'; + let children = commentContainer.children(blockQuoteSelector); + + for (let i = 0; i < 10; i++) { // I'm not sure how I could dynamically check the amount of nested blockquotes. 10 is prob already too much to stay readable + if (children.length > 0) { + let thisQuoteText = ''; + + thisQuoteText += children.children('.bb_quoteauthor').text() + '\n'; // Get quote header and add a proper newline + + // Replace
's with newlines to get a proper output + let quoteWithNewlines = children.first().find('br').replaceWith('\n'); + + thisQuoteText += quoteWithNewlines.end().contents().filter(function() { return this.type === 'text'; }).text().trim(); // Get blockquote content without child content - https://stackoverflow.com/a/23956052 + if (i > 0) thisQuoteText += '\n-------\n'; // Add spacer + + commentText = thisQuoteText + commentText; // Concat quoteText to the start of commentText as the most nested quote is the first one inside the comment chain itself + + // Go one level deeper + children = children.children(blockQuoteSelector); + + } else { + + commentText += '\n\n-------\n\n'; // Add spacer + break; + } + } + + let quoteWithNewlines = commentContainer.first().find('br').replaceWith('\n'); // Replace
's with newlines to get a proper output + + commentText += quoteWithNewlines.end().contents().filter(function() { return this.type === 'text'; }).text().trim(); // Add comment content without child content - https://stackoverflow.com/a/23956052 + + + comments.push({ + index: i, + commentId: thisCommentID, + commentLink: `${url}#c${thisCommentID}`, + authorLink: authorContainer.attr('href'), // I did not call 'resolveVanityURL()' here and convert to SteamID to reduce the amount of potentially unused Steam pings + postedDate: Helpers.decodeSteamTime(authorContainer.children('.commentthread_comment_timestamp').text().trim()), + content: commentText.trim() + }); + } + + + // Resolve with our result + resolve(comments); + + }); +}; + +/** + * Posts a comment to a discussion + * @param {String} topicOwner - ID of the topic owner + * @param {String} gidforum - GID of the discussion's forum + * @param {String} discussionId - ID of the discussion + * @param {String} message - Content of the comment to post + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.postDiscussionComment = function(topicOwner, gidforum, discussionId, message, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/ForumTopic/post/${topicOwner}/${gidforum}/`, + form: { + comment: message, + count: 15, + sessionid: this.getSessionID(), + extended_data: '{"topic_permissions":{"can_view":1,"can_post":1,"can_reply":1}}', + feature2: discussionId, + json: 1 + }, + source: 'steamcommunity' + }); + + if (result.jsonBody.success) { + resolve(null); + } else { + reject(new Error(result.jsonBody.error)); + } + }); +}; + +/** + * Deletes a comment from a discussion + * @param {String} topicOwner - ID of the topic owner + * @param {String} gidforum - GID of the discussion's forum + * @param {String} discussionId - ID of the discussion + * @param {String} gidcomment - ID of the comment to delete + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.deleteDiscussionComment = function(topicOwner, gidforum, discussionId, gidcomment, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/ForumTopic/delete/${topicOwner}/${gidforum}/`, + form: { + gidcomment: gidcomment, + count: 15, + sessionid: this.getSessionID(), + extended_data: '{"topic_permissions":{"can_view":1,"can_post":1,"can_reply":1}}', + feature2: discussionId, + json: 1 + }, + source: 'steamcommunity' + }); + + if (result.jsonBody.success) { + resolve(null); + } else { + reject(new Error(result.jsonBody.error)); + } + }); +}; + +/** + * Subscribes to a discussion's comment section + * @param {String} topicOwner - ID of the topic owner + * @param {String} gidforum - GID of the discussion's forum + * @param {String} discussionId - ID of the discussion + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.subscribeDiscussionComments = function(topicOwner, gidforum, discussionId, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/ForumTopic/subscribe/${topicOwner}/${gidforum}/`, + form: { + count: 15, + sessionid: this.getSessionID(), + extended_data: '{"topic_permissions":{"can_view":1,"can_post":1,"can_reply":1}}', + feature2: discussionId, + json: 1 + }, + source: 'steamcommunity' + }); + + if (result.jsonBody.success && result.jsonBody.success != SteamCommunity.EResult.OK) { + reject(Helpers.eresultError(result.jsonBody.success)); + return; + } + + resolve(null); + }); +}; + +/** + * Unsubscribes from a discussion's comment section + * @param {String} topicOwner - ID of the topic owner + * @param {String} gidforum - GID of the discussion's forum + * @param {String} discussionId - ID of the discussion + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.unsubscribeDiscussionComments = function(topicOwner, gidforum, discussionId, callback) { + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'POST', + url: `https://steamcommunity.com/comment/ForumTopic/unsubscribe/${topicOwner}/${gidforum}/`, + form: { + count: 15, + sessionid: this.getSessionID(), + extended_data: '{}', // Unsubscribing does not require any data here + feature2: discussionId, + json: 1 + }, + source: 'steamcommunity' + }); + + if (result.jsonBody.success && result.jsonBody.success != SteamCommunity.EResult.OK) { + reject(Helpers.eresultError(result.jsonBody.success)); + return; + } + + resolve(null); + }); +}; + +/** + * Sets an amount of comments per page + * @param {String} value - 15, 30 or 50 + * @param {function} callback - Takes only an Error object/null as the first argument + */ +SteamCommunity.prototype.setDiscussionCommentsPerPage = function(value, callback) { + if (!['15', '30', '50'].includes(value)) value = '50'; // Check for invalid setting + + return StdLib.Promises.callbackPromise(null, callback, true, async (resolve, reject) => { + let result = await this.httpRequest({ + method: 'POST', + url: 'https://steamcommunity.com/forum/0/0/setpreference', + form: { + preference: 'topicrepliesperpage', + value: value, + sessionid: this.getSessionID(), + }, + source: 'steamcommunity' + }); + + if (result.jsonBody.success && result.jsonBody.success != SteamCommunity.EResult.OK) { + reject(Helpers.eresultError(result.jsonBody.success)); + return; + } + + resolve(null); + }); +}; \ No newline at end of file diff --git a/index.js b/index.js index 0fc31ed..e8ce722 100644 --- a/index.js +++ b/index.js @@ -587,9 +587,11 @@ require('./components/inventoryhistory.js'); require('./components/webapi.js'); require('./components/twofactor.js'); require('./components/confirmations.js'); +require('./components/discussions.js'); require('./components/help.js'); require('./classes/CMarketItem.js'); require('./classes/CMarketSearchResult.js'); +require('./classes/CSteamDiscussion.js'); require('./classes/CSteamGroup.js'); require('./classes/CSteamSharedFile.js'); require('./classes/CSteamUser.js'); diff --git a/resources/EDiscussionType.js b/resources/EDiscussionType.js new file mode 100644 index 0000000..30996bd --- /dev/null +++ b/resources/EDiscussionType.js @@ -0,0 +1,15 @@ +/** + * @enum EDiscussionType + */ +module.exports = { + Forum: 0, + App: 1, + Group: 2, + Eventcomments: 3, + + // Value-to-name mapping for convenience + 0: 'Forum', + 1: 'App', + 2: 'Group', + 3: 'Eventcomments' +};