vlr-stats-scraper 1.4.1 → 2.0.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,170 +1,326 @@
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 --clear-event <event-name>` | Remove one event and its players |
28
- | `valstats --clear` | Wipe the whole database (asks for confirmation) |
29
-
30
- ### Scraping an event
31
-
32
- ```bash
33
- npx valstats <link> <year> <event-name>
34
- ```
35
-
36
- ```bash
37
- npx valstats https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage 2026 "Stage 2"
38
- ```
39
-
40
- - `link` — the vlr.gg event/group-stage page to scrape.
41
- - `year` — the year the event took place.
42
- - `event-name` — a label for this tournament/stage (used to group players together later).
43
-
44
- What happens:
45
-
46
- 1. If the event name contains the word **"stage"** (case-insensitive), you're prompted to
47
- enter a region (e.g. `AMER`, `EMEA`). That region is saved on the event and on every
48
- player scraped. Otherwise region is left empty.
49
- 2. Every player profile linked on the event page is collected. Stats are pulled using
50
- vlr.gg's **all-time** timespan.
51
- 3. Each player is scraped, saved to `players.db` in your current directory, linked to the
52
- event, and printed to the console.
53
-
54
- Running it again with an event name and year that are already saved replaces that event's
55
- players with the freshly scraped ones. A player already in the database is updated with the
56
- new data rather than duplicated.
57
-
58
- ### Clearing data
59
-
60
- ```bash
61
- npx valstats --clear-event "Stage 2" # remove one event and its players
62
- npx valstats --clear # wipe the whole database (type "yes" to confirm)
63
- ```
64
-
65
- Players that also belong to another event are kept when a single event is cleared.
66
-
67
- ## What gets scraped
68
-
69
- Each player is stored as an object like this:
70
-
71
- ```js
72
- {
73
- name: 'tenz', // from the profile URL
74
- team: 'Sentinels', // current team
75
- past_teams: ['Cloud9', ...], // previous teams
76
- winnings: '$123,456', // total event winnings
77
- most_played_agents: ['Jett', ...], // top 3 agents by usage
78
- avg_kda: 1.23, // average K:D across those top 3 agents
79
- nationality: 'Canada',
80
- region: 'AMER', // from the CLI prompt / argument, or null
81
- stats: [ // full agent stat rows for the top 3 agents,
82
- { Agent: 'Jett', 'K:D': '1.25', ... } // keyed by vlr.gg's column headers
83
- ]
84
- }
85
- ```
86
-
87
- ## Library usage
88
-
89
- ```js
90
- import {
91
- scrapePlayer,
92
- scrapeWebsite,
93
- savePlayer,
94
- saveEvent,
95
- addPlayerToEvent,
96
- getPlayersByEvent,
97
- getRandomPlayerFromEvent,
98
- } from 'vlr-stats-scraper';
99
-
100
- const eventId = saveEvent({ name: 'Stage 2', region: 'AMER', year: 2026 });
101
-
102
- const links = await scrapeWebsite('https://www.vlr.gg/event/.../group-stage');
103
- for (const link of links) {
104
- const player = await scrapePlayer(link, 'AMER');
105
- if (player) {
106
- savePlayer(player);
107
- addPlayerToEvent(eventId, player.name);
108
- }
109
- }
110
-
111
- const roster = getPlayersByEvent('Stage 2');
112
- const pick = getRandomPlayerFromEvent('Stage 2');
113
- ```
114
-
115
- ### Exports
116
-
117
- #### Scraping
118
-
119
- | Function | Returns | Description |
120
- | --- | --- | --- |
121
- | `scrapeWebsite(url)` | `Promise<string[]>` | Scrapes an event page and returns every player profile link on it (with `?timespan=all`). |
122
- | `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. |
123
-
124
- #### Players
125
-
126
- | Function | Returns | Description |
127
- | --- | --- | --- |
128
- | `savePlayer(player)` | — | Inserts a player, or updates them if one with the same name exists. |
129
- | `getPlayer(name)` | `Player \| null` | Looks up a player by name. |
130
- | `getAllPlayers()` | `Player[]` | Every player in the database. |
131
- | `getRandomPlayer()` | `Player \| null` | A random player from the whole database. |
132
-
133
- #### Events (tournaments / stages)
134
-
135
- | Function | Returns | Description |
136
- | --- | --- | --- |
137
- | `saveEvent({ name, url, region, year, stage })` | `number` | Creates or updates an event by name and returns its id. Only `name` is required. |
138
- | `addPlayerToEvent(eventId, playerName)` | — | Links a saved player to an event (ignored if already linked). |
139
- | `getEvents()` | `Event[]` | Every saved event. |
140
- | `getEventByName(name)` | `Event \| undefined` | Looks up an event by name. |
141
- | `getPlayersByEvent(eventName)` | `Player[]` | All players linked to an event. |
142
- | `getRandomPlayerFromEvent(eventName)` | `Player \| null` | A random player from one event. |
143
-
144
- #### Clearing
145
-
146
- | Function | Returns | Description |
147
- | --- | --- | --- |
148
- | `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. |
149
- | `clearEvent(eventName)` | `boolean` | Removes an event and its players. `false` if the event doesn't exist. |
150
- | `clearDatabase()` | — | Deletes every event and player. |
151
-
152
- Players that still belong to another event are never deleted by `emptyEvent` or
153
- `clearEvent`.
154
-
155
- ## Data storage
156
-
157
- Player and event data is stored in a `players.db` SQLite file created in whichever
158
- directory you run the tool from. It has three tables:
159
-
160
- - `players` — one row per player (`past_teams`, `most_played_agents` and `stats` are stored
161
- as JSON and parsed back automatically when read).
162
- - `events` — one row per tournament/stage (`name`, `url`, `region`, `year`, `stage`).
163
- - `event_players` — links players to the events they were scraped from.
164
-
165
- Databases created by older versions (with `tournaments` / `tournament_players` tables) are
166
- migrated automatically.
167
-
168
- ## License
169
-
170
- 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.
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
package/index.mjs CHANGED
@@ -1,17 +1,20 @@
1
- export { default as scrapePlayer } from './scripts/player-info-scraper.mjs';
2
- export { scrapeWebsite } from './scripts/player-link-scraper.mjs';
3
- export {
4
- savePlayer,
5
- getPlayer,
6
- getAllPlayers,
7
- getRandomPlayer,
8
- saveEvent,
9
- addPlayerToEvent,
10
- getEvents,
11
- getEventByName,
12
- getPlayersByEvent,
13
- getRandomPlayerFromEvent,
14
- emptyEvent,
15
- clearEvent,
16
- clearDatabase,
17
- } from './scripts/db.mjs';
1
+ export { default as scrapePlayer } from './scripts/player-info-scraper.mjs';
2
+ export { scrapeWebsite } from './scripts/player-link-scraper.mjs';
3
+ export {
4
+ savePlayer,
5
+ getPlayerById,
6
+ getPlayer,
7
+ getPlayerHistory,
8
+ searchPlayers,
9
+ getAllPlayers,
10
+ getRandomPlayer,
11
+ saveEvent,
12
+ getEvents,
13
+ getEventByName,
14
+ getEventId,
15
+ getPlayersByEvent,
16
+ getRandomPlayerFromEvent,
17
+ emptyEvent,
18
+ clearEvent,
19
+ clearDatabase,
20
+ } from './scripts/db.mjs';