vlr-stats-scraper 1.5.0 → 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 +326 -189
- package/index.mjs +20 -18
- package/main.mjs +166 -138
- package/package.json +1 -1
- package/scripts/db.mjs +100 -80
- package/scripts/player-info-scraper.mjs +1 -1
- package/scripts/player-link-scraper.mjs +36 -9
package/README.md
CHANGED
|
@@ -1,189 +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 --
|
|
28
|
-
| `valstats --
|
|
29
|
-
| `valstats --clear
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- `
|
|
43
|
-
- `
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
|
166
|
-
|
|
|
167
|
-
|
|
|
168
|
-
| `
|
|
169
|
-
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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,18 +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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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';
|
package/main.mjs
CHANGED
|
@@ -1,138 +1,166 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import promptSync from 'prompt-sync'
|
|
3
|
-
import { scrapeWebsite } from './scripts/player-link-scraper.mjs'
|
|
4
|
-
import scrapePlayer from './scripts/player-info-scraper.mjs'
|
|
5
|
-
import {
|
|
6
|
-
savePlayer,
|
|
7
|
-
saveEvent,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
console.log(
|
|
23
|
-
console.log(`
|
|
24
|
-
console.log(`
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
console.log(`
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
console.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (!
|
|
126
|
-
console.error(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import promptSync from 'prompt-sync'
|
|
3
|
+
import { scrapeWebsite } from './scripts/player-link-scraper.mjs'
|
|
4
|
+
import scrapePlayer from './scripts/player-info-scraper.mjs'
|
|
5
|
+
import {
|
|
6
|
+
savePlayer,
|
|
7
|
+
saveEvent,
|
|
8
|
+
getEventByName,
|
|
9
|
+
getEventId,
|
|
10
|
+
getPlayersByEvent,
|
|
11
|
+
emptyEvent,
|
|
12
|
+
clearEvent,
|
|
13
|
+
clearDatabase,
|
|
14
|
+
searchPlayers,
|
|
15
|
+
} from './scripts/db.mjs'
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2)
|
|
18
|
+
|
|
19
|
+
function printPlayerStats(player) {
|
|
20
|
+
// Players saved from the database belong to an event; ones scraped live don't.
|
|
21
|
+
const event = player.event_name ? ` (${player.event_name}${player.event_year ? `, ${player.event_year}` : ''})` : ''
|
|
22
|
+
console.log(`\n${player.name}${event}`)
|
|
23
|
+
console.log(` Team: ${player.team ?? 'Free agent'}`)
|
|
24
|
+
console.log(` Past teams: ${player.past_teams?.length ? player.past_teams.join(', ') : 'None'}`)
|
|
25
|
+
console.log(` Nationality: ${player.nationality || 'Unknown'}`)
|
|
26
|
+
console.log(` Region: ${player.region ?? 'Unknown'}`)
|
|
27
|
+
console.log(` Winnings: ${player.winnings ?? 'Unknown'}`)
|
|
28
|
+
// Older saves stored agents as raw <img> tags, so pull the name out of the alt text.
|
|
29
|
+
const agents = (player.most_played_agents ?? []).map(agent => agent?.match(/alt="([^"]*)"/)?.[1] ?? agent)
|
|
30
|
+
console.log(` Top agents: ${agents.join(', ') || 'None'}`)
|
|
31
|
+
console.log(` Avg K:D: ${Number(player.avg_kda).toFixed(2)}`)
|
|
32
|
+
if (player.stats?.length) {
|
|
33
|
+
console.table(player.stats)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Manually adds one player to an event that's already saved.
|
|
38
|
+
if (args[0] === '--player') {
|
|
39
|
+
const [, query, eventName, eventYear, regionArg] = args
|
|
40
|
+
if (!query?.startsWith('http') || !eventName || !eventYear) {
|
|
41
|
+
console.error('Usage: valstats --player <vlr.gg player link> <event-name> <year> [region]')
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Look the event up before scraping so a typo doesn't waste a scrape.
|
|
46
|
+
const eventId = getEventId(eventName, parseInt(eventYear, 10))
|
|
47
|
+
if (!eventId) {
|
|
48
|
+
console.error(`No event named "${eventName}" (${eventYear}) in players.db`)
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Without a region argument, use the region the event's other players were saved with.
|
|
53
|
+
const region = regionArg ?? getPlayersByEvent(eventName)[0]?.region ?? null
|
|
54
|
+
|
|
55
|
+
const url = query.includes('timespan=') ? query : query.replace(/\/?$/, '/?timespan=all')
|
|
56
|
+
const player = await scrapePlayer(url, region)
|
|
57
|
+
if (!player) {
|
|
58
|
+
console.error(`Couldn't scrape a player from ${query}`)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
savePlayer(eventId, player)
|
|
62
|
+
console.log(`Saved ${player.name} to players.db under "${eventName}" (${eventYear})`)
|
|
63
|
+
printPlayerStats(player)
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (args[0] === '--search') {
|
|
68
|
+
const query = args[1]
|
|
69
|
+
if (!query) {
|
|
70
|
+
console.error('Usage: valstats --search <name | vlr.gg player link>')
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A vlr.gg link is scraped live anything else is searched for in players.db.
|
|
75
|
+
if (query.startsWith('http')) {
|
|
76
|
+
const url = query.includes('timespan=') ? query : query.replace(/\/?$/, '/?timespan=all')
|
|
77
|
+
const player = await scrapePlayer(url, null)
|
|
78
|
+
if (!player) {
|
|
79
|
+
console.error(`Couldn't scrape a player from ${query}`)
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
printPlayerStats(player)
|
|
83
|
+
process.exit(0)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const players = searchPlayers(query)
|
|
87
|
+
if (!players.length) {
|
|
88
|
+
console.error(`No players matching "${query}" in players.db (pass a vlr.gg player link to scrape them live)`)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
console.log(`${players.length} player${players.length === 1 ? '' : 's'} matching "${query}":`)
|
|
92
|
+
players.forEach(printPlayerStats)
|
|
93
|
+
process.exit(0)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (args[0] === '--clear') {
|
|
97
|
+
const prompt = promptSync()
|
|
98
|
+
const answer = prompt('This deletes every event and player in players.db. Type "yes" to continue: ')
|
|
99
|
+
if (answer?.trim().toLowerCase() === 'yes') {
|
|
100
|
+
clearDatabase()
|
|
101
|
+
console.log('Cleared players.db')
|
|
102
|
+
} else {
|
|
103
|
+
console.log('Cancelled')
|
|
104
|
+
}
|
|
105
|
+
process.exit(0)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (args[0] === '--clear-event') {
|
|
109
|
+
const name = args[1]
|
|
110
|
+
if (!name) {
|
|
111
|
+
console.error('Usage: valstats --clear-event <event-name>')
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
if (clearEvent(name)) {
|
|
115
|
+
console.log(`Removed "${name}" and its players from players.db`)
|
|
116
|
+
} else {
|
|
117
|
+
console.error(`No event named "${name}" in players.db`)
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
process.exit(0)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [link, year, eventName] = args
|
|
124
|
+
|
|
125
|
+
if (!link || !year || !eventName) {
|
|
126
|
+
console.error('Usage: valstats <link> <year> <event-name>')
|
|
127
|
+
console.error(' valstats --player <vlr.gg player link> <event-name> <year> [region]')
|
|
128
|
+
console.error(' valstats --search <name | vlr.gg player link>')
|
|
129
|
+
console.error(' valstats --clear-event <event-name>')
|
|
130
|
+
console.error(' valstats --clear')
|
|
131
|
+
process.exit(1)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Scrape first so a failed scrape doesn't wipe an event that's already saved.
|
|
135
|
+
const scraped = await scrapeWebsite(link)
|
|
136
|
+
|
|
137
|
+
if (!scraped) {
|
|
138
|
+
console.error(`Couldn't get the player list from ${link}`)
|
|
139
|
+
process.exit(1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { playerLinks, region } = scraped
|
|
143
|
+
console.log(`Found ${playerLinks.length} players, region: ${region ?? 'unknown'}`)
|
|
144
|
+
|
|
145
|
+
// Re-adding an event + year that's already saved replaces its old players.
|
|
146
|
+
const existing = getEventByName(eventName)
|
|
147
|
+
if (existing && existing.year === parseInt(year, 10)) {
|
|
148
|
+
emptyEvent(eventName)
|
|
149
|
+
console.log(`"${eventName}" (${year}) is already in players.db, replacing it`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const eventId = saveEvent({
|
|
153
|
+
name: eventName,
|
|
154
|
+
url: link,
|
|
155
|
+
year: parseInt(year, 10),
|
|
156
|
+
stage: null,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
for (const playerLink of playerLinks) {
|
|
160
|
+
const player = await scrapePlayer(playerLink, region)
|
|
161
|
+
if (player) {
|
|
162
|
+
savePlayer(eventId, player)
|
|
163
|
+
console.log(`Saved ${player.name} to players.db under "${eventName}"`)
|
|
164
|
+
console.log(player)
|
|
165
|
+
}
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vlr-stats-scraper",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Scrapes player stats from vlr.gg and stores them in a local SQLite database, grouped by tournament or stage.",
|
|
5
5
|
"homepage": "https://github.com/OverYander/VLR-Webscraper#readme",
|
|
6
6
|
"bugs": {
|
package/scripts/db.mjs
CHANGED
|
@@ -2,25 +2,7 @@ import Database from 'better-sqlite3'
|
|
|
2
2
|
import path from 'path'
|
|
3
3
|
|
|
4
4
|
const db = new Database(path.join(process.cwd(), 'players.db'))
|
|
5
|
-
|
|
6
|
-
db.exec(`
|
|
7
|
-
CREATE TABLE IF NOT EXISTS players (
|
|
8
|
-
name TEXT PRIMARY KEY,
|
|
9
|
-
team TEXT,
|
|
10
|
-
past_teams TEXT,
|
|
11
|
-
winnings TEXT,
|
|
12
|
-
most_played_agents TEXT,
|
|
13
|
-
avg_kda REAL,
|
|
14
|
-
nationality TEXT,
|
|
15
|
-
region TEXT,
|
|
16
|
-
stats TEXT
|
|
17
|
-
)
|
|
18
|
-
`)
|
|
19
|
-
|
|
20
|
-
const existingPlayerColumns = db.prepare('PRAGMA table_info(players)').all().map(col => col.name)
|
|
21
|
-
if (!existingPlayerColumns.includes('stats')) {
|
|
22
|
-
db.exec('ALTER TABLE players ADD COLUMN stats TEXT')
|
|
23
|
-
}
|
|
5
|
+
db.pragma('foreign_keys = ON')
|
|
24
6
|
|
|
25
7
|
const existingTableNames = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name)
|
|
26
8
|
if (existingTableNames.includes('tournaments') && !existingTableNames.includes('events')) {
|
|
@@ -36,7 +18,6 @@ db.exec(`
|
|
|
36
18
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
19
|
name TEXT UNIQUE NOT NULL,
|
|
38
20
|
url TEXT,
|
|
39
|
-
region TEXT,
|
|
40
21
|
year INTEGER,
|
|
41
22
|
stage INTEGER
|
|
42
23
|
)
|
|
@@ -49,19 +30,60 @@ if (!existingEventColumns.includes('year')) {
|
|
|
49
30
|
if (!existingEventColumns.includes('stage')) {
|
|
50
31
|
db.exec('ALTER TABLE events ADD COLUMN stage INTEGER')
|
|
51
32
|
}
|
|
33
|
+
// Region is stored on each player, not on the event.
|
|
34
|
+
if (existingEventColumns.includes('region')) {
|
|
35
|
+
db.exec('ALTER TABLE events DROP COLUMN region')
|
|
36
|
+
}
|
|
52
37
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
PRIMARY KEY
|
|
38
|
+
// Each row is a player as they were in one event, so the same player can have a row per event
|
|
39
|
+
// with different stats.
|
|
40
|
+
const createPlayersTableSql = (tableName) => `
|
|
41
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
|
44
|
+
name TEXT NOT NULL,
|
|
45
|
+
team TEXT,
|
|
46
|
+
past_teams TEXT,
|
|
47
|
+
winnings TEXT,
|
|
48
|
+
most_played_agents TEXT,
|
|
49
|
+
avg_kda REAL,
|
|
50
|
+
nationality TEXT,
|
|
51
|
+
region TEXT,
|
|
52
|
+
stats TEXT,
|
|
53
|
+
UNIQUE (event_id, name)
|
|
58
54
|
)
|
|
59
|
-
`
|
|
55
|
+
`
|
|
56
|
+
|
|
57
|
+
// Older databases kept one shared row per player and linked them to events through event_players.
|
|
58
|
+
// Copy each of those links into its own per-event player row.
|
|
59
|
+
const existingPlayerColumns = db.prepare('PRAGMA table_info(players)').all().map(col => col.name)
|
|
60
|
+
if (existingPlayerColumns.length && !existingPlayerColumns.includes('event_id')) {
|
|
61
|
+
db.transaction(() => {
|
|
62
|
+
if (!existingPlayerColumns.includes('stats')) {
|
|
63
|
+
db.exec('ALTER TABLE players ADD COLUMN stats TEXT')
|
|
64
|
+
}
|
|
65
|
+
db.exec(createPlayersTableSql('players_new'))
|
|
66
|
+
db.exec(`
|
|
67
|
+
INSERT INTO players_new (event_id, name, team, past_teams, winnings, most_played_agents, avg_kda, nationality, region, stats)
|
|
68
|
+
SELECT event_players.event_id, players.name, players.team, players.past_teams, players.winnings,
|
|
69
|
+
players.most_played_agents, players.avg_kda, players.nationality, players.region, players.stats
|
|
70
|
+
FROM event_players
|
|
71
|
+
JOIN players ON players.name = event_players.player_name
|
|
72
|
+
JOIN events ON events.id = event_players.event_id
|
|
73
|
+
`)
|
|
74
|
+
db.exec('DROP TABLE event_players')
|
|
75
|
+
db.exec('DROP TABLE players')
|
|
76
|
+
db.exec('ALTER TABLE players_new RENAME TO players')
|
|
77
|
+
})()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
db.exec(createPlayersTableSql('players'))
|
|
81
|
+
db.exec('DROP TABLE IF EXISTS event_players')
|
|
60
82
|
|
|
61
83
|
const upsertPlayerStmt = db.prepare(`
|
|
62
|
-
INSERT INTO players (name, team, past_teams, winnings, most_played_agents, avg_kda, nationality, region, stats)
|
|
63
|
-
VALUES (@name, @team, @past_teams, @winnings, @most_played_agents, @avg_kda, @nationality, @region, @stats)
|
|
64
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
84
|
+
INSERT INTO players (event_id, name, team, past_teams, winnings, most_played_agents, avg_kda, nationality, region, stats)
|
|
85
|
+
VALUES (@event_id, @name, @team, @past_teams, @winnings, @most_played_agents, @avg_kda, @nationality, @region, @stats)
|
|
86
|
+
ON CONFLICT(event_id, name) DO UPDATE SET
|
|
65
87
|
team = excluded.team,
|
|
66
88
|
past_teams = excluded.past_teams,
|
|
67
89
|
winnings = excluded.winnings,
|
|
@@ -73,19 +95,20 @@ const upsertPlayerStmt = db.prepare(`
|
|
|
73
95
|
`)
|
|
74
96
|
|
|
75
97
|
const upsertEventStmt = db.prepare(`
|
|
76
|
-
INSERT INTO events (name, url,
|
|
77
|
-
VALUES (@name, @url, @
|
|
98
|
+
INSERT INTO events (name, url, year, stage)
|
|
99
|
+
VALUES (@name, @url, @year, @stage)
|
|
78
100
|
ON CONFLICT(name) DO UPDATE SET
|
|
79
101
|
url = excluded.url,
|
|
80
|
-
region = excluded.region,
|
|
81
102
|
year = excluded.year,
|
|
82
103
|
stage = excluded.stage
|
|
83
104
|
`)
|
|
84
105
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
// Every player query also pulls in the event's name and year, newest event first.
|
|
107
|
+
const selectPlayersSql = `
|
|
108
|
+
SELECT players.*, events.name AS event_name, events.year AS event_year FROM players
|
|
109
|
+
JOIN events ON events.id = players.event_id
|
|
110
|
+
`
|
|
111
|
+
const newestFirst = 'ORDER BY events.year DESC, players.id DESC'
|
|
89
112
|
|
|
90
113
|
function rowToPlayer(row) {
|
|
91
114
|
if (!row) return null
|
|
@@ -97,50 +120,60 @@ function rowToPlayer(row) {
|
|
|
97
120
|
}
|
|
98
121
|
}
|
|
99
122
|
|
|
100
|
-
|
|
123
|
+
// Saves a player's stats for one event, replacing any earlier save of them in that same event.
|
|
124
|
+
// Returns the player row's id.
|
|
125
|
+
function savePlayer(eventId, player) {
|
|
101
126
|
upsertPlayerStmt.run({
|
|
102
127
|
...player,
|
|
128
|
+
event_id: eventId,
|
|
103
129
|
past_teams: JSON.stringify(player.past_teams),
|
|
104
130
|
most_played_agents: JSON.stringify(player.most_played_agents),
|
|
105
131
|
stats: JSON.stringify(player.stats),
|
|
106
132
|
})
|
|
133
|
+
return db.prepare('SELECT id FROM players WHERE event_id = ? AND name = ?').get(eventId, player.name).id
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getPlayerById(id) {
|
|
137
|
+
return rowToPlayer(db.prepare(`${selectPlayersSql} WHERE players.id = ?`).get(id))
|
|
107
138
|
}
|
|
108
139
|
|
|
140
|
+
// The player as they were in their most recent event.
|
|
109
141
|
function getPlayer(name) {
|
|
110
|
-
return rowToPlayer(db.prepare(
|
|
142
|
+
return rowToPlayer(db.prepare(`${selectPlayersSql} WHERE players.name = ? ${newestFirst} LIMIT 1`).get(name))
|
|
111
143
|
}
|
|
112
144
|
|
|
113
|
-
//
|
|
145
|
+
// Every saved version of a player, one per event, newest first.
|
|
146
|
+
function getPlayerHistory(name) {
|
|
147
|
+
return db.prepare(`${selectPlayersSql} WHERE players.name = ? ${newestFirst}`).all(name).map(rowToPlayer)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Case-insensitive prefix match, e.g. "nat" finds "nats" but not "gnat". Returns one row per event.
|
|
114
151
|
function searchPlayers(query) {
|
|
115
|
-
return db.prepare(
|
|
116
|
-
.all(
|
|
152
|
+
return db.prepare(`${selectPlayersSql} WHERE players.name LIKE ? ESCAPE '\\' ORDER BY players.name, events.year DESC, players.id DESC`)
|
|
153
|
+
.all(`${query.replace(/[\\%_]/g, '\\$&')}%`)
|
|
117
154
|
.map(rowToPlayer)
|
|
118
155
|
}
|
|
119
156
|
|
|
120
157
|
function getAllPlayers() {
|
|
121
|
-
return db.prepare(
|
|
158
|
+
return db.prepare(selectPlayersSql).all().map(rowToPlayer)
|
|
122
159
|
}
|
|
123
160
|
|
|
124
161
|
function getRandomPlayer() {
|
|
125
|
-
return rowToPlayer(db.prepare(
|
|
162
|
+
return rowToPlayer(db.prepare(`${selectPlayersSql} ORDER BY RANDOM() LIMIT 1`).get())
|
|
126
163
|
}
|
|
127
164
|
|
|
165
|
+
|
|
128
166
|
// Creates the event (tournament, stage, etc.) if it doesn't exist yet, and returns its id either way.
|
|
129
|
-
function saveEvent({ name, url,
|
|
167
|
+
function saveEvent({ name, url, year, stage }) {
|
|
130
168
|
upsertEventStmt.run({
|
|
131
169
|
name,
|
|
132
170
|
url: url ?? null,
|
|
133
|
-
region: region ?? null,
|
|
134
171
|
year: year ?? null,
|
|
135
172
|
stage: stage ?? null,
|
|
136
173
|
})
|
|
137
174
|
return db.prepare('SELECT id FROM events WHERE name = ?').get(name).id
|
|
138
175
|
}
|
|
139
176
|
|
|
140
|
-
function addPlayerToEvent(eventId, playerName) {
|
|
141
|
-
linkPlayerToEventStmt.run({ eventId, playerName })
|
|
142
|
-
}
|
|
143
|
-
|
|
144
177
|
function getEvents() {
|
|
145
178
|
return db.prepare('SELECT * FROM events').all()
|
|
146
179
|
}
|
|
@@ -149,62 +182,49 @@ function getEventByName(name) {
|
|
|
149
182
|
return db.prepare('SELECT * FROM events WHERE name = ?').get(name)
|
|
150
183
|
}
|
|
151
184
|
|
|
152
|
-
function
|
|
153
|
-
return db.prepare(
|
|
154
|
-
SELECT players.* FROM players
|
|
155
|
-
JOIN event_players ON event_players.player_name = players.name
|
|
156
|
-
JOIN events ON events.id = event_players.event_id
|
|
157
|
-
WHERE events.name = ?
|
|
158
|
-
`).all(eventName).map(rowToPlayer)
|
|
185
|
+
function getEventId(name, year) {
|
|
186
|
+
return db.prepare('SELECT id FROM events WHERE name = ? AND year = ?').get(name, year)?.id
|
|
159
187
|
}
|
|
160
188
|
|
|
161
|
-
function
|
|
162
|
-
return
|
|
163
|
-
SELECT players.* FROM players
|
|
164
|
-
JOIN event_players ON event_players.player_name = players.name
|
|
165
|
-
JOIN events ON events.id = event_players.event_id
|
|
166
|
-
WHERE events.name = ?
|
|
167
|
-
ORDER BY RANDOM() LIMIT 1
|
|
168
|
-
`).get(eventName))
|
|
189
|
+
function getPlayersByEvent(eventName) {
|
|
190
|
+
return db.prepare(`${selectPlayersSql} WHERE events.name = ?`).all(eventName).map(rowToPlayer)
|
|
169
191
|
}
|
|
170
192
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
db.prepare('DELETE FROM players WHERE name NOT IN (SELECT player_name FROM event_players)').run()
|
|
193
|
+
function getRandomPlayerFromEvent(eventName) {
|
|
194
|
+
return rowToPlayer(db.prepare(`${selectPlayersSql} WHERE events.name = ? ORDER BY RANDOM() LIMIT 1`).get(eventName))
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
// Removes every player from an event but keeps the event itself, so it can be re-scraped.
|
|
177
|
-
|
|
178
|
-
const emptyEvent = db.transaction((eventName) => {
|
|
198
|
+
function emptyEvent(eventName) {
|
|
179
199
|
const event = getEventByName(eventName)
|
|
180
200
|
if (!event) return false
|
|
181
|
-
db.prepare('DELETE FROM
|
|
182
|
-
deleteOrphanedPlayers()
|
|
201
|
+
db.prepare('DELETE FROM players WHERE event_id = ?').run(event.id)
|
|
183
202
|
return true
|
|
184
|
-
}
|
|
203
|
+
}
|
|
185
204
|
|
|
186
|
-
// Removes an event and its players.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
db.prepare('DELETE FROM events WHERE name = ?').run(eventName)
|
|
190
|
-
|
|
191
|
-
})
|
|
205
|
+
// Removes an event and its players.
|
|
206
|
+
function clearEvent(eventName) {
|
|
207
|
+
// The event's players are deleted along with it (ON DELETE CASCADE).
|
|
208
|
+
return db.prepare('DELETE FROM events WHERE name = ?').run(eventName).changes > 0
|
|
209
|
+
}
|
|
192
210
|
|
|
193
211
|
// Wipes every event and player from the database.
|
|
194
212
|
const clearDatabase = db.transaction(() => {
|
|
195
|
-
db.exec('DELETE FROM
|
|
213
|
+
db.exec('DELETE FROM players; DELETE FROM events;')
|
|
196
214
|
})
|
|
197
215
|
|
|
198
216
|
export {
|
|
199
217
|
savePlayer,
|
|
218
|
+
getPlayerById,
|
|
200
219
|
getPlayer,
|
|
220
|
+
getPlayerHistory,
|
|
201
221
|
searchPlayers,
|
|
202
222
|
getAllPlayers,
|
|
203
223
|
getRandomPlayer,
|
|
204
224
|
saveEvent,
|
|
205
|
-
addPlayerToEvent,
|
|
206
225
|
getEvents,
|
|
207
226
|
getEventByName,
|
|
227
|
+
getEventId,
|
|
208
228
|
getPlayersByEvent,
|
|
209
229
|
getRandomPlayerFromEvent,
|
|
210
230
|
emptyEvent,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio'
|
|
1
2
|
import { Hyperbrowser } from "@hyperbrowser/sdk"
|
|
2
3
|
import { config } from "dotenv";
|
|
3
4
|
|
|
@@ -19,24 +20,50 @@ async function scrapeWebsite(url) {
|
|
|
19
20
|
url: url,
|
|
20
21
|
scrapeOptions: {
|
|
21
22
|
formats: ["html"],
|
|
22
|
-
includeTags: [".event-team-players-item"]
|
|
23
|
+
includeTags: [".event-header-main-title", ".event-team-players-item"]
|
|
23
24
|
},
|
|
24
25
|
})
|
|
26
|
+
const data = scrapeData.data.html
|
|
27
|
+
const $ = cheerio.load(data)
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 }
|
|
33
60
|
}
|
|
34
61
|
catch (error) {
|
|
35
62
|
console.error("Error scraping website:", error)
|
|
36
63
|
}
|
|
37
64
|
}
|
|
38
65
|
|
|
39
|
-
export { scrapeWebsite }
|
|
66
|
+
export { scrapeWebsite }
|
|
40
67
|
/*
|
|
41
68
|
node teamscraper.mjs
|
|
42
69
|
*/
|