vlr-stats-scraper 0.0.0-stage → 1.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/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,74 @@
1
- # Temporary Holding Version
1
+ # vlr-stats-scraper
2
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.
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 for a
5
+ specific event or game mode later.
6
+
7
+ ## Setup
8
+
9
+ Requires 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
+ ```
20
+
21
+ ## CLI usage
22
+
23
+ ```bash
24
+ npx valstats <link> <year> <event-name>
25
+ ```
26
+
27
+ ```bash
28
+ npx valstats https://www.vlr.gg/event/2977/vct-2026-americas-stage-2/group-stage 2026 "Stage 2"
29
+ ```
30
+
31
+ - `link` — the vlr.gg event/group-stage page to scrape.
32
+ - `year` — the year the event took place.
33
+ - `event-name` — a label for this tournament/stage (used to group players together later).
34
+
35
+ Each player on that event page gets scraped and saved to `players.db` in your current
36
+ directory, linked to that event name.
37
+
38
+ ## Library usage
39
+
40
+ ```js
41
+ import {
42
+ scrapePlayer,
43
+ scrapeWebsite,
44
+ savePlayer,
45
+ saveEvent,
46
+ addPlayerToEvent,
47
+ getPlayersByEvent,
48
+ getRandomPlayerFromEvent,
49
+ } from 'vlr-stats-scraper';
50
+
51
+ const links = await scrapeWebsite('https://www.vlr.gg/event/.../group-stage');
52
+ const player = await scrapePlayer(links[0], 'AMER');
53
+ savePlayer(player);
54
+ ```
55
+
56
+ ### Exports
57
+
58
+ - `scrapePlayer(url, region)` — scrapes one player's profile page.
59
+ - `scrapeWebsite(url)` — scrapes an event page for player profile links.
60
+ - `savePlayer(player)` / `getPlayer(name)` / `getAllPlayers()` / `getRandomPlayer()`
61
+ - `saveEvent({ name, url, region, year, stage })` — creates/updates a tournament or stage,
62
+ returns its id.
63
+ - `addPlayerToEvent(eventId, playerName)`
64
+ - `getEvents()` / `getEventByName(name)`
65
+ - `getPlayersByEvent(eventName)` / `getRandomPlayerFromEvent(eventName)`
66
+
67
+ ## Data storage
68
+
69
+ Player and event data is stored in a `players.db` SQLite file created in whichever
70
+ directory you run the tool from.
71
+
72
+ ## License
73
+
74
+ 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.0",
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,116 @@
1
+ import * as cheerio from 'cheerio';
2
+ import { Hyperbrowser } from "@hyperbrowser/sdk"
3
+ import { config } from "dotenv";
4
+
5
+ config();
6
+
7
+ const client = new Hyperbrowser({
8
+ apiKey: process.env.HYPERBROWSER_API_KEY,
9
+ })
10
+
11
+ async function scrapePlayer(url, region) {
12
+ console.log('scrapePlayer ran')
13
+ try {
14
+ const scrapeData = await client.scrape.startAndWait({
15
+ url: url,
16
+ scrapeOptions: {
17
+ formats: ["html"]
18
+ }
19
+ })
20
+
21
+ const $ = cheerio.load(scrapeData.data.html);
22
+
23
+ const agentsPlayed = findAgents($)
24
+ return {
25
+ name: getPlayerName(url),
26
+ team: getCurTeam($),
27
+ past_teams: getPastTeam($),
28
+ winnings: getWinnings($),
29
+ most_played_agents: agentsPlayed.map(data => data.Agent),
30
+ avg_kda: agentsPlayed.reduce((acc, data) => acc + (parseFloat(data['K:D'])) / agentsPlayed.length, 0),
31
+ nationality: getNationality($),
32
+ region: region,
33
+ stats: agentsPlayed
34
+ }
35
+ }
36
+ catch (error) {
37
+ console.error("Error scraping website:", error);
38
+ }
39
+ }
40
+
41
+ export default scrapePlayer;
42
+
43
+ /*
44
+ node scripts/player-info-scraper.mjs
45
+ */
46
+
47
+ function getPlayerName(link) {
48
+ console.log('getPlayerName ran')
49
+ return link.substring(link.lastIndexOf('/') + 1)
50
+ }
51
+
52
+ function findAgents($) {
53
+ console.log('findAgents ran')
54
+ const header = $('.mod-agent-rows').first().children().first()
55
+
56
+ const statCategories = header.text().split('\n').map(ele => ele.trim()).filter(ele => ele !== '')
57
+ statCategories.unshift('Agent')
58
+
59
+ const agentStats = header.next().children()
60
+ const agents = agentStats.map((i, el) => {
61
+ el = $(el)
62
+ return {
63
+ agent: el.children().first().html().trim(),
64
+ stats: el.text().split('\t').map(ele => ele.trim()).filter(ele => ele !== '')
65
+ }
66
+ }).get()
67
+
68
+ const agentData = agents.map(agent => {
69
+ return statCategories.reduce((acc, category, index) => {
70
+ if(index == 0) {
71
+ acc[category] = agent.agent
72
+ }
73
+ else {
74
+ acc[category] = agent.stats[index-1]
75
+ }
76
+ return acc
77
+ }, {})
78
+ })
79
+
80
+ return agentData.length<3 ? agentData : agentData.slice(0,3)
81
+ }
82
+
83
+ function getCurTeam($) {
84
+ console.log('getCurTeam ran')
85
+ const currentTeam = $('h2:contains("Current Teams")').first().next().text().trim()
86
+ const currentTeamName = currentTeam.split('\t').filter(ele => ele.trim() !== '')
87
+ return currentTeamName[0].trim()
88
+ }
89
+
90
+ function getPastTeam($) {
91
+ console.log('getPastTeam ran')
92
+ const pastTeams = $('h2:contains("Past Teams")').first().next().text().trim()
93
+ const pastTeamNames = pastTeams.split('\t').filter(ele => ele.trim() !== ''
94
+ && !ele.includes('joined')
95
+ && !ele.includes(' – ')
96
+ && !ele.includes('left'))
97
+ .map(ele => ele.trim())
98
+ return pastTeamNames
99
+ }
100
+
101
+ function getWinnings($) {
102
+ console.log('getWinnings ran')
103
+ const events = $('h2:contains("Event Placements")').first().next()
104
+ const winnings = events.children().first().text().trim()
105
+ // console.log(winnings.split('\t').map(ele => ele.trim()).filter(ele => ele !== '')[1])
106
+ return winnings.split('\t').map(ele => ele.trim()).filter(ele => ele !== '')[1]
107
+
108
+ }
109
+
110
+ function getNationality($) {
111
+ console.log('getNationality ran')
112
+ const header = $('.flag').first()
113
+ console.log('flags found:', $('.flag').length);
114
+ console.log(header.parent().text().trim())
115
+ return header.parent().text().trim()
116
+ }
@@ -0,0 +1,36 @@
1
+ import { Hyperbrowser } from "@hyperbrowser/sdk"
2
+ import { config } from "dotenv";
3
+
4
+ config();
5
+
6
+ const client = new Hyperbrowser({
7
+ apiKey: process.env.HYPERBROWSER_API_KEY,
8
+ })
9
+
10
+ async function scrapeWebsite(url) {
11
+ try {
12
+ const scrapeData = await client.scrape.startAndWait({
13
+ url: url,
14
+ scrapeOptions: {
15
+ formats: ["html"],
16
+ includeTags: [".event-team-players-item"]
17
+ },
18
+ })
19
+
20
+ const players = scrapeData.data.html.split('\n').filter(line => line.includes('event-team-players-item'))
21
+ const playerLinks = players.map(player => {
22
+ const match = player.match(/href="(https:\/\/www\.vlr\.gg\/player\/[^"]*)"/);
23
+ return match ? match[1] : null;
24
+ }).filter(link => link !== null);
25
+
26
+ return playerLinks;
27
+ }
28
+ catch (error) {
29
+ console.error("Error scraping website:", error);
30
+ }
31
+ }
32
+
33
+ export { scrapeWebsite };
34
+ /*
35
+ node teamscraper.mjs
36
+ */