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/scripts/db.mjs CHANGED
@@ -1,219 +1,358 @@
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
- const db = new Database(path.join(process.cwd(), 'players.db'))
5
- db.pragma('foreign_keys = ON')
6
-
7
- const existingTableNames = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name)
8
- if (existingTableNames.includes('tournaments') && !existingTableNames.includes('events')) {
9
- db.exec('ALTER TABLE tournaments RENAME TO events')
10
- }
11
- if (existingTableNames.includes('tournament_players') && !existingTableNames.includes('event_players')) {
12
- db.exec('ALTER TABLE tournament_players RENAME TO event_players')
13
- db.exec('ALTER TABLE event_players RENAME COLUMN tournament_id TO event_id')
14
- }
15
-
16
- db.exec(`
17
- CREATE TABLE IF NOT EXISTS events (
18
- id INTEGER PRIMARY KEY AUTOINCREMENT,
19
- name TEXT UNIQUE NOT NULL,
20
- url TEXT,
21
- year INTEGER,
22
- stage INTEGER
23
- )
24
- `)
25
-
26
- const existingEventColumns = db.prepare('PRAGMA table_info(events)').all().map(col => col.name)
27
- if (!existingEventColumns.includes('year')) {
28
- db.exec('ALTER TABLE events ADD COLUMN year INTEGER')
29
- }
30
- if (!existingEventColumns.includes('stage')) {
31
- db.exec('ALTER TABLE events ADD COLUMN stage INTEGER')
32
- }
33
- // Region is stored on each player, not on the event.
34
- if (existingEventColumns.includes('region')) {
35
- db.exec('ALTER TABLE events DROP COLUMN region')
36
- }
37
-
38
- // Each row is a player as they were in one event, so the same player can have a row per event
39
- // with different stats.
40
- const createPlayersTableSql = (tableName) => `
41
- CREATE TABLE IF NOT EXISTS ${tableName} (
42
- id INTEGER PRIMARY KEY AUTOINCREMENT,
43
- event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
44
- name TEXT NOT NULL,
45
- team TEXT,
46
- past_teams TEXT,
47
- winnings TEXT,
48
- most_played_agents TEXT,
49
- avg_kda REAL,
50
- nationality TEXT,
51
- region TEXT,
52
- stats TEXT,
53
- UNIQUE (event_id, name)
54
- )
55
- `
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
+ }
56
32
 
