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/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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
db
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
db.
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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:
|
|
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
|
-
//
|
|
124
|
-
|
|
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
|
|
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(
|
|
142
|
-
return
|
|
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(
|
|
147
|
-
return
|
|
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
|
|
153
|
-
|
|
154
|
-
.
|
|
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
|
|
132
|
+
return hydrate(stmt(`${SELECT_PLAYERS} ORDER BY players.id`).all())
|
|
159
133
|
}
|
|
160
134
|
|
|
161
135
|
function getRandomPlayer() {
|
|
162
|
-
return
|
|
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
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
290
|
+
return stmt('SELECT * FROM events ORDER BY year DESC, name').all()
|
|
179
291
|
}
|
|
180
292
|
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
306
|
+
return getEvent(name, year)?.id
|
|
187
307
|
}
|
|
188
308
|
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
|
|
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 =
|
|
332
|
+
function emptyEvent(eventName, year) {
|
|
333
|
+
const event = getEventForChange(eventName, year)
|
|
200
334
|
if (!event) return false
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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 =
|
|
213
|
-
|
|
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 }
|