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.
@@ -0,0 +1,181 @@
1
+ // Brings any players.db from an older version up to the current schema.
2
+ // Runs automatically when the database is opened for writing. Read-only connections never migrate.
3
+ import { normalizeAgentRow, careerKd, cleanAgentName, toInteger, vlrIdFromUrl, AGENT_STAT_COLUMNS } from './stats.mjs'
4
+
5
+ const SCHEMA_VERSION = 3
6
+
7
+ const SCHEMA_SQL = `
8
+ CREATE TABLE IF NOT EXISTS events (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ name TEXT NOT NULL,
11
+ year INTEGER,
12
+ url TEXT,
13
+ vlr_event_id INTEGER,
14
+ stage INTEGER,
15
+ UNIQUE (name, year)
16
+ );
17
+
18
+ -- One row per player per event: who they were (team, region, ...) when that event was scraped.
19
+ CREATE TABLE IF NOT EXISTS players (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
22
+ vlr_id INTEGER,
23
+ name TEXT NOT NULL COLLATE NOCASE,
24
+ url TEXT,
25
+ team TEXT,
26
+ past_teams TEXT NOT NULL DEFAULT '[]',
27
+ winnings INTEGER,
28
+ most_played_agents TEXT NOT NULL DEFAULT '[]',
29
+ avg_kda REAL,
30
+ nationality TEXT,
31
+ region TEXT,
32
+ scraped_at TEXT,
33
+ UNIQUE (event_id, vlr_id)
34
+ );
35
+ CREATE INDEX IF NOT EXISTS players_name ON players(name);
36
+ CREATE INDEX IF NOT EXISTS players_vlr_id ON players(vlr_id);
37
+
38
+ -- Career stats on the player's top agents, as they were at players.scraped_at.
39
+ CREATE TABLE IF NOT EXISTS agent_stats (
40
+ player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
41
+ position INTEGER NOT NULL,
42
+ agent TEXT NOT NULL,
43
+ maps INTEGER, use_pct REAL, rounds INTEGER, rating REAL, acs REAL, kd REAL, kast REAL,
44
+ adr REAL, kpr REAL, apr REAL, fkpr REAL, fdpr REAL, fk_fd REAL,
45
+ kills INTEGER, deaths INTEGER, assists INTEGER, first_kills INTEGER, first_deaths INTEGER,
46
+ PRIMARY KEY (player_id, agent)
47
+ );
48
+ CREATE INDEX IF NOT EXISTS agent_stats_agent ON agent_stats(agent);
49
+
50
+ -- The player's stats in this event only, from the event's stats page.
51
+ CREATE TABLE IF NOT EXISTS event_stats (
52
+ player_id INTEGER PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE,
53
+ team TEXT,
54
+ agents TEXT NOT NULL DEFAULT '[]',
55
+ maps INTEGER, rounds INTEGER, rating REAL, acs REAL, kd REAL, kast REAL, adr REAL,
56
+ kpr REAL, apr REAL, fk_fd REAL, fk_pct REAL, fd_pct REAL, hs_pct REAL,
57
+ clutch_pct REAL, clutches_won INTEGER, clutches_played INTEGER, kills_max INTEGER,
58
+ kills INTEGER, deaths INTEGER, assists INTEGER, first_kills INTEGER, first_deaths INTEGER
59
+ );
60
+ `
61
+
62
+ const tableNames = db => db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name)
63
+ const columnNames = (db, table) => db.prepare(`PRAGMA table_info(${table})`).all().map(col => col.name)
64
+
65
+ // 1.x: tournaments/tournament_players, then events/event_players with one shared row per player.
66
+ // Copies each event link into its own player row, as 2.x did.
67
+ function migrateFromV1(db) {
68
+ let tables = tableNames(db)
69
+ if (tables.includes('tournaments') && !tables.includes('events')) {
70
+ db.exec('ALTER TABLE tournaments RENAME TO events')
71
+ }
72
+ if (tables.includes('tournament_players') && !tables.includes('event_players')) {
73
+ db.exec('ALTER TABLE tournament_players RENAME TO event_players')
74
+ db.exec('ALTER TABLE event_players RENAME COLUMN tournament_id TO event_id')
75
+ }
76
+ tables = tableNames(db)
77
+ if (!tables.includes('players') || columnNames(db, 'players').includes('event_id')) return
78
+
79
+ // Players that weren't linked to any event have nowhere to go, so they're dropped.
80
+ if (!tables.includes('event_players') || !tables.includes('events')) {
81
+ db.exec('DROP TABLE IF EXISTS event_players; DROP TABLE players;')
82
+ return
83
+ }
84
+ const stats = columnNames(db, 'players').includes('stats') ? 'players.stats' : 'NULL'
85
+ db.exec(`
86
+ CREATE TABLE players_v2 AS
87
+ SELECT event_players.event_id AS event_id, players.name, players.team, players.past_teams,
88
+ players.winnings, players.most_played_agents, players.avg_kda, players.nationality,
89
+ players.region, ${stats} AS stats
90
+ FROM event_players
91
+ JOIN players ON players.name = event_players.player_name
92
+ JOIN events ON events.id = event_players.event_id
93
+ `)
94
+ db.exec('DROP TABLE event_players')
95
+ db.exec('DROP TABLE players')
96
+ db.exec('ALTER TABLE players_v2 RENAME TO players')
97
+ }
98
+
99
+ // 2.x -> 3: events unique by (name, year), vlr ids, numeric winnings, stats split into tables.
100
+ function migrateFromV2(db) {
101
+ const oldEvents = tableNames(db).includes('events') ? db.prepare('SELECT * FROM events').all() : []
102
+ const oldPlayers = tableNames(db).includes('players') ? db.prepare('SELECT * FROM players').all() : []
103
+
104
+ db.exec('DROP TABLE IF EXISTS players; DROP TABLE IF EXISTS events;')
105
+ db.exec(SCHEMA_SQL)
106
+
107
+ const insertEvent = db.prepare(`
108
+ INSERT INTO events (id, name, year, url, vlr_event_id, stage)
109
+ VALUES (@id, @name, @year, @url, @vlr_event_id, @stage)
110
+ `)
111
+ for (const event of oldEvents) {
112
+ insertEvent.run({
113
+ id: event.id,
114
+ name: event.name,
115
+ year: event.year ?? null,
116
+ url: event.url ?? null,
117
+ vlr_event_id: vlrIdFromUrl(event.url, 'event'),
118
+ stage: event.stage ?? null,
119
+ })
120
+ }
121
+
122
+ const insertPlayer = db.prepare(`
123
+ INSERT INTO players (id, event_id, vlr_id, name, url, team, past_teams, winnings,
124
+ most_played_agents, avg_kda, nationality, region, scraped_at)
125
+ VALUES (@id, @event_id, NULL, @name, NULL, @team, @past_teams, @winnings,
126
+ @most_played_agents, @avg_kda, @nationality, @region, NULL)
127
+ `)
128
+ const insertAgent = db.prepare(`
129
+ INSERT OR IGNORE INTO agent_stats (player_id, position, agent, ${AGENT_STAT_COLUMNS.join(', ')})
130
+ VALUES (@player_id, @position, @agent, ${AGENT_STAT_COLUMNS.map(c => `@${c}`).join(', ')})
131
+ `)
132
+ const parse = (json, fallback) => { try { return JSON.parse(json) ?? fallback } catch { return fallback } }
133
+
134
+ oldPlayers.forEach((player, index) => {
135
+ if (!player.event_id) return
136
+ const id = player.id ?? index + 1
137
+ const agentRows = parse(player.stats, []).map(normalizeAgentRow).filter(row => row.agent)
138
+ const agents = parse(player.most_played_agents, []).map(cleanAgentName)
139
+ insertPlayer.run({
140
+ id,
141
+ event_id: player.event_id,
142
+ name: player.name,
143
+ team: player.team ?? null,
144
+ past_teams: JSON.stringify(parse(player.past_teams, [])),
145
+ winnings: toInteger(player.winnings),
146
+ most_played_agents: JSON.stringify(agents.length ? agents : agentRows.map(row => row.agent)),
147
+ // 2.x stored a plain average of each agent's K:D; recompute it weighted when we can.
148
+ avg_kda: (agentRows.length ? careerKd(agentRows) : null) ?? player.avg_kda ?? null,
149
+ nationality: player.nationality ?? null,
150
+ region: player.region ?? null,
151
+ })
152
+ agentRows.forEach((row, position) => insertAgent.run({ ...row, player_id: id, position }))
153
+ })
154
+ }
155
+
156
+ function migrate(db) {
157
+ const version = db.pragma('user_version', { simple: true })
158
+ if (version >= SCHEMA_VERSION) return
159
+
160
+ const hasOldData = tableNames(db).some(name => ['players', 'events', 'tournaments'].includes(name))
161
+
162
+ // Foreign keys have to be off while tables are rebuilt, and that can't change inside a transaction.
163
+ db.pragma('foreign_keys = OFF')
164
+ try {
165
+ db.transaction(() => {
166
+ if (hasOldData) {
167
+ migrateFromV1(db)
168
+ migrateFromV2(db)
169
+ } else {
170
+ db.exec(SCHEMA_SQL)
171
+ }
172
+ const problems = db.pragma('foreign_key_check')
173
+ if (problems.length) throw new Error(`Migration left ${problems.length} broken references`)
174
+ db.pragma(`user_version = ${SCHEMA_VERSION}`)
175
+ })()
176
+ } finally {
177
+ db.pragma('foreign_keys = ON')
178
+ }
179
+ }
180
+
181
+ export { migrate, SCHEMA_VERSION }
@@ -1,95 +1,59 @@
1
1
  import * as cheerio from 'cheerio'
