Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

868 lines
32 KiB
JavaScript

import { ArgumentError, CommandExecutionError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors';
export const BASE = 'https://www.hltv.org';
function isHltvHost(hostname) {
return hostname === 'hltv.org' || hostname === 'www.hltv.org';
}
function parseHltvUserUrl(raw, label) {
let url;
try {
url = new URL(raw);
} catch {
throw new ArgumentError(`${label} must be a valid hltv.org URL`);
}
if (!isHltvHost(url.hostname)) {
throw new ArgumentError(`${label} must be an hltv.org URL`);
}
return url;
}
export const EVENT_TYPES = {
all: null,
majors: 'Majors',
bigEvents: 'BigEvents',
mvpEvents: 'MvpEvents',
lan: 'Lan',
online: 'Online',
};
export const RANKING_FILTERS = {
all: null,
top5: 'Top5',
top10: 'Top10',
top20: 'Top20',
top30: 'Top30',
top50: 'Top50',
};
export const MAPS = {
all: null,
ancient: 'de_ancient',
anubis: 'de_anubis',
dust2: 'de_dust2',
inferno: 'de_inferno',
mirage: 'de_mirage',
nuke: 'de_nuke',
overpass: 'de_overpass',
cache: 'de_cache',
cobblestone: 'de_cobblestone',
season: 'de_season',
train: 'de_train',
tuscan: 'de_tuscan',
vertigo: 'de_vertigo',
};
export const VERSIONS = {
both: null,
cs2: 'CS2',
csgo: 'CSGO',
};
export function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) throw new ArgumentError(`${label} must be a positive integer`);
if (n > maxValue) throw new ArgumentError(`${label} must be <= ${maxValue}`);
return n;
}
export function normalizeOffset(value, defaultValue = 0) {
const raw = value ?? defaultValue;
const n = Number(raw);
if (!Number.isInteger(n) || n < 0) throw new ArgumentError('offset must be a non-negative integer');
if (n % 100 !== 0) throw new ArgumentError('offset must be a multiple of 100');
return n;
}
export function normalizeChoice(value, defaultValue, choices, label) {
const raw = String(value ?? defaultValue);
if (!Object.prototype.hasOwnProperty.call(choices, raw)) {
throw new ArgumentError(`${label} must be one of: ${Object.keys(choices).join(', ')}`);
}
return raw;
}
export function parseNumber(value) {
const raw = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!raw || raw === '-' || raw.toLowerCase() === 'n/a') return null;
const match = raw.replace(/,/g, '').match(/-?\d+(?:\.\d+)?/);
if (!match) return null;
const n = Number(match[0]);
return Number.isFinite(n) ? n : null;
}
export function parseMoneyUsd(value) {
return parseNumber(String(value ?? '').replace(/\$/g, ''));
}
export function absolutizeUrl(value) {
if (!value) return null;
let url;
try {
url = new URL(value, BASE);
} catch {
throw new CommandExecutionError('HLTV parser returned a malformed URL');
}
if (!isHltvHost(url.hostname)) {
throw new CommandExecutionError(`HLTV parser returned an off-domain URL: ${url.toString()}`);
}
return url.toString();
}
export function extractIdFromUrl(url, kind) {
if (!url) return null;
let parsed;
try {
parsed = new URL(url, BASE);
} catch {
return null;
}
if (!isHltvHost(parsed.hostname)) return null;
const path = parsed.pathname;
const patterns = {
player: /^\/player\/(\d+)\//,
team: /^\/team\/(\d+)\//,
event: /^\/events\/(\d+)\//,
article: /^\/news\/(\d+)\//,
matchStats: /^\/stats\/matches\/mapstatsid\/(\d+)\//,
statsSeries: /^\/stats\/matches\/(\d+)\//,
match: /^\/matches\/(\d+)\//,
};
const match = path.match(patterns[kind]);
return match ? match[1] : null;
}
export function parseEventRef(value) {
const raw = String(value ?? '').trim();
if (!raw || raw === 'all') return null;
if (/^\d+$/.test(raw)) return raw;
let url = null;
if (/^https?:\/\//i.test(raw)) url = parseHltvUserUrl(raw, 'event');
if (url?.searchParams.get('event')) {
const eventId = url.searchParams.get('event');
if (/^\d+$/.test(eventId)) return eventId;
throw new ArgumentError('event query parameter must be a numeric event id');
}
const path = (url ? url.pathname : raw).replace(/^\/+/, '');
const eventPath = path.match(/^events\/(\d+)\//i);
if (eventPath) return eventPath[1];
throw new ArgumentError('event must be an event id, an /events/:id URL, a stats URL with event=, or all');
}
export function parseTeamRef(value, defaultValue = null) {
const raw = String(value ?? defaultValue ?? '').trim();
if (!raw) throw new ArgumentError('team is required');
let path = raw;
if (/^https?:\/\//i.test(raw)) path = parseHltvUserUrl(raw, 'team').pathname;
path = path.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, '');
const statsMatch = path.match(/^stats\/teams(?:\/matches)?\/(\d+)\/([a-z0-9-]+)/i);
const teamMatch = path.match(/^team\/(\d+)\/([a-z0-9-]+)/i);
const compactMatch = path.match(/^(\d+)\/([a-z0-9-]+)$/i);
const match = statsMatch || teamMatch || compactMatch;
if (!match) {
throw new ArgumentError('team must be like 6667/falcons, a team URL, or a stats team URL');
}
return { teamId: match[1], slug: match[2].toLowerCase() };
}
export function parsePlayerRef(value, defaultValue = '3741/niko') {
const raw = String(value ?? defaultValue).trim();
if (!raw) throw new ArgumentError('player is required');
let path = raw;
if (/^https?:\/\//i.test(raw)) path = parseHltvUserUrl(raw, 'player').pathname;
path = path.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, '');
const statsMatch = path.match(/^stats\/players(?:\/matches)?\/(\d+)\/([a-z0-9-]+)/i);
const playerMatch = path.match(/^player\/(\d+)\/([a-z0-9-]+)/i);
const compactMatch = path.match(/^(\d+)\/([a-z0-9-]+)$/i);
const match = statsMatch || playerMatch || compactMatch;
if (!match) {
throw new ArgumentError('player must be like 3741/niko, a player URL, or a stats player URL');
}
return { playerId: match[1], slug: match[2].toLowerCase() };
}
function formatDate(date) {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonths(date, months) {
const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
copy.setUTCMonth(copy.getUTCMonth() + months);
return copy;
}
export function resolvePeriod(period) {
const raw = String(period ?? 'all').trim();
if (raw === 'all') return null;
const now = new Date();
if (raw === 'lastMonth') return { startDate: formatDate(addMonths(now, -1)), endDate: formatDate(now) };
if (raw === 'last3Months') return { startDate: formatDate(addMonths(now, -3)), endDate: formatDate(now) };
if (raw === 'last6Months') return { startDate: formatDate(addMonths(now, -6)), endDate: formatDate(now) };
if (raw === 'last12Months') return { startDate: formatDate(addMonths(now, -12)), endDate: formatDate(now) };
if (/^\d{4}$/.test(raw)) return { startDate: `${raw}-01-01`, endDate: `${raw}-12-31` };
if (/^\d{4}-\d{2}-\d{2}:\d{4}-\d{2}-\d{2}$/.test(raw)) {
const [startDate, endDate] = raw.split(':');
return { startDate, endDate };
}
throw new ArgumentError('period must be all, lastMonth, last3Months, last6Months, last12Months, a year like 2025, or YYYY-MM-DD:YYYY-MM-DD');
}
export function buildPlayerUrl(player) {
const { playerId, slug } = parsePlayerRef(player);
return new URL(`/player/${playerId}/${slug}`, BASE);
}
export function buildPlayerMatchesUrl(args) {
const { playerId, slug } = parsePlayerRef(args.player);
const eventType = normalizeChoice(args.eventType, 'all', EVENT_TYPES, 'eventType');
const ranking = normalizeChoice(args.ranking, 'all', RANKING_FILTERS, 'ranking');
const map = normalizeChoice(args.map, 'all', MAPS, 'map');
const version = normalizeChoice(args.version, 'both', VERSIONS, 'version');
const offset = normalizeOffset(args.offset, 0);
const period = String(args.period ?? 'all');
const event = parseEventRef(args.event);
const url = new URL(`/stats/players/matches/${playerId}/${slug}`, BASE);
const range = resolvePeriod(period);
if (range) {
url.searchParams.set('startDate', range.startDate);
url.searchParams.set('endDate', range.endDate);
} else {
url.searchParams.set('startDate', 'all');
}
if (EVENT_TYPES[eventType]) url.searchParams.set('matchType', EVENT_TYPES[eventType]);
if (RANKING_FILTERS[ranking]) url.searchParams.set('rankingFilter', RANKING_FILTERS[ranking]);
if (MAPS[map]) url.searchParams.set('maps', MAPS[map]);
if (VERSIONS[version]) url.searchParams.set('csVersion', VERSIONS[version]);
if (event) url.searchParams.set('event', event);
if (offset > 0) url.searchParams.set('offset', String(offset));
return { url, playerId, slug, filters: { period, eventType, event: event ?? 'all', ranking, map, version, offset } };
}
export function buildTeamMatchesUrl(args) {
const { teamId, slug } = parseTeamRef(args.team);
const eventType = normalizeChoice(args.eventType, 'all', EVENT_TYPES, 'eventType');
const ranking = normalizeChoice(args.ranking, 'all', RANKING_FILTERS, 'ranking');
const map = normalizeChoice(args.map, 'all', MAPS, 'map');
const version = normalizeChoice(args.version, 'both', VERSIONS, 'version');
const offset = normalizeOffset(args.offset, 0);
const period = String(args.period ?? 'all');
const event = parseEventRef(args.event);
const url = new URL(`/team/${teamId}/${slug}`, BASE);
const range = resolvePeriod(period);
if (range) {
url.searchParams.set('startDate', range.startDate);
url.searchParams.set('endDate', range.endDate);
} else {
url.searchParams.set('startDate', 'all');
}
if (EVENT_TYPES[eventType]) url.searchParams.set('matchType', EVENT_TYPES[eventType]);
if (RANKING_FILTERS[ranking]) url.searchParams.set('rankingFilter', RANKING_FILTERS[ranking]);
if (MAPS[map]) url.searchParams.set('maps', MAPS[map]);
if (VERSIONS[version]) url.searchParams.set('csVersion', VERSIONS[version]);
if (event) url.searchParams.set('event', event);
if (offset > 0) url.searchParams.set('offset', String(offset));
return { url, teamId, slug, filters: { period, eventType, event: event ?? 'all', ranking, map, version, offset } };
}
function rowResult(teamScore, opponentScore) {
if (teamScore === null || opponentScore === null) return null;
if (teamScore > opponentScore) return 'win';
if (teamScore < opponentScore) return 'loss';
return 'draw';
}
export async function readPlayerMatches(page, args, limit) {
const { url, playerId, slug, filters } = buildPlayerMatchesUrl(args);
await gotoAndWait(page, url, '.stats-player-matches .stats-matches-table', 'hltv player matches page');
const rows = await page.evaluate((payload) => {
const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
const parseScoreName = (value) => {
const raw = clean(value);
const match = raw.match(/^(.*?)\s*\((\d+)\)$/);
if (!match) return { name: raw, score: null };
return { name: match[1].trim(), score: Number(match[2]) };
};
const numberFrom = (value) => {
const match = String(value ?? '').replace(/,/g, '').match(/-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : null;
};
const nickname = clean(document.querySelector('.context-item-name, .summaryShortInfo .context-item-name')?.textContent)
|| clean(document.querySelector('.stats-profile-name, .playerNickname')?.textContent)
|| payload.slug;
const filtersText = Object.entries(payload.filters).map(([key, value]) => `${key}=${value}`).join(',');
const out = [];
for (const tr of document.querySelectorAll('.stats-matches-table tr.group-1, .stats-matches-table tr.group-2')) {
if (out.length >= payload.limit) break;
const cells = [...tr.children];
if (cells.length < 7) continue;
const dateLink = cells[0].querySelector('a[href]');
const matchStatsUrl = dateLink ? new URL(dateLink.getAttribute('href'), payload.base).toString() : null;
const playerTeam = parseScoreName(cells[1].innerText);
const opponent = parseScoreName(cells[2].innerText);
const killsDeaths = clean(cells[4].innerText).match(/(\d+)\s*-\s*(\d+)/);
const plusMinus = numberFrom(cells[5].innerText);
const rating = numberFrom(cells[6].innerText);
const result = playerTeam.score === null || opponent.score === null
? null
: playerTeam.score > opponent.score ? 'win' : playerTeam.score < opponent.score ? 'loss' : 'draw';
out.push({
rank: payload.offset + out.length + 1,
date: clean(cells[0].innerText),
playerTeam: playerTeam.name,
playerTeamScore: playerTeam.score,
opponent: opponent.name,
opponentScore: opponent.score,
map: clean(cells[3].innerText),
kills: killsDeaths ? Number(killsDeaths[1]) : null,
deaths: killsDeaths ? Number(killsDeaths[2]) : null,
plusMinus,
rating,
result,
matchStatsId: matchStatsUrl ? (new URL(matchStatsUrl).pathname.match(/mapstatsid\/(\d+)\//)?.[1] ?? null) : null,
matchStatsUrl,
playerId: payload.playerId,
nickname,
filters: filtersText,
});
}
return out;
}, { base: BASE, playerId, slug, filters, limit, offset: filters.offset });
return assertRequiredFields(rows.map((row) => ({
rank: row.rank,
date: parseHltvDate(row.date),
playerTeam: row.playerTeam,
opponent: row.opponent,
map: row.map,
kills: parseNumber(row.kills),
deaths: parseNumber(row.deaths),
plusMinus: parseNumber(row.plusMinus),
rating: parseNumber(row.rating),
playerId: row.playerId,
matchStatsId: row.matchStatsId ?? extractIdFromUrl(row.matchStatsUrl, 'matchStats'),
details: {
result: row.result,
playerTeamScore: parseNumber(row.playerTeamScore),
opponentScore: parseNumber(row.opponentScore),
matchStatsUrl: row.matchStatsUrl,
nickname: row.nickname,
filters: row.filters,
},
})), 'hltv player-matches', ['rank', 'date', 'playerTeam', 'opponent', 'map', 'playerId', 'matchStatsId']);
}
export async function readTeamMatches(page, args, limit) {
const { url, teamId, slug, filters } = buildTeamMatchesUrl(args);
await gotoAndWait(page, url, '.team-row, .match-table', 'hltv team profile matches page');
const rows = await page.evaluate((payload) => {
const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
const teamName = clean(document.querySelector('.profile-team-name, .teamName, .context-item-name')?.textContent)
|| payload.slug;
const filtersText = Object.entries(payload.filters).map(([key, value]) => `${key}=${value}`).join(',');
const out = [];
for (const tr of document.querySelectorAll('tr.team-row')) {
if (out.length >= payload.limit) break;
const text = clean(tr.innerText);
if (text.includes('-:-')) continue;
const link = tr.querySelector('a[href*="/matches/"]');
const matchUrl = link ? new URL(link.getAttribute('href'), payload.base).toString() : null;
const score = text.match(/\s(\d+)\s*:\s*(\d+)\s/);
if (!score) continue;
const before = text.slice(0, score.index).trim();
const after = text.slice(score.index + score[0].length).replace(/\bMatch\b.*$/, '').trim();
const date = (before.match(/\d{2}\/\d{2}\/\d{4}/)?.[0]) ?? (before.match(/\d{2}:\d{2}/)?.[0]) ?? '';
const teamText = before.replace(date, '').trim() || teamName;
const opponent = after || clean(link?.textContent);
const teamScore = Number(score[1]);
const opponentScore = Number(score[2]);
out.push({
rank: payload.offset + out.length + 1,
date,
team: teamText || teamName,
teamScore,
opponent,
opponentScore,
map: 'series',
event: null,
roundDiff: teamScore === null || opponentScore === null ? null : teamScore - opponentScore,
matchId: matchUrl ? (new URL(matchUrl).pathname.match(/\/matches\/(\d+)\//)?.[1] ?? null) : null,
matchUrl,
teamId: payload.teamId,
teamName,
filters: filtersText,
});
}
return out;
}, { base: BASE, teamId, slug, filters, limit, offset: filters.offset });
return assertRequiredFields(rows.map((row) => ({
rank: row.rank,
date: parseHltvDate(row.date),
team: row.teamName || row.team,
opponent: row.opponent,
map: row.map,
result: rowResult(parseNumber(row.teamScore), parseNumber(row.opponentScore)),
teamId: row.teamId,
matchStatsId: row.matchId,
details: {
teamScore: parseNumber(row.teamScore),
opponentScore: parseNumber(row.opponentScore),
event: row.event,
roundDiff: parseNumber(row.roundDiff),
matchUrl: row.matchUrl,
filters: row.filters,
},
})), 'hltv team-matches', ['rank', 'date', 'team', 'opponent', 'teamId', 'matchStatsId']);
}
export function parseHltvDate(value) {
const raw = String(value ?? '').trim();
const shortMatch = raw.match(/^(\d{2})\/(\d{2})\/(\d{2})$/);
if (shortMatch) {
const [, dd, mm, yy] = shortMatch;
return `20${yy}-${mm}-${dd}`;
}
const longMatch = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (longMatch) {
const [, dd, mm, yyyy] = longMatch;
return `${yyyy}-${mm}-${dd}`;
}
return raw;
}
export function normalizeMatchUrl(value) {
const raw = String(value ?? '').trim();
if (!raw) throw new ArgumentError('match is required');
const url = /^https?:\/\//i.test(raw) ? parseHltvUserUrl(raw, 'match') : new URL(raw.replace(/^\/+/, ''), `${BASE}/`);
if (!/^\/(?:matches\/\d+\/|stats\/matches\/(?:mapstatsid\/)?\d+\/)/.test(url.pathname)) {
throw new ArgumentError('match must be an HLTV match, stats series, or mapstats URL');
}
return url;
}
function buildPerformanceUrl(mapstatsUrl) {
const url = new URL(mapstatsUrl, BASE);
url.pathname = url.pathname.replace('/stats/matches/mapstatsid/', '/stats/matches/performance/mapstatsid/');
return url;
}
export async function resolveMatchMapUrls(page, match) {
const url = normalizeMatchUrl(match);
if (/^\/stats\/matches\/mapstatsid\/\d+\//.test(url.pathname)) return [url.toString()];
await gotoAndWait(page, url, 'a[href*="/stats/matches/mapstatsid/"]', 'hltv match map link page');
const links = await page.evaluate((payload) => {
const seen = new Set();
const out = [];
for (const a of document.querySelectorAll('a[href*="/stats/matches/mapstatsid/"]')) {
const href = new URL(a.getAttribute('href'), payload.base);
href.search = '';
href.hash = '';
const key = href.pathname.match(/mapstatsid\/(\d+)\//)?.[1];
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(href.toString());
}
return out;
}, { base: BASE });
return assertRows(links, 'hltv match map urls');
}
export async function resolveStatsSeriesUrlFromMap(page, mapstatsUrl) {
const url = normalizeMatchUrl(mapstatsUrl);
if (!/^\/stats\/matches\/mapstatsid\/\d+\//.test(url.pathname)) {
throw new ArgumentError('series resolution requires a /stats/matches/mapstatsid/:id/:slug URL');
}
await gotoAndWait(page, url, 'a[href*="/stats/matches/"]', 'hltv match series link page');
const seriesUrl = await page.evaluate((payload) => {
for (const a of document.querySelectorAll('a[href*="/stats/matches/"]')) {
const href = new URL(a.getAttribute('href'), payload.base);
href.search = '';
href.hash = '';
if (/^\/stats\/matches\/\d+\//.test(href.pathname)) return href.toString();
}
return null;
}, { base: BASE });
return seriesUrl ?? url.toString();
}
export async function readMatchMap(page, match) {
const url = normalizeMatchUrl(match);
if (!/^\/stats\/matches\/mapstatsid\/\d+\//.test(url.pathname)) {
throw new ArgumentError('match map parser requires a /stats/matches/mapstatsid/:id/:slug URL');
}
const matchStatsId = extractIdFromUrl(url.toString(), 'matchStats');
await gotoAndWait(page, url, '.stats-section.stats-match, .stats-table.totalstats', 'hltv match map page');
const rows = await page.evaluate((payload) => {
const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
const cleanLines = (value) => String(value ?? '').split('\n').map((line) => clean(line)).filter(Boolean);
const numberFrom = (value) => {
const match = String(value ?? '').replace(/,/g, '').match(/-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : null;
};
const textOf = (root, selector) => clean(root.querySelector(selector)?.textContent);
const splitMainParen = (value) => {
const match = clean(value).match(/^(-?\d+(?:\.\d+)?)\s*(?:\(([-\d.]+)\))?/);
return { main: match ? Number(match[1]) : null, paren: match?.[2] !== undefined ? Number(match[2]) : null };
};
const infoLines = cleanLines(document.querySelector('.match-info-box')?.innerText);
const dateIndex = infoLines.findIndex((line) => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(line));
const event = dateIndex > 0 ? infoLines.slice(0, dateIndex).join(' ') : null;
const dateTime = dateIndex >= 0 ? infoLines[dateIndex] : null;
const mapLabelIndex = infoLines.indexOf('Map');
const selectedMap = mapLabelIndex >= 0 ? infoLines[mapLabelIndex + 1] : null;
const scoreStart = mapLabelIndex >= 0 ? mapLabelIndex + 2 : -1;
const teamOne = scoreStart >= 0 ? infoLines[scoreStart] : null;
const teamOneScore = scoreStart >= 0 ? numberFrom(infoLines[scoreStart + 1]) : null;
const teamTwo = scoreStart >= 0 ? infoLines[scoreStart + 2] : null;
const teamTwoScore = scoreStart >= 0 ? numberFrom(infoLines[scoreStart + 3]) : null;
const rows = [];
let currentTeam = null;
for (const tr of document.querySelectorAll('.stats-table.totalstats tr')) {
const teamName = textOf(tr, '.st-teamname');
if (teamName) {
currentTeam = teamName;
continue;
}
const playerLink = tr.querySelector('a[href^="/stats/players/"], a[href*="/stats/players/"], a[href^="/player/"], a[href*="/player/"]');
if (!playerLink || !currentTeam) continue;
const playerUrl = new URL(playerLink.getAttribute('href'), payload.base).toString();
const playerId = new URL(playerUrl).pathname.match(/\/(?:stats\/players|player)\/(\d+)\//)?.[1] ?? null;
const kills = splitMainParen(textOf(tr, '.st-kills'));
const assists = splitMainParen(textOf(tr, '.st-assists'));
const deaths = splitMainParen(textOf(tr, '.st-deaths'));
const opponent = currentTeam === teamOne ? teamTwo : teamOne;
rows.push({
matchStatsId: payload.matchStatsId,
playerId,
playerName: clean(playerLink.textContent),
team: currentTeam,
kills: kills.main,
deaths: deaths.main,
adr: numberFrom(textOf(tr, '.st-adr')),
kastPct: numberFrom(textOf(tr, '.st-kast')),
rating: numberFrom(textOf(tr, '.st-rating')),
opKd: textOf(tr, '.st-opkd'),
details: {
map: selectedMap,
event,
dateTime,
teamScore: currentTeam === teamOne ? teamOneScore : teamTwoScore,
opponent,
opponentScore: currentTeam === teamOne ? teamTwoScore : teamOneScore,
headshots: kills.paren,
assists: assists.main,
flashAssists: assists.paren,
tradedDeaths: deaths.paren,
multiKills: numberFrom(textOf(tr, '.st-mks')),
clutches: numberFrom(textOf(tr, '.st-clutches')),
roundSwingPct: numberFrom(textOf(tr, '.st-roundSwing')),
},
url: playerUrl,
});
}
return rows;
}, { base: BASE, matchStatsId });
if (!Array.isArray(rows)) throw new CommandExecutionError('hltv match-map parser returned an unexpected shape');
if (rows.length === 0) throw new CommandExecutionError('hltv match-map parser found no player rows');
return assertRequiredFields(rows.map((row) => ({
matchStatsId: row.matchStatsId,
playerId: row.playerId,
playerName: row.playerName,
team: row.team,
kills: parseNumber(row.kills),
deaths: parseNumber(row.deaths),
adr: parseNumber(row.adr),
kastPct: parseNumber(row.kastPct),
rating: parseNumber(row.rating),
opKd: row.opKd,
details: row.details,
url: row.url,
})), 'hltv match-map', ['matchStatsId', 'playerId', 'playerName', 'team', 'url']);
}
function round(value, digits = 2) {
if (!Number.isFinite(value)) return null;
return Number(value.toFixed(digits));
}
function summarizePlayers(mapRows) {
const byTeam = new Map();
for (const row of mapRows) {
if (!byTeam.has(row.team)) {
byTeam.set(row.team, {
team: row.team,
opponent: row.details?.opponent ?? null,
maps: 0,
kills: 0,
deaths: 0,
ratingSum: 0,
ratingCount: 0,
players: 0,
scores: [],
});
}
const bucket = byTeam.get(row.team);
bucket.players += 1;
bucket.kills += row.kills ?? 0;
bucket.deaths += row.deaths ?? 0;
if (Number.isFinite(row.rating)) {
bucket.ratingSum += row.rating;
bucket.ratingCount += 1;
}
if (Number.isFinite(row.details?.teamScore)) bucket.scores.push(row.details.teamScore);
}
return [...byTeam.values()].map((bucket) => ({
team: bucket.team,
opponent: bucket.opponent,
kills: bucket.kills,
deaths: bucket.deaths,
avgRating: bucket.ratingCount ? round(bucket.ratingSum / bucket.ratingCount) : null,
score: bucket.scores[0] ?? null,
}));
}
export async function readMatchSeries(page, match) {
const mapUrls = await resolveMatchMapUrls(page, match);
const outRows = [];
const mapSummaries = [];
const seriesTotals = new Map();
let firstMeta = null;
for (const mapUrl of mapUrls) {
const mapRows = await readMatchMap(page, mapUrl);
const first = mapRows[0];
if (!firstMeta) firstMeta = first?.details ?? null;
const teams = summarizePlayers(mapRows);
const mapName = first?.details?.map ?? null;
const date = first?.details?.dateTime?.slice(0, 10) ?? null;
const event = first?.details?.event ?? null;
const teamLine = teams.map((team) => `${team.team} ${team.score ?? ''}`.trim()).join(' vs ');
mapSummaries.push({
matchStatsId: first?.matchStatsId ?? extractIdFromUrl(mapUrl, 'matchStats'),
map: mapName,
date,
event,
score: teamLine,
url: mapUrl,
teams,
});
for (const team of teams) {
if (!seriesTotals.has(team.team)) {
seriesTotals.set(team.team, {
team: team.team,
opponent: team.opponent,
maps: 0,
mapWins: 0,
kills: 0,
deaths: 0,
ratingSum: 0,
ratingCount: 0,
});
}
const total = seriesTotals.get(team.team);
total.maps += 1;
total.mapWins += teams.length > 1 && team.score > (teams.find((other) => other.team !== team.team)?.score ?? -1) ? 1 : 0;
total.kills += team.kills;
total.deaths += team.deaths;
if (Number.isFinite(team.avgRating)) {
total.ratingSum += team.avgRating;
total.ratingCount += 1;
}
}
for (const team of teams) {
outRows.push({
rowType: 'map',
date,
event,
map: mapName,
team: team.team,
opponent: team.opponent,
score: team.score === null ? null : `${team.score}-${teams.find((other) => other.team !== team.team)?.score ?? ''}`,
matchStatsId: first?.matchStatsId ?? extractIdFromUrl(mapUrl, 'matchStats'),
playerId: null,
playerName: null,
rating: team.avgRating,
details: {
kills: team.kills,
deaths: team.deaths,
kdRatio: team.deaths > 0 ? round(team.kills / team.deaths) : null,
mapStatsUrl: mapUrl,
},
});
}
for (const row of mapRows) {
outRows.push({
rowType: 'player',
date: row.details?.dateTime?.slice(0, 10) ?? null,
event: row.details?.event ?? null,
map: row.details?.map ?? null,
team: row.team,
opponent: row.details?.opponent ?? null,
score: row.details?.teamScore === null ? null : `${row.details?.teamScore}-${row.details?.opponentScore}`,
matchStatsId: row.matchStatsId,
playerId: row.playerId,
playerName: row.playerName,
rating: row.rating,
details: {
kills: row.kills,
deaths: row.deaths,
adr: row.adr,
kastPct: row.kastPct,
opKd: row.opKd,
headshots: row.details?.headshots ?? null,
assists: row.details?.assists ?? null,
flashAssists: row.details?.flashAssists ?? null,
tradedDeaths: row.details?.tradedDeaths ?? null,
mapStatsUrl: mapUrl,
playerUrl: row.url,
},
});
}
}
const summaryDetails = {
maps: mapUrls.length,
event: firstMeta?.event ?? null,
firstDate: mapSummaries.map((row) => row.date).filter(Boolean).sort()[0] ?? null,
lastDate: mapSummaries.map((row) => row.date).filter(Boolean).sort().at(-1) ?? null,
mapStatsIds: mapSummaries.map((row) => row.matchStatsId).join(','),
mapsPlayed: mapSummaries.map((row) => row.map).filter(Boolean).join(','),
mapScores: mapSummaries.map((row) => `${row.map}:${row.score}`).join('; '),
};
const summaryRows = [...seriesTotals.values()].map((team) => ({
rowType: 'summary',
date: summaryDetails.lastDate,
event: summaryDetails.event,
map: 'all',
team: team.team,
opponent: team.opponent,
score: `${team.mapWins}-${team.maps - team.mapWins}`,
matchStatsId: 'all',
playerId: null,
playerName: null,
rating: team.ratingCount ? round(team.ratingSum / team.ratingCount) : null,
details: {
...summaryDetails,
mapsWon: team.mapWins,
mapsLost: team.maps - team.mapWins,
kills: team.kills,
deaths: team.deaths,
kdRatio: team.deaths > 0 ? round(team.kills / team.deaths) : null,
},
}));
return assertRows([...summaryRows, ...outRows], 'hltv match-series');
}
export async function readPerformanceKillMatrix(page, mapstatsUrl) {
const url = buildPerformanceUrl(mapstatsUrl);
await gotoAndWait(page, url, '.stats-table', 'hltv match performance page');
const matrix = await page.evaluate(() => {
const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
const parseCell = (value) => {
const match = clean(value).match(/^(\d+)\s*:\s*(\d+)$/);
return match ? { left: Number(match[1]), right: Number(match[2]) } : null;
};
const table = [...document.querySelectorAll('table.stats-table')].find((candidate) => {
const text = clean(candidate.innerText);
return /\d+\s*:\s*\d+/.test(text);
});
if (!table) return null;
const trs = [...table.querySelectorAll('tr')];
const headerCells = [...trs[0]?.children ?? []].map((cell) => clean(cell.textContent)).filter(Boolean);
const entries = [];
for (const tr of trs.slice(1)) {
const cells = [...tr.children].map((cell) => clean(cell.textContent));
const rowPlayer = cells[0];
if (!rowPlayer) continue;
for (let i = 1; i < cells.length; i += 1) {
const parsed = parseCell(cells[i]);
const colPlayer = headerCells[i - 1];
if (!parsed || !colPlayer) continue;
entries.push({
rowPlayer,
colPlayer,
rowKillsCol: parsed.left,
colKillsRow: parsed.right,
});
}
}
return entries;
});
if (!Array.isArray(matrix)) return [];
return matrix;
}
export async function gotoAndWait(page, url, selector, label) {
try {
await page.goto(url.toString(), { waitUntil: 'domcontentloaded', settleMs: 1000, timeout: 20000 });
await page.wait({ selector, timeout: 15000 });
} catch (error) {
if (/timeout/i.test(String(error?.message ?? error))) {
throw new TimeoutError(label, 15);
}
throw new CommandExecutionError(`${label} failed: ${error?.message ?? error}`);
}
}
export function assertRows(rows, command) {
if (!Array.isArray(rows)) throw new CommandExecutionError(`${command} parser returned an unexpected shape`);
if (rows.length === 0) throw new EmptyResultError(command, 'No rows were found in the visible HLTV page');
return rows;
}
export function assertRequiredFields(rows, command, fields) {
assertRows(rows, command);
for (const [index, row] of rows.entries()) {
for (const field of fields) {
if (row?.[field] === null || row?.[field] === undefined || row?.[field] === '') {
throw new CommandExecutionError(`${command} parser returned row ${index + 1} without required ${field}`);
}
}
}
return rows;
}