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.
- package/index.js +5 -1
- package/package.json +1 -1
- 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
|
-
|
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
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
|
-
|
9
|
-
* @
|
10
|
-
* @property {
|
11
|
-
* @property {number}
|
12
|
-
* @property {number}
|
13
|
-
* @property {
|
14
|
-
* @property {string}
|
15
|
-
* @property {string} [
|
16
|
-
* @property {string} [
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
*
|
22
|
-
*
|
23
|
-
*
|
24
|
-
*
|
25
|
-
* @
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
const
|
48
|
-
const
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
* @
|
74
|
-
* @property {string}
|
75
|
-
* @property {
|
76
|
-
* @property {
|
77
|
-
* @property {string}
|
78
|
-
* @property {string}
|
79
|
-
* @property {string}
|
80
|
-
* @property {string}
|
81
|
-
* @property {string}
|
82
|
-
* @property {string}
|
83
|
-
* @property {string}
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
*
|
89
|
-
*
|
90
|
-
* @param {
|
91
|
-
* @
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
.
|
98
|
-
|
99
|
-
const
|
100
|
-
const
|
101
|
-
|
102
|
-
|
103
|
-
const
|
104
|
-
const
|
105
|
-
const
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
*
|
128
|
-
*
|
129
|
-
* @
|
130
|
-
*
|
131
|
-
*
|
132
|
-
*
|
133
|
-
*
|
134
|
-
*
|
135
|
-
*
|
136
|
-
*
|
137
|
-
*
|
138
|
-
*
|
139
|
-
*
|
140
|
-
*
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
const
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
const
|
165
|
-
|
166
|
-
|
167
|
-
const
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
const
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
*
|
196
|
-
* @
|
197
|
-
* @property {string}
|
198
|
-
* @property {string}
|
199
|
-
* @property {string}
|
200
|
-
* @property {string}
|
201
|
-
* @property {string}
|
202
|
-
* @property {string}
|
203
|
-
* @property {string}
|
204
|
-
* @property {
|
205
|
-
* @property {number}
|
206
|
-
* @property {number}
|
207
|
-
* @property {number}
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
*
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
*
|
218
|
-
* @
|
219
|
-
* @property {string}
|
220
|
-
* @property {string}
|
221
|
-
* @property {string}
|
222
|
-
* @property {string}
|
223
|
-
* @property {
|
224
|
-
* @property {number}
|
225
|
-
* @property {
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
*
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
*
|
236
|
-
* @
|
237
|
-
* @property {string}
|
238
|
-
* @property {string}
|
239
|
-
* @property {string}
|
240
|
-
* @property {string}
|
241
|
-
* @property {
|
242
|
-
* @property {
|
243
|
-
* @property {string}
|
244
|
-
* @property {string}
|
245
|
-
* @property {string}
|
246
|
-
* @property {
|
247
|
-
* @property {
|
248
|
-
* @property {
|
249
|
-
* @property {
|
250
|
-
* @property {
|
251
|
-
* @property {
|
252
|
-
* @property {
|
253
|
-
* @property {
|
254
|
-
* @property {string}
|
255
|
-
* @property {string}
|
256
|
-
* @property {string}
|
257
|
-
* @property {
|
258
|
-
* @property {
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
*
|
264
|
-
*
|
265
|
-
* @
|
266
|
-
*
|
267
|
-
*
|
268
|
-
*
|
269
|
-
*
|
270
|
-
* const
|
271
|
-
*
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
if (
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
for (const field of
|
305
|
-
if (field in profile) profile[field] =
|
306
|
-
}
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
const
|
313
|
-
profile.
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
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
|
+
}
|