57
- // Older databases kept one shared row per player and linked them to events through event_players.
58
- // Copy each of those links into its own per-event player row.
59
- const existingPlayerColumns = db.prepare('PRAGMA table_info(players)').all().map(col => col.name)
60
- if (existingPlayerColumns.length && !existingPlayerColumns.includes('event_id')) {
61
- db.transaction(() => {
62
- if (!existingPlayerColumns.includes('stats')) {
63
- db.exec('ALTER TABLE players ADD COLUMN stats TEXT')
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.`)
64
43
  }
65
- db.exec(createPlayersTableSql('players_new'))
66
- db.exec(`
67
- INSERT INTO players_new (event_id, name, team, past_teams, winnings, most_played_agents, avg_kda, nationality, region, stats)
68
- SELECT event_players.event_id, players.name, players.team, players.past_teams, players.winnings,
69
- players.most_played_agents, players.avg_kda, players.nationality, players.region, players.stats
70
- FROM event_players
71
- JOIN players ON players.name = event_players.player_name
72
- JOIN events ON events.id = event_players.event_id
73
- `)
74
- db.exec('DROP TABLE event_players')
75
- db.exec('DROP TABLE players')
76
- db.exec('ALTER TABLE players_new RENAME TO players')
77
- })()
78
- }
79
-
80
- db.exec(createPlayersTableSql('players'))
81
- db.exec('DROP TABLE IF EXISTS event_players')
82
-
83
- const upsertPlayerStmt = db.prepare(`
84
- INSERT INTO players (event_id, name, team, past_teams, winnings, most_played_agents, avg_kda, nationality, region, stats)
85
- VALUES (@event_id, @name, @team, @past_teams, @winnings, @most_played_agents, @avg_kda, @nationality, @region, @stats)
86
- ON CONFLICT(event_id, name) DO UPDATE SET
87
- team = excluded.team,
88
- past_teams = excluded.past_teams,
89
- winnings = excluded.winnings,
90
- most_played_agents = excluded.most_played_agents,
91
- avg_kda = excluded.avg_kda,
92
- nationality = excluded.nationality,
93
- region = excluded.region,
94
- stats = excluded.stats
95
- `)
96
-
97
- const upsertEventStmt = db.prepare(`
98
- INSERT INTO events (name, url, year, stage)
99
- VALUES (@name, @url, @year, @stage)
100
- ON CONFLICT(name) DO UPDATE SET
101
- url = excluded.url,
102
- year = excluded.year,
103
- stage = excluded.stage
104
- `)
105
-
106
- // Every player query also pulls in the event's name and year, newest event first.
107
- const selectPlayersSql = `
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 = `
108
66
  SELECT players.*, events.name AS event_name, events.year AS event_year FROM players
109
67
  JOIN events ON events.id = players.event_id
110
68
  `
111
- const newestFirst = 'ORDER BY events.year DESC, players.id DESC'
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))
112
82
 
113
- function rowToPlayer(row) {
114
- if (!row) return null
115
- return {
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 => ({
116
97
  ...row,
117
98
  past_teams: JSON.parse(row.past_teams),
118
99
  most_played_agents: JSON.parse(row.most_played_agents),
119
- stats: JSON.parse(row.stats),
120
- }
100
+ stats: agentsByPlayer.get(row.id) ?? [],
101
+ event_stats: eventByPlayer.get(row.id) ?? null,
102
+ }))
121
103
  }
104
+ const hydrateOne = row => (row ? hydrate([row])[0] : null)
122
105
 
123
- // Saves a player's stats for one event, replacing any earlier save of them in that same event.
124
- // Returns the player row's id.
125
- function savePlayer(eventId, player) {
126
- upsertPlayerStmt.run({
127
- ...player,
128
- event_id: eventId,
129
- past_teams: JSON.stringify(player.past_teams),
130
- most_played_agents: JSON.stringify(player.most_played_agents),
131
- stats: JSON.stringify(player.stats),
132
- })
133
- return db.prepare('SELECT id FROM players WHERE event_id = ? AND name = ?').get(eventId, player.name).id
134
- }
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 = ?')
135
108
 
136
109
  function getPlayerById(id) {
137
- return rowToPlayer(db.prepare(`${selectPlayersSql} WHERE players.id = ?`).get(id))
110
+ return hydrateOne(stmt(`${SELECT_PLAYERS} WHERE players.id = ?`).get(id))
138
111
  }
139
112
 
140
113
  // The player as they were in their most recent event.
141
- function getPlayer(name) {
142
- return rowToPlayer(db.prepare(`${selectPlayersSql} WHERE players.name = ? ${newestFirst} LIMIT 1`).get(name))
114
+ function getPlayer(nameOrVlrId) {
115
+ return hydrateOne(stmt(`${SELECT_PLAYERS} WHERE ${playerKey(nameOrVlrId)} ${NEWEST_FIRST} LIMIT 1`).get(nameOrVlrId))
143
116
  }
144
117
 
145
118
  // Every saved version of a player, one per event, newest first.
146
- function getPlayerHistory(name) {
147
- return db.prepare(`${selectPlayersSql} WHERE players.name = ? ${newestFirst}`).all(name).map(rowToPlayer)
119
+ function getPlayerHistory(nameOrVlrId) {
120
+ return hydrate(stmt(`${SELECT_PLAYERS} WHERE ${playerKey(nameOrVlrId)} ${NEWEST_FIRST}`).all(nameOrVlrId))
148
121
  }
149
122
 
150
123
  // Case-insensitive prefix match, e.g. "nat" finds "nats" but not "gnat". Returns one row per event.
151
124
  function searchPlayers(query) {
152
- return db.prepare(`${selectPlayersSql} WHERE players.name LIKE ? ESCAPE '\\' ORDER BY players.name, events.year DESC, players.id DESC`)
153
- .all(`${query.replace(/[\\%_]/g, '\\$&')}%`)
154
- .map(rowToPlayer)
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, '\\$&')}%`))
155
129
  }
156
130
 
157
131
  function getAllPlayers() {
158
- return db.prepare(selectPlayersSql).all().map(rowToPlayer)
132
+ return hydrate(stmt(`${SELECT_PLAYERS} ORDER BY players.id`).all())
159
133
  }
160
134
 
161
135
  function getRandomPlayer() {
162
- return rowToPlayer(db.prepare(`${selectPlayersSql} ORDER BY RANDOM() LIMIT 1`).get())
136
+ return hydrateOne(stmt(`${SELECT_PLAYERS} ORDER BY RANDOM() LIMIT 1`).get())
163
137
  }
164
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
+ }
165
185
 
166
- // Creates the event (tournament, stage, etc.) if it doesn't exist yet, and returns its id either way.
167
- function saveEvent({ name, url, year, stage }) {
168
- upsertEventStmt.run({
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))
196
+ }
197
+
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 = {
169
273
  name,
170
- url: url ?? null,
171
274
  year: year ?? null,
275
+ url: url ?? null,
276
+ vlr_event_id: vlrIdFromUrl(url, 'event'),
172
277
  stage: stage ?? null,
173
- })
174
- return db.prepare('SELECT id FROM events WHERE name = ?').get(name).id
175
- }
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
+ })
176
288
 
177
289
  function getEvents() {
178
- return db.prepare('SELECT * FROM events').all()
290
+ return stmt('SELECT * FROM events ORDER BY year DESC, name').all()
179
291
  }
180
292
 
181
- function getEventByName(name) {
182
- return db.prepare('SELECT * FROM events WHERE name = ?').get(name)
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)
183
300
  }
184
301
 
302
+ // Kept for 2.x callers. Same as getEvent.
303
+ const getEventByName = getEvent
304
+
185
305
  function getEventId(name, year) {
186
- return db.prepare('SELECT id FROM events WHERE name = ? AND year = ?').get(name, year)?.id
306
+ return getEvent(name, year)?.id
187
307
  }
188
308
 
189
- function getPlayersByEvent(eventName) {
190
- return db.prepare(`${selectPlayersSql} WHERE events.name = ?`).all(eventName).map(rowToPlayer)
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]
191
317
  }
192
318
 
193
- function getRandomPlayerFromEvent(eventName) {
194
- return rowToPlayer(db.prepare(`${selectPlayersSql} WHERE events.name = ? ORDER BY RANDOM() LIMIT 1`).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))
323
+ }
324
+
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))
195
329
  }
196
330
 
197
331
  // Removes every player from an event but keeps the event itself, so it can be re-scraped.
198
- function emptyEvent(eventName) {
199
- const event = getEventByName(eventName)
332
+ function emptyEvent(eventName, year) {
333
+ const event = getEventForChange(eventName, year)
200
334
  if (!event) return false
201
- db.prepare('DELETE FROM players WHERE event_id = ?').run(event.id)
335
+ stmt('DELETE FROM players WHERE event_id = ?').run(event.id)
202
336
  return true
203
337
  }
204
338
 
205
339
  // Removes an event and its players.
206
- function clearEvent(eventName) {
207
- // The event's players are deleted along with it (ON DELETE CASCADE).
208
- return db.prepare('DELETE FROM events WHERE name = ?').run(eventName).changes > 0
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)
345
+ return true
209
346
  }
210
347
 
211
348
  // Wipes every event and player from the database.
212
- const clearDatabase = db.transaction(() => {
213
- db.exec('DELETE FROM players; DELETE FROM events;')
349
+ const clearDatabase = transaction(() => {
350
+ getDb().exec('DELETE FROM events')
214
351
  })
215
352
 
216
353
  export {
354
+ openDatabase,
355
+ closeDatabase,
217
356
  savePlayer,
218
357
  getPlayerById,
219
358
  getPlayer,
@@ -221,8 +360,12 @@ export {
221
360
  searchPlayers,
222
361
  getAllPlayers,
223
362
  getRandomPlayer,
363
+ getPlayersByTeam,
364
+ getEventLeaderboard,
365
+ getAgentLeaderboard,
224
366
  saveEvent,
225
367
  getEvents,
368
+ getEvent,
226
369
  getEventByName,
227
370
  getEventId,
228
371
  getPlayersByEvent,
@@ -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 }