mtg-playerinfo 1.2.0 → 1.3.0
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/.github/workflows/ci.yml +29 -3
- package/.github/workflows/update-test-data.yml +33 -0
- package/README.md +16 -11
- package/cli.js +30 -13
- package/package.json +21 -2
- package/scripts/update-test-data.js +29 -0
- package/src/fetchers/melee.js +26 -35
- package/src/fetchers/mtgElo.js +40 -40
- package/src/fetchers/topdeck.js +70 -67
- package/src/fetchers/unityLeague.js +45 -45
- package/src/fetchers/untapped.js +54 -0
- package/src/index.js +75 -42
- package/src/utils/httpClient.js +30 -30
- package/test/data/melee.html +77 -62
- package/test/data/mtgElo.html +8 -8
- package/test/data/topdeck.html +778 -662
- package/test/data/topdeck.json +1 -0
- package/test/data/unityLeague.html +1445 -1340
- package/test/data/untapped.json +104 -0
- package/test/edgeCases.test.js +128 -0
- package/test/melee.test.js +23 -23
- package/test/meleeEdgeCases.test.js +53 -0
- package/test/mtgElo.test.js +21 -21
- package/test/mtgEloEdgeCases.test.js +92 -0
- package/test/playerInfoManager.test.js +312 -0
- package/test/topdeck.test.js +42 -29
- package/test/unityLeague.test.js +24 -24
- package/test/unityLeagueEdgeCases.test.js +123 -0
- package/test/untapped.test.js +58 -0
- package/test/verboseLogging.test.js +215 -0
- package/test/winRatePrecision.test.js +25 -0
- package/.github/workflows/pull-player-data.yml +0 -27
package/src/fetchers/topdeck.js
CHANGED
|
@@ -1,106 +1,109 @@
|
|
|
1
|
-
const
|
|
2
|
-
const cheerio = require('cheerio')
|
|
1
|
+
const httpClient = require('../utils/httpClient')
|
|
2
|
+
const cheerio = require('cheerio')
|
|
3
3
|
|
|
4
4
|
class TopdeckFetcher {
|
|
5
|
-
async fetchById(handle) {
|
|
6
|
-
const cleanHandle = handle.startsWith('@') ? handle : `@${handle}
|
|
7
|
-
const url = `https://topdeck.gg/profile/${cleanHandle}
|
|
5
|
+
async fetchById (handle) {
|
|
6
|
+
const cleanHandle = handle.startsWith('@') ? handle : `@${handle}`
|
|
7
|
+
const url = `https://topdeck.gg/profile/${cleanHandle}`
|
|
8
8
|
try {
|
|
9
|
-
const { data } = await request(url)
|
|
10
|
-
const playerInfo = this.parseHtml(data, url, cleanHandle)
|
|
9
|
+
const { data } = await httpClient.request(url)
|
|
10
|
+
const playerInfo = this.parseHtml(data, url, cleanHandle)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const internalId = internalIdMatch ? internalIdMatch[1] : null;
|
|
12
|
+
const internalIdMatch = data.match(/https:\/\/topdeck\.gg\/profile\/([a-zA-Z0-9]+)\/stats/) || data.match(/const playerId = "([a-zA-Z0-9]+)";/)
|
|
13
|
+
const internalId = internalIdMatch ? internalIdMatch[1] : null
|
|
15
14
|
|
|
16
15
|
if (internalId) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
await this.fetchStats(internalId, playerInfo)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return playerInfo
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error(`Error fetching Topdeck profile ${handle}:`, error.message)
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
async fetchStats (internalId, playerInfo) {
|
|
27
|
+
try {
|
|
28
|
+
const statsUrl = `https://topdeck.gg/profile/${internalId}/stats`
|
|
29
|
+
const statsResponse = await httpClient.request(statsUrl)
|
|
30
|
+
const statsJson = statsResponse.data
|
|
31
|
+
const stats = typeof statsJson === 'string' ? JSON.parse(statsJson) : statsJson
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
});
|
|
33
|
+
if (stats) {
|
|
34
|
+
if (stats.yearlyStats) {
|
|
35
|
+
let totalTournaments = 0
|
|
36
|
+
let wins = 0
|
|
37
|
+
let losses = 0
|
|
38
|
+
let draws = 0
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
playerInfo['tournaments'] = stats.totalTournaments || playerInfo['tournaments'] || '0';
|
|
46
|
-
playerInfo['record'] = stats.overallRecord || playerInfo['record'] || '0-0-0';
|
|
47
|
-
playerInfo['win rate'] = stats.overallWinRate || playerInfo['win rate'] || '0.00%';
|
|
48
|
-
playerInfo['conversion'] = stats.conversionRate || playerInfo['conversion'] || '0.00%';
|
|
40
|
+
Object.values(stats.yearlyStats).forEach(yearData => {
|
|
41
|
+
if (yearData.overall) {
|
|
42
|
+
totalTournaments += yearData.overall.totalTournaments || 0
|
|
43
|
+
wins += yearData.overall.wins || 0
|
|
44
|
+
losses += yearData.overall.losses || 0
|
|
45
|
+
draws += yearData.overall.draws || 0
|
|
49
46
|
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (totalTournaments > 0) {
|
|
50
|
+
playerInfo.tournaments = totalTournaments.toString()
|
|
51
|
+
playerInfo.record = `${wins}-${losses}-${draws}`
|
|
52
|
+
playerInfo['win rate'] = ((wins / (wins + losses + draws)) * 100).toFixed(2) + '%'
|
|
50
53
|
}
|
|
51
|
-
}
|
|
52
|
-
|
|
54
|
+
} else {
|
|
55
|
+
playerInfo.tournaments = stats.totalTournaments || playerInfo.tournaments || '0'
|
|
56
|
+
playerInfo.record = stats.overallRecord || playerInfo.record || '0-0-0'
|
|
57
|
+
playerInfo['win rate'] = stats.overallWinRate || playerInfo['win rate'] || '0.00%'
|
|
58
|
+
playerInfo.conversion = stats.conversionRate || playerInfo.conversion || '0.00%'
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} catch (error) {
|
|
58
|
-
console.error(`Error fetching Topdeck profile ${handle}:`, error.message);
|
|
59
|
-
return null;
|
|
61
|
+
} catch (statsError) {
|
|
62
|
+
console.error(`Error fetching Topdeck stats for ${internalId}:`, statsError.message)
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
parseHtml(html, url, handle) {
|
|
64
|
-
const $ = cheerio.load(html)
|
|
65
|
-
const name = $('h2.text-white.fw-bold.mb-1').first().text().trim() || $('h1').first().text().trim() || handle
|
|
66
|
-
const photo = $('img.rounded-circle.shadow-lg').first().attr('src') || $('img[src*="avatar"], img[src*="profile"]').first().attr('src')
|
|
66
|
+
parseHtml (html, url, handle) {
|
|
67
|
+
const $ = cheerio.load(html)
|
|
68
|
+
const name = $('h2.text-white.fw-bold.mb-1').first().text().trim() || $('h1').first().text().trim() || handle
|
|
69
|
+
const photo = $('img.rounded-circle.shadow-lg').first().attr('src') || $('img[src*="avatar"], img[src*="profile"]').first().attr('src')
|
|
67
70
|
|
|
68
71
|
const data = {
|
|
69
72
|
source: 'Topdeck',
|
|
70
73
|
url,
|
|
71
74
|
name,
|
|
72
75
|
photo: photo ? (photo.startsWith('http') ? photo : `https://topdeck.gg${photo}`) : null
|
|
73
|
-
}
|
|
76
|
+
}
|
|
74
77
|
|
|
75
78
|
const statsMap = {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
79
|
+
totalTournaments: 'tournaments',
|
|
80
|
+
overallRecord: 'record',
|
|
81
|
+
overallWinRate: 'win rate',
|
|
82
|
+
conversionRate: 'conversion'
|
|
83
|
+
}
|
|
81
84
|
|
|
82
85
|
Object.entries(statsMap).forEach(([id, label]) => {
|
|
83
|
-
const val = $(`#${id}`).text().trim()
|
|
86
|
+
const val = $(`#${id}`).text().trim()
|
|
84
87
|
if (val) {
|
|
85
|
-
data[label] = val
|
|
88
|
+
data[label] = val
|
|
86
89
|
}
|
|
87
|
-
})
|
|
90
|
+
})
|
|
88
91
|
|
|
89
|
-
const currentStats = Object.keys(data).filter(k => Object.values(statsMap).includes(k))
|
|
92
|
+
const currentStats = Object.keys(data).filter(k => Object.values(statsMap).includes(k))
|
|
90
93
|
if (currentStats.length <= 1) {
|
|
91
94
|
$('.stats-container, .player-stats').each((i, el) => {
|
|
92
95
|
$(el).find('.stat').each((j, statEl) => {
|
|
93
|
-
const label = $(statEl).find('.label').text().trim().toLowerCase()
|
|
94
|
-
const value = $(statEl).find('.value').text().trim()
|
|
96
|
+
const label = $(statEl).find('.label').text().trim().toLowerCase()
|
|
97
|
+
const value = $(statEl).find('.value').text().trim()
|
|
95
98
|
if (label && value) {
|
|
96
|
-
data[label] = value
|
|
99
|
+
data[label] = value
|
|
97
100
|
}
|
|
98
|
-
})
|
|
99
|
-
})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
return data
|
|
105
|
+
return data
|
|
103
106
|
}
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
module.exports = TopdeckFetcher
|
|
109
|
+
module.exports = TopdeckFetcher
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
const { request } = require('../utils/httpClient')
|
|
2
|
-
const cheerio = require('cheerio')
|
|
1
|
+
const { request } = require('../utils/httpClient')
|
|
2
|
+
const cheerio = require('cheerio')
|
|
3
3
|
|
|
4
4
|
class UnityLeagueFetcher {
|
|
5
|
-
async fetchById(id) {
|
|
6
|
-
const url = `https://unityleague.gg/player/${id}
|
|
5
|
+
async fetchById (id) {
|
|
6
|
+
const url = `https://unityleague.gg/player/${id}/`
|
|
7
7
|
try {
|
|
8
|
-
const { data } = await request(url)
|
|
9
|
-
return this.parseHtml(data, url)
|
|
8
|
+
const { data } = await request(url)
|
|
9
|
+
return this.parseHtml(data, url)
|
|
10
10
|
} catch (error) {
|
|
11
|
-
console.error(`Error fetching Unity League player ${id}:`, error.message)
|
|
12
|
-
return null
|
|
11
|
+
console.error(`Error fetching Unity League player ${id}:`, error.message)
|
|
12
|
+
return null
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
parseHtml(html, url) {
|
|
17
|
-
const $ = cheerio.load(html)
|
|
18
|
-
const name = $('h1.d-inline').text().trim()
|
|
19
|
-
let photo = $('.card-body img.img-fluid').first().attr('src')
|
|
16
|
+
parseHtml (html, url) {
|
|
17
|
+
const $ = cheerio.load(html)
|
|
18
|
+
const name = $('h1.d-inline').text().trim()
|
|
19
|
+
let photo = $('.card-body img.img-fluid').first().attr('src')
|
|
20
20
|
if (photo && !photo.includes('player_profile')) {
|
|
21
|
-
photo = null
|
|
21
|
+
photo = null
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const data = {
|
|
@@ -26,74 +26,74 @@ class UnityLeagueFetcher {
|
|
|
26
26
|
url,
|
|
27
27
|
name,
|
|
28
28
|
photo: photo ? (photo.startsWith('http') ? photo : `https://unityleague.gg${photo}`) : null
|
|
29
|
-
}
|
|
29
|
+
}
|
|
30
30
|
|
|
31
|
-
const headerFlag = $('.card-body i.fi').first()
|
|
31
|
+
const headerFlag = $('.card-body i.fi').first()
|
|
32
32
|
if (headerFlag.length > 0) {
|
|
33
|
-
const classes = headerFlag.attr('class').split(' ')
|
|
34
|
-
const countryClass = classes.find(c => c.startsWith('fi-'))
|
|
33
|
+
const classes = headerFlag.attr('class').split(' ')
|
|
34
|
+
const countryClass = classes.find(c => c.startsWith('fi-'))
|
|
35
35
|
if (countryClass) {
|
|
36
|
-
data.country = countryClass.replace('fi-', '')
|
|
36
|
+
data.country = countryClass.replace('fi-', '')
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
$('dt.small.text-muted').each((i, el) => {
|
|
41
|
-
const key = $(el).text().trim().replace(/:$/, '').toLowerCase()
|
|
42
|
-
const dd = $(el).next('dd')
|
|
43
|
-
let value = dd.text().trim()
|
|
41
|
+
const key = $(el).text().trim().replace(/:$/, '').toLowerCase()
|
|
42
|
+
const dd = $(el).next('dd')
|
|
43
|
+
let value = dd.text().trim()
|
|
44
44
|
|
|
45
45
|
if (key === 'country') {
|
|
46
|
-
const flagIcon = dd.find('i.fi')
|
|
46
|
+
const flagIcon = dd.find('i.fi')
|
|
47
47
|
if (flagIcon.length > 0) {
|
|
48
|
-
const classes = flagIcon.attr('class').split(' ')
|
|
49
|
-
const countryClass = classes.find(c => c.startsWith('fi-'))
|
|
48
|
+
const classes = flagIcon.attr('class').split(' ')
|
|
49
|
+
const countryClass = classes.find(c => c.startsWith('fi-'))
|
|
50
50
|
if (countryClass) {
|
|
51
|
-
value = countryClass.replace('fi-', '')
|
|
51
|
+
value = countryClass.replace('fi-', '')
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
data[key] = value
|
|
57
|
-
})
|
|
56
|
+
data[key] = value
|
|
57
|
+
})
|
|
58
58
|
|
|
59
|
-
const bioElement = $('.card-body > small.mt-2').first()
|
|
59
|
+
const bioElement = $('.card-body > small.mt-2').first()
|
|
60
60
|
if (bioElement.length > 0) {
|
|
61
|
-
data.bio = bioElement.text().trim()
|
|
61
|
+
data.bio = bioElement.text().trim()
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const rankingTable = $('table.table-sm').first()
|
|
64
|
+
const rankingTable = $('table.table-sm').first()
|
|
65
65
|
if (rankingTable.length) {
|
|
66
|
-
const headers = rankingTable.find('th').map((i, el) => $(el).text().trim()).get()
|
|
67
|
-
const values = rankingTable.find('tbody td').map((i, el) => $(el).text().trim()).get()
|
|
66
|
+
const headers = rankingTable.find('th').map((i, el) => $(el).text().trim()).get()
|
|
67
|
+
const values = rankingTable.find('tbody td').map((i, el) => $(el).text().trim()).get()
|
|
68
68
|
|
|
69
69
|
headers.forEach((header, i) => {
|
|
70
70
|
if (header && values[i]) {
|
|
71
|
-
let val = values[i]
|
|
72
|
-
val = val.replace(/\D/g, '')
|
|
71
|
+
let val = values[i]
|
|
72
|
+
val = val.replace(/\D/g, '') // remove non-numeric characters to support #23->23, 1st->1, and 42nd->42 etc.
|
|
73
73
|
if (val) {
|
|
74
|
-
data[`rank ${header.toLowerCase()}`] = val
|
|
74
|
+
data[`rank ${header.toLowerCase()}`] = val
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
})
|
|
77
|
+
})
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// Extract tournament record and win rate
|
|
81
81
|
const overallRow = $('table.table tr').filter((i, el) => {
|
|
82
|
-
return $(el).find('td').first().text().trim() === 'Overall'
|
|
83
|
-
})
|
|
82
|
+
return $(el).find('td').first().text().trim() === 'Overall'
|
|
83
|
+
})
|
|
84
84
|
|
|
85
85
|
if (overallRow.length > 0) {
|
|
86
|
-
const cells = overallRow.find('td')
|
|
86
|
+
const cells = overallRow.find('td')
|
|
87
87
|
if (cells.length >= 3) {
|
|
88
|
-
const record = $(cells[1]).text().trim().replace(/\s+/g, '')
|
|
89
|
-
const winRate = $(cells[2]).text().trim()
|
|
90
|
-
data.record = record
|
|
91
|
-
data['win rate'] = winRate
|
|
88
|
+
const record = $(cells[1]).text().trim().replace(/\s+/g, '')
|
|
89
|
+
const winRate = $(cells[2]).text().trim()
|
|
90
|
+
data.record = record
|
|
91
|
+
data['win rate'] = winRate
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
return data
|
|
95
|
+
return data
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
module.exports = UnityLeagueFetcher
|
|
99
|
+
module.exports = UnityLeagueFetcher
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const { request } = require('../utils/httpClient')
|
|
2
|
+
|
|
3
|
+
class UntappedFetcher {
|
|
4
|
+
async fetchById (id) {
|
|
5
|
+
// Split the two-part ID
|
|
6
|
+
const parts = id.split('/')
|
|
7
|
+
if (parts.length !== 2) {
|
|
8
|
+
console.error('Untapped ID must be in format "userId/playerCode"')
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const userId = parts[0]
|
|
13
|
+
const playerCode = parts[1]
|
|
14
|
+
const url = `https://mtga.untapped.gg/profile/${userId}/${playerCode}`
|
|
15
|
+
const apiUrl = `https://api.mtga.untapped.gg/api/v1/games/users/${userId}/players/${playerCode}/?card_set=ECL`
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { data } = await request(apiUrl)
|
|
19
|
+
const matches = typeof data === 'string' ? JSON.parse(data) : data
|
|
20
|
+
|
|
21
|
+
if (!Array.isArray(matches) || matches.length === 0) {
|
|
22
|
+
console.warn(`No matches found for Untapped player ${id}`)
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mostRecentMatch = matches.reduce((latest, current) => {
|
|
27
|
+
return (current.match_start > latest.match_start) ? current : latest
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return this.parseMatch(mostRecentMatch, url)
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`Error fetching Untapped profile ${id}:`, error.message)
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
parseMatch (match, url) {
|
|
38
|
+
const data = {
|
|
39
|
+
source: 'Untapped.gg',
|
|
40
|
+
url,
|
|
41
|
+
mtga_rank: null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (match.friendly_ranking_class_after && match.friendly_ranking_tier_after !== null && match.friendly_ranking_tier_after !== undefined) {
|
|
45
|
+
data.mtga_rank = `${match.friendly_ranking_class_after} ${match.friendly_ranking_tier_after}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return data
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = UntappedFetcher
|
|
53
|
+
|
|
54
|
+
|
package/src/index.js
CHANGED
|
@@ -1,89 +1,122 @@
|
|
|
1
|
-
const UnityLeagueFetcher = require('./fetchers/unityLeague')
|
|
2
|
-
const MtgEloFetcher = require('./fetchers/mtgElo')
|
|
3
|
-
const MeleeFetcher = require('./fetchers/melee')
|
|
4
|
-
const TopdeckFetcher = require('./fetchers/topdeck')
|
|
1
|
+
const UnityLeagueFetcher = require('./fetchers/unityLeague')
|
|
2
|
+
const MtgEloFetcher = require('./fetchers/mtgElo')
|
|
3
|
+
const MeleeFetcher = require('./fetchers/melee')
|
|
4
|
+
const TopdeckFetcher = require('./fetchers/topdeck')
|
|
5
|
+
const UntappedFetcher = require('./fetchers/untapped')
|
|
5
6
|
|
|
6
7
|
class PlayerInfoManager {
|
|
7
|
-
constructor() {
|
|
8
|
+
constructor () {
|
|
8
9
|
this.fetchers = {
|
|
9
10
|
unity: new UnityLeagueFetcher(),
|
|
10
11
|
mtgelo: new MtgEloFetcher(),
|
|
11
12
|
melee: new MeleeFetcher(),
|
|
12
|
-
topdeck: new TopdeckFetcher()
|
|
13
|
-
|
|
13
|
+
topdeck: new TopdeckFetcher(),
|
|
14
|
+
untapped: new UntappedFetcher()
|
|
15
|
+
}
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
async getPlayerInfo(options) {
|
|
17
|
-
const
|
|
18
|
+
async getPlayerInfo (options, priorityOrder = []) {
|
|
19
|
+
const fetcherMap = {
|
|
20
|
+
unity: { option: 'unityId', fetcher: this.fetchers.unity },
|
|
21
|
+
mtgelo: { option: 'mtgeloId', fetcher: this.fetchers.mtgelo },
|
|
22
|
+
melee: { option: 'meleeUser', fetcher: this.fetchers.melee },
|
|
23
|
+
topdeck: { option: 'topdeckHandle', fetcher: this.fetchers.topdeck },
|
|
24
|
+
untapped: { option: 'untappedId', fetcher: this.fetchers.untapped }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Default order if no priority specified
|
|
28
|
+
const defaultOrder = ['unity', 'mtgelo', 'melee', 'topdeck', 'untapped']
|
|
29
|
+
const order = priorityOrder.length > 0 ? priorityOrder : defaultOrder
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
const results = []
|
|
32
|
+
|
|
33
|
+
for (const source of order) {
|
|
34
|
+
const config = fetcherMap[source]
|
|
35
|
+
if (config && options[config.option]) {
|
|
36
|
+
const result = await config.fetcher.fetchById(options[config.option])
|
|
37
|
+
results.push(result)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
23
40
|
|
|
24
|
-
const filteredResults = results.filter(r => r !== null)
|
|
25
|
-
return this.mergeData(filteredResults, options.verbose)
|
|
41
|
+
const filteredResults = results.filter(r => r !== null)
|
|
42
|
+
return this.mergeData(filteredResults, options.verbose)
|
|
26
43
|
}
|
|
27
44
|
|
|
28
|
-
mergeData(results, verbose = false) {
|
|
45
|
+
mergeData (results, verbose = false) {
|
|
29
46
|
const player = {
|
|
30
47
|
general: {},
|
|
31
48
|
sources: {}
|
|
32
|
-
}
|
|
49
|
+
}
|
|
33
50
|
|
|
34
|
-
const seenUrls = new Set()
|
|
35
|
-
const firstSeenValues = {}
|
|
51
|
+
const seenUrls = new Set()
|
|
52
|
+
const firstSeenValues = {}
|
|
36
53
|
|
|
37
|
-
const winRates = []
|
|
54
|
+
const winRates = []
|
|
55
|
+
let totalWins = 0
|
|
56
|
+
let totalLosses = 0
|
|
57
|
+
let totalDraws = 0
|
|
38
58
|
|
|
39
59
|
results.forEach(res => {
|
|
40
|
-
if (seenUrls.has(res.url)) return
|
|
41
|
-
seenUrls.add(res.url)
|
|
60
|
+
if (seenUrls.has(res.url)) return
|
|
61
|
+
seenUrls.add(res.url)
|
|
42
62
|
|
|
43
|
-
const generalProps = ['name', 'photo', 'age', 'bio', 'team', 'country', 'hometown', 'pronouns', 'facebook', 'twitch', 'youtube']
|
|
63
|
+
const generalProps = ['name', 'photo', 'age', 'bio', 'team', 'country', 'hometown', 'pronouns', 'facebook', 'twitch', 'youtube', 'mtga_rank']
|
|
44
64
|
generalProps.forEach(prop => {
|
|
45
65
|
if (res[prop]) {
|
|
46
66
|
if (!player.general[prop]) {
|
|
47
|
-
player.general[prop] = res[prop]
|
|
48
|
-
firstSeenValues[prop] = res[prop]
|
|
67
|
+
player.general[prop] = res[prop]
|
|
68
|
+
firstSeenValues[prop] = res[prop]
|
|
49
69
|
if (verbose) {
|
|
50
|
-
console.log(`⬆️: Promoted '${prop}' from ${res.source} to general information: ${res[prop]}`)
|
|
70
|
+
console.log(`⬆️: Promoted '${prop}' from ${res.source} to general information: ${res[prop]}`)
|
|
51
71
|
}
|
|
52
72
|
} else if (verbose) {
|
|
53
73
|
if (res[prop] === firstSeenValues[prop]) {
|
|
54
|
-
console.log(`🆗: ${res.source} has the same '${prop}' as seen before: ${res[prop]}`)
|
|
74
|
+
console.log(`🆗: ${res.source} has the same '${prop}' as seen before: ${res[prop]}`)
|
|
55
75
|
} else {
|
|
56
|
-
console.log(`${prop === 'photo' ? '🆕': '🆚'}️: ${res.source} has different '${prop}' than seen before: ${res[prop]} (instead of ${firstSeenValues[prop]})`)
|
|
76
|
+
console.log(`${prop === 'photo' ? '🆕' : '🆚'}️: ${res.source} has different '${prop}' than seen before: ${res[prop]} (instead of ${firstSeenValues[prop]})`)
|
|
57
77
|
}
|
|
58
78
|
}
|
|
59
79
|
}
|
|
60
|
-
})
|
|
80
|
+
})
|
|
61
81
|
|
|
62
|
-
const winRateStr = res['win rate'] || res.winRate
|
|
82
|
+
const winRateStr = res['win rate'] || res.winRate
|
|
63
83
|
if (winRateStr && typeof winRateStr === 'string') {
|
|
64
|
-
const winRate = parseFloat(winRateStr.replace('%', ''))
|
|
84
|
+
const winRate = parseFloat(winRateStr.replace('%', ''))
|
|
65
85
|
if (!isNaN(winRate)) {
|
|
66
|
-
winRates.push(winRate)
|
|
86
|
+
winRates.push(winRate)
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
89
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
if (res.record && typeof res.record === 'string' && res.record.includes('-')) {
|
|
91
|
+
const parts = res.record.split('-').map(Number)
|
|
92
|
+
if (parts.length >= 2 && parts.every(p => !isNaN(p))) {
|
|
93
|
+
totalWins += parts[0]
|
|
94
|
+
totalLosses += parts[1]
|
|
95
|
+
totalDraws += parts[2] || 0
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sourceData = { ...res }
|
|
100
|
+
delete sourceData.source
|
|
101
|
+
delete sourceData.url
|
|
73
102
|
|
|
74
103
|
player.sources[res.source] = {
|
|
75
104
|
url: res.url,
|
|
76
105
|
data: sourceData
|
|
77
|
-
}
|
|
78
|
-
})
|
|
106
|
+
}
|
|
107
|
+
})
|
|
79
108
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
const totalGames = totalWins + totalLosses + totalDraws
|
|
110
|
+
if (totalGames > 0) {
|
|
111
|
+
const overall = (totalWins / totalGames) * 100
|
|
112
|
+
player.general['win rate'] = overall.toFixed(2) + '%'
|
|
113
|
+
} else if (winRates.length > 0) {
|
|
114
|
+
const avgWinRate = winRates.reduce((a, b) => a + b, 0) / winRates.length
|
|
115
|
+
player.general['win rate'] = avgWinRate.toFixed(2) + '%'
|
|
83
116
|
}
|
|
84
117
|
|
|
85
|
-
return player
|
|
118
|
+
return player
|
|
86
119
|
}
|
|
87
120
|
}
|
|
88
121
|
|
|
89
|
-
module.exports = PlayerInfoManager
|
|
122
|
+
module.exports = PlayerInfoManager
|
package/src/utils/httpClient.js
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
const https = require('https')
|
|
2
|
-
const http = require('http')
|
|
3
|
-
const { URL } = require('url')
|
|
1
|
+
const https = require('https')
|
|
2
|
+
const http = require('http')
|
|
3
|
+
const { URL } = require('url')
|
|
4
4
|
|
|
5
|
-
function request(url, options = {}) {
|
|
5
|
+
function request (url, options = {}) {
|
|
6
6
|
return new Promise((resolve, reject) => {
|
|
7
|
-
const parsedUrl = new URL(url)
|
|
8
|
-
const protocol = parsedUrl.protocol === 'https:' ? https : http
|
|
9
|
-
|
|
7
|
+
const parsedUrl = new URL(url)
|
|
8
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http
|
|
9
|
+
|
|
10
10
|
const headers = {
|
|
11
11
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
12
12
|
...options.headers
|
|
13
|
-
}
|
|
13
|
+
}
|
|
14
14
|
|
|
15
15
|
const requestOptions = {
|
|
16
16
|
method: options.method || 'GET',
|
|
17
|
-
headers
|
|
17
|
+
headers,
|
|
18
18
|
timeout: 30000,
|
|
19
19
|
...options
|
|
20
|
-
}
|
|
20
|
+
}
|
|
21
21
|
|
|
22
22
|
const req = protocol.request(url, requestOptions, (res) => {
|
|
23
23
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
24
|
-
const redirectUrl = new URL(res.headers.location, url).href
|
|
25
|
-
const maxRedirects = options.maxRedirects || 5
|
|
24
|
+
const redirectUrl = new URL(res.headers.location, url).href
|
|
25
|
+
const maxRedirects = options.maxRedirects || 5
|
|
26
26
|
if (maxRedirects > 0) {
|
|
27
27
|
return request(redirectUrl, { ...options, maxRedirects: maxRedirects - 1 })
|
|
28
28
|
.then(resolve)
|
|
29
|
-
.catch(reject)
|
|
29
|
+
.catch(reject)
|
|
30
30
|
} else {
|
|
31
|
-
return reject(new Error('Too many redirects'))
|
|
31
|
+
return reject(new Error('Too many redirects'))
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
let data = ''
|
|
35
|
+
let data = ''
|
|
36
36
|
res.on('data', (chunk) => {
|
|
37
|
-
data += chunk
|
|
38
|
-
})
|
|
37
|
+
data += chunk
|
|
38
|
+
})
|
|
39
39
|
|
|
40
40
|
res.on('end', () => {
|
|
41
41
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
@@ -43,27 +43,27 @@ function request(url, options = {}) {
|
|
|
43
43
|
data,
|
|
44
44
|
status: res.statusCode,
|
|
45
45
|
headers: res.headers
|
|
46
|
-
})
|
|
46
|
+
})
|
|
47
47
|
} else {
|
|
48
|
-
reject(new Error(`Request failed with status ${res.statusCode}`))
|
|
48
|
+
reject(new Error(`Request failed with status ${res.statusCode}`))
|
|
49
49
|
}
|
|
50
|
-
})
|
|
51
|
-
})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
52
|
|
|
53
53
|
req.on('error', (err) => {
|
|
54
|
-
reject(err)
|
|
55
|
-
})
|
|
54
|
+
reject(err)
|
|
55
|
+
})
|
|
56
56
|
|
|
57
57
|
req.on('timeout', () => {
|
|
58
|
-
req.destroy()
|
|
59
|
-
reject(new Error('Request timed out'))
|
|
60
|
-
})
|
|
58
|
+
req.destroy()
|
|
59
|
+
reject(new Error('Request timed out'))
|
|
60
|
+
})
|
|
61
61
|
|
|
62
62
|
if (options.body) {
|
|
63
|
-
req.write(options.body)
|
|
63
|
+
req.write(options.body)
|
|
64
64
|
}
|
|
65
|
-
req.end()
|
|
66
|
-
})
|
|
65
|
+
req.end()
|
|
66
|
+
})
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
module.exports = { request }
|
|
69
|
+
module.exports = { request }
|