vlr-stats-scraper 1.5.0 → 3.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/main.mjs CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import promptSync from 'prompt-sync'
3
3
  import { scrapeWebsite } from './scripts/player-link-scraper.mjs'
4
+ import { scrapeEventStats } from './scripts/event-stats-scraper.mjs'
4
5
  import scrapePlayer from './scripts/player-info-scraper.mjs'
5
6
  import {
6
7
  savePlayer,
7
8
  saveEvent,
8
- addPlayerToEvent,
9
- getEventByName,
9
+ getEvent,
10
+ getPlayersByEvent,
10
11
  emptyEvent,
11
12
  clearEvent,
12
13
  clearDatabase,
@@ -15,33 +16,90 @@ import {
15
16
 
16
17
  const args = process.argv.slice(2)
17
18
 
19
+ const USAGE = [
20
+ 'Usage: valstats <link> <year> <event-name>',
21
+ ' valstats --player <vlr.gg player link> <event-name> <year> [region]',
22
+ ' valstats --search <name | vlr.gg player link>',
23
+ ' valstats --clear-event <event-name> [year]',
24
+ ' valstats --clear',
25
+ ].join('\n')
26
+
27
+ const fmt = (value, digits = 2) => (value === null || value === undefined ? '-' : Number(value).toFixed(digits))
28
+
18
29
  function printPlayerStats(player) {
19
- console.log(`\n${player.name}`)
30
+ // Players saved from the database belong to an event; ones scraped live don't.
31
+ const event = player.event_name ? ` (${player.event_name}${player.event_year ? `, ${player.event_year}` : ''})` : ''
32
+ console.log(`\n${player.name}${event}`)
20
33
  console.log(` Team: ${player.team ?? 'Free agent'}`)
21
34
  console.log(` Past teams: ${player.past_teams?.length ? player.past_teams.join(', ') : 'None'}`)
22
35
  console.log(` Nationality: ${player.nationality || 'Unknown'}`)
23
36
  console.log(` Region: ${player.region ?? 'Unknown'}`)
24
- console.log(` Winnings: ${player.winnings ?? 'Unknown'}`)
25
- // Older saves stored agents as raw <img> tags, so pull the name out of the alt text.
26
- const agents = (player.most_played_agents ?? []).map(agent => agent?.match(/alt="([^"]*)"/)?.[1] ?? agent)
27
- console.log(` Top agents: ${agents.join(', ') || 'None'}`)
28
- console.log(` Avg K:D: ${Number(player.avg_kda).toFixed(2)}`)
37
+ console.log(` Winnings: ${player.winnings === null || player.winnings === undefined ? 'Unknown' : `$${player.winnings.toLocaleString('en-US')}`}`)
38
+ console.log(` Top agents: ${player.most_played_agents?.join(', ') || 'None'}`)
39
+ console.log(` Career K:D: ${fmt(player.avg_kda)} (top agents)`)
40
+ if (player.scraped_at) console.log(` Scraped: ${player.scraped_at.slice(0, 10)}`)
41
+
42
+ const es = player.event_stats
43
+ if (es) {
44
+ const agents = es.agents.map(a => `${a.agent}${a.use_pct !== null ? ` ${a.use_pct}%` : ''}`).join(', ')
45
+ console.log(` This event: ${es.maps ?? '-'} maps, ${es.rounds ?? '-'} rounds on ${agents || 'unknown agents'}`)
46
+ console.log(` Rating ${fmt(es.rating)} | ACS ${fmt(es.acs, 0)} | K:D ${fmt(es.kd)} | ADR ${fmt(es.adr, 1)} | KAST ${fmt(es.kast, 0)}% | HS ${fmt(es.hs_pct, 0)}%`)
47
+ } else if (player.event_name) {
48
+ console.log(' This event: no stats saved')
49
+ }
50
+
29
51
  if (player.stats?.length) {
30
- console.table(player.stats)
52
+ console.log(' Career stats on top agents:')
53
+ console.table(player.stats.map(({ agent, maps, rounds, rating, acs, kd, kast, adr, kpr, apr, kills, deaths }) =>
54
+ ({ agent, maps, rounds, rating, acs, kd, kast, adr, kpr, apr, kills, deaths })))
31
55
  }
32
56
  }
33
57
 
58
+ const withTimespan = url => (url.includes('timespan=') ? url : url.replace(/\/?$/, '/?timespan=all'))
59
+
60
+ // Manually adds one player to an event that's already saved.
34
61
  if (args[0] === '--player') {
62
+ const [, query, eventName, eventYear, regionArg] = args
63
+ if (!query?.startsWith('http') || !eventName || !eventYear) {
64
+ console.error('Usage: valstats --player <vlr.gg player link> <event-name> <year> [region]')
65
+ process.exit(1)
66
+ }
67
+
68
+ // Look the event up before scraping so a typo doesn't waste a scrape.
69
+ const event = getEvent(eventName, parseInt(eventYear, 10))
70
+ if (!event) {
71
+ console.error(`No event named "${eventName}" (${eventYear}) in players.db`)
72
+ process.exit(1)
73
+ }
74
+
75
+ // Without a region argument, use the region the event's other players were saved with.
76
+ const region = regionArg ?? getPlayersByEvent(event.name, event.year)[0]?.region ?? null
77
+
78
+ const player = await scrapePlayer(withTimespan(query), region)
79
+ if (!player) {
80
+ console.error(`Couldn't scrape a player from ${query}`)
81
+ process.exit(1)
82
+ }
83
+ if (event.url) {
84
+ const eventStats = await scrapeEventStats(event.url)
85
+ player.event_stats = eventStats?.get(player.vlr_id) ?? null
86
+ }
87
+ savePlayer(event.id, player)
88
+ console.log(`Saved ${player.name} to players.db under "${eventName}" (${eventYear})`)
89
+ printPlayerStats({ ...player, event_name: event.name, event_year: event.year })
90
+ process.exit(0)
91
+ }
92
+
93
+ if (args[0] === '--search') {
35
94
  const query = args[1]
36
95
  if (!query) {
37
- console.error('Usage: valstats --player <name | vlr.gg player link>')
96
+ console.error('Usage: valstats --search <name | vlr.gg player link>')
38
97
  process.exit(1)
39
98
  }
40
99
 
41
- // A vlr.gg link is scraped live anything else is searched for in players.db.
100
+ // A vlr.gg link is scraped live; anything else is searched for in players.db.
42
101
  if (query.startsWith('http')) {
43
- const url = query.includes('timespan=') ? query : query.replace(/\/?$/, '/?timespan=all')
44
- const player = await scrapePlayer(url, null)
102
+ const player = await scrapePlayer(withTimespan(query), null)
45
103
  if (!player) {
46
104
  console.error(`Couldn't scrape a player from ${query}`)
47
105
  process.exit(1)
@@ -73,66 +131,64 @@ if (args[0] === '--clear') {
73
131
  }
74
132
 
75
133
  if (args[0] === '--clear-event') {
76
- const name = args[1]
134
+ const [, name, year] = args
77
135
  if (!name) {
78
- console.error('Usage: valstats --clear-event <event-name>')
136
+ console.error('Usage: valstats --clear-event <event-name> [year]')
79
137
  process.exit(1)
80
138
  }
81
- if (clearEvent(name)) {
82
- console.log(`Removed "${name}" and its players from players.db`)
83
- } else {
84
- console.error(`No event named "${name}" in players.db`)
139
+ try {
140
+ if (clearEvent(name, year ? parseInt(year, 10) : undefined)) {
141
+ console.log(`Removed "${name}"${year ? ` (${year})` : ''} and its players from players.db`)
142
+ } else {
143
+ console.error(`No event named "${name}"${year ? ` (${year})` : ''} in players.db`)
144
+ process.exit(1)
145
+ }
146
+ } catch (error) {
147
+ console.error(error.message)
85
148
  process.exit(1)
86
149
  }
87
150
  process.exit(0)
88
151
  }
89
152
 
90
- const [link, year, eventName] = args
153
+ const [link, yearArg, eventName] = args
154
+ const year = parseInt(yearArg, 10)
91
155
 
92
- if (!link || !year || !eventName) {
93
- console.error('Usage: valstats <link> <year> <event-name>')
94
- console.error(' valstats --player <name | vlr.gg player link>')
95
- console.error(' valstats --clear-event <event-name>')
96
- console.error(' valstats --clear')
156
+ if (!link || !yearArg || !eventName || Number.isNaN(year)) {
157
+ console.error(USAGE)
97
158
  process.exit(1)
98
159
  }
99
160
 
100
- const isStage = eventName.toLowerCase().includes('stage')
161
+ // Scrape first so a failed scrape doesn't wipe an event that's already saved.
162
+ const scraped = await scrapeWebsite(link)
101
163
 
102
- let region = null
103
- if (isStage) {
104
- const prompt = promptSync()
105
- region = prompt('Enter the region: ')
164
+ if (!scraped) {
165
+ console.error(`Couldn't get the player list from ${link}`)
166
+ process.exit(1)
167
+ }
168
+
169
+ const { playerLinks, region } = scraped
170
+ console.log(`Found ${playerLinks.length} players, region: ${region ?? 'unknown'}`)
171
+
172
+ // One scrape of the event's stats page gives every player's stats in this event only.
173
+ const eventStats = await scrapeEventStats(link)
174
+ if (!eventStats) {
175
+ console.warn('Couldn\'t get this event\'s stats page; players will be saved without event stats')
106
176
  }
107
177
 
108
178
  // Re-adding an event + year that's already saved replaces its old players.
109
- const existing = getEventByName(eventName)
110
- if (existing && existing.year === parseInt(year, 10)) {
111
- emptyEvent(eventName)
179
+ if (getEvent(eventName, year)) {
180
+ emptyEvent(eventName, year)
112
181
  console.log(`"${eventName}" (${year}) is already in players.db, replacing it`)
113
182
  }
114
183
 
115
- const eventId = saveEvent({
116
- name: eventName,
117
- url: link,
118
- region,
119
- year: parseInt(year, 10),
120
- stage: null,
121
- })
122
-
123
- const links = await scrapeWebsite(link)
124
-
125
- if (!links) {
126
- console.error(`Couldn't get the player list from ${link}`)
127
- process.exit(1)
128
- }
184
+ const eventId = saveEvent({ name: eventName, url: link, year, stage: null })
129
185
 
130
- for (const playerLink of links) {
186
+ for (const playerLink of playerLinks) {
131
187
  const player = await scrapePlayer(playerLink, region)
132
188
  if (player) {
133
- savePlayer(player)
134
- addPlayerToEvent(eventId, player.name)
135
- console.log(`Saved ${player.name} to players.db under "${eventName}"`)
136
- console.log(player)
189
+ player.event_stats = eventStats?.get(player.vlr_id) ?? null
190
+ savePlayer(eventId, player)
191
+ console.log(`Saved ${player.name} to players.db under "${eventName}" (${year})`)
192
+ printPlayerStats({ ...player, event_name: eventName, event_year: year })
137
193
  }
138
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vlr-stats-scraper",
3
- "version": "1.5.0",
3
+ "version": "3.1.0",
4
4
  "description": "Scrapes player stats from vlr.gg and stores them in a local SQLite database, grouped by tournament or stage.",
5
5
  "homepage": "https://github.com/OverYander/VLR-Webscraper#readme",
6
6
  "bugs": {
@@ -0,0 +1,27 @@
1
+ import { Hyperbrowser } from '@hyperbrowser/sdk'
2
+ import { config } from 'dotenv'
3
+
4
+ config()
5
+
6
+ let client
7
+ function getClient() {
8
+ if (!client) {
9
+ client = new Hyperbrowser({
10
+ apiKey: process.env.HYPERBROWSER_API_KEY,
11
+ })
12
+ }
13
+ return client
14
+ }
15
+
16
+ // Fetches a page's HTML through Hyperbrowser. Throws if the scrape fails.
17
+ async function fetchHtml(url, scrapeOptions = {}) {
18
+ const scrapeData = await getClient().scrape.startAndWait({
19
+ url,
20
+ scrapeOptions: { formats: ['html'], ...scrapeOptions },
21
+ })
22
+ const html = scrapeData?.data?.html
23
+ if (!html) throw new Error(`No HTML returned for ${url}`)
24
+ return html
25
+ }
26
+
27
+ export { fetchHtml }