mtg-playerinfo 1.0.3 → 1.1.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 +30 -0
- package/.github/workflows/pull-player-data.yml +1 -0
- package/cli.js +1 -1
- package/package.json +2 -2
- package/src/fetchers/melee.js +32 -2
- package/src/fetchers/mtgElo.js +58 -56
- package/src/fetchers/topdeck.js +17 -17
- package/src/fetchers/unityLeague.js +11 -12
- package/src/index.js +15 -26
- package/test/data/melee.html +1894 -0
- package/test/data/mtgElo.html +9 -0
- package/test/data/topdeck.html +664 -0
- package/test/data/unityLeague.html +1468 -0
- package/test/melee.test.js +29 -0
- package/test/mtgElo.test.js +31 -0
- package/test/topdeck.test.js +33 -0
- package/test/unityLeague.test.js +30 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: CI/CD Pipeline
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
node-version: [20, 22, 24]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
21
|
+
uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: ${{ matrix.node-version }}
|
|
24
|
+
cache: 'npm'
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: npm install
|
|
28
|
+
|
|
29
|
+
- name: Run tests
|
|
30
|
+
run: npm test
|
package/cli.js
CHANGED
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtg-playerinfo",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A simple NPM module and CLI tool to pull Magic: The Gathering player data from various sources",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mtg-playerinfo": "./cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "
|
|
10
|
+
"test": "node --test"
|
|
11
11
|
},
|
|
12
12
|
"keywords": [],
|
|
13
13
|
"author": "Björn Kimminich",
|
package/src/fetchers/melee.js
CHANGED
|
@@ -16,6 +16,8 @@ class MeleeFetcher {
|
|
|
16
16
|
parseHtml(html, url, username) {
|
|
17
17
|
const $ = cheerio.load(html);
|
|
18
18
|
const name = $('span[style*="font-size: xx-large"]').first().text().trim() || username;
|
|
19
|
+
const pronouns = $('.profile-details span.text-muted.mr-2').filter((i, el) => $(el).text().includes('/')).first().text().trim();
|
|
20
|
+
const bio = $('.profile-details div[style*="max-width: 75%"]').first().text().trim();
|
|
19
21
|
// FIXME Photos cannot be loaded with unauthenticated requests from Melee.gg
|
|
20
22
|
// const photo = $('.profile-button-column img').first().attr('src') || $('img.m-auto').attr('src');
|
|
21
23
|
|
|
@@ -23,10 +25,38 @@ class MeleeFetcher {
|
|
|
23
25
|
source: 'Melee',
|
|
24
26
|
url,
|
|
25
27
|
name,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
pronouns: pronouns || null,
|
|
29
|
+
bio: bio || null,
|
|
30
|
+
// photo: photo ? (photo.startsWith('http') ? photo : `https://melee.gg${photo}`) : null
|
|
28
31
|
};
|
|
29
32
|
|
|
33
|
+
$('.social-link').each((i, el) => {
|
|
34
|
+
const href = $(el).attr('href');
|
|
35
|
+
if (href) {
|
|
36
|
+
try {
|
|
37
|
+
const urlObj = new URL(href);
|
|
38
|
+
const platform = urlObj.hostname.replace('www.', '').split('.')[0];
|
|
39
|
+
let handle = urlObj.pathname.split('/').filter(Boolean).pop();
|
|
40
|
+
if (handle) {
|
|
41
|
+
handle = decodeURIComponent(handle);
|
|
42
|
+
}
|
|
43
|
+
if (platform === 'youtube' && handle.startsWith('@')) {
|
|
44
|
+
// keep @ for youtube
|
|
45
|
+
} else if (platform === 'facebook') {
|
|
46
|
+
// handle is correct
|
|
47
|
+
} else if (platform === 'twitch') {
|
|
48
|
+
// handle is correct
|
|
49
|
+
}
|
|
50
|
+
if (handle) {
|
|
51
|
+
const label = platform.charAt(0).toLowerCase() + platform.slice(1);
|
|
52
|
+
data[label] = handle;
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// ignore invalid URLs
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
30
60
|
return data;
|
|
31
61
|
}
|
|
32
62
|
}
|
package/src/fetchers/mtgElo.js
CHANGED
|
@@ -8,75 +8,77 @@ class MtgEloFetcher {
|
|
|
8
8
|
maxRedirects: 10
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
return this.parseHtml(html, url, id);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error(`Error fetching MTG Elo Project profile ${id}:`, error.message);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
parseHtml(html, url, id) {
|
|
19
|
+
const cheerio = require('cheerio');
|
|
20
|
+
const $ = cheerio.load(html);
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const props = JSON.parse(astroIsland.attr('props'));
|
|
22
|
-
const info = props.info[1];
|
|
23
|
-
name = `${info.first_name[1]} ${info.last_name[1]}`;
|
|
24
|
-
currentRating = Math.round(info.current_rating[1]).toString();
|
|
25
|
-
const r = info.record[1];
|
|
26
|
-
record = `${r[0][1]}-${r[1][1]}-${r[2][1]}`;
|
|
27
|
-
} catch (e) {
|
|
28
|
-
console.error('Error parsing MTG Elo props:', e.message);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
22
|
+
let name = '';
|
|
23
|
+
let currentRating = '';
|
|
24
|
+
let record = '';
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
26
|
+
const astroIsland = $('astro-island[component-url*="Profile"]');
|
|
27
|
+
if (astroIsland.length > 0) {
|
|
28
|
+
try {
|
|
29
|
+
const props = JSON.parse(astroIsland.attr('props'));
|
|
30
|
+
const info = props.info[1];
|
|
31
|
+
name = `${info.first_name[1]} ${info.last_name[1]}`;
|
|
32
|
+
currentRating = Math.round(info.current_rating[1]).toString();
|
|
33
|
+
const r = info.record[1];
|
|
34
|
+
record = `${r[0][1]}-${r[1][1]}-${r[2][1]}`;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error('Error parsing MTG Elo props:', e.message);
|
|
38
37
|
}
|
|
38
|
+
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
if (!name) {
|
|
41
|
+
name = $('.text-\\[22pt\\]').text().trim();
|
|
42
|
+
if (name.includes(',')) {
|
|
43
|
+
const parts = name.split(',').map(s => s.trim());
|
|
44
|
+
name = `${parts[1]} ${parts[0]}`;
|
|
42
45
|
}
|
|
46
|
+
}
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
+
if (!currentRating) {
|
|
49
|
+
currentRating = $('.text-\\[18pt\\]:contains("Current rating")').find('.font-bold').text().trim();
|
|
50
|
+
}
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
if (!record) {
|
|
53
|
+
const recordText = $('.text-\\[18pt\\]:contains("Record")').text();
|
|
54
|
+
record = recordText.replace('Record:', '').trim();
|
|
55
|
+
}
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
player_id: id,
|
|
53
|
-
current_rating: currentRating,
|
|
54
|
-
record: record
|
|
55
|
-
};
|
|
57
|
+
if (!name) return null;
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
const data = {
|
|
60
|
+
source: 'MTG Elo Project',
|
|
61
|
+
url: url,
|
|
62
|
+
name: name,
|
|
63
|
+
player_id: id,
|
|
64
|
+
current_rating: currentRating,
|
|
65
|
+
record: record
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (record && record.includes('-')) {
|
|
69
|
+
const [w, l, d] = record.split('-').map(Number);
|
|
70
|
+
if (!isNaN(w) && !isNaN(l)) {
|
|
71
|
+
const wins = w;
|
|
72
|
+
const losses = l;
|
|
73
|
+
const draws = isNaN(d) ? 0 : d;
|
|
74
|
+
const total = wins + losses + draws;
|
|
75
|
+
if (total > 0) {
|
|
76
|
+
data['win rate'] = ((wins / total) * 100).toFixed(2) + '%';
|
|
67
77
|
}
|
|
68
78
|
}
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
source: 'MTG Elo Project',
|
|
72
|
-
url: url,
|
|
73
|
-
name: name,
|
|
74
|
-
details: details
|
|
75
|
-
};
|
|
76
|
-
} catch (error) {
|
|
77
|
-
console.error(`Error fetching MTG Elo Project profile ${id}:`, error.message);
|
|
78
|
-
return null;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
return data;
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
|
package/src/fetchers/topdeck.js
CHANGED
|
@@ -37,15 +37,15 @@ class TopdeckFetcher {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
if (totalTournaments > 0) {
|
|
40
|
-
playerInfo
|
|
41
|
-
playerInfo
|
|
42
|
-
playerInfo
|
|
40
|
+
playerInfo['tournaments'] = totalTournaments.toString();
|
|
41
|
+
playerInfo['record'] = `${wins}-${losses}-${draws}`;
|
|
42
|
+
playerInfo['win rate'] = ((wins / (wins + losses + draws)) * 100).toFixed(2) + '%';
|
|
43
43
|
}
|
|
44
44
|
} else {
|
|
45
|
-
playerInfo
|
|
46
|
-
playerInfo
|
|
47
|
-
playerInfo
|
|
48
|
-
playerInfo
|
|
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%';
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
} catch (statsError) {
|
|
@@ -69,31 +69,31 @@ class TopdeckFetcher {
|
|
|
69
69
|
source: 'Topdeck',
|
|
70
70
|
url,
|
|
71
71
|
name,
|
|
72
|
-
photo: photo ? (photo.startsWith('http') ? photo : `https://topdeck.gg${photo}`) : null
|
|
73
|
-
details: { }
|
|
72
|
+
photo: photo ? (photo.startsWith('http') ? photo : `https://topdeck.gg${photo}`) : null
|
|
74
73
|
};
|
|
75
74
|
|
|
76
75
|
const statsMap = {
|
|
77
|
-
'totalTournaments': '
|
|
78
|
-
'overallRecord': '
|
|
79
|
-
'overallWinRate': '
|
|
80
|
-
'conversionRate': '
|
|
76
|
+
'totalTournaments': 'tournaments',
|
|
77
|
+
'overallRecord': 'record',
|
|
78
|
+
'overallWinRate': 'win rate',
|
|
79
|
+
'conversionRate': 'conversion'
|
|
81
80
|
};
|
|
82
81
|
|
|
83
82
|
Object.entries(statsMap).forEach(([id, label]) => {
|
|
84
83
|
const val = $(`#${id}`).text().trim();
|
|
85
84
|
if (val) {
|
|
86
|
-
data
|
|
85
|
+
data[label] = val;
|
|
87
86
|
}
|
|
88
87
|
});
|
|
89
88
|
|
|
90
|
-
|
|
89
|
+
const currentStats = Object.keys(data).filter(k => Object.values(statsMap).includes(k));
|
|
90
|
+
if (currentStats.length <= 1) {
|
|
91
91
|
$('.stats-container, .player-stats').each((i, el) => {
|
|
92
92
|
$(el).find('.stat').each((j, statEl) => {
|
|
93
|
-
const label = $(statEl).find('.label').text().trim();
|
|
93
|
+
const label = $(statEl).find('.label').text().trim().toLowerCase();
|
|
94
94
|
const value = $(statEl).find('.value').text().trim();
|
|
95
95
|
if (label && value) {
|
|
96
|
-
data
|
|
96
|
+
data[label] = value;
|
|
97
97
|
}
|
|
98
98
|
});
|
|
99
99
|
});
|
|
@@ -25,8 +25,7 @@ class UnityLeagueFetcher {
|
|
|
25
25
|
source: 'Unity League',
|
|
26
26
|
url,
|
|
27
27
|
name,
|
|
28
|
-
photo: photo ? (photo.startsWith('http') ? photo : `https://unityleague.gg${photo}`) : null
|
|
29
|
-
details: {}
|
|
28
|
+
photo: photo ? (photo.startsWith('http') ? photo : `https://unityleague.gg${photo}`) : null
|
|
30
29
|
};
|
|
31
30
|
|
|
32
31
|
const headerFlag = $('.card-body i.fi').first();
|
|
@@ -34,16 +33,16 @@ class UnityLeagueFetcher {
|
|
|
34
33
|
const classes = headerFlag.attr('class').split(' ');
|
|
35
34
|
const countryClass = classes.find(c => c.startsWith('fi-'));
|
|
36
35
|
if (countryClass) {
|
|
37
|
-
data.
|
|
36
|
+
data.country = countryClass.replace('fi-', '');
|
|
38
37
|
}
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
$('dt.small.text-muted').each((i, el) => {
|
|
42
|
-
const key = $(el).text().trim().replace(/:$/, '');
|
|
41
|
+
const key = $(el).text().trim().replace(/:$/, '').toLowerCase();
|
|
43
42
|
const dd = $(el).next('dd');
|
|
44
43
|
let value = dd.text().trim();
|
|
45
44
|
|
|
46
|
-
if (key === '
|
|
45
|
+
if (key === 'country') {
|
|
47
46
|
const flagIcon = dd.find('i.fi');
|
|
48
47
|
if (flagIcon.length > 0) {
|
|
49
48
|
const classes = flagIcon.attr('class').split(' ');
|
|
@@ -54,12 +53,12 @@ class UnityLeagueFetcher {
|
|
|
54
53
|
}
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
data
|
|
56
|
+
data[key] = value;
|
|
58
57
|
});
|
|
59
58
|
|
|
60
59
|
const bioElement = $('.card-body > small.mt-2').first();
|
|
61
60
|
if (bioElement.length > 0) {
|
|
62
|
-
data.
|
|
61
|
+
data.bio = bioElement.text().trim();
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
const rankingTable = $('table.table-sm').first();
|
|
@@ -70,10 +69,10 @@ class UnityLeagueFetcher {
|
|
|
70
69
|
headers.forEach((header, i) => {
|
|
71
70
|
if (header && values[i]) {
|
|
72
71
|
let val = values[i];
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
val = val.replace(/\D/g, ''); // remove non-numeric characters to support #23->23, 1st->1, and 42nd->42 etc.
|
|
73
|
+
if (val) {
|
|
74
|
+
data[`rank ${header.toLowerCase()}`] = val;
|
|
75
75
|
}
|
|
76
|
-
data.details[`Rank ${header}`] = val;
|
|
77
76
|
}
|
|
78
77
|
});
|
|
79
78
|
}
|
|
@@ -88,8 +87,8 @@ class UnityLeagueFetcher {
|
|
|
88
87
|
if (cells.length >= 3) {
|
|
89
88
|
const record = $(cells[1]).text().trim().replace(/\s+/g, '');
|
|
90
89
|
const winRate = $(cells[2]).text().trim();
|
|
91
|
-
data.
|
|
92
|
-
data
|
|
90
|
+
data.record = record;
|
|
91
|
+
data['win rate'] = winRate;
|
|
93
92
|
}
|
|
94
93
|
}
|
|
95
94
|
|
package/src/index.js
CHANGED
|
@@ -27,8 +27,6 @@ class PlayerInfoManager {
|
|
|
27
27
|
|
|
28
28
|
mergeData(results) {
|
|
29
29
|
const player = {
|
|
30
|
-
name: null,
|
|
31
|
-
photo: null,
|
|
32
30
|
general: {},
|
|
33
31
|
sources: {}
|
|
34
32
|
};
|
|
@@ -41,33 +39,24 @@ class PlayerInfoManager {
|
|
|
41
39
|
if (seenUrls.has(res.url)) return;
|
|
42
40
|
seenUrls.add(res.url);
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (res.details.Team && !player.general.Team) player.general.Team = res.details.Team;
|
|
51
|
-
if (res.details.Country && !player.general.Country) player.general.Country = res.details.Country;
|
|
52
|
-
if (res.details.Hometown && !player.general.Hometown) player.general.Hometown = res.details.Hometown;
|
|
42
|
+
const generalProps = ['name', 'photo', 'age', 'bio', 'team', 'country', 'hometown', 'pronouns', 'facebook', 'twitch', 'youtube'];
|
|
43
|
+
generalProps.forEach(prop => {
|
|
44
|
+
if (res[prop] && !player.general[prop]) {
|
|
45
|
+
player.general[prop] = res[prop];
|
|
46
|
+
}
|
|
47
|
+
});
|
|
53
48
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
49
|
+
const winRateStr = res['win rate'] || res.winRate;
|
|
50
|
+
if (winRateStr && typeof winRateStr === 'string') {
|
|
51
|
+
const winRate = parseFloat(winRateStr.replace('%', ''));
|
|
52
|
+
if (!isNaN(winRate)) {
|
|
53
|
+
winRates.push(winRate);
|
|
60
54
|
}
|
|
61
55
|
}
|
|
62
56
|
|
|
63
|
-
const sourceData = { ...res
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
delete sourceData.Bio;
|
|
67
|
-
delete sourceData.Hometown;
|
|
68
|
-
delete sourceData.Team;
|
|
69
|
-
delete sourceData.Country;
|
|
70
|
-
}
|
|
57
|
+
const sourceData = { ...res };
|
|
58
|
+
delete sourceData.source;
|
|
59
|
+
delete sourceData.url;
|
|
71
60
|
|
|
72
61
|
player.sources[res.source] = {
|
|
73
62
|
url: res.url,
|
|
@@ -77,7 +66,7 @@ class PlayerInfoManager {
|
|
|
77
66
|
|
|
78
67
|
if (winRates.length > 0) {
|
|
79
68
|
const avgWinRate = winRates.reduce((a, b) => a + b, 0) / winRates.length;
|
|
80
|
-
player.general['
|
|
69
|
+
player.general['win rate'] = avgWinRate.toFixed(2) + '%';
|
|
81
70
|
}
|
|
82
71
|
|
|
83
72
|
return player;
|