steamutils 1.5.55 → 1.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/index.js +5 -1
  2. package/package.json +1 -1
  3. package/parse_html.js +409 -348
package/index.js CHANGED
@@ -5967,7 +5967,11 @@ export default class SteamUser {
5967
5967
  partner = _.trim(partner, s);
5968
5968
  }
5969
5969
  const partnerSteamId = partner.split(`',`)[0].trim();
5970
- const partnerName = partner.split(`', "`)[1].trim();
5970
+ let partnerName = partner.split(`', "`)[1].trim();
5971
+ try {
5972
+ partnerName = JSON.parse(partnerName);
5973
+ } catch (e) {}
5974
+
5971
5975
  const playerAvatar = $1.find(".playerAvatar");
5972
5976
  const avatar = playerAvatar.find("img")?.attr("src");
5973
5977
  const link = playerAvatar.find("a")?.attr("href");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "steamutils",
3
- "version": "1.5.55",
3
+ "version": "1.5.57",
4
4
  "main": "index.js",
5
5
  "dependencies": {
6
6
  "alpha-common-utils": "^1.0.6",
package/parse_html.js CHANGED
@@ -1,348 +1,409 @@
1
- import { StringUtils } from "alpha-common-utils/index.js";
2
- import { formatMarketHistoryDate, getAvatarHashFromUrl } from "./utils.js";
3
- import * as cheerio from "cheerio";
4
- import { getJSObjectFronXML } from "./xml2json.js";
5
- import moment from "moment";
6
-
7
- /**
8
- * @typedef {Object} HoverItem
9
- * @property {string|null} listingId The listing ID extracted from the selector, if available (as string).
10
- * @property {number} appid The app (game) ID for this item.
11
- * @property {number} contextid The context ID for this item.
12
- * @property {number} assetid The unique asset ID.
13
- * @property {string} unknown A string field captured from the arguments (usually "0").
14
- * @property {string} [name_selector] The HTML selector for the item's name, if present.
15
- * @property {string} [image_selector] The HTML selector for the item's image, if present.
16
- * @property {string} [other_selector] The selector string if it does not match name/image pattern.
17
- */
18
-
19
- /**
20
- * Extracts item information from Steam inventory trade history or similar HTML fragments.
21
- * Scans the given HTML string for calls to `CreateItemHoverFromContainer` and extracts
22
- * key data for each referenced item. Returns each item's info in a structured array.
23
- *
24
- * @param {string} html - The HTML string to parse.
25
- * @returns {HoverItem[]} Array of hover item objects.
26
- */
27
- export function extractAssetItemsFromHovers(html) {
28
- // Use your preferred HTML/space scrubber.
29
- html = StringUtils.cleanSpace(html);
30
-
31
- // RegExp for CreateItemHoverFromContainer arguments
32
- const re = /CreateItemHoverFromContainer\s*\(\s*g_rgAssets\s*,\s*'([^']+)'\s*,\s*(\d+)\s*,\s*'(\d+)'\s*,\s*'(\d+)'\s*,\s*(\d+)\s*\)/g;
33
-
34
- /** @type {Record<number, HoverItem>} */
35
- const itemsMap = {};
36
- let match;
37
-
38
- while ((match = re.exec(html)) !== null) {
39
- const [_, selector, appid, contextid, assetid, unknown] = match;
40
-
41
- // Extract listingId as a string if present: "history_row_<listingId>_"
42
- let listingId = null;
43
- const lidMatch = selector.match(/history_row_(\d+)_/);
44
- if (lidMatch) listingId = lidMatch[1]; // assign as string
45
-
46
- const appidNum = Number(appid);
47
- const contextidNum = Number(contextid);
48
- const assetidNum = Number(assetid);
49
-
50
- if (!itemsMap[assetidNum]) {
51
- itemsMap[assetidNum] = {
52
- listingId,
53
- appid: appidNum,
54
- contextid: contextidNum,
55
- assetid: assetidNum,
56
- unknown,
57
- };
58
- }
59
-
60
- if (selector.endsWith("_name")) {
61
- itemsMap[assetidNum].name_selector = selector;
62
- } else if (selector.endsWith("_image")) {
63
- itemsMap[assetidNum].image_selector = selector;
64
- } else {
65
- itemsMap[assetidNum].other_selector = selector;
66
- }
67
- }
68
- return Object.values(itemsMap);
69
- }
70
-
71
- /**
72
- * @typedef {Object} MarketHistoryListing
73
- * @property {string} id - HTML row id attribute (e.g. "history_row_4316182845737233773_4316182845737233774").
74
- * @property {string} listingId - Listing id (e.g. "4316182845737233773").
75
- * @property {number} price - Price in integer form (e.g. 80270).
76
- * @property {string} itemName - The market item's name (e.g. "UMP-45 | Mudder").
77
- * @property {string} gameName - The game's name (e.g. "Counter-Strike 2").
78
- * @property {string} listedOn - Listing date as string (e.g. "27 May 2024").
79
- * @property {string} actedOn - Acted on date or empty string.
80
- * @property {string} image - Full image URL for the item.
81
- * @property {string} gainOrLoss - "+" for buys, "-" for sells.
82
- * @property {string} status - Status text, usually empty string.
83
- * @property {string} [assetId] - (optional) Associated asset id, if available.
84
- */
85
-
86
- /**
87
- * Parses Steam market history listing rows into structured data objects.
88
- *
89
- * @param {string} html - The raw HTML string to parse.
90
- * @param {Object} [assetByListingId={}] - Optional mapping of listingId to assetId.
91
- * @returns {MarketHistoryListing[]} Array of market listing objects.
92
- */
93
- export function parseMarketHistoryListings(html, assetByListingId = {}) {
94
- const $ = cheerio.load(html);
95
- return $(".market_listing_row")
96
- .toArray()
97
- .map((el) => {
98
- const $el = $(el);
99
- const id = $el.attr("id");
100
- const listingId = id?.match(/history_row_(.*?)_/)?.[1];
101
- if (!listingId) return null;
102
- const [gainOrLoss, image, priceText, itemName, gameName, listedOnText, actedOnText, status] = [$el.find(".market_listing_gainorloss").text().trim(), $el.find(".market_listing_item_img").attr("src"), $el.find(".market_table_value .market_listing_price").text(), $el.find(".market_listing_item_name").text().trim(), $el.find(".market_listing_game_name").text().trim(), $el.find(".market_listing_listed_date + .market_listing_listed_date").text(), $el.find(".market_listing_whoactedwith + .market_listing_listed_date").text(), $el.find(".market_listing_whoactedwith").text().trim()];
103
- const price = parseInt(priceText.replace(/[₫().,]/g, "").trim(), 10) || "";
104
- const listedOn = typeof formatMarketHistoryDate === "function" ? formatMarketHistoryDate(listedOnText.replace(/Listed:/, "").trim()) : listedOnText.replace(/Listed:/, "").trim();
105
- const actedOn = typeof formatMarketHistoryDate === "function" ? formatMarketHistoryDate(actedOnText.replace(/Listed:/, "").trim()) : actedOnText.replace(/Listed:/, "").trim();
106
-
107
- const result = {
108
- id,
109
- listingId,
110
- price,
111
- itemName,
112
- gameName,
113
- listedOn,
114
- actedOn,
115
- image,
116
- gainOrLoss,
117
- status,
118
- };
119
- if (assetByListingId?.[listingId]) result.assetId = assetByListingId[listingId];
120
- return result;
121
- })
122
- .filter(Boolean);
123
- }
124
-
125
- /**
126
- * Parses Steam Market HTML listings to an array of items.
127
- *
128
- * @param {string} html - HTML string containing listing rows.
129
- * @returns {Array<{
130
- * listingId: string,
131
- * appId: number,
132
- * contextId: number,
133
- * itemId: number,
134
- * imageUrl: string|null,
135
- * buyerPaysPrice: string,
136
- * receivePrice: string,
137
- * itemName: string,
138
- * gameName: string,
139
- * listedDate: string
140
- * }>}
141
- */
142
- export function parseMarketListings(html) {
143
- const $ = cheerio.load(html);
144
- const results = [];
145
-
146
- $(".market_listing_row").each((index, element) => {
147
- const $row = $(element);
148
-
149
- const cancelLink = $row.find(".market_listing_cancel_button > a").attr("href");
150
- if (!cancelLink) return;
151
-
152
- const paramsMatch = cancelLink.match(/\(([^)]*)\)/);
153
- if (!paramsMatch) return;
154
-
155
- let [, listingId, appId, contextId, itemId] = paramsMatch[1].split(",").map((param) => param.trim().replace(/^['"]|['"]$/g, ""));
156
-
157
- appId = Number(appId);
158
- contextId = Number(contextId);
159
- itemId = Number(itemId);
160
-
161
- const imageUrl = $row.find(`#mylisting_${listingId}_image`).attr("src") || null;
162
-
163
- const buyerPaysElem = $row.find('.market_listing_price span[title="This is the price the buyer pays."]');
164
- const buyerPaysPrice = StringUtils.cleanSpace(buyerPaysElem.text().replace(/[()]/g, ""));
165
-
166
- const receiveElem = $row.find('.market_listing_price span[title="This is how much you will receive."]');
167
- const receivePrice = StringUtils.cleanSpace(receiveElem.text().replace(/[()]/g, ""));
168
-
169
- const itemName = $row.find(".market_listing_item_name_link").text() || $row.find(".market_listing_item_name").text();
170
-
171
- const gameName = $row.find(".market_listing_game_name").text();
172
-
173
- const listedDateElem = $row.find(".market_listing_game_name + .market_listing_listed_date_combined");
174
- const listedDate = formatMarketHistoryDate(StringUtils.cleanSpace(listedDateElem.text()));
175
-
176
- results.push({
177
- listingId,
178
- appId,
179
- contextId,
180
- itemId,
181
- imageUrl,
182
- buyerPaysPrice,
183
- receivePrice,
184
- itemName,
185
- gameName,
186
- listedDate,
187
- });
188
- });
189
-
190
- return results;
191
- }
192
-
193
- /**
194
- * Represents a Steam Community group for a profile.
195
- * @typedef {Object} SteamProfileGroupXml
196
- * @property {string} groupID64
197
- * @property {string} groupName
198
- * @property {string} groupURL
199
- * @property {string} headline
200
- * @property {string} summary
201
- * @property {string} avatarIcon
202
- * @property {string} avatarMedium
203
- * @property {string} avatarFull
204
- * @property {number} memberCount
205
- * @property {number} membersInChat
206
- * @property {number} membersInGame
207
- * @property {number} membersOnline
208
- */
209
-
210
- /**
211
- * Array of Steam Community groups.
212
- * @typedef {SteamProfileGroupXml[]} SteamProfileGroupsXml
213
- */
214
-
215
- /**
216
- * Represents a most played game on a Steam profile.
217
- * @typedef {Object} SteamProfileMostPlayedGameXml
218
- * @property {string} gameName
219
- * @property {string} gameLink
220
- * @property {string} gameIcon
221
- * @property {string} gameLogo
222
- * @property {string} gameLogoSmall
223
- * @property {number} hoursPlayed
224
- * @property {number} hoursOnRecord
225
- * @property {string} statsName
226
- */
227
-
228
- /**
229
- * Array of most played games.
230
- * @typedef {SteamProfileMostPlayedGameXml[]} SteamProfileMostPlayedGamesXml
231
- */
232
-
233
- /**
234
- * Steam Community profile (parsed from XML, normalized).
235
- * @typedef {Object} SteamProfileXml
236
- * @property {string} name
237
- * @property {string} steamId
238
- * @property {string} onlineState
239
- * @property {string} stateMessage
240
- * @property {string} privacyState
241
- * @property {number} visibilityState
242
- * @property {string} avatarIcon
243
- * @property {string} avatarMedium
244
- * @property {string} avatarFull
245
- * @property {string} avatarHash
246
- * @property {number} vacBanned
247
- * @property {string} tradeBanState
248
- * @property {number} isLimitedAccount
249
- * @property {string} customURL
250
- * @property {number | null} memberSince
251
- * @property {string} steamRating
252
- * @property {number} hoursPlayed2Wk
253
- * @property {string} headline
254
- * @property {string} location
255
- * @property {string} realname
256
- * @property {string} summary
257
- * @property {SteamProfileMostPlayedGamesXml} [mostPlayedGames]
258
- * @property {SteamProfileGroupsXml} [groups]
259
- */
260
-
261
- /**
262
- * Parses a Steam profile XML into a normalized JSON format.
263
- *
264
- * @param {string} xml - The XML string as returned by the Steam Community profile endpoint.
265
- * @returns {SteamProfileXml|undefined} The normalized profile object or undefined on error.
266
- *
267
- * @example
268
- * import { parseSteamProfileXmlToJson } from "./your-module";
269
- * const xml = await fetch('https://steamcommunity.com/profiles/76561198386265483/?xml=1').then(r => r.text());
270
- * const profile = parseSteamProfileXmlToJson(xml);
271
- * console.log(profile.name);
272
- */
273
- export function parseSteamProfileXmlToJson(xml) {
274
- const parsed = getJSObjectFronXML(xml);
275
- if (!parsed || !parsed.profile) return;
276
-
277
- // Flatten out the xml2js _structure, for direct children
278
- const profile = {};
279
- Object.entries(parsed.profile).forEach(([key, value]) => {
280
- // If only text, keep as string
281
- if (typeof value === "object" && value !== null && "_" in value && Object.keys(value).length === 1) {
282
- profile[key] = value._;
283
- } else {
284
- profile[key] = value;
285
- }
286
- });
287
-
288
- // Rename fields
289
- if ("steamID" in profile) {
290
- profile.name = profile.steamID;
291
- delete profile.steamID;
292
- }
293
- if ("steamID64" in profile) {
294
- profile.steamId = profile.steamID64;
295
- delete profile.steamID64;
296
- }
297
-
298
- // Convert numbers
299
- const numberFields = ["vacBanned", "isLimitedAccount", "visibilityState", "memberCount", "membersInChat", "membersInGame", "membersOnline", "hoursOnRecord"];
300
- const floatFields = ["hoursPlayed2Wk", "hoursPlayed"];
301
- for (const field of numberFields) {
302
- if (field in profile) profile[field] = parseInt(profile[field], 10) || 0;
303
- }
304
- for (const field of floatFields) {
305
- if (field in profile) profile[field] = parseFloat(profile[field]) || 0;
306
- }
307
-
308
- // Add avatarHash
309
- const avatarUrl = profile.avatarFull || profile.avatarMedium || profile.avatarIcon;
310
- profile.avatarHash = getAvatarHashFromUrl(avatarUrl);
311
-
312
- const memberSince = profile.memberSince;
313
- profile.memberSince = null;
314
- const formats = ["MMMM DD, YYYY", "MMMM D, YYYY", "MMMM DD", "MMMM D"];
315
- for (const format of formats) {
316
- const memberSinceMoment = moment(memberSince, format, true);
317
- if (memberSinceMoment.isValid()) {
318
- profile.memberSince = memberSinceMoment.valueOf();
319
- break;
320
- }
321
- }
322
-
323
- // Ensure all simple fields are strings if empty object
324
- for (const key in profile) {
325
- if (typeof profile[key] === "object" && profile[key] !== null && Object.keys(profile[key]).length === 0) {
326
- profile[key] = "";
327
- }
328
- }
329
-
330
- // Move groups.group to top-level 'groups' if present
331
- if (profile.groups && profile.groups.group) {
332
- profile.groups = profile.groups.group;
333
- }
334
-
335
- // Remove _attributes from groups (if present)
336
- if (Array.isArray(profile.groups)) {
337
- profile.groups = profile.groups.map((group) => {
338
- if (group && typeof group === "object" && "_attributes" in group) {
339
- // shallow clone and remove _attributes
340
- const { _attributes, ...groupWithoutAttributes } = group;
341
- return groupWithoutAttributes;
342
- }
343
- return group;
344
- });
345
- }
346
-
347
- return profile;
348
- }
1
+ import { StringUtils } from "alpha-common-utils/index.js";
2
+ import { formatMarketHistoryDate, getAvatarHashFromUrl } from "./utils.js";
3
+ import * as cheerio from "cheerio";
4
+ import { getJSObjectFronXML } from "./xml2json.js";
5
+ import moment from "moment";
6
+ import { parseString } from "xml2js";
7
+
8
+ /**
9
+ * @typedef {Object} HoverItem
10
+ * @property {string|null} listingId The listing ID extracted from the selector, if available (as string).
11
+ * @property {number} appid The app (game) ID for this item.
12
+ * @property {number} contextid The context ID for this item.
13
+ * @property {number} assetid The unique asset ID.
14
+ * @property {string} unknown A string field captured from the arguments (usually "0").
15
+ * @property {string} [name_selector] The HTML selector for the item's name, if present.
16
+ * @property {string} [image_selector] The HTML selector for the item's image, if present.
17
+ * @property {string} [other_selector] The selector string if it does not match name/image pattern.
18
+ */
19
+
20
+ /**
21
+ * Extracts item information from Steam inventory trade history or similar HTML fragments.
22
+ * Scans the given HTML string for calls to `CreateItemHoverFromContainer` and extracts
23
+ * key data for each referenced item. Returns each item's info in a structured array.
24
+ *
25
+ * @param {string} html - The HTML string to parse.
26
+ * @returns {HoverItem[]} Array of hover item objects.
27
+ */
28
+ export function extractAssetItemsFromHovers(html) {
29
+ // Use your preferred HTML/space scrubber.
30
+ html = StringUtils.cleanSpace(html);
31
+
32
+ // RegExp for CreateItemHoverFromContainer arguments
33
+ const re = /CreateItemHoverFromContainer\s*\(\s*g_rgAssets\s*,\s*'([^']+)'\s*,\s*(\d+)\s*,\s*'(\d+)'\s*,\s*'(\d+)'\s*,\s*(\d+)\s*\)/g;
34
+
35
+ /** @type {Record<number, HoverItem>} */
36
+ const itemsMap = {};
37
+ let match;
38
+
39
+ while ((match = re.exec(html)) !== null) {
40
+ const [_, selector, appid, contextid, assetid, unknown] = match;
41
+
42
+ // Extract listingId as a string if present: "history_row_<listingId>_"
43
+ let listingId = null;
44
+ const lidMatch = selector.match(/history_row_(\d+)_/);
45
+ if (lidMatch) listingId = lidMatch[1]; // assign as string
46
+
47
+ const appidNum = Number(appid);
48
+ const contextidNum = Number(contextid);
49
+ const assetidNum = Number(assetid);
50
+
51
+ if (!itemsMap[assetidNum]) {
52
+ itemsMap[assetidNum] = {
53
+ listingId,
54
+ appid: appidNum,
55
+ contextid: contextidNum,
56
+ assetid: assetidNum,
57
+ unknown,
58
+ };
59
+ }
60
+
61
+ if (selector.endsWith("_name")) {
62
+ itemsMap[assetidNum].name_selector = selector;
63
+ } else if (selector.endsWith("_image")) {
64
+ itemsMap[assetidNum].image_selector = selector;
65
+ } else {
66
+ itemsMap[assetidNum].other_selector = selector;
67
+ }
68
+ }
69
+ return Object.values(itemsMap);
70
+ }
71
+
72
+ /**
73
+ * @typedef {Object} MarketHistoryListing
74
+ * @property {string} id - HTML row id attribute (e.g. "history_row_4316182845737233773_4316182845737233774").
75
+ * @property {string} listingId - Listing id (e.g. "4316182845737233773").
76
+ * @property {number} price - Price in integer form (e.g. 80270).
77
+ * @property {string} itemName - The market item's name (e.g. "UMP-45 | Mudder").
78
+ * @property {string} gameName - The game's name (e.g. "Counter-Strike 2").
79
+ * @property {string} listedOn - Listing date as string (e.g. "27 May 2024").
80
+ * @property {string} actedOn - Acted on date or empty string.
81
+ * @property {string} image - Full image URL for the item.
82
+ * @property {string} gainOrLoss - "+" for buys, "-" for sells.
83
+ * @property {string} status - Status text, usually empty string.
84
+ * @property {string} [assetId] - (optional) Associated asset id, if available.
85
+ */
86
+
87
+ /**
88
+ * Parses Steam market history listing rows into structured data objects.
89
+ *
90
+ * @param {string} html - The raw HTML string to parse.
91
+ * @param {Object} [assetByListingId={}] - Optional mapping of listingId to assetId.
92
+ * @returns {MarketHistoryListing[]} Array of market listing objects.
93
+ */
94
+ export function parseMarketHistoryListings(html, assetByListingId = {}) {
95
+ const $ = cheerio.load(html);
96
+ return $(".market_listing_row")
97
+ .toArray()
98
+ .map((el) => {
99
+ const $el = $(el);
100
+ const id = $el.attr("id");
101
+ const listingId = id?.match(/history_row_(.*?)_/)?.[1];
102
+ if (!listingId) return null;
103
+ const [gainOrLoss, image, priceText, itemName, gameName, listedOnText, actedOnText, status] = [$el.find(".market_listing_gainorloss").text().trim(), $el.find(".market_listing_item_img").attr("src"), $el.find(".market_table_value .market_listing_price").text(), $el.find(".market_listing_item_name").text().trim(), $el.find(".market_listing_game_name").text().trim(), $el.find(".market_listing_listed_date + .market_listing_listed_date").text(), $el.find(".market_listing_whoactedwith + .market_listing_listed_date").text(), $el.find(".market_listing_whoactedwith").text().trim()];
104
+ const price = parseInt(priceText.replace(/[₫().,]/g, "").trim(), 10) || "";
105
+ const listedOn = typeof formatMarketHistoryDate === "function" ? formatMarketHistoryDate(listedOnText.replace(/Listed:/, "").trim()) : listedOnText.replace(/Listed:/, "").trim();
106
+ const actedOn = typeof formatMarketHistoryDate === "function" ? formatMarketHistoryDate(actedOnText.replace(/Listed:/, "").trim()) : actedOnText.replace(/Listed:/, "").trim();
107
+
108
+ const result = {
109
+ id,
110
+ listingId,
111
+ price,
112
+ itemName,
113
+ gameName,
114
+ listedOn,
115
+ actedOn,
116
+ image,
117
+ gainOrLoss,
118
+ status,
119
+ };
120
+ if (assetByListingId?.[listingId]) result.assetId = assetByListingId[listingId];
121
+ return result;
122
+ })
123
+ .filter(Boolean);
124
+ }
125
+
126
+ /**
127
+ * Parses Steam Market HTML listings to an array of items.
128
+ *
129
+ * @param {string} html - HTML string containing listing rows.
130
+ * @returns {Array<{
131
+ * listingId: string,
132
+ * appId: number,
133
+ * contextId: number,
134
+ * itemId: number,
135
+ * imageUrl: string|null,
136
+ * buyerPaysPrice: string,
137
+ * receivePrice: string,
138
+ * itemName: string,
139
+ * gameName: string,
140
+ * listedDate: string
141
+ * }>}
142
+ */
143
+ export function parseMarketListings(html) {
144
+ const $ = cheerio.load(html);
145
+ const results = [];
146
+
147
+ $(".market_listing_row").each((index, element) => {
148
+ const $row = $(element);
149
+
150
+ const cancelLink = $row.find(".market_listing_cancel_button > a").attr("href");
151
+ if (!cancelLink) return;
152
+
153
+ const paramsMatch = cancelLink.match(/\(([^)]*)\)/);
154
+ if (!paramsMatch) return;
155
+
156
+ let [, listingId, appId, contextId, itemId] = paramsMatch[1].split(",").map((param) => param.trim().replace(/^['"]|['"]$/g, ""));
157
+
158
+ appId = Number(appId);
159
+ contextId = Number(contextId);
160
+ itemId = Number(itemId);
161
+
162
+ const imageUrl = $row.find(`#mylisting_${listingId}_image`).attr("src") || null;
163
+
164
+ const buyerPaysElem = $row.find('.market_listing_price span[title="This is the price the buyer pays."]');
165
+ const buyerPaysPrice = StringUtils.cleanSpace(buyerPaysElem.text().replace(/[()]/g, ""));
166
+
167
+ const receiveElem = $row.find('.market_listing_price span[title="This is how much you will receive."]');
168
+ const receivePrice = StringUtils.cleanSpace(receiveElem.text().replace(/[()]/g, ""));
169
+
170
+ const itemName = $row.find(".market_listing_item_name_link").text() || $row.find(".market_listing_item_name").text();
171
+
172
+ const gameName = $row.find(".market_listing_game_name").text();
173
+
174
+ const listedDateElem = $row.find(".market_listing_game_name + .market_listing_listed_date_combined");
175
+ const listedDate = formatMarketHistoryDate(StringUtils.cleanSpace(listedDateElem.text()));
176
+
177
+ results.push({
178
+ listingId,
179
+ appId,
180
+ contextId,
181
+ itemId,
182
+ imageUrl,
183
+ buyerPaysPrice,
184
+ receivePrice,
185
+ itemName,
186
+ gameName,
187
+ listedDate,
188
+ });
189
+ });
190
+
191
+ return results;
192
+ }
193
+
194
+ /**
195
+ * Represents a Steam Community group for a profile.
196
+ * @typedef {Object} SteamProfileGroupXml
197
+ * @property {string} groupID64
198
+ * @property {string} groupName
199
+ * @property {string} groupURL
200
+ * @property {string} headline
201
+ * @property {string} summary
202
+ * @property {string} avatarIcon
203
+ * @property {string} avatarMedium
204
+ * @property {string} avatarFull
205
+ * @property {number} memberCount
206
+ * @property {number} membersInChat
207
+ * @property {number} membersInGame
208
+ * @property {number} membersOnline
209
+ */
210
+
211
+ /**
212
+ * Array of Steam Community groups.
213
+ * @typedef {SteamProfileGroupXml[]} SteamProfileGroupsXml
214
+ */
215
+
216
+ /**
217
+ * Represents a most played game on a Steam profile.
218
+ * @typedef {Object} SteamProfileMostPlayedGameXml
219
+ * @property {string} gameName
220
+ * @property {string} gameLink
221
+ * @property {string} gameIcon
222
+ * @property {string} gameLogo
223
+ * @property {string} gameLogoSmall
224
+ * @property {number} hoursPlayed
225
+ * @property {number} hoursOnRecord
226
+ * @property {string} statsName
227
+ */
228
+
229
+ /**
230
+ * Array of most played games.
231
+ * @typedef {SteamProfileMostPlayedGameXml[]} SteamProfileMostPlayedGamesXml
232
+ */
233
+
234
+ /**
235
+ * Steam Community profile (parsed from XML, normalized).
236
+ * @typedef {Object} SteamProfileXml
237
+ * @property {string} name
238
+ * @property {string} steamId
239
+ * @property {string} onlineState
240
+ * @property {string} stateMessage
241
+ * @property {string} privacyState
242
+ * @property {number} visibilityState
243
+ * @property {string} avatarIcon
244
+ * @property {string} avatarMedium
245
+ * @property {string} avatarFull
246
+ * @property {string} avatarHash
247
+ * @property {number} vacBanned
248
+ * @property {string} tradeBanState
249
+ * @property {number} isLimitedAccount
250
+ * @property {string} customURL
251
+ * @property {number | null} memberSince
252
+ * @property {string} steamRating
253
+ * @property {number} hoursPlayed2Wk
254
+ * @property {string} headline
255
+ * @property {string} location
256
+ * @property {string} realname
257
+ * @property {string} summary
258
+ * @property {SteamProfileMostPlayedGamesXml} [mostPlayedGames]
259
+ * @property {SteamProfileGroupsXml} [groups]
260
+ */
261
+
262
+ /**
263
+ * Parses a Steam profile XML into a normalized JSON format.
264
+ *
265
+ * @param {string} xml - The XML string as returned by the Steam Community profile endpoint.
266
+ * @returns {SteamProfileXml|undefined} The normalized profile object or undefined on error.
267
+ *
268
+ * @example
269
+ * import { parseSteamProfileXmlToJson } from "./your-module";
270
+ * const xml = await fetch('https://steamcommunity.com/profiles/76561198386265483/?xml=1').then(r => r.text());
271
+ * const profile = parseSteamProfileXmlToJson(xml);
272
+ * console.log(profile.name);
273
+ */
274
+ export function parseSteamProfileXmlToJson(xml) {
275
+ if (guessDocumentType(xml) !== "xml") return;
276
+
277
+ const parsed = getJSObjectFronXML(xml);
278
+ if (!parsed || !parsed.profile) return;
279
+
280
+ // Flatten out the xml2js _structure, for direct children
281
+ const profile = {};
282
+ Object.entries(parsed.profile).forEach(([key, value]) => {
283
+ // If only text, keep as string
284
+ if (typeof value === "object" && value !== null && "_" in value && Object.keys(value).length === 1) {
285
+ profile[key] = value._;
286
+ } else {
287
+ profile[key] = value;
288
+ }
289
+ });
290
+
291
+ // Rename fields
292
+ if ("steamID" in profile) {
293
+ profile.name = profile.steamID;
294
+ delete profile.steamID;
295
+ }
296
+ if ("steamID64" in profile) {
297
+ profile.steamId = profile.steamID64;
298
+ delete profile.steamID64;
299
+ }
300
+
301
+ // Convert numbers
302
+ const numberFields = ["vacBanned", "isLimitedAccount", "visibilityState", "memberCount", "membersInChat", "membersInGame", "membersOnline", "hoursOnRecord"];
303
+ const floatFields = ["hoursPlayed2Wk", "hoursPlayed"];
304
+ for (const field of numberFields) {
305
+ if (field in profile) profile[field] = parseInt(profile[field], 10) || 0;
306
+ }
307
+ for (const field of floatFields) {
308
+ if (field in profile) profile[field] = parseFloat(profile[field]) || 0;
309
+ }
310
+
311
+ // Add avatarHash
312
+ const avatarUrl = profile.avatarFull || profile.avatarMedium || profile.avatarIcon;
313
+ profile.avatarHash = getAvatarHashFromUrl(avatarUrl);
314
+
315
+ const memberSince = profile.memberSince;
316
+ profile.memberSince = null;
317
+ const formats = ["MMMM DD, YYYY", "MMMM D, YYYY", "MMMM DD", "MMMM D"];
318
+ for (const format of formats) {
319
+ const memberSinceMoment = moment(memberSince, format, true);
320
+ if (memberSinceMoment.isValid()) {
321
+ profile.memberSince = memberSinceMoment.valueOf();
322
+ break;
323
+ }
324
+ }
325
+
326
+ // Ensure all simple fields are strings if empty object
327
+ for (const key in profile) {
328
+ if (typeof profile[key] === "object" && profile[key] !== null && Object.keys(profile[key]).length === 0) {
329
+ profile[key] = "";
330
+ }
331
+ }
332
+
333
+ // Move groups.group to top-level 'groups' if present
334
+ if (profile.groups && profile.groups.group) {
335
+ profile.groups = profile.groups.group;
336
+ }
337
+
338
+ // Remove _attributes from groups (if present)
339
+ if (Array.isArray(profile.groups)) {
340
+ profile.groups = profile.groups.map((group) => {
341
+ if (group && typeof group === "object" && "_attributes" in group) {
342
+ // shallow clone and remove _attributes
343
+ const { _attributes, ...groupWithoutAttributes } = group;
344
+ return groupWithoutAttributes;
345
+ }
346
+ return group;
347
+ });
348
+ }
349
+
350
+ return profile;
351
+ }
352
+
353
+ /**
354
+ * Attempts to heuristically determine if the input string is XML, HTML, or unknown.
355
+ *
356
+ * Checks for XML declarations, HTML DOCTYPE or root tags, XML namespaces, common error page patterns,
357
+ * and known XML root elements. Optionally uses strict XML parsing (if xml2js is installed) as a fallback.
358
+ *
359
+ * @param {string} input - The markup document as a string to be analyzed.
360
+ * @returns {"xml"|"html"|"unknown"} - Returns 'xml' if input appears to be XML, 'html' for HTML, or 'unknown' if indeterminate.
361
+ *
362
+ * @example
363
+ * guessDocumentType('<?xml version="1.0"?><profile></profile>'); // 'xml'
364
+ * guessDocumentType('<!DOCTYPE html><html><body></body></html>'); // 'html'
365
+ * guessDocumentType('<svg xmlns="http://www.w3.org/2000/svg"></svg>'); // 'xml'
366
+ * guessDocumentType('random text'); // 'unknown'
367
+ */
368
+ export function guessDocumentType(input) {
369
+ if (typeof input !== "string" || !input.trim()) return "unknown";
370
+ const trimmed = input.trim();
371
+
372
+ // XML declaration
373
+ if (/^\s*<\?xml\b/i.test(trimmed)) return "xml";
374
+
375
+ // HTML DOCTYPE or <html> as first tag
376
+ if (/<!DOCTYPE\s+html\b[^>]*>/i.test(trimmed)) return "html";
377
+ if (/^\s*<html\b[^>]*>/i.test(trimmed)) return "html";
378
+
379
+ // Common HTML error patterns
380
+ if (/<meta\s[^>]*http-equiv=["']?refresh["']?/i.test(trimmed)) return "html";
381
+ if (/<title>.*(error|not\s*found|forbidden).*<\/title>/i.test(trimmed)) return "html";
382
+
383
+ // Root tag clues
384
+ const rootTagMatch = trimmed.match(/^\s*<([\w:-]+)/);
385
+ if (rootTagMatch) {
386
+ const tag = rootTagMatch[1].toLowerCase();
387
+ // Common XML API roots
388
+ const xmlLikeRoots = ["rss", "feed", "svg", "profile", "users", "user", "response", "data", "opml", "atom", "plist", "soap:envelope", "xsl:stylesheet"];
389
+ if (tag === "html") return "html";
390
+ if (xmlLikeRoots.includes(tag) || tag.includes(":")) return "xml";
391
+ }
392
+
393
+ // Namespace? (usually in XML, rarely HTML)
394
+ if (/xmlns\s*=\s*['"]/i.test(trimmed)) return "xml";
395
+
396
+ // Try strict XML parse (requires xml2js, non-fatal if missing)
397
+ let isXml = false;
398
+ try {
399
+ // xml2js parseString uses a callback, so we wrap in a try/catch for sync usage
400
+ parseString(trimmed, { strict: true }, (err) => {
401
+ isXml = !err;
402
+ });
403
+ if (isXml) return "xml";
404
+ } catch (e) {
405
+ // xml2js parse failed, ignore
406
+ }
407
+
408
+ return "unknown";
409
+ }