2
- import { Hyperbrowser } from "@hyperbrowser/sdk"
3
- import { config } from "dotenv"
4
-
5
- config()
6
-
7
- let client;
8
- function getClient() {
9
- if (!client) {
10
- client = new Hyperbrowser({
11
- apiKey: process.env.HYPERBROWSER_API_KEY,
12
- })
13
- }
14
- return client;
15
- }
2
+ import { fetchHtml } from './client.mjs'
3
+ import { normalizeAgentRow, careerKd, toInteger, vlrIdFromUrl } from './stats.mjs'
16
4
 
5
+ // Scrapes a player's profile page. The stats are whatever timespan the URL asks for
6
+ // (the CLI uses ?timespan=all), so they're a career snapshot taken at `scraped_at`.
17
7
  async function scrapePlayer(url, region) {
18
- // console.log('scrapePlayer ran')
19
8
  try {
20
- const scrapeData = await getClient().scrape.startAndWait({
21
- url: url,
22
- scrapeOptions: {
23
- formats: ["html"]
24
- }
25
- })
26
-
27
- const $ = cheerio.load(scrapeData.data.html)
9
+ const $ = cheerio.load(await fetchHtml(url))
28
10
  const agentsPlayed = findAgents($)
29
11
 
30
12
  return {
13
+ vlr_id: vlrIdFromUrl(url, 'player'),
31
14
  name: getPlayerName(url),
15
+ url: url.split('?')[0],
32
16
  team: getCurTeam($),
33
17
  past_teams: getPastTeam($),
34
18
  winnings: getWinnings($),
35
- most_played_agents: agentsPlayed.map(data => data.Agent),
36
- avg_kda: agentsPlayed.reduce((acc, data) => acc + (parseFloat(data['K:D'])) / agentsPlayed.length, 0).toFixed(2),
19
+ most_played_agents: agentsPlayed.map(row => row.agent),
20
+ avg_kda: careerKd(agentsPlayed),
37
21
  nationality: getNationality($),
38
- region: region,
39
- stats: agentsPlayed
22
+ region: region ?? null,
23
+ stats: agentsPlayed,
24
+ scraped_at: new Date().toISOString(),
40
25
  }
41
26
  }
42
27
  catch (error) {
43
- console.error("Error scraping website:", error);
28
+ console.error('Error scraping website:', error)
44
29
  }
45
30
  }
