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/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
- const db = new Database(path.join(process.cwd(), 'players.db'))
5
-
6
- db.exec(`
7
- CREATE TABLE IF NOT EXISTS players (
8
- name TEXT PRIMARY KEY,
9
- team TEXT,
10
- past_teams TEXT,
11
- winnings TEXT,
12
- most_played_agents TEXT,
13
- avg_kda REAL,
14
- nationality TEXT,
15
- region TEXT,
16
- stats TEXT
17
- )
18
- `)
19
-
20
- const existingPlayerColumns = db.prepare('PRAGMA table_info(players)').all().map(col => col.name)
21
- if (!existingPlayerColumns.includes('stats')) {
22
- db.exec('ALTER TABLE players ADD COLUMN stats TEXT')
23
- }
24
-
25
- const existingTableNames = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name)
26
- if (existingTableNames.includes('tournaments') && !existingTableNames.includes('events')) {
27
- db.exec('ALTER TABLE tournaments RENAME TO events')
28
- }
29
- if (existingTableNames.includes('tournament_players') && !existingTableNames.includes('event_players')) {
30
- db.exec('ALTER TABLE tournament_players RENAME TO event_players')
31
- db.exec('ALTER TABLE event_players RENAME COLUMN tournament_id TO event_id')
32
- }
33
-
34
- db.exec(`
35
- CREATE TABLE IF NOT EXISTS events (
36
- id INTEGER PRIMARY KEY AUTOINCREMENT,
37
- name TEXT UNIQUE NOT NULL,
38
- url TEXT,
39
- region TEXT,
40
- year INTEGER,
41
- stage INTEGER
42
- )
43
- `)
44
-
45
- const existingEventColumns = db.prepare('PRAGMA table_info(events)').all().map(col => col.name)
46
- if (!existingEventColumns.includes('year')) {
47
- db.exec('ALTER TABLE events ADD COLUMN year INTEGER')
48
- }
49
- if (!existingEventColumns.includes('stage')) {
50
- db.exec('ALTER TABLE events ADD COLUMN stage INTEGER')
51
- }
52
-
53
- db.exec(`
54
- CREATE TABLE IF NOT EXISTS event_players (
55
- event_id INTEGER NOT NULL REFERENCES events(id),
56
- player_name TEXT NOT NULL REFERENCES players(name),
57
- PRIMARY KEY (event_id, player_name)
58
- )
59
- `)
60
-
61
- const upsertPlayerStmt = db.prepare(`
62
- INSERT INTO players (name, team, past_teams, winnings, most_played_agents, avg_kda, nationality, region, stats)
63
- VALUES (@name, @team, @past_teams, @winnings, @most_played_agents, @avg_kda, @nationality, @region, @stats)
64
- ON CONFLICT(name) DO UPDATE SET
65
- team = excluded.team,
66
- past_teams = excluded.past_teams,
67
- winnings = excluded.winnings,
68
- most_played_agents = excluded.most_played_agents,
69
- avg_kda = excluded.avg_kda,
70
- nationality = excluded.nationality,
71
- region = excluded.region,
72
- stats = excluded.stats
73
- `)
74
-
75
- const upsertEventStmt = db.prepare(`
76
- INSERT INTO events (name, url, region, year, stage)
77
- VALUES (@name, @url, @region, @year, @stage)
78
- ON CONFLICT(name) DO UPDATE SET
79
- url = excluded.url,
80
- region = excluded.region,
81
- year = excluded.year,
82
- stage = excluded.stage
83
- `)
84
-
85
- const linkPlayerToEventStmt = db.prepare(`
86
- INSERT OR IGNORE INTO event_players (event_id, player_name)
87
- VALUES (@eventId, @playerName)
88
- `)
89
-
90
- function rowToPlayer(row) {
91
- if (!row) return null
92
- return {
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: JSON.parse(row.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
- function savePlayer(player) {
101
- upsertPlayerStmt.run({
102
- ...player,
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
- function getPlayer(name) {
110
- return rowToPlayer(db.prepare('SELECT * FROM players WHERE name = ?').get(name))
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 partial match, e.g. "ten" finds "tenz" and "tenten".
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 db.prepare("SELECT * FROM players WHERE name LIKE ? ESCAPE '\\' ORDER BY name")
116
- .all(`%${query.replace(/[\\%_]/g, '\\$&')}%`)
117
- .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, '\\$&')}%`))
118
129
  }
119
130
 
120
131
  function getAllPlayers() {
121
- return db.prepare('SELECT * FROM players').all().map(rowToPlayer)
132
+ return hydrate(stmt(`${SELECT_PLAYERS} ORDER BY players.id`).all())
122
133
  }
123
134
 
124
135
  function getRandomPlayer() {
125
- return rowToPlayer(db.prepare('SELECT * FROM players ORDER BY RANDOM() LIMIT 1').get())
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
- // Creates the event (tournament, stage, etc.) if it doesn't exist yet, and returns its id either way.
129
- function saveEvent({ name, url, region, year, stage }) {
130
- upsertEventStmt.run({
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
- return db.prepare('SELECT id FROM events WHERE name = ?').get(name).id
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 addPlayerToEvent(eventId, playerName) {
141
- linkPlayerToEventStmt.run({ eventId, playerName })
289
+ function getEvents() {
290
+ return stmt('SELECT * FROM events ORDER BY year DESC, name').all()
142
291
  }
143
292
 
144
- function getEvents() {
145
- return db.prepare('SELECT * FROM events').all()
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
- function getEventByName(name) {
149
- return db.prepare('SELECT * FROM events WHERE name = ?').get(name)
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
- function getPlayersByEvent(eventName) {
153
- return db.prepare(`
154
- SELECT players.* FROM players
155
- JOIN event_players ON event_players.player_name = players.name
156
- JOIN events ON events.id = event_players.event_id
157
- WHERE events.name = ?
158
- `).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]
159
317
  }
160
318
 
161
- function getRandomPlayerFromEvent(eventName) {
162
- return rowToPlayer(db.prepare(`
163
- SELECT players.* FROM players
164
- JOIN event_players ON event_players.player_name = players.name
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
- // Deletes players that no longer belong to any event.
172
- function deleteOrphanedPlayers() {
173
- db.prepare('DELETE FROM players WHERE name NOT IN (SELECT player_name FROM event_players)').run()
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
- // Players that are still in other events are kept.
178
- const emptyEvent = db.transaction((eventName) => {
179
- const event = getEventByName(eventName)
332
+ function emptyEvent(eventName, year) {
333
+ const event = getEventForChange(eventName, year)
180
334
  if (!event) return false
181
- db.prepare('DELETE FROM event_players WHERE event_id = ?').run(event.id)
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. Players that are still in other events are kept.
187
- const clearEvent = db.transaction((eventName) => {
188
- if (!emptyEvent(eventName)) return false
189
- db.prepare('DELETE FROM events WHERE name = ?').run(eventName)
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 = db.transaction(() => {
195
- db.exec('DELETE FROM event_players DELETE FROM events DELETE FROM players')
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 }