Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cbdfa51
Add discussion class with data scraper
3urobeat Sep 14, 2023
5cce2e7
Scrape comment id marked as answer
3urobeat Sep 15, 2023
ab658f2
Add discussion comment scraping support
3urobeat Sep 15, 2023
bdb3b5b
Add discussion subscribing support
3urobeat Sep 16, 2023
80755d8
Add discussion commenting support
3urobeat Sep 16, 2023
3b8f92e
Scrape gidforum & topicOwner
3urobeat Sep 16, 2023
5c48d7b
Add discussion object methods
3urobeat Sep 16, 2023
660c915
Get id more reliably and misc
3urobeat Sep 16, 2023
d5f9dc7
Add missing getComments() object method and fix callback jsdoc
3urobeat Sep 16, 2023
ee4c59f
Fix first comment getting blockquote from another comment
3urobeat Sep 16, 2023
fd8a03b
Add type detection and support forum & group discussions
3urobeat Sep 17, 2023
4e9155c
Add setDiscussionCommentsPerPage()
3urobeat Sep 17, 2023
a5a3c8e
Add support for displaying multiple nested quotes
3urobeat Sep 18, 2023
eddc1d1
Fix checking for a rejected request
3urobeat Sep 28, 2023
a4eb964
Use eresultError() helper
3urobeat Oct 1, 2023
d46b7c8
Buff eslint's happiness by 80%
3urobeat Oct 2, 2023
64eaf1b
Return 'Discussion Not Found' error when breadcrumbs are missing to a…
3urobeat Oct 2, 2023
e3dbb2c
Rename discussion postComment() -> comment()
3urobeat Oct 2, 2023
983ae0f
Update httpRequest helper usage to v4
3urobeat Oct 2, 2023
3c7972a
feat(Discussions): Add eventcomments support
3urobeat Mar 2, 2024
985c2a5
feat(Discussions): Add accountCanComment prop
3urobeat Mar 2, 2024
33400dc
fix(Discussions): Fix discussion authorLink failing to resolve due to…
3urobeat May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions classes/CSteamDiscussion.js
Original file line number Diff line number Diff line change
@@ -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 = $('.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);
};
Loading
Loading