46
31
 
47
- // testing
48
- /*
49
- node scripts/player-info-scraper.mjs
50
- scrapePlayer("https://www.vlr.gg/player/939/mwzera/?timespan=all", "AMER")
51
- */
52
-
53
-
54
32
  function getPlayerName(link) {
55
- let name = new URL(link).pathname.split('/').filter(ele => ele !== '').pop()
56
- return name
33
+ return new URL(link).pathname.split('/').filter(ele => ele !== '').pop()
57
34
  }
58
35
 
36
+ // The player's top 3 agents, one normalized row each (see normalizeAgentRow in stats.mjs).
59
37
  function findAgents($) {
60
- // console.log('findAgents ran')
61
- const header = $('.mod-agent-rows').first().children().first()
62
-
63
- const statCategories = header.text().split('\n').map(ele => ele.trim()).filter(ele => ele !== '')
64
- statCategories.unshift('Agent')
65
-
66
- const agentStats = header.next().children()
67
- const agents = agentStats.map((i, el) => {
68
- el = $(el)
69
- // console.log('agent found:', el.children().first().find('img').attr('alt'))?.trim()
70
- return {
71
- agent: el.children().first().find('img').attr('alt')?.trim(),
72
- stats: el.text().split('\t').map(ele => ele.trim()).filter(ele => ele !== '')
73
- }
74
- }).get()
75
-
76
- const agentData = agents.map(agent => {
77
- return statCategories.reduce((acc, category, index) => {
78
- if(index == 0) {
79
- acc[category] = agent.agent
80
- }
81
- else {
82
- acc[category] = agent.stats[index-1]
83
- }
84
- return acc
85
- }, {})
86
- })
87
-
88
- return agentData.length<3 ? agentData : agentData.slice(0,3)
38
+ const table = $('.mod-agent-rows').first()
39
+ const headers = table.find('thead th').map((i, el) => $(el).text().trim()).get()
40
+ headers[0] = 'Agent'
41
+
42
+ return table.find('tbody tr').map((i, row) => {
43
+ const cells = $(row).children('td')
44
+ const raw = {}
45
+ cells.each((index, cell) => {
46
+ const header = headers[index]
47
+ if (!header) return
48
+ raw[header] = index === 0
49
+ ? $(cell).find('img').attr('alt')?.trim()
50
+ : $(cell).text().trim()
51
+ })
52
+ return normalizeAgentRow(raw)
53
+ }).get().slice(0, 3)
89
54
  }
