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.
@@ -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
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  branches:
6
6
  - main
7
+ workflow_dispatch:
7
8
 
8
9
  jobs:
9
10
  run-tool:
package/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  const { program } = require('commander');
3
3
  const PlayerInfoManager = require('./src/index');
4
4
 
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "mtg-playerinfo",
3
- "version": "1.0.3",
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": "echo \"Error: no test specified\" && exit 1"
10
+ "test": "node --test"
11
11
  },
12
12
  "keywords": [],
13
13
  "author": "Björn Kimminich",
@@ -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
- // photo: photo ? (photo.startsWith('http') ? photo : `https://melee.gg${photo}`) : null,
27
- details: { }
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
  }
@@ -8,75 +8,77 @@ class MtgEloFetcher {
8
8
  maxRedirects: 10
9
9
  });
10
10
 
11
- const cheerio = require('cheerio');
12
- const $ = cheerio.load(html);
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
- let name = '';
15
- let currentRating = '';
16
- let record = '';
18
+ parseHtml(html, url, id) {
19
+ const cheerio = require('cheerio');
20
+ const $ = cheerio.load(html);
17
21
 
18
- const astroIsland = $('astro-island[component-url*="Profile"]');
19
- if (astroIsland.length > 0) {
20
- try {
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
- if (!name) {
33
- name = $('.text-\\[22pt\\]').text().trim();
34
- if (name.includes(',')) {
35
- const parts = name.split(',').map(s => s.trim());
36
- name = `${parts[1]} ${parts[0]}`;
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
- if (!currentRating) {
41
- currentRating = $('.text-\\[18pt\\]:contains("Current rating")').find('.font-bold').text().trim();
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
- if (!record) {
45
- const recordText = $('.text-\\[18pt\\]:contains("Record")').text();
46
- record = recordText.replace('Record:', '').trim();
47
- }
48
+ if (!currentRating) {
49
+ currentRating = $('.text-\\[18pt\\]:contains("Current rating")').find('.font-bold').text().trim();
50
+ }
48
51
 
49
- if (!name) return null;
52
+ if (!record) {
53
+ const recordText = $('.text-\\[18pt\\]:contains("Record")').text();
54
+ record = recordText.replace('Record:', '').trim();
55
+ }
50
56
 
51
- const details = {
52
- player_id: id,
53
- current_rating: currentRating,
54
- record: record
55
- };
57
+ if (!name) return null;
56
58
 
57
- if (record && record.includes('-')) {
58
- const [w, l, d] = record.split('-').map(Number);
59
- if (!isNaN(w) && !isNaN(l)) {
60
- const wins = w;
61
- const losses = l;
62
- const draws = isNaN(d) ? 0 : d;
63
- const total = wins + losses + draws;
64
- if (total > 0) {
65
- details['Win Rate'] = ((wins / total) * 100).toFixed(2) + '%';
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
 
@@ -37,15 +37,15 @@ class TopdeckFetcher {
37
37
  });
38
38
 
39
39
  if (totalTournaments > 0) {
40
- playerInfo.details['Tournaments'] = totalTournaments.toString();
41
- playerInfo.details['Record'] = `${wins}-${losses}-${draws}`;
42
- playerInfo.details['Win Rate'] = ((wins / (wins + losses + draws)) * 100).toFixed(2) + '%';
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.details['Tournaments'] = stats.totalTournaments || playerInfo.details['Tournaments'] || '0';
46
- playerInfo.details['Record'] = stats.overallRecord || playerInfo.details['Record'] || '0-0-0';
47
- playerInfo.details['Win Rate'] = stats.overallWinRate || playerInfo.details['Win Rate'] || '0.00%';
48
- playerInfo.details['Conversion'] = stats.conversionRate || playerInfo.details['Conversion'] || '0.00%';
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': 'Tournaments',
78
- 'overallRecord': 'Record',
79
- 'overallWinRate': 'Win Rate',
80
- 'conversionRate': 'Conversion'
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.details[label] = val;
85
+ data[label] = val;
87
86
  }
88
87
  });
89
88
 
90
- if (Object.keys(data.details).length === 1) {
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.details[label] = value;
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.details.Country = countryClass.replace('fi-', '');
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 === 'Country') {
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.details[key] = value;
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.details.Bio = bioElement.text().trim();
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
- if (val.startsWith('#')) {
74
- val = val.substring(1);
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.details.Record = record;
92
- data.details['Win Rate'] = winRate;
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
- if (!player.name && res.name) player.name = res.name;
45
- if (!player.photo && res.photo) player.photo = res.photo;
46
-
47
- if (res.details) {
48
- if (res.details.Age && !player.general.Age) player.general.Age = res.details.Age;
49
- if (res.details.Bio && !player.general.Bio) player.general.Bio = res.details.Bio;
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
- const winRateStr = res.details['Win Rate'] || res.details.winRate;
55
- if (winRateStr && typeof winRateStr === 'string') {
56
- const winRate = parseFloat(winRateStr.replace('%', ''));
57
- if (!isNaN(winRate)) {
58
- winRates.push(winRate);
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.details };
64
- if (res.source === 'Unity League') {
65
- delete sourceData.Age;
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['Win Rate'] = avgWinRate.toFixed(2) + '%';
69
+ player.general['win rate'] = avgWinRate.toFixed(2) + '%';
81
70
  }
82
71
 
83
72
  return player;