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 CHANGED
@@ -1,326 +1,344 @@
1
- # vlr-stats-scraper
2
-
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.
5
-
6
- ## Setup
7
-
8
- Requires Node.js 18+ and a [Hyperbrowser](https://hyperbrowser.ai) API key for scraping.
9
-
10
- ```bash
11
- npm install vlr-stats-scraper
12
- ```
13
-
14
- Create a `.env` file in the directory you run it from:
15
-
16
- ```
17
- HYPERBROWSER_API_KEY=your_key_here
18
- ```
19
-
20
- ## CLI usage
21
-
22
- The package installs a `valstats` command.
23
-
24
- | Command | What it does |
25
- | --- | --- |
26
- | `valstats <link> <year> <event-name>` | Scrape every player on an event page and save them under that event |
27
- | `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 |
28
- | `valstats --player <link> <event-name> <year> [region]` | Scrape one player and add them to an event that's already saved |
29
- | `valstats --clear-event <event-name>` | Remove one event and its players |
30
- | `valstats --clear` | Wipe the whole database (asks for confirmation) |
31
-
32
- ### Scraping an event
33
-
34
- ```bash
35
- npx valstats <link> <year> <event-name>
36
- ```
37
-
38
- ```bash
39
- npx valstats https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage 2026 "Stage 2"
40
- ```
41
-
42
- - `link` — the vlr.gg event/group-stage page to scrape.
43
- - `year` — the year the event took place.
44
- - `event-name` — a label for this tournament/stage (used to group players together later).
45
-
46
- What happens:
47
-
48
- 1. The event page is scraped for its title and every player profile linked on it.
49
- 2. The region is read from the event title and saved on every player (not on the event):
50
-
51
- | Title contains | Region |
52
- | --- | --- |
53
- | `America` (e.g. "VCT 2026: Americas Stage 2") | `AMER` |
54
- | `EMEA` | `EMEA` |
55
- | `Pacific` | `PAC` |
56
- | `China` | `CN` |
57
- | none of these (e.g. Masters, Champions) | `null` |
58
-
59
- 3. Each player is scraped using vlr.gg's **all-time** timespan, saved to `players.db` in your
60
- current directory under that event, and printed to the console.
61
-
62
- If the event page can't be scraped, nothing is saved or deleted.
63
-
64
- Running it again with an event name and year that are already saved replaces that event's
65
- players with the freshly scraped ones. A player who is already saved under a different event
66
- gets a new, separate row for this event, so their stats from each event are kept apart (see
67
- [Data storage](#data-storage)).
68
-
69
- ### Viewing a player's stats
70
-
71
- ```bash
72
- npx valstats --search tenz # every saved player with "tenz" in their name
73
- npx valstats --search ten # matches names starting with "ten": tenz, tenten, ... (not "kitten")
74
- npx valstats --search https://www.vlr.gg/player/9/tenz # scrape a player live without saving
75
- ```
76
-
77
- - `--search <name>` searches `players.db` and prints every player whose name starts with
78
- those characters (case-insensitive). Names are the ones from the vlr.gg profile URL,
79
- e.g. `tenz`. Nothing is scraped. A player saved under several events is printed once per
80
- event, labelled with the event name and year, e.g. `tenz (Champs, 2026)`.
81
- - `--search <link>` scrapes that profile live using the all-time timespan. The result is
82
- printed but not saved to the database.
83
-
84
- For each player, their team, past teams, nationality, region, winnings, top agents and
85
- average K:D are printed, followed by a table of their stats on their top 3 agents.
86
-
87
- ### Adding a player to an event by hand
88
-
89
- ```bash
90
- npx valstats --player <link> <event-name> <year> [region]
91
- ```
92
-
93
- ```bash
94
- npx valstats --player https://www.vlr.gg/player/9/tenz "Stage 2" 2026 # region copied from the event's other players
95
- npx valstats --player https://www.vlr.gg/player/9/tenz "Stage 2" 2026 AMER # region set explicitly
96
- ```
97
-
98
- Scrapes one player and saves them under an event that's already in `players.db`, e.g. a player
99
- the event page missed. The event is checked before scraping, so a mistyped name or year fails
100
- straight away without using a scrape. If you leave out `region`, the player gets the same
101
- region as the players already saved under that event.
102
-
103
- ### Clearing data
104
-
105
- ```bash
106
- npx valstats --clear-event "Stage 2" # remove one event and its players
107
- npx valstats --clear # wipe the whole database (type "yes" to confirm)
108
- ```
109
-
110
- Clearing one event only removes that event's player rows. The same player's saves under other
111
- events are kept.
112
-
113
- ## What gets scraped
114
-
115
- Each player is stored as an object like this:
116
-
117
- ```js
118
- {
119
- name: 'tenz', // from the profile URL
120
- team: 'Sentinels', // current team
121
- past_teams: ['Cloud9', ...], // previous teams
122
- winnings: '$123,456', // total event winnings
123
- most_played_agents: ['Jett', ...], // top 3 agents by usage
124
- avg_kda: 1.23, // average K:D across those top 3 agents
125
- nationality: 'Canada',
126
- region: 'AMER', // from the event title, or null
127
- stats: [ // full agent stat rows for the top 3 agents,
128
- { Agent: 'Jett', 'K:D': '1.25', ... } // keyed by vlr.gg's column headers
129
- ]
130
- }
131
- ```
132
-
133
- ## Library usage
134
-
135
- ```js
136
- import {
137
- scrapePlayer,
138
- scrapeWebsite,
139
- savePlayer,
140
- saveEvent,
141
- getPlayersByEvent,
142
- getRandomPlayerFromEvent,
143
- } from 'vlr-stats-scraper';
144
-
145
- const { playerLinks, region } = await scrapeWebsite('https://www.vlr.gg/event/.../group-stage');
146
- const eventId = saveEvent({ name: 'Stage 2', year: 2026 });
147
-
148
- for (const link of playerLinks) {
149
- const player = await scrapePlayer(link, region);
150
- if (player) {
151
- savePlayer(eventId, player);
152
- }
153
- }
154
-
155
- const roster = getPlayersByEvent('Stage 2');
156
- const pick = getRandomPlayerFromEvent('Stage 2');
157
- ```
158
-
159
- ### Upgrading from 1.x
160
-
161
- 2.0.0 changes how players are stored and has breaking API changes:
162
-
163
- | 1.x | 2.0.0 |
164
- | --- | --- |
165
- | `savePlayer(player)` then `addPlayerToEvent(eventId, player.name)` | `savePlayer(eventId, player)`. `addPlayerToEvent` is removed. |
166
- | `scrapeWebsite(url)` returns `string[]` | Returns `{ playerLinks, region }` |
167
- | One row per player, shared across events | One row per player **per event** (see [Data storage](#data-storage)) |
168
- | `valstats --player <name>` | `valstats --search <name>`. `--player` now adds a player to an event. |
169
- | Region typed in at a prompt and saved on the event | Region read from the event title and saved on each player only |
170
- | `saveEvent({ name, url, region, year, stage })` | `saveEvent({ name, url, year, stage })` |
171
-
172
- Existing `players.db` files are migrated automatically the first time they're opened.
173
-
174
- ### Exports
175
-
176
- #### Scraping
177
-
178
- | Function | Returns | Description |
179
- | --- | --- | --- |
180
- | `scrapeWebsite(url)` | `Promise<{ playerLinks, region }>` | 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. |
181
- | `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. |
182
-
183
- #### Players
184
-
185
- A player is saved separately for each event, so the same player can have different stats in
186
- different events. Players read from the database also have `id`, `event_id`, `event_name` and
187
- `event_year`.
188
-
189
- | Function | Returns | Description |
190
- | --- | --- | --- |
191
- | `savePlayer(eventId, player)` | `number` | Saves a player's stats for one event and returns the row's id. Re-saving the same player in the same event updates them. |
192
- | `getPlayerById(id)` | `Player \| null` | Looks up one saved player row by id. |
193
- | `getPlayer(name)` | `Player \| null` | The player as they were in their most recent event. |
194
- | `getPlayerHistory(name)` | `Player[]` | Every saved version of a player, one per event, newest first. |
195
- | `searchPlayers(query)` | `Player[]` | Every player whose name starts with `query` (case-insensitive), sorted by name, one entry per event. |
196
- | `getAllPlayers()` | `Player[]` | Every player row in the database. |
197
- | `getRandomPlayer()` | `Player \| null` | A random player row from the whole database. |
198
-
199
- #### Events (tournaments / stages)
200
-
201
- | Function | Returns | Description |
202
- | --- | --- | --- |
203
- | `saveEvent({ name, url, year, stage })` | `number` | Creates or updates an event by name and returns its id. Only `name` is required. |
204
- | `getEvents()` | `Event[]` | Every saved event. |
205
- | `getEventByName(name)` | `Event \| undefined` | Looks up an event by name. |
206
- | `getEventId(name, year)` | `number \| undefined` | The id of the event with that name and year. |
207
- | `getPlayersByEvent(eventName)` | `Player[]` | Every player saved under an event. |
208
- | `getRandomPlayerFromEvent(eventName)` | `Player \| null` | A random player from one event. |
209
-
210
- #### Clearing
211
-
212
- | Function | Returns | Description |
213
- | --- | --- | --- |
214
- | `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. |
215
- | `clearEvent(eventName)` | `boolean` | Removes an event and its players. `false` if the event doesn't exist. |
216
- | `clearDatabase()` | — | Deletes every event and player. |
217
-
218
- `emptyEvent` and `clearEvent` only delete that event's player rows, so the same player's saves in
219
- other events are kept.
220
-
221
- ## Data storage
222
-
223
- Player and event data is stored in a `players.db` SQLite file created in whichever
224
- directory you run the tool from. It has two tables, and **every player row belongs to exactly
225
- one event**:
226
-
227
- ```
228
- events players
229
- ────────────── ────────────────────────────
230
- id (PK) ◄───────────────────── event_id (which event this row is from)
231
- name (unique) id (PK) (unique per row)
232
- url name
233
- year team, past_teams, winnings,
234
- stage most_played_agents, avg_kda,
235
- nationality, region, stats
236
- ```
237
-
238
- - `events` — one row per tournament/stage. Region isn't stored here; it's on each player.
239
- - `players` — one row per player **per event**. `past_teams`, `most_played_agents` and `stats`
240
- are stored as JSON and parsed back automatically when read.
241
-
242
- A player's stats change over time, so the same player in two events is saved as two separate
243
- rows, each with the stats they had when that event was scraped.
244
-
245
- ### Example
246
-
247
- **events**
248
-
249
- | id | name | year |
250
- |----|---------|------|
251
- | 1 | Masters | 2025 |
252
- | 2 | Champs | 2026 |
253
-
254
- **players**
255
-
256
- | id | event_id | name | avg_kda |
257
- |----|----------|-------|---------|
258
- | 1 | 1 | tenz | 1.10 |
259
- | 2 | 2 | tenz | 1.35 |
260
- | 3 | 2 | aspas | 1.40 |
261
-
262
- `tenz` has two rows: one for how they played at Masters and one for Champs. Each has its own
263
- `id` and stats.
264
-
265
- ### Rules the database enforces
266
-
267
- - **One copy of a player per event.** `(event_id, name)` is unique, so re-scraping Champs
268
- updates tenz's Champs row instead of adding another one. Their Masters row isn't touched.
269
- - **Every player row must point to a real event.** Foreign keys are enforced, so saving a
270
- player under an event id that doesn't exist fails.
271
- - **Deleting an event deletes its players.** Player rows are removed along with their event
272
- (`ON DELETE CASCADE`).
273
-
274
- ### Which function reads what
275
-
276
- | You want… | Function | What it does |
277
- |---|---|---|
278
- | Save a scraped player | `savePlayer(eventId, player)` | Inserts or updates that player's row for the event and returns its `id` |
279
- | An event's roster | `getPlayersByEvent('Champs')` | Every row whose `event_id` matches that event |
280
- | A player's latest version | `getPlayer('tenz')` | Their row from the newest event (highest year) |
281
- | Every version of a player | `getPlayerHistory('tenz')` | All their rows, newest first |
282
- | One specific row | `getPlayerById(2)` | Looks a row up by `id` |
283
- | Search by name | `searchPlayers('ten')` | Partial match, one result per player per event |
284
- | Re-scrape an event | `emptyEvent('Champs')` | Deletes that event's players but keeps the event |
285
- | Remove an event | `clearEvent('Champs')` | Deletes the event, and its players go with it |
286
-
287
- Every player read from the database also has `event_name` and `event_year`, so you can tell
288
- which event a row came from without a separate lookup.
289
-
290
- ### What happens when you scrape an event
291
-
292
- ```
293
- valstats <link> <year> <event-name>
294
- │
295
- ▼
296
- Already saved with this name + year? ──yes──► emptyEvent(name)
297
- │ (delete its old player rows)
298
- ▼ │
299
- saveEvent(...) ◄─────────────────────────────────────┘
300
- (create or update the event, get its id)
301
- │
302
- ▼
303
- For each player on the event page:
304
- scrapePlayer(link) ──► savePlayer(eventId, player)
305
- ```
306
-
307
- ### Migrating older databases
308
-
309
- Older versions stored one shared row per player and linked players to events through an
310
- `event_players` table (or `tournaments` / `tournament_players` before that). These are
311
- migrated automatically the first time the database is opened:
312
-
313
- ```
314
- Before After
315
- players: tenz players: id 1, tenz, event_id 1
316
- event_players: (1, tenz), (2, tenz) ──► id 2, tenz, event_id 2
317
- ```
318
-
319
- Each player is copied into a row for every event they were linked to, and `event_players` is
320
- removed. Older versions overwrote a player's stats on every scrape, so migrated rows for the
321
- same player all start with the same (most recent) stats. Players who weren't linked to any
322
- event are dropped.
323
-
324
- ## License
325
-
326
- ISC
1
+ # vlr-stats-scraper
2
+
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 or serve them
5
+ from a website.
6
+
7
+ ## Setup
8
+
9
+ Requires Node.js 18+ and a [Hyperbrowser](https://hyperbrowser.ai) API key for scraping.
10
+
11
+ ```bash
12
+ npm install vlr-stats-scraper
13
+ ```
14
+
15
+ Create a `.env` file in the directory you run it from:
16
+
17
+ ```
18
+ HYPERBROWSER_API_KEY=your_key_here
19
+ VLR_DB_PATH=/path/to/players.db # optional, defaults to ./players.db
20
+ ```
21
+
22
+ ## CLI usage
23
+
24
+ The package installs a `valstats` command.
25
+
26
+ | Command | What it does |
27
+ | --- | --- |
28
+ | `valstats <link> <year> <event-name>` | Scrape every player on an event page and save them under that event |
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 |
32
+ | `valstats --clear` | Wipe the whole database (asks for confirmation) |
33
+
34
+ ### Scraping an event
35
+
36
+ ```bash
37
+ npx valstats <link> <year> <event-name>
38
+ ```
39
+
40
+ ```bash
41
+ npx valstats https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage 2026 "Stage 2"
42
+ ```
43
+
44
+ - `link` — the vlr.gg event/group-stage page to scrape.
45
+ - `year` — the year the event took place.
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.
48
+
49
+ What happens:
50
+
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.
71
+
72
+ Running it again with an event name and year that are already saved replaces that event's
73
+ players with the freshly scraped ones.
74
+
75
+ ### Viewing a player's stats
76
+
77
+ ```bash
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
81
+ ```
82
+
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.
92
+
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.
107
+
108
+ ### Clearing data
109
+
110
+ ```bash
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)
114
+ ```
115
+
116
+ If several years share the name, `--clear-event` asks you to give the year instead of guessing.
117
+
118
+ ## What gets scraped
119
+
120
+ `scrapePlayer` returns an object like this:
121
+
122
+ ```js
123
+ {
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
132
+ nationality: 'Canada',
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
+ ...
139
+ ]
140
+ }
141
+ ```
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
+
159
+ ## Library usage
160
+
161
+ ```js
162
+ import {
163
+ scrapeWebsite,
164
+ scrapeEventStats,
165
+ scrapePlayer,
166
+ saveEvent,
167
+ savePlayer,
168
+ getPlayersByEvent,
169
+ getEventLeaderboard,
170
+ } from 'vlr-stats-scraper';
171
+
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 });
176
+
177
+ for (const playerLink of playerLinks) {
178
+ const player = await scrapePlayer(playerLink, region);
179
+ if (player) {
180
+ savePlayer(eventId, { ...player, event_stats: eventStats?.get(player.vlr_id) ?? null });
181
+ }
182
+ }
183
+
184
+ const roster = getPlayersByEvent('Stage 2', 2026);
185
+ const topFive = getEventLeaderboard('Stage 2', 2026, { stat: 'acs', minRounds: 100, limit: 5 });
186
+ ```
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
+
227
+ ### Exports
228
+
229
+ #### Scraping
230
+
231
+ | Function | Returns | Description |
232
+ | --- | --- | --- |
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. |
244
+
245
+ #### Players
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
+
250
+ | Function | Returns | Description |
251
+ | --- | --- | --- |
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. |
267
+
268
+ #### Events (tournaments / stages)
269
+
270
+ | Function | Returns | Description |
271
+ | --- | --- | --- |
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. |
278
+
279
+ #### Clearing
280
+
281
+ | Function | Returns | Description |
282
+ | --- | --- | --- |
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. |
285
+ | `clearDatabase()` | — | Deletes every event and player. |
286
+
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.
290
+
291
+ ## Data storage
292
+
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**
334
+
335
+ | player_id | rating | acs |
336
+ |-----------|--------|-----|
337
+ | 1 | 1.05 | 221 |
338
+ | 2 | 1.19 | 243 |
339
+
340
+ `tenz` has a row per event, each with how they played in that event.
341
+
342
+ ## License
343
+
344
+ ISC