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/scripts/db.mjs
CHANGED
|
@@ -1,210 +1,373 @@
|
|
|
1
1
|
import Database from 'better-sqlite3'
|
|
2
2
|
import path from 'path'
|
|
3
|
+
import { migrate, SCHEMA_VERSION } from './migrate.mjs'
|
|
4
|
+
import { normalizeAgentRow, careerKd, vlrIdFromUrl, AGENT_STAT_COLUMNS } from './stats.mjs'
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
db
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
db
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
db
|
|
48
|
-
}
|
|
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
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
6
|
+
// ---------------------------------------------------------------------------------------------
|
|
7
|
+
// Connection
|
|
8
|
+
// ---------------------------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
let db = null
|
|
11
|
+
let statements = new Map()
|
|
12
|
+
const defaultSettings = () => ({
|
|
13
|
+
path: process.env.VLR_DB_PATH || path.join(process.cwd(), 'players.db'),
|
|
14
|
+
readonly: false,
|
|
15
|
+
})
|
|
16
|
+
let settings = defaultSettings()
|
|
17
|
+
|
|
18
|
+
// Opens (or reopens) the database. Call it before anything else to choose where players.db lives
|
|
19
|
+
// or to open it read-only, e.g. from a web server: openDatabase({ path: '/data/players.db', readonly: true }).
|
|
20
|
+
// Without a call, the first query opens $VLR_DB_PATH, or players.db in the current directory, for writing.
|
|
21
|
+
function openDatabase(options = {}) {
|
|
22
|
+
closeDatabase()
|
|
23
|
+
settings = { ...defaultSettings(), ...options }
|
|
24
|
+
return getDb()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function closeDatabase() {
|
|
28
|
+
db?.close()
|
|
29
|
+
db = null
|
|
30
|
+
statements = new Map()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getDb() {
|
|
34
|
+
if (db) return db
|
|
35
|
+
const { path: file, readonly } = settings
|
|
36
|
+
db = new Database(file, { readonly, fileMustExist: readonly })
|
|
37
|
+
db.pragma('foreign_keys = ON')
|
|
38
|
+
if (readonly) {
|
|
39
|
+
const version = db.pragma('user_version', { simple: true })
|
|
40
|
+
if (version < SCHEMA_VERSION) {
|
|
41
|
+
closeDatabase()
|
|
42
|
+
throw new Error(`${file} is schema version ${version}, expected ${SCHEMA_VERSION}. Open it once without readonly (e.g. run valstats) to migrate it.`)
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
// WAL lets a web server keep reading while the scraper writes.
|
|
46
|
+
db.pragma('journal_mode = WAL')
|
|
47
|
+
migrate(db)
|
|
48
|
+
}
|
|
49
|
+
return db
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Prepared statements are cached per connection.
|
|
53
|
+
function stmt(sql) {
|
|
54
|
+
if (!statements.has(sql)) statements.set(sql, getDb().prepare(sql))
|
|
55
|
+
return statements.get(sql)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const transaction = fn => (...args) => getDb().transaction(fn)(...args)
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------------------------
|
|
61
|
+
// Reading players
|
|
62
|
+
// ---------------------------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
// Every player query also pulls in the event's name and year.
|
|
65
|
+
const SELECT_PLAYERS = `
|
|
66
|
+
SELECT players.*, events.name AS event_name, events.year AS event_year FROM players
|
|
67
|
+
JOIN events ON events.id = players.event_id
|
|
68
|
+
`
|
|
69
|
+
const NEWEST_FIRST = 'ORDER BY events.year DESC, players.scraped_at DESC, players.id DESC'
|
|
70
|
+
|
|
71
|
+
const EVENT_STAT_COLUMNS = [
|
|
72
|
+
'team', 'agents', 'maps', 'rounds', 'rating', 'acs', 'kd', 'kast', 'adr', 'kpr', 'apr', 'fk_fd',
|
|
73
|
+
'fk_pct', 'fd_pct', 'hs_pct', 'clutch_pct', 'clutches_won', 'clutches_played', 'kills_max',
|
|
74
|
+
'kills', 'deaths', 'assists', 'first_kills', 'first_deaths',
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
// Turns database rows into player objects: JSON columns parsed, plus `stats` (career stats on their
|
|
78
|
+
// top agents) and `event_stats` (their stats in this row's event, or null). Loads both in two queries.
|
|
79
|
+
function hydrate(rows) {
|
|
80
|
+
if (!rows.length) return []
|
|
81
|
+
const ids = JSON.stringify(rows.map(row => row.id))
|
|
82
|
+
|
|
83
|
+
const agentRows = stmt(`
|
|
84
|
+
SELECT * FROM agent_stats WHERE player_id IN (SELECT value FROM json_each(?))
|
|
85
|
+
ORDER BY player_id, position
|
|
86
|
+
`).all(ids)
|
|
87
|
+
const agentsByPlayer = new Map()
|
|
88
|
+
for (const { player_id, position, ...row } of agentRows) {
|
|
89
|
+
if (!agentsByPlayer.has(player_id)) agentsByPlayer.set(player_id, [])
|
|
90
|
+
agentsByPlayer.get(player_id).push(row)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const eventRows = stmt('SELECT * FROM event_stats WHERE player_id IN (SELECT value FROM json_each(?))').all(ids)
|
|
94
|
+
const eventByPlayer = new Map(eventRows.map(({ player_id, ...row }) => [player_id, { ...row, agents: JSON.parse(row.agents) }]))
|
|
95
|
+
|
|
96
|
+
return rows.map(row => ({
|
|
93
97
|
...row,
|
|
94
98
|
past_teams: JSON.parse(row.past_teams),
|
|
95
99
|
most_played_agents: JSON.parse(row.most_played_agents),
|
|
96
|
-
stats:
|
|
97
|
-
|
|
100
|
+
stats: agentsByPlayer.get(row.id) ?? [],
|
|
101
|
+
event_stats: eventByPlayer.get(row.id) ?? null,
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
const hydrateOne = row => (row ? hydrate([row])[0] : null)
|
|
105
|
+
|
|
106
|
+
// Players can be looked up by name ('tenz') or by vlr.gg id (9).
|
|
107
|
+
const playerKey = nameOrVlrId => (typeof nameOrVlrId === 'number' ? 'players.vlr_id = ?' : 'players.name = ?')
|
|
108
|
+
|
|
109
|
+
function getPlayerById(id) {
|
|
110
|
+
return hydrateOne(stmt(`${SELECT_PLAYERS} WHERE players.id = ?`).get(id))
|
|
98
111
|
}
|
|
99
112
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
past_teams: JSON.stringify(player.past_teams),
|
|
104
|
-
most_played_agents: JSON.stringify(player.most_played_agents),
|
|
105
|
-
stats: JSON.stringify(player.stats),
|
|
106
|
-
})
|
|
113
|
+
// The player as they were in their most recent event.
|
|
114
|
+
function getPlayer(nameOrVlrId) {
|
|
115
|
+
return hydrateOne(stmt(`${SELECT_PLAYERS} WHERE ${playerKey(nameOrVlrId)} ${NEWEST_FIRST} LIMIT 1`).get(nameOrVlrId))
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
// Every saved version of a player, one per event, newest first.
|
|
119
|
+
function getPlayerHistory(nameOrVlrId) {
|
|
120
|
+
return hydrate(stmt(`${SELECT_PLAYERS} WHERE ${playerKey(nameOrVlrId)} ${NEWEST_FIRST}`).all(nameOrVlrId))
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
// Case-insensitive
|
|
123
|
+
// Case-insensitive prefix match, e.g. "nat" finds "nats" but not "gnat". Returns one row per event.
|
|
114
124
|
function searchPlayers(query) {
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
.
|
|
125
|
+
return hydrate(stmt(`
|
|
126
|
+
${SELECT_PLAYERS} WHERE players.name LIKE ? ESCAPE '\\'
|
|
127
|
+
ORDER BY players.name, events.year DESC, players.id DESC
|
|
128
|
+
`).all(`${query.replace(/[\\%_]/g, '\\$&')}%`))
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
function getAllPlayers() {
|
|
121
|
-
return
|
|
132
|
+
return hydrate(stmt(`${SELECT_PLAYERS} ORDER BY players.id`).all())
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
function getRandomPlayer() {
|
|
125
|
-
return
|
|
136
|
+
return hydrateOne(stmt(`${SELECT_PLAYERS} ORDER BY RANDOM() LIMIT 1`).get())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The id of each player's most recent row (by event year, then scrape time).
|
|
140
|
+
const LATEST_ROW_PER_PLAYER = `
|
|
141
|
+
SELECT id FROM (
|
|
142
|
+
SELECT players.id, ROW_NUMBER() OVER (
|
|
143
|
+
PARTITION BY COALESCE(players.vlr_id, players.name)
|
|
144
|
+
ORDER BY events.year DESC, players.scraped_at DESC, players.id DESC
|
|
145
|
+
) AS n
|
|
146
|
+
FROM players JOIN events ON events.id = players.event_id
|
|
147
|
+
) WHERE n = 1
|
|
148
|
+
`
|
|
149
|
+
|
|
150
|
+
// Players whose current team matches, plus anyone who used to play for it when includePast is set.
|
|
151
|
+
// Each player is returned once, from their most recent event.
|
|
152
|
+
function getPlayersByTeam(team, { includePast = false } = {}) {
|
|
153
|
+
return hydrate(stmt(`
|
|
154
|
+
${SELECT_PLAYERS}
|
|
155
|
+
WHERE players.id IN (${LATEST_ROW_PER_PLAYER})
|
|
156
|
+
AND (players.team = @team COLLATE NOCASE
|
|
157
|
+
OR (@includePast AND EXISTS (SELECT 1 FROM json_each(players.past_teams) WHERE value = @team COLLATE NOCASE)))
|
|
158
|
+
ORDER BY players.name
|
|
159
|
+
`).all({ team, includePast: includePast ? 1 : 0 }))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------------------------
|
|
163
|
+
// Leaderboards
|
|
164
|
+
// ---------------------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const EVENT_SORTABLE = ['rating', 'acs', 'kd', 'kast', 'adr', 'kpr', 'apr', 'fk_fd', 'fk_pct', 'fd_pct',
|
|
167
|
+
'hs_pct', 'clutch_pct', 'clutches_won', 'kills_max', 'kills', 'deaths', 'assists', 'first_kills', 'first_deaths', 'rounds', 'maps']
|
|
168
|
+
const AGENT_SORTABLE = AGENT_STAT_COLUMNS
|
|
169
|
+
|
|
170
|
+
function checkStat(stat, allowed) {
|
|
171
|
+
if (!allowed.includes(stat)) throw new Error(`Can't sort by "${stat}". Pick one of: ${allowed.join(', ')}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Best players in one event by an event stat, e.g. getEventLeaderboard('Stage 2', 2026, { stat: 'acs' }).
|
|
175
|
+
function getEventLeaderboard(eventName, year, { stat = 'rating', minRounds = 0, limit = 20, ascending = false } = {}) {
|
|
176
|
+
checkStat(stat, EVENT_SORTABLE)
|
|
177
|
+
const event = getEvent(eventName, year)
|
|
178
|
+
if (!event) return []
|
|
179
|
+
return hydrate(stmt(`
|
|
180
|
+
${SELECT_PLAYERS} JOIN event_stats ON event_stats.player_id = players.id
|
|
181
|
+
WHERE players.event_id = ? AND COALESCE(event_stats.rounds, 0) >= ? AND event_stats.${stat} IS NOT NULL
|
|
182
|
+
ORDER BY event_stats.${stat} ${ascending ? 'ASC' : 'DESC'} LIMIT ?
|
|
183
|
+
`).all(event.id, minRounds, limit))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Best players on one agent by a career stat, using each player's latest scrape,
|
|
187
|
+
// e.g. getAgentLeaderboard('jett', { stat: 'kd', minRounds: 500 }).
|
|
188
|
+
function getAgentLeaderboard(agent, { stat = 'rating', minRounds = 0, limit = 20, ascending = false } = {}) {
|
|
189
|
+
checkStat(stat, AGENT_SORTABLE)
|
|
190
|
+
return hydrate(stmt(`
|
|
191
|
+
${SELECT_PLAYERS} JOIN agent_stats ON agent_stats.player_id = players.id
|
|
192
|
+
WHERE agent_stats.agent = ? COLLATE NOCASE AND players.id IN (${LATEST_ROW_PER_PLAYER})
|
|
193
|
+
AND COALESCE(agent_stats.rounds, 0) >= ? AND agent_stats.${stat} IS NOT NULL
|
|
194
|
+
ORDER BY agent_stats.${stat} ${ascending ? 'ASC' : 'DESC'} LIMIT ?
|
|
195
|
+
`).all(agent, minRounds, limit))
|
|
126
196
|
}
|
|
127
197
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
198
|
+
// ---------------------------------------------------------------------------------------------
|
|
199
|
+
// Saving players
|
|
200
|
+
// ---------------------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
const PLAYER_COLUMNS = ['vlr_id', 'name', 'url', 'team', 'past_teams', 'winnings', 'most_played_agents',
|
|
203
|
+
'avg_kda', 'nationality', 'region', 'scraped_at']
|
|
204
|
+
|
|
205
|
+
// Saves a player's stats for one event, replacing any earlier save of them in that same event.
|
|
206
|
+
// A player is matched by vlr_id (taken from `url` if missing), or by name for rows saved before
|
|
207
|
+
// vlr ids were stored. Returns the player row's id.
|
|
208
|
+
const savePlayer = transaction((eventId, player) => {
|
|
209
|
+
const vlrId = player.vlr_id ?? vlrIdFromUrl(player.url, 'player')
|
|
210
|
+
const agentRows = (player.stats ?? []).map(normalizeAgentRow).filter(row => row.agent)
|
|
211
|
+
const values = {
|
|
212
|
+
vlr_id: vlrId,
|
|
213
|
+
name: player.name,
|
|
214
|
+
url: player.url ?? null,
|
|
215
|
+
team: player.team ?? null,
|
|
216
|
+
past_teams: JSON.stringify(player.past_teams ?? []),
|
|
217
|
+
winnings: player.winnings ?? null,
|
|
218
|
+
most_played_agents: JSON.stringify(player.most_played_agents ?? agentRows.map(row => row.agent)),
|
|
219
|
+
avg_kda: player.avg_kda ?? careerKd(agentRows),
|
|
220
|
+
nationality: player.nationality ?? null,
|
|
221
|
+
region: player.region ?? null,
|
|
222
|
+
scraped_at: player.scraped_at ?? new Date().toISOString(),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const existing = stmt(`
|
|
226
|
+
SELECT id FROM players WHERE event_id = @eventId
|
|
227
|
+
AND (vlr_id = @vlrId OR (vlr_id IS NULL AND name = @name))
|
|
228
|
+
ORDER BY vlr_id IS NULL LIMIT 1
|
|
229
|
+
`).get({ eventId, vlrId, name: player.name })
|
|
230
|
+
|
|
231
|
+
let id
|
|
232
|
+
if (existing) {
|
|
233
|
+
id = existing.id
|
|
234
|
+
stmt(`UPDATE players SET ${PLAYER_COLUMNS.map(c => `${c} = @${c}`).join(', ')} WHERE id = @id`).run({ ...values, id })
|
|
235
|
+
} else {
|
|
236
|
+
id = Number(stmt(`
|
|
237
|
+
INSERT INTO players (event_id, ${PLAYER_COLUMNS.join(', ')})
|
|
238
|
+
VALUES (@event_id, ${PLAYER_COLUMNS.map(c => `@${c}`).join(', ')})
|
|
239
|
+
`).run({ ...values, event_id: eventId }).lastInsertRowid)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
stmt('DELETE FROM agent_stats WHERE player_id = ?').run(id)
|
|
243
|
+
const insertAgent = stmt(`
|
|
244
|
+
INSERT OR IGNORE INTO agent_stats (player_id, position, agent, ${AGENT_STAT_COLUMNS.join(', ')})
|
|
245
|
+
VALUES (@player_id, @position, @agent, ${AGENT_STAT_COLUMNS.map(c => `@${c}`).join(', ')})
|
|
246
|
+
`)
|
|
247
|
+
agentRows.forEach((row, position) => insertAgent.run({ ...row, player_id: id, position }))
|
|
248
|
+
|
|
249
|
+
// Leaving event_stats out keeps what's saved; passing null removes it.
|
|
250
|
+
if (player.event_stats !== undefined) {
|
|
251
|
+
stmt('DELETE FROM event_stats WHERE player_id = ?').run(id)
|
|
252
|
+
if (player.event_stats) {
|
|
253
|
+
const eventValues = Object.fromEntries(EVENT_STAT_COLUMNS.map(c => [c, player.event_stats[c] ?? null]))
|
|
254
|
+
eventValues.agents = JSON.stringify(player.event_stats.agents ?? [])
|
|
255
|
+
stmt(`
|
|
256
|
+
INSERT INTO event_stats (player_id, ${EVENT_STAT_COLUMNS.join(', ')})
|
|
257
|
+
VALUES (@player_id, ${EVENT_STAT_COLUMNS.map(c => `@${c}`).join(', ')})
|
|
258
|
+
`).run({ ...eventValues, player_id: id })
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return id
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------------------------
|
|
266
|
+
// Events
|
|
267
|
+
// ---------------------------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
// Creates the event (tournament, stage, etc.) if no event has this name and year yet, updates it
|
|
270
|
+
// otherwise, and returns its id either way.
|
|
271
|
+
const saveEvent = transaction(({ name, url, year, stage }) => {
|
|
272
|
+
const values = {
|
|
131
273
|
name,
|
|
132
|
-
url: url ?? null,
|
|
133
|
-
region: region ?? null,
|
|
134
274
|
year: year ?? null,
|
|
275
|
+
url: url ?? null,
|
|
276
|
+
vlr_event_id: vlrIdFromUrl(url, 'event'),
|
|
135
277
|
stage: stage ?? null,
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
278
|
+
}
|
|
279
|
+
const existing = stmt('SELECT id FROM events WHERE name = @name AND year IS @year').get(values)
|
|
280
|
+
if (existing) {
|
|
281
|
+
stmt('UPDATE events SET url = @url, vlr_event_id = @vlr_event_id, stage = @stage WHERE id = @id').run({ ...values, id: existing.id })
|
|
282
|
+
return existing.id
|
|
283
|
+
}
|
|
284
|
+
return Number(stmt(`
|
|
285
|
+
INSERT INTO events (name, year, url, vlr_event_id, stage) VALUES (@name, @year, @url, @vlr_event_id, @stage)
|
|
286
|
+
`).run(values).lastInsertRowid)
|
|
287
|
+
})
|
|
139
288
|
|
|
140
|
-
function
|
|
141
|
-
|
|
289
|
+
function getEvents() {
|
|
290
|
+
return stmt('SELECT * FROM events ORDER BY year DESC, name').all()
|
|
142
291
|
}
|
|
143
292
|
|
|
144
|
-
|
|
145
|
-
|
|
293
|
+
// The event with this name and year. Without a year, the only event with that name; if several
|
|
294
|
+
// years share the name, the newest one. Returns undefined if there's none.
|
|
295
|
+
function getEvent(name, year) {
|
|
296
|
+
if (year !== undefined && year !== null) {
|
|
297
|
+
return stmt('SELECT * FROM events WHERE name = ? AND year = ?').get(name, Number(year))
|
|
298
|
+
}
|
|
299
|
+
return stmt('SELECT * FROM events WHERE name = ? ORDER BY year DESC LIMIT 1').get(name)
|
|
146
300
|
}
|
|
147
301
|
|
|
148
|
-
|
|
149
|
-
|
|
302
|
+
// Kept for 2.x callers. Same as getEvent.
|
|
303
|
+
const getEventByName = getEvent
|
|
304
|
+
|
|
305
|
+
function getEventId(name, year) {
|
|
306
|
+
return getEvent(name, year)?.id
|
|
150
307
|
}
|
|
151
308
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
309
|
+
// Like getEvent, but for deletes: refuses to guess which year was meant.
|
|
310
|
+
function getEventForChange(name, year) {
|
|
311
|
+
if (year !== undefined && year !== null) return getEvent(name, year)
|
|
312
|
+
const matches = stmt('SELECT * FROM events WHERE name = ?').all(name)
|
|
313
|
+
if (matches.length > 1) {
|
|
314
|
+
throw new Error(`There are ${matches.length} events named "${name}" (${matches.map(e => e.year).join(', ')}). Say which year.`)
|
|
315
|
+
}
|
|
316
|
+
return matches[0]
|
|
159
317
|
}
|
|
160
318
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
JOIN events ON events.id = event_players.event_id
|
|
166
|
-
WHERE events.name = ?
|
|
167
|
-
ORDER BY RANDOM() LIMIT 1
|
|
168
|
-
`).get(eventName))
|
|
319
|
+
function getPlayersByEvent(eventName, year) {
|
|
320
|
+
const event = getEvent(eventName, year)
|
|
321
|
+
if (!event) return []
|
|
322
|
+
return hydrate(stmt(`${SELECT_PLAYERS} WHERE players.event_id = ? ORDER BY players.name`).all(event.id))
|
|
169
323
|
}
|
|
170
324
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
325
|
+
function getRandomPlayerFromEvent(eventName, year) {
|
|
326
|
+
const event = getEvent(eventName, year)
|
|
327
|
+
if (!event) return null
|
|
328
|
+
return hydrateOne(stmt(`${SELECT_PLAYERS} WHERE players.event_id = ? ORDER BY RANDOM() LIMIT 1`).get(event.id))
|
|
174
329
|
}
|
|
175
330
|
|
|
176
331
|
// Removes every player from an event but keeps the event itself, so it can be re-scraped.
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
const event = getEventByName(eventName)
|
|
332
|
+
function emptyEvent(eventName, year) {
|
|
333
|
+
const event = getEventForChange(eventName, year)
|
|
180
334
|
if (!event) return false
|
|
181
|
-
|
|
182
|
-
deleteOrphanedPlayers()
|
|
335
|
+
stmt('DELETE FROM players WHERE event_id = ?').run(event.id)
|
|
183
336
|
return true
|
|
184
|
-
}
|
|
337
|
+
}
|
|
185
338
|
|
|
186
|
-
// Removes an event and its players.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
339
|
+
// Removes an event and its players.
|
|
340
|
+
function clearEvent(eventName, year) {
|
|
341
|
+
const event = getEventForChange(eventName, year)
|
|
342
|
+
if (!event) return false
|
|
343
|
+
// The event's players and their stats are deleted along with it (ON DELETE CASCADE).
|
|
344
|
+
stmt('DELETE FROM events WHERE id = ?').run(event.id)
|
|
190
345
|
return true
|
|
191
|
-
}
|
|
346
|
+
}
|
|
192
347
|
|
|
193
348
|
// Wipes every event and player from the database.
|
|
194
|
-
const clearDatabase =
|
|
195
|
-
|
|
349
|
+
const clearDatabase = transaction(() => {
|
|
350
|
+
getDb().exec('DELETE FROM events')
|
|
196
351
|
})
|
|
197
352
|
|
|
198
353
|
export {
|
|
354
|
+
openDatabase,
|
|
355
|
+
closeDatabase,
|
|
199
356
|
savePlayer,
|
|
357
|
+
getPlayerById,
|
|
200
358
|
getPlayer,
|
|
359
|
+
getPlayerHistory,
|
|
201
360
|
searchPlayers,
|
|
202
361
|
getAllPlayers,
|
|
203
362
|
getRandomPlayer,
|
|
363
|
+
getPlayersByTeam,
|
|
364
|
+
getEventLeaderboard,
|
|
365
|
+
getAgentLeaderboard,
|
|
204
366
|
saveEvent,
|
|
205
|
-
addPlayerToEvent,
|
|
206
367
|
getEvents,
|
|
368
|
+
getEvent,
|
|
207
369
|
getEventByName,
|
|
370
|
+
getEventId,
|
|
208
371
|
getPlayersByEvent,
|
|
209
372
|
getRandomPlayerFromEvent,
|
|
210
373
|
emptyEvent,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio'
|
|
2
|
+
import { fetchHtml } from './client.mjs'
|
|
3
|
+
import { toNumber, toInteger, vlrIdFromUrl } from './stats.mjs'
|
|
4
|
+
|
|
5
|
+
// https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage
|
|
6
|
+
// -> https://www.vlr.gg/event/stats/2977/vct-2026-americas-stage-2
|
|
7
|
+
function eventStatsUrl(eventUrl) {
|
|
8
|
+
const match = String(eventUrl ?? '').match(/\/event\/(?:stats\/)?(\d+)(?:\/([^/?#]+))?/)
|
|
9
|
+
if (!match) return null
|
|
10
|
+
return `https://www.vlr.gg/event/stats/${match[1]}${match[2] ? `/${match[2]}` : ''}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// vlr.gg's data-col name -> our column name and how to parse it.
|
|
14
|
+
const EVENT_COLUMNS = {
|
|
15
|
+
maps: ['maps', toInteger],
|
|
16
|
+
rnd: ['rounds', toInteger],
|
|
17
|
+
rating: ['rating', toNumber],
|
|
18
|
+
acs: ['acs', toNumber],
|
|
19
|
+
kd: ['kd', toNumber],
|
|
20
|
+
kast: ['kast', toNumber],
|
|
21
|
+
adr: ['adr', toNumber],
|
|
22
|
+
kpr: ['kpr', toNumber],
|
|
23
|
+
apr: ['apr', toNumber],
|
|
24
|
+
fkfd: ['fk_fd', toNumber],
|
|
25
|
+
fkpr: ['fk_pct', toNumber],
|
|
26
|
+
fdpr: ['fd_pct', toNumber],
|
|
27
|
+
hsp: ['hs_pct', toNumber],
|
|
28
|
+
clp: ['clutch_pct', toNumber],
|
|
29
|
+
kmax: ['kills_max', toInteger],
|
|
30
|
+
k: ['kills', toInteger],
|
|
31
|
+
d: ['deaths', toInteger],
|
|
32
|
+
a: ['assists', toInteger],
|
|
33
|
+
fk: ['first_kills', toInteger],
|
|
34
|
+
fd: ['first_deaths', toInteger],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Scrapes the stats page of the event the URL points at: every player's stats in that event only.
|
|
38
|
+
// Returns a Map of vlr player id -> event stats, or undefined if the scrape fails.
|
|
39
|
+
async function scrapeEventStats(eventUrl) {
|
|
40
|
+
const url = eventStatsUrl(eventUrl)
|
|
41
|
+
if (!url) {
|
|
42
|
+
console.error(`Couldn't work out the stats page for ${eventUrl}`)
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const $ = cheerio.load(await fetchHtml(url))
|
|
47
|
+
const statsByPlayer = new Map()
|
|
48
|
+
|
|
49
|
+
$('table tbody tr').each((i, row) => {
|
|
50
|
+
row = $(row)
|
|
51
|
+
const link = row.find('td.mod-player a').attr('href')
|
|
52
|
+
const vlrId = vlrIdFromUrl(link, 'player')
|
|
53
|
+
if (!vlrId) return
|
|
54
|
+
|
|
55
|
+
const stats = {
|
|
56
|
+
team: row.find('.st-pl-country').first().text().trim() || null,
|
|
57
|
+
agents: row.find('.st-agent').map((j, el) => ({
|
|
58
|
+
agent: $(el).find('img').attr('src')?.match(/agents\/([^./]+)\./)?.[1] ?? null,
|
|
59
|
+
use_pct: toNumber($(el).find('.st-agent-n').text()),
|
|
60
|
+
})).get(),
|
|
61
|
+
clutches_won: toInteger(row.find('.st-cl-x').text()),
|
|
62
|
+
clutches_played: toInteger(row.find('.st-cl-y').text()),
|
|
63
|
+
}
|
|
64
|
+
for (const [dataCol, [column, parse]] of Object.entries(EVENT_COLUMNS)) {
|
|
65
|
+
stats[column] = parse(row.find(`td[data-col="${dataCol}"]`).first().text().trim())
|
|
66
|
+
}
|
|
67
|
+
statsByPlayer.set(vlrId, stats)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return statsByPlayer
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
console.error('Error scraping event stats:', error)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { scrapeEventStats, eventStatsUrl }
|