vlr-stats-scraper 0.0.0-stage → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, OverYander
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 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
+ ```bash
23
+ npx valstats <link> <year> <event-name>
24
+ ```
25
+
26
+ ```bash
27
+ npx valstats https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage 2026 "Stage 2"
28
+ ```
29
+
30
+ - `link` — the vlr.gg event/group-stage page to scrape.
31
+ - `year` — the year the event took place.
32
+ - `event-name` — a label for this tournament/stage (used to group players together later).
33
+
34
+ Each player on that event page gets scraped and saved to `players.db` in your current
35
+ directory, linked to that event name.
36
+
37
+ ## Library usage
38
+
39
+ ```js
40
+ import {
41
+ scrapePlayer,
42
+ scrapeWebsite,
43
+ savePlayer,
44
+ saveEvent,
45
+ addPlayerToEvent,
46
+ getPlayersByEvent,
47
+ getRandomPlayerFromEvent,
48
+ } from 'vlr-stats-scraper';
49
+
50
+ const links = await scrapeWebsite('https://www.vlr.gg/event/.../group-stage');
51
+ const player = await scrapePlayer(links[0], 'AMER');
52
+ savePlayer(player);
53
+ ```
54
+
55
+ ### Exports
56
+
57
+ - `scrapePlayer(url, region)` — scrapes one player's profile page.
58
+ - `scrapeWebsite(url)` — scrapes an event page for player profile links.
59
+ - `savePlayer(player)` / `getPlayer(name)` / `getAllPlayers()` / `getRandomPlayer()`
60
+ - `saveEvent({ name, url, region, year, stage })` — creates/updates a tournament or stage,
61
+ returns its id.
62
+ - `addPlayerToEvent(eventId, playerName)`
63
+ - `getEvents()` / `getEventByName(name)`
64
+ - `getPlayersByEvent(eventName)` / `getRandomPlayerFromEvent(eventName)`
65
+
66
+ ## Data storage
67
+
68
+ Player and event data is stored in a `players.db` SQLite file created in whichever
69
+ directory you run the tool from.
70
+
71
+ ## License
72
+
73
+ ISC
package/index.mjs ADDED
@@ -0,0 +1,14 @@
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
+ } from './scripts/db.mjs';
package/main.mjs ADDED
@@ -0,0 +1,40 @@
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 { savePlayer, saveEvent, addPlayerToEvent } from './scripts/db.mjs';
6
+
7
+ const [link, year, eventName] = process.argv.slice(2);
8
+
9
+ if (!link || !year || !eventName) {
10
+ console.error('Usage: valstats <link> <year> <event-name>');
11
+ process.exit(1);
12
+ }
13
+
14
+ const isStage = eventName.toLowerCase().includes('stage');
15
+
16
+ let region = null;
17
+ if (isStage) {
18
+ const prompt = promptSync();
19
+ region = prompt('Enter the region: ');
20
+ }
21
+
22
+ const eventId = saveEvent({
23
+ name: eventName,
24
+ url: link,
25
+ region,
26
+ year: parseInt(year, 10),
27
+ stage: null,
28
+ });
29
+
30
+ const links = await scrapeWebsite(link);
31
+
32
+ for (const playerLink of links) {
33
+ const player = await scrapePlayer(playerLink, region);
34
+ if (player) {
35
+ savePlayer(player);
36
+ addPlayerToEvent(eventId, player.name);
37
+ console.log(`Saved ${player.name} to players.db under "${eventName}"`);
38
+ console.log(player);
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "vlr-stats-scraper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Scrapes player stats from vlr.gg and stores them in a local SQLite database, grouped by tournament or stage.",
5
+ "homepage": "https://github.com/OverYander/VLR-Webscraper#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/OverYander/VLR-Webscraper/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/OverYander/VLR-Webscraper.git"
12
+ },
13
+ "license": "ISC",
14
+ "author": "OverYander <gavinchen8706@gmail.com>",
15
+ "type": "module",
16
+ "main": "./index.mjs",
17
+ "exports": "./index.mjs",
18
+ "bin": {
19
+ "valstats": "main.mjs"
20
+ },
21
+ "files": [
22
+ "index.mjs",
23
+ "main.mjs",
24
+ "scripts/",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "keywords": [
29
+ "vlr",
30
+ "valorant",
31
+ "esports",
32
+ "scraper",
33
+ "sqlite",
34
+ "stats"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "scripts": {
40
+ "test": "echo \"Error: no test specified\" && exit 1"
41
+ },
42
+ "dependencies": {
43
+ "@hyperbrowser/sdk": "^0.92.3",
44
+ "better-sqlite3": "^13.0.3",
45
+ "cheerio": "^1.2.0",
46
+ "dotenv": "^17.4.2",
47
+ "prompt-sync": "^4.2.0"
48
+ }
49
+ }
package/scripts/db.mjs ADDED
@@ -0,0 +1,175 @@
1
+ import Database from 'better-sqlite3';
2
+ import path from 'path';
3
+
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
+ }
24
+
25
+ const existingTableNames = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
26
+ if (existingTableNames.includes('tournaments') && !existingTableNames.includes('events')) {
27
+ db.exec('ALTER TABLE tournaments RENAME TO events');
28
+ }
29
+ if (existingTableNames.includes('tournament_players') && !existingTableNames.includes('event_players')) {
30
+ db.exec('ALTER TABLE tournament_players RENAME TO event_players');
31
+ db.exec('ALTER TABLE event_players RENAME COLUMN tournament_id TO event_id');
32
+ }
33
+
34
+ db.exec(`
35
+ CREATE TABLE IF NOT EXISTS events (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ name TEXT UNIQUE NOT NULL,
38
+ url TEXT,
39
+ region TEXT,
40
+ year INTEGER,
41
+ stage INTEGER
42
+ )
43
+ `);
44
+
45
+ const existingEventColumns = db.prepare('PRAGMA table_info(events)').all().map(col => col.name);
46
+ if (!existingEventColumns.includes('year')) {
47
+ db.exec('ALTER TABLE events ADD COLUMN year INTEGER');
48
+ }
49
+ if (!existingEventColumns.includes('stage')) {
50
+ db.exec('ALTER TABLE events ADD COLUMN stage INTEGER');
51
+ }
52
+
53
+ db.exec(`
54
+ CREATE TABLE IF NOT EXISTS event_players (
55
+ event_id INTEGER NOT NULL REFERENCES events(id),
56
+ player_name TEXT NOT NULL REFERENCES players(name),
57
+ PRIMARY KEY (event_id, player_name)
58
+ )
59
+ `);
60
+
61
+ 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
65
+ team = excluded.team,
66
+ past_teams = excluded.past_teams,
67
+ winnings = excluded.winnings,
68
+ most_played_agents = excluded.most_played_agents,
69
+ avg_kda = excluded.avg_kda,
70
+ nationality = excluded.nationality,
71
+ region = excluded.region,
72
+ stats = excluded.stats
73
+ `);
74
+
75
+ const upsertEventStmt = db.prepare(`
76
+ INSERT INTO events (name, url, region, year, stage)
77
+ VALUES (@name, @url, @region, @year, @stage)
78
+ ON CONFLICT(name) DO UPDATE SET
79
+ url = excluded.url,
80
+ region = excluded.region,
81
+ year = excluded.year,
82
+ stage = excluded.stage
83
+ `);
84
+
85
+ const linkPlayerToEventStmt = db.prepare(`
86
+ INSERT OR IGNORE INTO event_players (event_id, player_name)
87
+ VALUES (@eventId, @playerName)
88
+ `);
89
+
90
+ function rowToPlayer(row) {
91
+ if (!row) return null;
92
+ return {
93
+ ...row,
94
+ past_teams: JSON.parse(row.past_teams),
95
+ most_played_agents: JSON.parse(row.most_played_agents),
96
+ stats: JSON.parse(row.stats),
97
+ };
98
+ }
99
+
100
+ function savePlayer(player) {
101
+ upsertPlayerStmt.run({
102
+ ...player,
103
+ past_teams: JSON.stringify(player.past_teams),
104
+ most_played_agents: JSON.stringify(player.most_played_agents),
105
+ stats: JSON.stringify(player.stats),
106
+ });
107
+ }
108
+
109
+ function getPlayer(name) {
110
+ return rowToPlayer(db.prepare('SELECT * FROM players WHERE name = ?').get(name));
111
+ }
112
+
113
+ function getAllPlayers() {
114
+ return db.prepare('SELECT * FROM players').all().map(rowToPlayer);
115
+ }
116
+
117
+ function getRandomPlayer() {
118
+ return rowToPlayer(db.prepare('SELECT * FROM players ORDER BY RANDOM() LIMIT 1').get());
119
+ }
120
+
121
+ // Creates the event (tournament, stage, etc.) if it doesn't exist yet, and returns its id either way.
122
+ function saveEvent({ name, url, region, year, stage }) {
123
+ upsertEventStmt.run({
124
+ name,
125
+ url: url ?? null,
126
+ region: region ?? null,
127
+ year: year ?? null,
128
+ stage: stage ?? null,
129
+ });
130
+ return db.prepare('SELECT id FROM events WHERE name = ?').get(name).id;
131
+ }
132
+
133
+ function addPlayerToEvent(eventId, playerName) {
134
+ linkPlayerToEventStmt.run({ eventId, playerName });
135
+ }
136
+
137
+ function getEvents() {
138
+ return db.prepare('SELECT * FROM events').all();
139
+ }
140
+
141
+ function getEventByName(name) {
142
+ return db.prepare('SELECT * FROM events WHERE name = ?').get(name);
143
+ }
144
+
145
+ function getPlayersByEvent(eventName) {
146
+ return db.prepare(`
147
+ SELECT players.* FROM players
148
+ JOIN event_players ON event_players.player_name = players.name
149
+ JOIN events ON events.id = event_players.event_id
150
+ WHERE events.name = ?
151
+ `).all(eventName).map(rowToPlayer);
152
+ }
153
+
154
+ function getRandomPlayerFromEvent(eventName) {
155
+ return rowToPlayer(db.prepare(`
156
+ SELECT players.* FROM players
157
+ JOIN event_players ON event_players.player_name = players.name
158
+ JOIN events ON events.id = event_players.event_id
159
+ WHERE events.name = ?
160
+ ORDER BY RANDOM() LIMIT 1
161
+ `).get(eventName));
162
+ }
163
+
164
+ export {
165
+ savePlayer,
166
+ getPlayer,
167
+ getAllPlayers,
168
+ getRandomPlayer,
169
+ saveEvent,
170
+ addPlayerToEvent,
171
+ getEvents,
172
+ getEventByName,
173
+ getPlayersByEvent,
174
+ getRandomPlayerFromEvent,
175
+ };
@@ -0,0 +1,122 @@
1
+ import * as cheerio from 'cheerio';
2
+ import { Hyperbrowser } from "@hyperbrowser/sdk"
3
+ import { config } from "dotenv";
4
+
5
+ config();
6
+
7
+ let client;
8
+ function getClient() {
9
+ if (!client) {
10
+ client = new Hyperbrowser({
11
+ apiKey: process.env.HYPERBROWSER_API_KEY,
12
+ })
13
+ }
14
+ return client;
15
+ }
16
+
17
+ async function scrapePlayer(url, region) {
18
+ console.log('scrapePlayer ran')
19
+ try {
20
+ const scrapeData = await getClient().scrape.startAndWait({
21
+ url: url,
22
+ scrapeOptions: {
23
+ formats: ["html"]
24
+ }
25
+ })
26
+
27
+ const $ = cheerio.load(scrapeData.data.html);
28
+
29
+ const agentsPlayed = findAgents($)
30
+ return {
31
+ name: getPlayerName(url),
32
+ team: getCurTeam($),
33
+ past_teams: getPastTeam($),
34
+ winnings: getWinnings($),
35
+ most_played_agents: agentsPlayed.map(data => data.Agent),
36
+ avg_kda: agentsPlayed.reduce((acc, data) => acc + (parseFloat(data['K:D'])) / agentsPlayed.length, 0),
37
+ nationality: getNationality($),
38
+ region: region,
39
+ stats: agentsPlayed
40
+ }
41
+ }
42
+ catch (error) {
43
+ console.error("Error scraping website:", error);
44
+ }
45
+ }
46
+
47
+ export default scrapePlayer;
48
+
49
+ /*
50
+ node scripts/player-info-scraper.mjs
51
+ */
52
+
53
+ function getPlayerName(link) {
54
+ console.log('getPlayerName ran')
55
+ return link.substring(link.lastIndexOf('/') + 1)
56
+ }
57
+
58
+ function findAgents($) {
59
+ console.log('findAgents ran')
60
+ const header = $('.mod-agent-rows').first().children().first()
61
+
62
+ const statCategories = header.text().split('\n').map(ele => ele.trim()).filter(ele => ele !== '')
63
+ statCategories.unshift('Agent')
64
+
65
+ const agentStats = header.next().children()
66
+ const agents = agentStats.map((i, el) => {
67
+ el = $(el)
68
+ return {
69
+ agent: el.children().first().html().trim(),
70
+ stats: el.text().split('\t').map(ele => ele.trim()).filter(ele => ele !== '')
71
+ }
72
+ }).get()
73
+
74
+ const agentData = agents.map(agent => {
75
+ return statCategories.reduce((acc, category, index) => {
76
+ if(index == 0) {
77
+ acc[category] = agent.agent
78
+ }
79
+ else {
80
+ acc[category] = agent.stats[index-1]
81
+ }
82
+ return acc
83
+ }, {})
84
+ })
85
+
86
+ return agentData.length<3 ? agentData : agentData.slice(0,3)
87
+ }
88
+
89
+ function getCurTeam($) {
90
+ console.log('getCurTeam ran')
91
+ const currentTeam = $('h2:contains("Current Teams")').first().next().text().trim()
92
+ const currentTeamName = currentTeam.split('\t').filter(ele => ele.trim() !== '')
93
+ return currentTeamName[0].trim()
94
+ }
95
+
96
+ function getPastTeam($) {
97
+ console.log('getPastTeam ran')
98
+ const pastTeams = $('h2:contains("Past Teams")').first().next().text().trim()
99
+ const pastTeamNames = pastTeams.split('\t').filter(ele => ele.trim() !== ''
100
+ && !ele.includes('joined')
101
+ && !ele.includes(' – ')
102
+ && !ele.includes('left'))
103
+ .map(ele => ele.trim())
104
+ return pastTeamNames
105
+ }
106
+
107
+ function getWinnings($) {
108
+ console.log('getWinnings ran')
109
+ const events = $('h2:contains("Event Placements")').first().next()
110
+ const winnings = events.children().first().text().trim()
111
+ // console.log(winnings.split('\t').map(ele => ele.trim()).filter(ele => ele !== '')[1])
112
+ return winnings.split('\t').map(ele => ele.trim()).filter(ele => ele !== '')[1]
113
+
114
+ }
115
+
116
+ function getNationality($) {
117
+ console.log('getNationality ran')
118
+ const header = $('.flag').first()
119
+ console.log('flags found:', $('.flag').length);
120
+ console.log(header.parent().text().trim())
121
+ return header.parent().text().trim()
122
+ }
@@ -0,0 +1,42 @@
1
+ import { Hyperbrowser } from "@hyperbrowser/sdk"
2
+ import { config } from "dotenv";
3
+
4
+ config();
5
+
6
+ let client;
7
+ function getClient() {
8
+ if (!client) {
9
+ client = new Hyperbrowser({
10
+ apiKey: process.env.HYPERBROWSER_API_KEY,
11
+ })
12
+ }
13
+ return client;
14
+ }
15
+
16
+ async function scrapeWebsite(url) {
17
+ try {
18
+ const scrapeData = await getClient().scrape.startAndWait({
19
+ url: url,
20
+ scrapeOptions: {
21
+ formats: ["html"],
22
+ includeTags: [".event-team-players-item"]
23
+ },
24
+ })
25
+
26
+ const players = scrapeData.data.html.split('\n').filter(line => line.includes('event-team-players-item'))
27
+ const playerLinks = players.map(player => {
28
+ const match = player.match(/href="(https:\/\/www\.vlr\.gg\/player\/[^"]*)"/)
29
+ return match ? match[1] + "/?timespan=all" : null;
30
+ }).filter(link => link !== null);
31
+
32
+ return playerLinks
33
+ }
34
+ catch (error) {
35
+ console.error("Error scraping website:", error)
36
+ }
37
+ }
38
+
39
+ export { scrapeWebsite };
40
+ /*
41
+ node teamscraper.mjs
42
+ */