90
55
 
91
56
  function getCurTeam($) {
92
- // console.log('getCurTeam ran')
93
57
  const currentTeam = $('h2:contains("Current Teams")').first().next().text().trim()
94
58
  const currentTeamName = currentTeam.split('\t').filter(ele => ele.trim() !== '')
95
59
  // Free agents have no current team
@@ -97,31 +61,23 @@ function getCurTeam($) {
97
61
  }
98
62
 
99
63
  function getPastTeam($) {
100
- // console.log('getPastTeam ran')
101
- const pastTeams = $('h2:contains("Past Teams")').first().next().text().trim()
102
- const pastTeamNames = pastTeams.split('\t').filter(ele => ele.trim() !== ''
103
- && !ele.includes('joined')
104
- && !ele.includes(' – ')
105
- && !ele.includes('left'))
106
- .map(ele => ele.trim())
107
- return pastTeamNames
64
+ const pastTeams = $('h2:contains("Past Teams")').first().next().text().trim()
65
+ return pastTeams.split('\t').filter(ele => ele.trim() !== ''
66
+ && !ele.includes('joined')
67
+ && !ele.includes(' – ')
68
+ && !ele.includes('left'))
69
+ .map(ele => ele.trim())
108
70
  }
109
71
 
72
+ // Total event winnings in dollars, e.g. 123456. null if the player has none listed.
110
73
  function getWinnings($) {
111
- // console.log('getWinnings ran')
112
- const events = $('h2:contains("Event Placements")').first().next()
113
- const winnings = events.children().first().text().trim()
114
- // // console.log(winnings.split('\t').map(ele => ele.trim()).filter(ele => ele !== '')[1])
115
- return winnings.split('\t').map(ele => ele.trim()).filter(ele => ele !== '')[1]
116
-
74
+ const events = $('h2:contains("Event Placements")').first().next()
75
+ const winnings = events.children().first().text().trim()
76
+ return toInteger(winnings.split('\t').map(ele => ele.trim()).filter(ele => ele !== '')[1])
117
77
  }
118
78
 
119
79
  function getNationality($) {
120
- // console.log('getNationality ran')
121
- const header = $('.flag').first()
122
- // console.log('flags found:', $('.flag').length);
123
- // console.log(header.parent().text().trim())
124
- return header.parent().text().trim()
80
+ return $('.flag').first().parent().text().trim()
125
81
  }
126
82
 
127
- export default scrapePlayer
83
+ export default scrapePlayer
@@ -1,69 +1,41 @@
1
- import * as cheerio from 'cheerio'
2
- import { Hyperbrowser } from "@hyperbrowser/sdk"
3
- import { config } from "dotenv";
4
-
5
- config();
6
-
7
- let client;
8
- function getClient() {
9
- if (!client) {
10
- client = new Hyperbrowser({
11
- apiKey: process.env.HYPERBROWSER_API_KEY,
12
- })
13
- }
14
- return client;
15
- }
16
-
17
- async function scrapeWebsite(url) {
18
- try {
19
- const scrapeData = await getClient().scrape.startAndWait({
20
- url: url,
21
- scrapeOptions: {
22
- formats: ["html"],
23
- includeTags: [".event-header-main-title", ".event-team-players-item"]
24
- },
25
- })
26
- const data = scrapeData.data.html
27
- const $ = cheerio.load(data)
28
-
29
- /*
30
- <h1 class="event-header-main-title">
31
- VCT 2027: America Kickoff - North America Open Qualifier
32
- </h1>
33
- */
34
- const playerLinks = $('.event-team-players-item')
35
- .map((i, el) => $(el).attr('href'))
36
- .get()
37
- .filter(href => href.includes('/player/'))
38
- .map(href => href + '/?timespan=all')
39
-
40
- let region = $('.event-header-main-title').first().text().trim()
41
- console.log(region)
42
-
43
- if (region.includes('America')) {
44
- region = 'AMER'
45
- }
46
- else if (region.includes('EMEA')) {
47
- region = 'EMEA'
48
- }
49
- else if (region.includes('Pacific')) {
50
- region = 'PAC'
51
- }
52
- else if (region.includes('China')) {
53
- region = 'CN'
54
- }
55
- else {
56
- region = null
57
- }
58
-
59
- return { playerLinks, region }
60
- }
61
- catch (error) {
62
- console.error("Error scraping website:", error)
63
- }
64
- }
65
-
66
- export { scrapeWebsite }
67
- /*
68
- node teamscraper.mjs
69
- */
1
+ import * as cheerio from 'cheerio'
2
+ import { fetchHtml } from './client.mjs'
3
+ import { vlrIdFromUrl } from './stats.mjs'
4
+
5
+ // Scrapes an event page for its title, region and every player profile linked on it.
6
+ async function scrapeWebsite(url) {
7
+ try {
8
+ const html = await fetchHtml(url, {
9
+ includeTags: ['.event-header-main-title', '.event-team-players-item'],
10
+ })
11
+ const $ = cheerio.load(html)
12
+
13
+ const playerLinks = [...new Set($('.event-team-players-item')
14
+ .map((i, el) => $(el).attr('href'))
15
+ .get()
16
+ .filter(href => href.includes('/player/'))
17
+ .map(href => new URL(href, 'https://www.vlr.gg').href.replace(/\/?$/, '/?timespan=all')))]
18
+
19
+ const title = $('.event-header-main-title').first().text().trim()
20
+
21
+ return {
22
+ playerLinks,
23
+ region: regionFromTitle(title),
24
+ title,
25
+ vlrEventId: vlrIdFromUrl(url, 'event'),
26
+ }
27
+ }
28
+ catch (error) {
29
+ console.error('Error scraping website:', error)
30
+ }
31
+ }
32
+
33
+ function regionFromTitle(title) {
34
+ if (title.includes('America')) return 'AMER'
35
+ if (title.includes('EMEA')) return 'EMEA'
36
+ if (title.includes('Pacific')) return 'PAC'
37
+ if (title.includes('China')) return 'CN'
38
+ return null
39
+ }
40
+
41
+ export { scrapeWebsite, regionFromTitle }
@@ -0,0 +1,111 @@
1
+ // Helpers that turn vlr.gg's display strings into numbers we can sort and filter on.
2
+
3
+ // "1.25" -> 1.25, "77%" -> 77, "$123,456" -> 123456, "" / "-" / undefined -> null
4
+ function toNumber(value) {
5
+ if (value === null || value === undefined) return null
6
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
7
+ const cleaned = String(value).replace(/[$,%\s]/g, '')
8
+ if (cleaned === '' || cleaned === '-') return null
9
+ const number = Number(cleaned)
10
+ return Number.isFinite(number) ? number : null
11
+ }
12
+
13
+ function toInteger(value) {
14
+ const number = toNumber(value)
15
+ return number === null ? null : Math.round(number)
16
+ }
17
+
18
+ // "(207) 45%" -> { maps: 207, use_pct: 45 }
19
+ function parseUse(value) {
20
+ const match = String(value ?? '').match(/\((\d+)\)\s*([\d.]+)%?/)
21
+ return match ? { maps: Number(match[1]), use_pct: Number(match[2]) } : { maps: null, use_pct: null }
22
+ }
23
+
24
+ // Older saves stored agents as raw <img> tags, so pull the name out of the alt text.
25
+ function cleanAgentName(agent) {
26
+ if (!agent) return agent ?? null
27
+ return String(agent).match(/alt="([^"]*)"/)?.[1] ?? String(agent)
28
+ }
29
+
30
+ // Column names of the per-agent stats table, keyed by every header vlr.gg has used for them.
31
+ const AGENT_HEADER_TO_COLUMN = {
32
+ 'RND': 'rounds',
33
+ 'R': 'rating',
34
+ 'RATING': 'rating',
35
+ 'R2.0': 'rating',
36
+ 'ACS': 'acs',
37
+ 'K:D': 'kd',
38
+ 'KAST': 'kast',
39
+ 'ADR': 'adr',
40
+ 'KPR': 'kpr',
41
+ 'APR': 'apr',
42
+ 'FKPR': 'fkpr',
43
+ 'FDPR': 'fdpr',
44
+ 'FK:FD': 'fk_fd',
45
+ 'K': 'kills',
46
+ 'D': 'deaths',
47
+ 'A': 'assists',
48
+ 'FK': 'first_kills',
49
+ 'FD': 'first_deaths',
50
+ }
51
+
52
+ const AGENT_STAT_COLUMNS = [
53
+ 'maps', 'use_pct', 'rounds', 'rating', 'acs', 'kd', 'kast', 'adr', 'kpr', 'apr',
54
+ 'fkpr', 'fdpr', 'fk_fd', 'kills', 'deaths', 'assists', 'first_kills', 'first_deaths',
55
+ ]
56
+ const INTEGER_COLUMNS = new Set(['maps', 'rounds', 'kills', 'deaths', 'assists', 'first_kills', 'first_deaths'])
57
+
58
+ // Turns one agent row keyed by vlr.gg's headers ({ Agent: 'jett', Use: '(207) 45%', 'K:D': '1.32', ... })
59
+ // into numeric columns ({ agent: 'jett', maps: 207, use_pct: 45, kd: 1.32, ... }).
60
+ function normalizeAgentRow(raw) {
61
+ const row = Object.fromEntries(AGENT_STAT_COLUMNS.map(column => [column, null]))
62
+ row.agent = null
63
+ for (const [header, value] of Object.entries(raw ?? {})) {
64
+ const key = header.trim().toUpperCase()
65
+ if (key === 'AGENT') {
66
+ row.agent = cleanAgentName(value)?.toLowerCase() ?? null
67
+ } else if (key === 'USE') {
68
+ Object.assign(row, parseUse(value))
69
+ } else if (AGENT_HEADER_TO_COLUMN[key]) {
70
+ const column = AGENT_HEADER_TO_COLUMN[key]
71
+ row[column] = INTEGER_COLUMNS.has(column) ? toInteger(value) : toNumber(value)
72
+ } else if (AGENT_STAT_COLUMNS.includes(header)) {
73
+ // Already normalized (e.g. re-saving a player read back from the database).
74
+ row[header] = value ?? null
75
+ }
76
+ }
77
+ return row
78
+ }
79
+
80
+ // K:D across several agents, weighted by how much each was played: total kills / total deaths.
81
+ // Falls back to a rounds-weighted average of each agent's K:D when kill/death totals are missing.
82
+ function careerKd(agentRows) {
83
+ const rows = agentRows ?? []
84
+ const kills = rows.reduce((sum, row) => sum + (row.kills ?? 0), 0)
85
+ const deaths = rows.reduce((sum, row) => sum + (row.deaths ?? 0), 0)
86
+ if (deaths > 0 && rows.every(row => row.kills !== null && row.deaths !== null)) {
87
+ return Number((kills / deaths).toFixed(2))
88
+ }
89
+ const weighted = rows.filter(row => row.kd !== null)
90
+ if (!weighted.length) return null
91
+ const weightOf = row => row.rounds ?? 1
92
+ const totalWeight = weighted.reduce((sum, row) => sum + weightOf(row), 0)
93
+ return Number((weighted.reduce((sum, row) => sum + row.kd * weightOf(row), 0) / totalWeight).toFixed(2))
94
+ }
95
+
96
+ // https://www.vlr.gg/player/9/tenz/?timespan=all -> 9
97
+ function vlrIdFromUrl(url, kind) {
98
+ const match = String(url ?? '').match(new RegExp(`/${kind}/(\\d+)`))
99
+ return match ? Number(match[1]) : null
100
+ }
101
+
102
+ export {
103
+ toNumber,
104
+ toInteger,
105
+ parseUse,
106
+ cleanAgentName,
107
+ normalizeAgentRow,
108
+ careerKd,
109
+ vlrIdFromUrl,
110
+ AGENT_STAT_COLUMNS,
111
+ }