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

147 lines
5.8 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { BASE, gotoAndWait, parseNumber, parseTeamRef } from './utils.js';
function round(value, digits = 2) {
if (!Number.isFinite(value)) return null;
return Number(value.toFixed(digits));
}
function splitRecord(value) {
const match = String(value ?? '').match(/(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)/);
return match ? { wins: Number(match[1]), draws: Number(match[2]), losses: Number(match[3]) } : { wins: null, draws: null, losses: null };
}
cli({
site: 'hltv',
name: 'team-map-pool',
description: 'Read the visible HLTV team map-pool page with win rate, pick rate, and ban rate',
access: 'read',
example: 'opencli hltv team-map-pool 11283/falcons -f json',
domain: 'www.hltv.org',
strategy: Strategy.UI,
browser: true,
navigateBefore: false,
args: [
{ name: 'team', type: 'string', positional: true, required: true, help: 'Team ref: 11283/falcons, team URL, or stats team URL' },
],
columns: ['category', 'key', 'teamId', 'team', 'maps', 'winMaps', 'lossMaps', 'winRatePct', 'avgRoundDiff', 'details'],
func: async (page, args) => {
const { teamId, slug } = parseTeamRef(args.team);
await gotoAndWait(page, new URL(`/team/${teamId}/${slug}`, BASE), '.team-row, .profile-team-name, .teamName', 'hltv team profile warmup page');
const url = new URL(`/stats/teams/maps/${teamId}/${slug}`, BASE);
await gotoAndWait(page, url, '.map-pool-map-holder.map-stats, .two-grid .stats-row', 'hltv team map-pool page');
const rawRows = await page.evaluate((payload) => {
const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
const readMetric = (root, label) => {
const row = [...root.querySelectorAll('.stats-row')].find((item) => clean(item.textContent).startsWith(label));
return row ? clean(row.textContent).slice(label.length).trim() : null;
};
const team = clean(document.querySelector('.context-item-name, .profile-team-name, .teamName')?.textContent) || payload.slug;
const byMap = new Map();
for (const link of document.querySelectorAll('.map-pool-map-holder.map-stats')) {
const text = clean(link.textContent);
const match = text.match(/^(.+?)\s*-\s*([\d.]+)%$/);
if (!match) continue;
byMap.set(match[1].trim(), {
category: 'map',
key: match[1].trim(),
teamId: payload.teamId,
team,
record: null,
winRate: `${match[2]}%`,
totalRounds: null,
firstKillWinPct: null,
firstDeathWinPct: null,
pickPct: null,
banPct: null,
mapUrl: new URL(link.getAttribute('href'), payload.base).toString(),
});
}
for (const grid of document.querySelectorAll('.two-grid')) {
const mapName = clean(grid.querySelector('.map-pool-map-holder')?.textContent);
if (!mapName) continue;
const record = readMetric(grid, 'Wins / draws / losses');
const winRate = readMetric(grid, 'Win rate');
const totalRounds = readMetric(grid, 'Total rounds');
const firstKillWinPct = readMetric(grid, 'Round win-% after getting first kill');
const firstDeathWinPct = readMetric(grid, 'Round win-% after receiving first death');
const pickPct = readMetric(grid, 'Pick %');
const banPct = readMetric(grid, 'Ban %');
if (!record && !winRate) continue;
byMap.set(mapName, {
category: 'map',
key: mapName,
teamId: payload.teamId,
team,
record,
winRate,
totalRounds,
firstKillWinPct,
firstDeathWinPct,
pickPct,
banPct,
mapUrl: byMap.get(mapName)?.mapUrl ?? null,
});
}
return [...byMap.values()];
}, { base: BASE, teamId, slug });
if (!Array.isArray(rawRows)) throw new CommandExecutionError('hltv team-map-pool parser returned an unexpected shape');
if (rawRows.length === 0) throw new EmptyResultError('hltv team-map-pool', `No map-pool rows found for team ${teamId}/${slug}`);
const rows = rawRows.map((row) => {
const record = splitRecord(row.record);
const maps = record.wins === null ? null : (record.wins ?? 0) + (record.draws ?? 0) + (record.losses ?? 0);
return {
category: row.category,
key: row.key,
teamId: row.teamId,
team: row.team,
maps,
winMaps: record.wins,
lossMaps: record.losses,
winRatePct: parseNumber(row.winRate),
avgRoundDiff: null,
details: {
draws: record.draws,
totalRounds: parseNumber(row.totalRounds),
roundsPerMap: maps ? round(parseNumber(row.totalRounds) / maps) : null,
firstKillWinPct: parseNumber(row.firstKillWinPct),
firstDeathWinPct: parseNumber(row.firstDeathWinPct),
pickPct: parseNumber(row.pickPct),
banPct: parseNumber(row.banPct),
url: url.toString(),
},
};
}).sort((a, b) => (b.maps ?? -1) - (a.maps ?? -1) || String(a.key).localeCompare(String(b.key)));
const totals = rows.reduce((acc, row) => {
acc.maps += row.maps ?? 0;
acc.winMaps += row.winMaps ?? 0;
acc.lossMaps += row.lossMaps ?? 0;
return acc;
}, { maps: 0, winMaps: 0, lossMaps: 0 });
return [
{
category: 'summary',
key: 'all',
teamId,
team: rows[0]?.team ?? slug,
maps: totals.maps,
winMaps: totals.winMaps,
lossMaps: totals.lossMaps,
winRatePct: totals.maps ? round((totals.winMaps / totals.maps) * 100) : null,
avgRoundDiff: null,
details: {
mapsCovered: rows.length,
source: url.toString(),
},
},
...rows,
];
},
});