vlr-stats-scraper 1.5.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # vlr-stats-scraper
2
2
 
3
3
  Scrapes player stats from [vlr.gg](https://www.vlr.gg) and stores them in a local SQLite
4
- database, grouped by tournament or stage, so you can pull a backlog of players.
4
+ database, grouped by tournament or stage, so you can pull a backlog of players or serve them
5
+ from a website.
5
6
 
6
7
  ## Setup
7
8
 
@@ -15,6 +16,7 @@ Create a `.env` file in the directory you run it from:
15
16
 
16
17
  ```
17
18
  HYPERBROWSER_API_KEY=your_key_here
19
+ VLR_DB_PATH=/path/to/players.db # optional, defaults to ./players.db
18
20
  ```
19
21
 
20
22
  ## CLI usage
@@ -24,8 +26,9 @@ The package installs a `valstats` command.
24
26
  | Command | What it does |
25
27
  | --- | --- |
26
28
  | `valstats <link> <year> <event-name>` | Scrape every player on an event page and save them under that event |
27
- | `valstats --player <name \| link>` | Print the stats of every saved player whose name contains `name`, or of one player scraped live from a link |
28
- | `valstats --clear-event <event-name>` | Remove one event and its players |
29
+ | `valstats --search <name \| link>` | Print the stats of every saved player whose name starts with `name`, or of one player scraped live from a link |
30
+ | `valstats --player <link> <event-name> <year> [region]` | Scrape one player and add them to an event that's already saved |
31
+ | `valstats --clear-event <event-name> [year]` | Remove one event and its players |
29
32
  | `valstats --clear` | Wipe the whole database (asks for confirmation) |
30
33
 
31
34
  ### Scraping an event
@@ -40,149 +43,301 @@ npx valstats https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage
40
43
 
41
44
  - `link` — the vlr.gg event/group-stage page to scrape.
42
45
  - `year` — the year the event took place.
43
- - `event-name` — a label for this tournament/stage (used to group players together later).
46
+ - `event-name` — a label for this tournament/stage. Events are identified by name **and** year,
47
+ so "Stage 2" 2025 and "Stage 2" 2026 are kept apart.
44
48
 
45
49
  What happens:
46
50
 
47
- 1. If the event name contains the word **"stage"** (case-insensitive), you're prompted to
48
- enter a region (e.g. `AMER`, `EMEA`). That region is saved on the event and on every
49
- player scraped. Otherwise region is left empty.
50
- 2. Every player profile linked on the event page is collected. Stats are pulled using
51
- vlr.gg's **all-time** timespan.
52
- 3. Each player is scraped, saved to `players.db` in your current directory, linked to the
53
- event, and printed to the console.
51
+ 1. The event page is scraped for its title and every player profile linked on it.
52
+ 2. The region is read from the event title and saved on every player (not on the event):
53
+
54
+ | Title contains | Region |
55
+ | --- | --- |
56
+ | `America` (e.g. "VCT 2026: Americas Stage 2") | `AMER` |
57
+ | `EMEA` | `EMEA` |
58
+ | `Pacific` | `PAC` |
59
+ | `China` | `CN` |
60
+ | none of these (e.g. Masters, Champions) | `null` |
61
+
62
+ 3. The event's stats page (`vlr.gg/event/stats/<id>`) is scraped once. It gives every player's
63
+ stats **in this event only**: rating, ACS, K:D, ADR, KAST, agents played, and so on. The stats
64
+ page covers the whole event, so a link to one stage of it still gets stats from every stage.
65
+ 4. Each player's profile is scraped using vlr.gg's **all-time** timespan for their team, past
66
+ teams, nationality, winnings and career stats on their top 3 agents. Both sets of stats are
67
+ saved to the database under that event and printed to the console.
68
+
69
+ If the event page can't be scraped, nothing is saved or deleted. If only the stats page fails,
70
+ players are saved without event stats.
54
71
 
55
72
  Running it again with an event name and year that are already saved replaces that event's
56
- players with the freshly scraped ones. A player already in the database is updated with the
57
- new data rather than duplicated.
73
+ players with the freshly scraped ones.
58
74
 
59
75
  ### Viewing a player's stats
60
76
 
61
77
  ```bash
62
- npx valstats --player tenz # every saved player with "tenz" in their name
63
- npx valstats --player ten # partial names work too: tenz, tenten, ...
64
- npx valstats --player https://www.vlr.gg/player/9/tenz # scrape a player live
78
+ npx valstats --search tenz # every saved player with a name starting "tenz"
79
+ npx valstats --search ten # tenz, tenten, ... (not "kitten")
80
+ npx valstats --search https://www.vlr.gg/player/9/tenz # scrape a player live without saving
65
81
  ```
66
82
 
67
- - Passing a **name** searches `players.db` and prints every player whose name contains
68
- those characters (case-insensitive). Names are the ones from the vlr.gg profile URL,
69
- e.g. `tenz`. Nothing is scraped.
70
- - Passing a **vlr.gg player link** scrapes that profile live using the all-time timespan.
71
- The result is printed but not saved to the database.
83
+ - `--search <name>` searches the database and prints every player whose name starts with
84
+ those characters (case-insensitive). Nothing is scraped. A player saved under several events
85
+ is printed once per event, labelled with the event name and year, e.g. `tenz (Champs, 2026)`.
86
+ - `--search <link>` scrapes that profile live using the all-time timespan. The result is
87
+ printed but not saved.
88
+
89
+ For each player, their team, past teams, nationality, region, winnings, top agents, career K:D
90
+ and their stats in that event are printed, followed by a table of their career stats on their
91
+ top 3 agents.
72
92
 
73
- For each player, their team, past teams, nationality, region, winnings, top agents and
74
- average K:D are printed, followed by a table of their stats on their top 3 agents.
93
+ ### Adding a player to an event by hand
94
+
95
+ ```bash
96
+ npx valstats --player <link> <event-name> <year> [region]
97
+ ```
98
+
99
+ ```bash
100
+ npx valstats --player https://www.vlr.gg/player/9/tenz "Stage 2" 2026 # region copied from the event's other players
101
+ npx valstats --player https://www.vlr.gg/player/9/tenz "Stage 2" 2026 AMER # region set explicitly
102
+ ```
103
+
104
+ Scrapes one player, plus the event's stats page for their event stats, and saves them under an
105
+ event that's already saved, e.g. a player the event page missed. The event is checked before
106
+ scraping, so a mistyped name or year fails straight away without using a scrape.
75
107
 
76
108
  ### Clearing data
77
109
 
78
110
  ```bash
79
- npx valstats --clear-event "Stage 2" # remove one event and its players
80
- npx valstats --clear # wipe the whole database (type "yes" to confirm)
111
+ npx valstats --clear-event "Stage 2" 2026 # remove one event and its players
112
+ npx valstats --clear-event "Stage 2" # works when only one year has that name
113
+ npx valstats --clear # wipe the whole database (type "yes" to confirm)
81
114
  ```
82
115
 
83
- Players that also belong to another event are kept when a single event is cleared.
116
+ If several years share the name, `--clear-event` asks you to give the year instead of guessing.
84
117
 
85
118
  ## What gets scraped
86
119
 
87
- Each player is stored as an object like this:
120
+ `scrapePlayer` returns an object like this:
88
121
 
89
122
  ```js
90
123
  {
91
- name: 'tenz', // from the profile URL
92
- team: 'Sentinels', // current team
93
- past_teams: ['Cloud9', ...], // previous teams
94
- winnings: '$123,456', // total event winnings
95
- most_played_agents: ['Jett', ...], // top 3 agents by usage
96
- avg_kda: 1.23, // average K:D across those top 3 agents
124
+ vlr_id: 9, // vlr.gg's player id, from the profile URL
125
+ name: 'tenz', // URL slug
126
+ url: 'https://www.vlr.gg/player/9/tenz/',
127
+ team: 'Sentinels', // current team, or null
128
+ past_teams: ['Cloud9', ...],
129
+ winnings: 183650, // total event winnings in dollars
130
+ most_played_agents: ['jett', 'omen', 'reyna'],
131
+ avg_kda: 1.31, // career K:D on those agents: total kills / total deaths
97
132
  nationality: 'Canada',
98
- region: 'AMER', // from the CLI prompt / argument, or null
99
- stats: [ // full agent stat rows for the top 3 agents,
100
- { Agent: 'Jett', 'K:D': '1.25', ... } // keyed by vlr.gg's column headers
133
+ region: 'AMER', // from the event title, or null
134
+ scraped_at: '2026-10-31T12:00:00.000Z',
135
+ stats: [ // career stats on the top 3 agents
136
+ { agent: 'jett', maps: 207, use_pct: 45, rounds: 4328, rating: 1.17, acs: 262.4, kd: 1.32,
137
+ kast: 72, adr: 156.4, kpr: 0.92, apr: 0.2, fk_fd: 1.66, kills: 3989, deaths: 3028, ... },
138
+ ...
101
139
  ]
102
140
  }
103
141
  ```
104
142
 
143
+ Players read from the database also have `id`, `event_id`, `event_name`, `event_year` and
144
+ `event_stats` — their stats in that event, or `null`:
145
+
146
+ ```js
147
+ event_stats: {
148
+ team: '100T',
149
+ agents: [{ agent: 'phoenix', use_pct: 95 }, { agent: 'omen', use_pct: 5 }],
150
+ maps: 21, rounds: 449, rating: 1.19, acs: 243, kd: 1.22, kast: 77, adr: 160.5,
151
+ kpr: 0.83, apr: 0.4, fk_fd: 1.12, fk_pct: 11, fd_pct: 10, hs_pct: 22, clutch_pct: 14,
152
+ clutches_won: 5, clutches_played: 35, kills_max: 28,
153
+ kills: 374, deaths: 307, assists: 180, first_kills: 48, first_deaths: 43,
154
+ }
155
+ ```
156
+
157
+ Every stat is a number. Percentages are stored as 0–100 (`kast: 77` means 77%).
158
+
105
159
  ## Library usage
106
160
 
107
161
  ```js
108
162
  import {
109
- scrapePlayer,
110
163
  scrapeWebsite,
111
- savePlayer,
164
+ scrapeEventStats,
165
+ scrapePlayer,
112
166
  saveEvent,
113
- addPlayerToEvent,
167
+ savePlayer,
114
168
  getPlayersByEvent,
115
- getRandomPlayerFromEvent,
169
+ getEventLeaderboard,
116
170
  } from 'vlr-stats-scraper';
117
171
 
118
- const eventId = saveEvent({ name: 'Stage 2', region: 'AMER', year: 2026 });
172
+ const link = 'https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage';
173
+ const { playerLinks, region } = await scrapeWebsite(link);
174
+ const eventStats = await scrapeEventStats(link);
175
+ const eventId = saveEvent({ name: 'Stage 2', year: 2026, url: link });
119
176
 
120
- const links = await scrapeWebsite('https://www.vlr.gg/event/.../group-stage');
121
- for (const link of links) {
122
- const player = await scrapePlayer(link, 'AMER');
177
+ for (const playerLink of playerLinks) {
178
+ const player = await scrapePlayer(playerLink, region);
123
179
  if (player) {
124
- savePlayer(player);
125
- addPlayerToEvent(eventId, player.name);
180
+ savePlayer(eventId, { ...player, event_stats: eventStats?.get(player.vlr_id) ?? null });
126
181
  }
127
182
  }
128
183
 
129
- const roster = getPlayersByEvent('Stage 2');
130
- const pick = getRandomPlayerFromEvent('Stage 2');
184
+ const roster = getPlayersByEvent('Stage 2', 2026);
185
+ const topFive = getEventLeaderboard('Stage 2', 2026, { stat: 'acs', minRounds: 100, limit: 5 });
131
186
  ```
132
187
 
188
+ ### Using it from a website
189
+
190
+ Scrape on a schedule (cron, a GitHub Action, ...) with the CLI or library, and have the web server
191
+ open the same file read-only:
192
+
193
+ ```js
194
+ import { openDatabase, getPlayer } from 'vlr-stats-scraper';
195
+
196
+ openDatabase({ path: '/data/players.db', readonly: true });
197
+ const player = getPlayer('tenz');
198
+ ```
199
+
200
+ - The database uses WAL mode, so the site keeps reading while a scrape is writing.
201
+ - A read-only connection never migrates. If the file is from an older version, it throws and asks
202
+ you to open it once for writing (e.g. run `valstats`) to upgrade it.
203
+ - better-sqlite3 needs a real, persistent disk: a VPS, or Fly.io / Railway / Render with a
204
+ volume. Serverless hosts like Vercel and Netlify don't keep files between requests; for those,
205
+ copy the data into a hosted database such as Turso or Postgres.
206
+
207
+ ### Upgrading from 2.x
208
+
209
+ 3.0.0 changes the database layout and has breaking API changes. Existing `players.db` files are
210
+ migrated automatically the first time they're opened for writing.
211
+
212
+ | 2.x | 3.0.0 |
213
+ | --- | --- |
214
+ | Event names unique on their own; re-scraping "Stage 2" for a new year overwrote the old one | Events unique by name **and** year |
215
+ | `getEventByName(name)`, `getPlayersByEvent(name)`, `emptyEvent(name)`, `clearEvent(name)` | Take an optional `year`: `getPlayersByEvent('Stage 2', 2026)`. Without it, reads use the newest year and deletes refuse to guess if there are several. `getEvent` is the new name for `getEventByName`. |
216
+ | Per-event rows held all-time profile stats | Rows also get `event_stats`, scraped from the event's stats page |
217
+ | Players matched by URL slug | Players matched by `vlr_id`; `getPlayer(9)` also works |
218
+ | `winnings: '$123,456'` | `winnings: 123456` |
219
+ | `stats: [{ Agent: 'jett', 'K:D': '1.25', ... }]` | `stats: [{ agent: 'jett', kd: 1.25, ... }]`, all numbers |
220
+ | `avg_kda` was a plain average of each agent's K:D | Total kills / total deaths on those agents |
221
+ | `players.db` always in the current directory, opened on import | `VLR_DB_PATH` or `openDatabase({ path, readonly })`, opened on first use |
222
+ | `scrapeWebsite` returned `{ playerLinks, region }` | Also returns `title` and `vlrEventId` |
223
+
224
+ Rows migrated from 2.x have no `vlr_id`, `url`, `scraped_at` or `event_stats` until their event is
225
+ scraped again; re-scraping matches them up by name.
226
+
133
227
  ### Exports
134
228
 
135
229
  #### Scraping
136
230
 
137
231
  | Function | Returns | Description |
138
232
  | --- | --- | --- |
139
- | `scrapeWebsite(url)` | `Promise<string[]>` | Scrapes an event page and returns every player profile link on it (with `?timespan=all`). |
140
- | `scrapePlayer(url, region)` | `Promise<Player>` | Scrapes one player's profile page into a player object (see above). `region` is stored as-is. Returns `undefined` if scraping fails. |
233
+ | `scrapeWebsite(url)` | `Promise<{ playerLinks, region, title, vlrEventId }>` | Scrapes an event page. `playerLinks` is every player profile link on it (with `?timespan=all`); `region` is read from the event title (`'AMER'`, `'EMEA'`, `'PAC'`, `'CN'` or `null`). Returns `undefined` if scraping fails. |
234
+ | `scrapeEventStats(url)` | `Promise<Map<vlrId, EventStats>>` | Scrapes the stats page of the event `url` points at (any event or stage link works). Returns `undefined` if scraping fails. |
235
+ | `eventStatsUrl(url)` | `string \| null` | The stats page URL for an event link. |
236
+ | `scrapePlayer(url, region)` | `Promise<Player>` | Scrapes one player's profile page into a player object (see above). Returns `undefined` if scraping fails. |
237
+
238
+ #### Connection
239
+
240
+ | Function | Returns | Description |
241
+ | --- | --- | --- |
242
+ | `openDatabase({ path, readonly })` | `Database` | Opens the database file. Both options are optional. Without a call, the first query opens `$VLR_DB_PATH` or `./players.db` for writing. |
243
+ | `closeDatabase()` | — | Closes the connection. |
141
244
 
142
245
  #### Players
143
246
 
247
+ A player is saved separately for each event. Functions that take a player accept a name
248
+ (`'tenz'`, case-insensitive) or a vlr id (`9`).
249
+
144
250
  | Function | Returns | Description |
145
251
  | --- | --- | --- |
146
- | `savePlayer(player)` | — | Inserts a player, or updates them if one with the same name exists. |
147
- | `getPlayer(name)` | `Player \| null` | Looks up a player by name. |
148
- | `searchPlayers(query)` | `Player[]` | Every player whose name contains `query` (case-insensitive), sorted by name. |
149
- | `getAllPlayers()` | `Player[]` | Every player in the database. |
150
- | `getRandomPlayer()` | `Player \| null` | A random player from the whole database. |
252
+ | `savePlayer(eventId, player)` | `number` | Saves a player for one event and returns the row's id. Re-saving the same player in the same event updates them. Leaving out `event_stats` keeps what's saved; `null` removes it. |
253
+ | `getPlayerById(id)` | `Player \| null` | One saved row by id. |
254
+ | `getPlayer(nameOrVlrId)` | `Player \| null` | The player as they were in their most recent event. |
255
+ | `getPlayerHistory(nameOrVlrId)` | `Player[]` | Every saved version of a player, one per event, newest first. |
256
+ | `searchPlayers(query)` | `Player[]` | Every player whose name starts with `query` (case-insensitive), one entry per event. |
257
+ | `getPlayersByTeam(team, { includePast })` | `Player[]` | Players currently on `team`, plus former players with `includePast: true`. Newest row per player. |
258
+ | `getAllPlayers()` | `Player[]` | Every player row. |
259
+ | `getRandomPlayer()` | `Player \| null` | A random player row. |
260
+
261
+ #### Leaderboards
262
+
263
+ | Function | Returns | Description |
264
+ | --- | --- | --- |
265
+ | `getEventLeaderboard(name, year, { stat, minRounds, limit, ascending })` | `Player[]` | Players in one event sorted by an `event_stats` column (default `rating`, 20 players). |
266
+ | `getAgentLeaderboard(agent, { stat, minRounds, limit, ascending })` | `Player[]` | Players sorted by their career stats on one agent, e.g. `getAgentLeaderboard('jett', { stat: 'kd', minRounds: 500 })`. Uses each player's newest row. |
151
267
 
152
268
  #### Events (tournaments / stages)
153
269
 
154
270
  | Function | Returns | Description |
155
271
  | --- | --- | --- |
156
- | `saveEvent({ name, url, region, year, stage })` | `number` | Creates or updates an event by name and returns its id. Only `name` is required. |
157
- | `addPlayerToEvent(eventId, playerName)` | — | Links a saved player to an event (ignored if already linked). |
158
- | `getEvents()` | `Event[]` | Every saved event. |
159
- | `getEventByName(name)` | `Event \| undefined` | Looks up an event by name. |
160
- | `getPlayersByEvent(eventName)` | `Player[]` | All players linked to an event. |
161
- | `getRandomPlayerFromEvent(eventName)` | `Player \| null` | A random player from one event. |
272
+ | `saveEvent({ name, year, url, stage })` | `number` | Creates or updates the event with this name and year and returns its id. Only `name` is required. |
273
+ | `getEvents()` | `Event[]` | Every saved event, newest first. |
274
+ | `getEvent(name, year?)` | `Event \| undefined` | Looks up an event. Without `year`, the newest event with that name. `getEventByName` is an alias. |
275
+ | `getEventId(name, year?)` | `number \| undefined` | The event's id. |
276
+ | `getPlayersByEvent(name, year?)` | `Player[]` | Every player saved under an event. |
277
+ | `getRandomPlayerFromEvent(name, year?)` | `Player \| null` | A random player from one event. |
162
278
 
163
279
  #### Clearing
164
280
 
165
281
  | Function | Returns | Description |
166
282
  | --- | --- | --- |
167
- | `emptyEvent(eventName)` | `boolean` | Removes an event's players but keeps the event so it can be re-scraped. `false` if the event doesn't exist. |
168
- | `clearEvent(eventName)` | `boolean` | Removes an event and its players. `false` if the event doesn't exist. |
283
+ | `emptyEvent(name, year?)` | `boolean` | Removes an event's players but keeps the event so it can be re-scraped. |
284
+ | `clearEvent(name, year?)` | `boolean` | Removes an event and its players. |
169
285
  | `clearDatabase()` | — | Deletes every event and player. |
170
286
 
171
- Players that still belong to another event are never deleted by `emptyEvent` or
172
- `clearEvent`.
287
+ Both return `false` if the event doesn't exist, and throw if `year` is left out and several years
288
+ share the name. They only delete that event's rows, so the same player's saves in other events
289
+ are kept.
173
290
 
174
291
  ## Data storage
175
292
 
176
- Player and event data is stored in a `players.db` SQLite file created in whichever
177
- directory you run the tool from. It has three tables:
293
+ ```
294
+ events players agent_stats
295
+ ─────────── ─────────────────── ─────────────────────────
296
+ id (PK) ◄──────────────── event_id ┌────── player_id
297
+ name ┐ unique together id (PK) ◄───────────────┤ agent, position
298
+ year ┘ vlr_id │ maps, use_pct, rounds, rating,
299
+ url, vlr_event_id name, url │ acs, kd, kast, adr, kills, deaths...
300
+ stage team, past_teams (JSON) │
301
+ winnings, avg_kda │ event_stats
302
+ most_played_agents │ ─────────────────────────
303
+ nationality, region └────── player_id (PK)
304
+ scraped_at team, agents (JSON), maps, rounds,
305
+ rating, acs, kd, kast, adr, hs_pct...
306
+ ```
307
+
308
+ - `events` — one row per tournament/stage, unique by `(name, year)`.
309
+ - `players` — one row per player **per event**, unique by `(event_id, vlr_id)`.
310
+ - `agent_stats` — the player's career stats on their top 3 agents, as of `scraped_at`.
311
+ - `event_stats` — the player's stats in that event only.
312
+
313
+ Deleting an event deletes its players, and deleting a player deletes their stats
314
+ (`ON DELETE CASCADE`). The schema version is kept in SQLite's `user_version`; older files are
315
+ migrated automatically when opened for writing.
316
+
317
+ ### Example
318
+
319
+ **events**
320
+
321
+ | id | name | year |
322
+ |----|---------|------|
323
+ | 1 | Stage 2 | 2025 |
324
+ | 2 | Stage 2 | 2026 |
325
+
326
+ **players**
327
+
328
+ | id | event_id | vlr_id | name |
329
+ |----|----------|--------|------|
330
+ | 1 | 1 | 9 | tenz |
331
+ | 2 | 2 | 9 | tenz |
332
+
333
+ **event_stats**
178
334
 
179
- - `players` — one row per player (`past_teams`, `most_played_agents` and `stats` are stored
180
- as JSON and parsed back automatically when read).
181
- - `events` — one row per tournament/stage (`name`, `url`, `region`, `year`, `stage`).
182
- - `event_players` — links players to the events they were scraped from.
335
+ | player_id | rating | acs |
336
+ |-----------|--------|-----|
337
+ | 1 | 1.05 | 221 |
338
+ | 2 | 1.19 | 243 |
183
339
 
184
- Databases created by older versions (with `tournaments` / `tournament_players` tables) are
185
- migrated automatically.
340
+ `tenz` has a row per event, each with how they played in that event.
186
341
 
187
342
  ## License
188
343
 
package/index.mjs CHANGED
@@ -1,15 +1,24 @@
1
1
  export { default as scrapePlayer } from './scripts/player-info-scraper.mjs';
2
2
  export { scrapeWebsite } from './scripts/player-link-scraper.mjs';
3
+ export { scrapeEventStats, eventStatsUrl } from './scripts/event-stats-scraper.mjs';
3
4
  export {
5
+ openDatabase,
6
+ closeDatabase,
4
7
  savePlayer,
8
+ getPlayerById,
5
9
  getPlayer,
10
+ getPlayerHistory,
6
11
  searchPlayers,
7
12
  getAllPlayers,
8
13
  getRandomPlayer,
14
+ getPlayersByTeam,
15
+ getEventLeaderboard,
16
+ getAgentLeaderboard,
9
17
  saveEvent,
10
- addPlayerToEvent,
11
18
  getEvents,
19
+ getEvent,
12
20
  getEventByName,
21
+ getEventId,
13
22
  getPlayersByEvent,
14
23
  getRandomPlayerFromEvent,
15
24
  emptyEvent,