vlr-stats-scraper 2.0.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 +344 -326
- package/index.mjs +27 -20
- package/main.mjs +194 -166
- package/package.json +1 -1
- package/scripts/client.mjs +27 -0
- package/scripts/db.mjs +296 -153
- 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 -69
- package/scripts/stats.mjs +111 -0
package/index.mjs
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
export { default as scrapePlayer } from './scripts/player-info-scraper.mjs';
|
|
2
|
-
export { scrapeWebsite } from './scripts/player-link-scraper.mjs';
|
|
3
|
-
export {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
export { default as scrapePlayer } from './scripts/player-info-scraper.mjs';
|
|
2
|
+
export { scrapeWebsite } from './scripts/player-link-scraper.mjs';
|
|
3
|
+
export { scrapeEventStats, eventStatsUrl } from './scripts/event-stats-scraper.mjs';
|
|
4
|
+
export {
|
|
5
|
+
openDatabase,
|
|
6
|
+
closeDatabase,
|
|
7
|
+
savePlayer,
|
|
8
|
+
getPlayerById,
|
|
9
|
+
getPlayer,
|
|
10
|
+
getPlayerHistory,
|
|
11
|
+
searchPlayers,
|
|
12
|
+
getAllPlayers,
|
|
13
|
+
getRandomPlayer,
|
|
14
|
+
getPlayersByTeam,
|
|
15
|
+
getEventLeaderboard,
|
|
16
|
+
getAgentLeaderboard,
|
|
17
|
+
saveEvent,
|
|
18
|
+
getEvents,
|
|
19
|
+
getEvent,
|
|
20
|
+
getEventByName,
|
|
21
|
+
getEventId,
|
|
22
|
+
getPlayersByEvent,
|
|
23
|
+
getRandomPlayerFromEvent,
|
|
24
|
+
emptyEvent,
|
|
25
|
+
clearEvent,
|
|
26
|
+
clearDatabase,
|
|
27
|
+
} from './scripts/db.mjs';
|
package/main.mjs
CHANGED
|
@@ -1,166 +1,194 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import promptSync from 'prompt-sync'
|
|
3
|
-
import { scrapeWebsite } from './scripts/player-link-scraper.mjs'
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
getPlayersByEvent,
|
|
11
|
-
emptyEvent,
|
|
12
|
-
clearEvent,
|
|
13
|
-
clearDatabase,
|
|
14
|
-
searchPlayers,
|
|
15
|
-
} from './scripts/db.mjs'
|
|
16
|
-
|
|
17
|
-
const args = process.argv.slice(2)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
console.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
console.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import promptSync from 'prompt-sync'
|
|
3
|
+
import { scrapeWebsite } from './scripts/player-link-scraper.mjs'
|
|
4
|
+
import { scrapeEventStats } from './scripts/event-stats-scraper.mjs'
|
|
5
|
+
import scrapePlayer from './scripts/player-info-scraper.mjs'
|
|
6
|
+
import {
|
|
7
|
+
savePlayer,
|
|
8
|
+
saveEvent,
|
|
9
|
+
getEvent,
|
|
10
|
+
getPlayersByEvent,
|
|
11
|
+
emptyEvent,
|
|
12
|
+
clearEvent,
|
|
13
|
+
clearDatabase,
|
|
14
|
+
searchPlayers,
|
|
15
|
+
} from './scripts/db.mjs'
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2)
|
|
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
|
+
|
|
29
|
+
function printPlayerStats(player) {
|
|
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}`)
|
|
33
|
+
console.log(` Team: ${player.team ?? 'Free agent'}`)
|
|
34
|
+
console.log(` Past teams: ${player.past_teams?.length ? player.past_teams.join(', ') : 'None'}`)
|
|
35
|
+
console.log(` Nationality: ${player.nationality || 'Unknown'}`)
|
|
36
|
+
console.log(` Region: ${player.region ?? 'Unknown'}`)
|
|
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
|
+
|
|
51
|
+
if (player.stats?.length) {
|
|
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 })))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
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.
|
|
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') {
|
|
94
|
+
const query = args[1]
|
|
95
|
+
if (!query) {
|
|
96
|
+
console.error('Usage: valstats --search <name | vlr.gg player link>')
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A vlr.gg link is scraped live; anything else is searched for in players.db.
|
|
101
|
+
if (query.startsWith('http')) {
|
|
102
|
+
const player = await scrapePlayer(withTimespan(query), null)
|
|
103
|
+
if (!player) {
|
|
104
|
+
console.error(`Couldn't scrape a player from ${query}`)
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
printPlayerStats(player)
|
|
108
|
+
process.exit(0)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const players = searchPlayers(query)
|
|
112
|
+
if (!players.length) {
|
|
113
|
+
console.error(`No players matching "${query}" in players.db (pass a vlr.gg player link to scrape them live)`)
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
console.log(`${players.length} player${players.length === 1 ? '' : 's'} matching "${query}":`)
|
|
117
|
+
players.forEach(printPlayerStats)
|
|
118
|
+
process.exit(0)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (args[0] === '--clear') {
|
|
122
|
+
const prompt = promptSync()
|
|
123
|
+
const answer = prompt('This deletes every event and player in players.db. Type "yes" to continue: ')
|
|
124
|
+
if (answer?.trim().toLowerCase() === 'yes') {
|
|
125
|
+
clearDatabase()
|
|
126
|
+
console.log('Cleared players.db')
|
|
127
|
+
} else {
|
|
128
|
+
console.log('Cancelled')
|
|
129
|
+
}
|
|
130
|
+
process.exit(0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (args[0] === '--clear-event') {
|
|
134
|
+
const [, name, year] = args
|
|
135
|
+
if (!name) {
|
|
136
|
+
console.error('Usage: valstats --clear-event <event-name> [year]')
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}
|
|
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)
|
|
148
|
+
process.exit(1)
|
|
149
|
+
}
|
|
150
|
+
process.exit(0)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const [link, yearArg, eventName] = args
|
|
154
|
+
const year = parseInt(yearArg, 10)
|
|
155
|
+
|
|
156
|
+
if (!link || !yearArg || !eventName || Number.isNaN(year)) {
|
|
157
|
+
console.error(USAGE)
|
|
158
|
+
process.exit(1)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Scrape first so a failed scrape doesn't wipe an event that's already saved.
|
|
162
|
+
const scraped = await scrapeWebsite(link)
|
|
163
|
+
|
|
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')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Re-adding an event + year that's already saved replaces its old players.
|
|
179
|
+
if (getEvent(eventName, year)) {
|
|
180
|
+
emptyEvent(eventName, year)
|
|
181
|
+
console.log(`"${eventName}" (${year}) is already in players.db, replacing it`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const eventId = saveEvent({ name: eventName, url: link, year, stage: null })
|
|
185
|
+
|
|
186
|
+
for (const playerLink of playerLinks) {
|
|
187
|
+
const player = await scrapePlayer(playerLink, region)
|
|
188
|
+
if (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 })
|
|
193
|
+
}
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vlr-stats-scraper",
|
|
3
|
-
"version": "
|
|
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 }
|