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/README.md +228 -73
- package/index.mjs +10 -1
- package/main.mjs +108 -52
- package/package.json +1 -1
- package/scripts/client.mjs +27 -0
- package/scripts/db.mjs +315 -152
- package/scripts/event-stats-scraper.mjs +77 -0
- package/scripts/migrate.mjs +181 -0
- package/scripts/player-info-scraper.mjs +43 -87
- package/scripts/player-link-scraper.mjs +41 -42
- package/scripts/stats.mjs +111 -0
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
console.log(`
|
|
28
|
-
|
|
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.
|
|
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 --
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
153
|
+
const [link, yearArg, eventName] = args
|
|
154
|
+
const year = parseInt(yearArg, 10)
|
|
91
155
|
|
|
92
|
-
if (!link || !
|
|
93
|
-
console.error(
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
186
|
+
for (const playerLink of playerLinks) {
|
|
131
187
|
const player = await scrapePlayer(playerLink, region)
|
|
132
188
|
if (player) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
console.log(`Saved ${player.name} to players.db under "${eventName}"`)
|
|
136
|
-
|
|
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.
|
|
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 }
|