mtg-playerinfo 1.0.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,26 @@
1
+ name: Pull Player Data
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ run-tool:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout repository
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Setup Node.js
16
+ uses: actions/setup-node@v4
17
+ with:
18
+ node-version: '24'
19
+ cache: 'npm'
20
+
21
+ - name: Install dependencies
22
+ run: npm install
23
+
24
+ - name: Run MTG Player Info Tool for sample player
25
+ run: node cli.js --unity-id 16215 --mtgelo-id 3irvwtmk --melee-user k0shiii --topdeck-handle k0shiii
26
+
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Björn Kimminich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # MTG Player Info
2
+
3
+ A simple NPM module and CLI tool to pull Magic: The Gathering player data from various sources (Unity League, MTG Elo Project, Melee, and Topdeck).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install
9
+ ```
10
+
11
+ ## CLI Usage
12
+
13
+ ```bash
14
+ node cli.js --unity-id 16215 --mtgelo-id 3irvwtmk --melee-user k0shiii --topdeck-handle k0shiii
15
+ ```
16
+
17
+ ## Output Format
18
+
19
+ The tool returns a JSON object representing the player and their combined metadata. Redundant information like Name, Photo, Country, Age, and Hometown is merged into a `general` section, while source-specific data is kept in the `sources` section.
20
+
21
+ ### Deduplication and Merging Logic
22
+ - **Priority**: Merging follows a "first-come, first-served" approach based on the order of sources provided in the command line or processed by the manager. For instance, the first valid `photo` URL found will be used as the primary photo for the player profile.
23
+ - **Deduplication**: If multiple IDs point to the exact same profile URL, the profile is only processed once to avoid redundant data in the `sources` section.
24
+ - **General Metadata**: Fields like `Age`, `Country`, and `Hometown` are extracted from the first source that provides them and placed in the `general` section.
25
+
26
+ Example output:
27
+ ```json
28
+ {
29
+ "name": "Björn Kimminich",
30
+ "photo": "https://unityleague.gg/media/player_profile/1000023225.jpg",
31
+ "general": {
32
+ "Age": "45",
33
+ "Bio": "Smugly held back on an Untimely Malfunction against a Storm player going off, being totally sure that you can redirect the summed-up damage of their Grapeshots back to their face with its \"Change the target of target spell or ability with a single target\" mode.",
34
+ "Team": "Mull to Five",
35
+ "Country": "de",
36
+ "Hometown": "Hamburg",
37
+ "Win Rate": "42.09%"
38
+ },
39
+ "sources": {
40
+ "Unity League": {
41
+ "url": "https://unityleague.gg/player/16215/",
42
+ "data": {
43
+ "Local organizer": "Mulligan TCG Shop",
44
+ "Rank Germany": "58",
45
+ "Rank Europe": "578",
46
+ "Rank Points": "274",
47
+ "Record": "38-38-5",
48
+ "Win Rate": "49.0%"
49
+ }
50
+ },
51
+ "MTG Elo Project": {
52
+ "url": "https://mtgeloproject.net/profile/3irvwtmk",
53
+ "data": {
54
+ "player_id": "3irvwtmk",
55
+ "current_rating": "1466",
56
+ "record": "9-12-1",
57
+ "Win Rate": "40.91%"
58
+ }
59
+ },
60
+ "Melee": {
61
+ "url": "https://melee.gg/Profile/Index/k0shiii",
62
+ "data": {
63
+ "username": "k0shiii"
64
+ }
65
+ },
66
+ "Topdeck": {
67
+ "url": "https://topdeck.gg/profile/@k0shiii",
68
+ "data": {
69
+ "handle": "@k0shiii",
70
+ "Tournaments": "2",
71
+ "Record": "4-6-1",
72
+ "Win Rate": "36.36%",
73
+ "Conversion": "0%"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## Supported Sources
81
+
82
+ | Site | Method |
83
+ |-----------------|----------|
84
+ | Unity League | Scraping |
85
+ | MTG Elo Project | Scraping |
86
+ | Melee | Scraping |
87
+ | Topdeck | Scraping |
88
+
89
+ Note: Some sites may have anti-bot protections that can lead to "Maximum number of redirects exceeded" or "403 Forbidden" errors depending on the execution environment.
package/cli.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ const { program } = require('commander');
3
+ const PlayerInfoManager = require('./src/index');
4
+
5
+ program
6
+ .name('mtg-playerinfo')
7
+ .description('CLI to pull MTG player data from various sources')
8
+ .version('1.0.0');
9
+
10
+ program
11
+ .option('--unity-id <id>', 'Unity League Player ID')
12
+ .option('--mtgelo-id <id>', 'MTG Elo Project Player ID')
13
+ .option('--melee-user <username>', 'Melee Username')
14
+ .option('--topdeck-handle <handle>', 'Topdeck Handle')
15
+ .action(async (options) => {
16
+ if (!options.unityId && !options.mtgeloId && !options.meleeUser && !options.topdeckHandle) {
17
+ console.error('Error: Please provide at least one search option (unity-id, mtgelo-id, melee-user, or topdeck-handle).');
18
+ process.exit(1);
19
+ }
20
+
21
+ const manager = new PlayerInfoManager();
22
+ try {
23
+ const playerInfo = await manager.getPlayerInfo(options);
24
+ console.log(JSON.stringify(playerInfo, null, 2));
25
+ } catch (error) {
26
+ console.error('An error occurred:', error.message);
27
+ process.exit(1);
28
+ }
29
+ });
30
+
31
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "mtg-playerinfo",
3
+ "version": "1.0.0",
4
+ "description": "A simple NPM module and CLI tool to pull Magic: The Gathering player data from various sources",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "mtg-playerinfo": "./cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [],
13
+ "author": "Björn Kimminich",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "cheerio": "^1.2.0",
17
+ "commander": "^14.0.3"
18
+ }
19
+ }
@@ -0,0 +1,35 @@
1
+ const { request } = require('../utils/httpClient');
2
+ const cheerio = require('cheerio');
3
+
4
+ class MeleeFetcher {
5
+ async fetchById(username) {
6
+ const url = `https://melee.gg/Profile/Index/${username}`;
7
+ try {
8
+ const { data } = await request(url);
9
+ return this.parseHtml(data, url, username);
10
+ } catch (error) {
11
+ console.error(`Error fetching Melee profile ${username}:`, error.message);
12
+ return null;
13
+ }
14
+ }
15
+
16
+ parseHtml(html, url, username) {
17
+ const $ = cheerio.load(html);
18
+ const name = $('span[style*="font-size: xx-large"]').first().text().trim() || username;
19
+ const photo = $('img.m-auto').attr('src');
20
+
21
+ const data = {
22
+ source: 'Melee',
23
+ url,
24
+ name,
25
+ photo: photo ? (photo.startsWith('http') ? photo : `https://melee.gg${photo}`) : null,
26
+ details: {
27
+ username
28
+ }
29
+ };
30
+
31
+ return data;
32
+ }
33
+ }
34
+
35
+ module.exports = MeleeFetcher;
@@ -0,0 +1,83 @@
1
+ const { request } = require('../utils/httpClient');
2
+
3
+ class MtgEloFetcher {
4
+ async fetchById(id) {
5
+ const url = `https://mtgeloproject.net/profile/${id}`;
6
+ try {
7
+ const { data: html } = await request(url, {
8
+ maxRedirects: 10
9
+ });
10
+
11
+ const cheerio = require('cheerio');
12
+ const $ = cheerio.load(html);
13
+
14
+ let name = '';
15
+ let currentRating = '';
16
+ let record = '';
17
+
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
+ }
31
+
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
+ }
38
+ }
39
+
40
+ if (!currentRating) {
41
+ currentRating = $('.text-\\[18pt\\]:contains("Current rating")').find('.font-bold').text().trim();
42
+ }
43
+
44
+ if (!record) {
45
+ const recordText = $('.text-\\[18pt\\]:contains("Record")').text();
46
+ record = recordText.replace('Record:', '').trim();
47
+ }
48
+
49
+ if (!name) return null;
50
+
51
+ const details = {
52
+ player_id: id,
53
+ current_rating: currentRating,
54
+ record: record
55
+ };
56
+
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
+ }
67
+ }
68
+ }
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
+ }
80
+ }
81
+ }
82
+
83
+ module.exports = MtgEloFetcher;
@@ -0,0 +1,108 @@
1
+ const { request } = require('../utils/httpClient');
2
+ const cheerio = require('cheerio');
3
+
4
+ class TopdeckFetcher {
5
+ async fetchById(handle) {
6
+ const cleanHandle = handle.startsWith('@') ? handle : `@${handle}`;
7
+ const url = `https://topdeck.gg/profile/${cleanHandle}`;
8
+ try {
9
+ const { data } = await request(url);
10
+ const playerInfo = this.parseHtml(data, url, cleanHandle);
11
+
12
+ // Try to fetch stats via XHR if internal ID is found
13
+ const internalIdMatch = data.match(/https:\/\/topdeck\.gg\/profile\/([a-zA-Z0-9]+)\/stats/) || data.match(/const playerId = "([a-zA-Z0-9]+)";/);
14
+ const internalId = internalIdMatch ? internalIdMatch[1] : null;
15
+
16
+ if (internalId) {
17
+ try {
18
+ const statsUrl = `https://topdeck.gg/profile/${internalId}/stats`;
19
+ const statsResponse = await request(statsUrl);
20
+ const statsJson = statsResponse.data;
21
+ const stats = typeof statsJson === 'string' ? JSON.parse(statsJson) : statsJson;
22
+
23
+ if (stats) {
24
+ if (stats.yearlyStats) {
25
+ let totalTournaments = 0;
26
+ let wins = 0;
27
+ let losses = 0;
28
+ let draws = 0;
29
+
30
+ Object.values(stats.yearlyStats).forEach(yearData => {
31
+ if (yearData.overall) {
32
+ totalTournaments += yearData.overall.totalTournaments || 0;
33
+ wins += yearData.overall.wins || 0;
34
+ losses += yearData.overall.losses || 0;
35
+ draws += yearData.overall.draws || 0;
36
+ }
37
+ });
38
+
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) + '%';
43
+ }
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%';
49
+ }
50
+ }
51
+ } catch (statsError) {
52
+ console.error(`Error fetching Topdeck stats for ${handle}:`, statsError.message);
53
+ }
54
+ }
55
+
56
+ return playerInfo;
57
+ } catch (error) {
58
+ console.error(`Error fetching Topdeck profile ${handle}:`, error.message);
59
+ return null;
60
+ }
61
+ }
62
+
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');
67
+
68
+ const data = {
69
+ source: 'Topdeck',
70
+ url,
71
+ name,
72
+ photo: photo ? (photo.startsWith('http') ? photo : `https://topdeck.gg${photo}`) : null,
73
+ details: {
74
+ handle
75
+ }
76
+ };
77
+
78
+ const statsMap = {
79
+ 'totalTournaments': 'Tournaments',
80
+ 'overallRecord': 'Record',
81
+ 'overallWinRate': 'Win Rate',
82
+ 'conversionRate': 'Conversion'
83
+ };
84
+
85
+ Object.entries(statsMap).forEach(([id, label]) => {
86
+ const val = $(`#${id}`).text().trim();
87
+ if (val) {
88
+ data.details[label] = val;
89
+ }
90
+ });
91
+
92
+ if (Object.keys(data.details).length === 1) {
93
+ $('.stats-container, .player-stats').each((i, el) => {
94
+ $(el).find('.stat').each((j, statEl) => {
95
+ const label = $(statEl).find('.label').text().trim();
96
+ const value = $(statEl).find('.value').text().trim();
97
+ if (label && value) {
98
+ data.details[label] = value;
99
+ }
100
+ });
101
+ });
102
+ }
103
+
104
+ return data;
105
+ }
106
+ }
107
+
108
+ module.exports = TopdeckFetcher;
@@ -0,0 +1,100 @@
1
+ const { request } = require('../utils/httpClient');
2
+ const cheerio = require('cheerio');
3
+
4
+ class UnityLeagueFetcher {
5
+ async fetchById(id) {
6
+ const url = `https://unityleague.gg/player/${id}/`;
7
+ try {
8
+ const { data } = await request(url);
9
+ return this.parseHtml(data, url);
10
+ } catch (error) {
11
+ console.error(`Error fetching Unity League player ${id}:`, error.message);
12
+ return null;
13
+ }
14
+ }
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');
20
+ if (photo && !photo.includes('player_profile')) {
21
+ photo = null;
22
+ }
23
+
24
+ const data = {
25
+ source: 'Unity League',
26
+ url,
27
+ name,
28
+ photo: photo ? (photo.startsWith('http') ? photo : `https://unityleague.gg${photo}`) : null,
29
+ details: {}
30
+ };
31
+
32
+ const headerFlag = $('.card-body i.fi').first();
33
+ if (headerFlag.length > 0) {
34
+ const classes = headerFlag.attr('class').split(' ');
35
+ const countryClass = classes.find(c => c.startsWith('fi-'));
36
+ if (countryClass) {
37
+ data.details.Country = countryClass.replace('fi-', '');
38
+ }
39
+ }
40
+
41
+ $('dt.small.text-muted').each((i, el) => {
42
+ const key = $(el).text().trim().replace(/:$/, '');
43
+ const dd = $(el).next('dd');
44
+ let value = dd.text().trim();
45
+
46
+ if (key === 'Country') {
47
+ const flagIcon = dd.find('i.fi');
48
+ if (flagIcon.length > 0) {
49
+ const classes = flagIcon.attr('class').split(' ');
50
+ const countryClass = classes.find(c => c.startsWith('fi-'));
51
+ if (countryClass) {
52
+ value = countryClass.replace('fi-', '');
53
+ }
54
+ }
55
+ }
56
+
57
+ data.details[key] = value;
58
+ });
59
+
60
+ const bioElement = $('.card-body > small.mt-2').first();
61
+ if (bioElement.length > 0) {
62
+ data.details.Bio = bioElement.text().trim();
63
+ }
64
+
65
+ const rankingTable = $('table.table-sm').first();
66
+ if (rankingTable.length) {
67
+ const headers = rankingTable.find('th').map((i, el) => $(el).text().trim()).get();
68
+ const values = rankingTable.find('tbody td').map((i, el) => $(el).text().trim()).get();
69
+
70
+ headers.forEach((header, i) => {
71
+ if (header && values[i]) {
72
+ let val = values[i];
73
+ if (val.startsWith('#')) {
74
+ val = val.substring(1);
75
+ }
76
+ data.details[`Rank ${header}`] = val;
77
+ }
78
+ });
79
+ }
80
+
81
+ // Extract tournament record and win rate
82
+ const overallRow = $('table.table tr').filter((i, el) => {
83
+ return $(el).find('td').first().text().trim() === 'Overall';
84
+ });
85
+
86
+ if (overallRow.length > 0) {
87
+ const cells = overallRow.find('td');
88
+ if (cells.length >= 3) {
89
+ const record = $(cells[1]).text().trim().replace(/\s+/g, '');
90
+ const winRate = $(cells[2]).text().trim();
91
+ data.details.Record = record;
92
+ data.details['Win Rate'] = winRate;
93
+ }
94
+ }
95
+
96
+ return data;
97
+ }
98
+ }
99
+
100
+ module.exports = UnityLeagueFetcher;
package/src/index.js ADDED
@@ -0,0 +1,87 @@
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
+
6
+ class PlayerInfoManager {
7
+ constructor() {
8
+ this.fetchers = {
9
+ unity: new UnityLeagueFetcher(),
10
+ mtgelo: new MtgEloFetcher(),
11
+ melee: new MeleeFetcher(),
12
+ topdeck: new TopdeckFetcher()
13
+ };
14
+ }
15
+
16
+ async getPlayerInfo(options) {
17
+ const results = [];
18
+
19
+ if (options.unityId) results.push(await this.fetchers.unity.fetchById(options.unityId));
20
+ if (options.mtgeloId) results.push(await this.fetchers.mtgelo.fetchById(options.mtgeloId));
21
+ if (options.meleeUser) results.push(await this.fetchers.melee.fetchById(options.meleeUser));
22
+ if (options.topdeckHandle) results.push(await this.fetchers.topdeck.fetchById(options.topdeckHandle));
23
+
24
+ const filteredResults = results.filter(r => r !== null);
25
+ return this.mergeData(filteredResults);
26
+ }
27
+
28
+ mergeData(results) {
29
+ const player = {
30
+ name: null,
31
+ photo: null,
32
+ general: {},
33
+ sources: {}
34
+ };
35
+
36
+ const seenUrls = new Set();
37
+
38
+ const winRates = [];
39
+
40
+ results.forEach(res => {
41
+ if (seenUrls.has(res.url)) return;
42
+ seenUrls.add(res.url);
43
+
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;
53
+
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
+ }
60
+ }
61
+ }
62
+
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
+ }
71
+
72
+ player.sources[res.source] = {
73
+ url: res.url,
74
+ data: sourceData
75
+ };
76
+ });
77
+
78
+ if (winRates.length > 0) {
79
+ const avgWinRate = winRates.reduce((a, b) => a + b, 0) / winRates.length;
80
+ player.general['Win Rate'] = avgWinRate.toFixed(2) + '%';
81
+ }
82
+
83
+ return player;
84
+ }
85
+ }
86
+
87
+ module.exports = PlayerInfoManager;
@@ -0,0 +1,69 @@
1
+ const https = require('https');
2
+ const http = require('http');
3
+ const { URL } = require('url');
4
+
5
+ function request(url, options = {}) {
6
+ return new Promise((resolve, reject) => {
7
+ const parsedUrl = new URL(url);
8
+ const protocol = parsedUrl.protocol === 'https:' ? https : http;
9
+
10
+ const headers = {
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
+ ...options.headers
13
+ };
14
+
15
+ const requestOptions = {
16
+ method: options.method || 'GET',
17
+ headers: headers,
18
+ timeout: 30000,
19
+ ...options
20
+ };
21
+
22
+ const req = protocol.request(url, requestOptions, (res) => {
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;
26
+ if (maxRedirects > 0) {
27
+ return request(redirectUrl, { ...options, maxRedirects: maxRedirects - 1 })
28
+ .then(resolve)
29
+ .catch(reject);
30
+ } else {
31
+ return reject(new Error('Too many redirects'));
32
+ }
33
+ }
34
+
35
+ let data = '';
36
+ res.on('data', (chunk) => {
37
+ data += chunk;
38
+ });
39
+
40
+ res.on('end', () => {
41
+ if (res.statusCode >= 200 && res.statusCode < 300) {
42
+ resolve({
43
+ data,
44
+ status: res.statusCode,
45
+ headers: res.headers
46
+ });
47
+ } else {
48
+ reject(new Error(`Request failed with status ${res.statusCode}`));
49
+ }
50
+ });
51
+ });
52
+
53
+ req.on('error', (err) => {
54
+ reject(err);
55
+ });
56
+
57
+ req.on('timeout', () => {
58
+ req.destroy();
59
+ reject(new Error('Request timed out'));
60
+ });
61
+
62
+ if (options.body) {
63
+ req.write(options.body);
64
+ }
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ module.exports = { request };