campeonato-brasileiro-api
Version:
API moderna para consultar classificação e rodada atual das Séries A, B, C e D do Brasileirão
750 lines (647 loc) • 19.2 kB
JavaScript
;
const COMPETITIONS = Object.freeze({
a: Object.freeze({
code: 'a',
slug: 'brasileirao-serie-a',
name: 'Campeonato Brasileiro Série A',
grouped: false,
url: 'https://ge.globo.com/futebol/brasileirao-serie-a/'
}),
b: Object.freeze({
code: 'b',
slug: 'brasileirao-serie-b',
name: 'Campeonato Brasileiro Série B',
grouped: false,
url: 'https://ge.globo.com/futebol/brasileirao-serie-b/'
}),
c: Object.freeze({
code: 'c',
slug: 'brasileirao-serie-c',
name: 'Campeonato Brasileiro Série C',
grouped: false,
url: 'https://ge.globo.com/futebol/brasileirao-serie-c/'
}),
d: Object.freeze({
code: 'd',
slug: 'brasileirao-serie-d',
name: 'Campeonato Brasileiro Série D',
grouped: true,
url: 'https://ge.globo.com/futebol/brasileirao-serie-d/'
})
});
const SUPPORTED_SERIES = Object.freeze(
Object.values(COMPETITIONS).map((competition) =>
Object.freeze({
code: competition.code,
slug: competition.slug,
name: competition.name,
grouped: competition.grouped,
url: competition.url
})
)
);
const STATUS_BY_BROADCAST = Object.freeze({
ENCERRADA: 'finished',
PRE_DIA: 'scheduled',
PRE_JOGO: 'scheduled',
AO_VIVO: 'live',
EM_ANDAMENTO: 'live'
});
class BrasileiroApiError extends Error {
constructor(code, message, details) {
super(message);
this.name = 'BrasileiroApiError';
this.code = code;
if (details !== undefined) {
this.details = details;
}
}
}
function listSeries() {
return SUPPORTED_SERIES.map((competition) => ({ ...competition }));
}
async function getCompetition(serie, options = {}) {
const competition = resolveCompetition(serie);
const html = await resolveHtml(competition, options);
return parseCompetitionDocument(html, {
competition,
url: options.url || competition.url
});
}
async function getStandings(serie, options = {}) {
const competition = await getCompetition(serie, options);
const table = options.group == null ? null : selectGroup(competition.tables, options.group);
const tables = table ? [table] : competition.tables;
return {
competition: competition.competition,
grouped: competition.grouped,
legends: competition.legends,
tables
};
}
async function getTable(serie, options = {}) {
const standings = await getStandings(serie, options);
if (standings.tables.length === 1) {
return standings.tables[0];
}
throw new BrasileiroApiError(
'GROUP_REQUIRED',
'A grouped competition requires the "group" option. Use getGroups() or pass { group }.',
{
availableGroups: standings.tables.map((table) => table.name)
}
);
}
async function getGroups(serie, options = {}) {
const standings = await getStandings(serie, options);
return standings.grouped ? standings.tables : [];
}
async function getRounds(serie, options = {}) {
const competition = await getCompetition(serie, options);
const round = options.group == null ? null : selectGroup(competition.rounds, options.group);
const rounds = round ? [round] : competition.rounds;
ensureRoundIsAvailable(rounds, options.number);
return {
competition: competition.competition,
grouped: competition.grouped,
rounds
};
}
async function getCurrentRound(serie, options = {}) {
return getRounds(serie, options);
}
async function tabela(serie, options = {}) {
const standings = await getStandings(serie, options);
if (standings.tables.length === 1) {
return standings.tables[0].entries.map(toLegacyTableEntry);
}
return standings.tables.map((table) => ({
grupo: table.name,
classificacao: table.entries.map(toLegacyTableEntry)
}));
}
async function rodadaAtual(serie, rodadaOrOptions, maybeOptions) {
let requestedRound = null;
let options = {};
if (isPlainObject(rodadaOrOptions)) {
options = rodadaOrOptions;
} else {
options = maybeOptions || {};
requestedRound = rodadaOrOptions == null ? null : Number(rodadaOrOptions);
}
const roundsResult = await getRounds(serie, {
...options,
number: requestedRound
});
if (!roundsResult.grouped && roundsResult.rounds.length === 1) {
return roundsResult.rounds[0].matches.map(toLegacyRoundMatch);
}
return roundsResult.rounds.map((round) => ({
grupo: round.groupName,
rodada: round.number,
jogos: round.matches.map(toLegacyRoundMatch)
}));
}
async function resolveHtml(competition, options) {
if (typeof options.html === 'string') {
return options.html;
}
const fetchFn = options.fetch || globalThis.fetch;
if (typeof fetchFn !== 'function') {
throw new BrasileiroApiError(
'FETCH_UNAVAILABLE',
'Global fetch is not available. Use Node.js 18+ or pass a custom fetch implementation.'
);
}
const url = options.url || competition.url;
let response;
try {
response = await fetchFn(url, {
method: 'GET',
redirect: 'follow',
signal: options.signal,
headers: {
accept: 'text/html,application/xhtml+xml',
...(options.headers || {})
}
});
} catch (error) {
throw new BrasileiroApiError(
'FETCH_FAILED',
`Could not fetch ${competition.name}.`,
{
cause: error instanceof Error ? error.message : String(error),
url
}
);
}
if (!response || typeof response.ok !== 'boolean' || typeof response.text !== 'function') {
throw new BrasileiroApiError(
'FETCH_FAILED',
'The provided fetch implementation did not return a valid Response-like object.'
);
}
if (!response.ok) {
throw new BrasileiroApiError(
'FETCH_FAILED',
`The source page returned HTTP ${response.status}.`,
{
status: response.status,
url
}
);
}
return response.text();
}
function parseCompetitionDocument(html, options) {
const script = extractScriptReact(html);
const data = parseJsonLiteral(extractConstLiteral(script, 'classificacao'), 'classificacao');
const grouped = Array.isArray(data.grupos);
const legends = normalizeLegends(data.faixas_classificacao || []);
const legendByColor = new Map(
legends
.filter((legend) => legend.color)
.map((legend) => [legend.color, legend])
);
const edition = data.edicao || {};
const phase = normalizePhase(data.fase || {});
const resourceId = extractResourceId(html);
const tUUID = extractTuuid(script);
const season = extractSeason(edition.nome || data.fase?.slug || options.competition.slug);
const competition = {
code: options.competition.code,
slug: options.competition.slug,
name: edition.nome || options.competition.name,
season,
sport: 'futebol',
grouped,
phase,
edition: {
name: edition.nome || options.competition.name,
location: edition.localizacao || null,
startsAt: edition.data_inicio || null,
endsAt: edition.data_fim || null,
regulation: edition.regulamento || null
},
source: {
provider: 'ge',
url: options.url,
resourceId,
tUUID
}
};
if (grouped) {
const tables = data.grupos.map((group) => normalizeGroupTable(group, legendByColor));
const rounds = data.grupos.map((group) => normalizeGroupRound(group));
return {
competition,
grouped: true,
legends,
tables,
rounds,
matches: rounds.flatMap((round) => round.matches)
};
}
const roundMeta = normalizeRoundMeta(data.rodada);
const tables = [
{
id: 'overall',
name: 'Classificacao geral',
round: roundMeta,
entries: (data.classificacao || []).map((entry) => normalizeEntry(entry, legendByColor))
}
];
const rounds = [
{
id: 'overall',
groupId: null,
groupName: null,
number: roundMeta.number,
total: roundMeta.total,
label: roundMeta.label,
matches: (data.lista_jogos || []).map((match) => normalizeMatch(match, roundMeta))
}
];
return {
competition,
grouped: false,
legends,
tables,
rounds,
matches: rounds[0].matches
};
}
function normalizePhase(phase) {
const description = phase?.tipo?.descricao || null;
const typeId = phase?.tipo?.tipo_id || null;
return {
slug: phase?.slug || null,
disclaimer: phase?.disclaimer || null,
description,
typeId,
grouped: typeId === '3'
};
}
function normalizeLegends(legends) {
return legends.map((legend) => ({
id: legend.id ?? null,
name: legend.nome || null,
color: typeof legend.cor === 'string' ? legend.cor.toLowerCase() : null
}));
}
function normalizeGroupTable(group, legendByColor) {
return {
id: group.id ?? group.grupo_id ?? null,
name: group.nome_grupo || null,
round: normalizeRoundMeta(group.rodada),
entries: (group.classificacao || []).map((entry) => normalizeEntry(entry, legendByColor))
};
}
function normalizeGroupRound(group) {
const roundMeta = normalizeRoundMeta(group.rodada);
return {
id: group.id ?? group.grupo_id ?? null,
groupId: group.id ?? group.grupo_id ?? null,
groupName: group.nome_grupo || null,
number: roundMeta.number,
total: roundMeta.total,
label: roundMeta.label,
matches: (group.lista_jogos || []).map((match) =>
normalizeMatch(match, roundMeta, {
groupId: group.id ?? group.grupo_id ?? null,
groupName: group.nome_grupo || null
})
)
};
}
function normalizeEntry(entry, legendByColor) {
const legendColor = typeof entry.faixa_classificacao_cor === 'string'
? entry.faixa_classificacao_cor.toLowerCase()
: null;
return {
position: numberOrNull(entry.ordem),
team: {
id: numberOrNull(entry.equipe_id),
name: entry.nome_popular || null,
shortName: entry.sigla || null,
badge: entry.escudo || null
},
points: numberOrNull(entry.pontos),
matches: numberOrNull(entry.jogos),
wins: numberOrNull(entry.vitorias),
draws: numberOrNull(entry.empates),
losses: numberOrNull(entry.derrotas),
goalsFor: numberOrNull(entry.gols_pro),
goalsAgainst: numberOrNull(entry.gols_contra),
goalDifference: numberOrNull(entry.saldo_gols),
efficiency: numberOrNull(entry.aproveitamento),
movement: numberOrNull(entry.variacao),
recentForm: Array.isArray(entry.ultimos_jogos)
? entry.ultimos_jogos.map(normalizeFormResult)
: [],
legend: legendColor ? legendByColor.get(legendColor) || { color: legendColor, name: null } : null
};
}
function normalizeRoundMeta(round) {
const number = numberOrNull(round?.atual);
const total = numberOrNull(round?.ultima);
return {
number,
total,
label: number == null ? null : `${number}a rodada`
};
}
function normalizeMatch(match, roundMeta, options = {}) {
const broadcastCode = match?.transmissao?.broadcast?.id || null;
return {
id: numberOrNull(match?.id),
groupId: options.groupId ?? null,
groupName: options.groupName ?? null,
round: roundMeta.number,
totalRounds: roundMeta.total,
dateTime: match?.data_realizacao || null,
date: match?.data_realizacao ? match.data_realizacao.slice(0, 10) : null,
time: match?.hora_realizacao || null,
started: Boolean(match?.jogo_ja_comecou),
status: normalizeMatchStatus(broadcastCode, match?.jogo_ja_comecou),
statusCode: broadcastCode,
venue: match?.sede?.nome_popular || null,
homeTeam: normalizeTeam(match?.equipes?.mandante),
awayTeam: normalizeTeam(match?.equipes?.visitante),
score: {
home: numberOrNull(match?.placar_oficial_mandante),
away: numberOrNull(match?.placar_oficial_visitante),
penalties:
match?.placar_penaltis_mandante == null && match?.placar_penaltis_visitante == null
? null
: {
home: numberOrNull(match?.placar_penaltis_mandante),
away: numberOrNull(match?.placar_penaltis_visitante)
}
},
coverage: match?.transmissao
? {
label: match.transmissao.label || null,
url: match.transmissao.url || null,
statusCode: broadcastCode
}
: null
};
}
function normalizeTeam(team) {
return {
id: numberOrNull(team?.id),
name: team?.nome_popular || null,
shortName: team?.sigla || null,
badge: team?.escudo || null
};
}
function normalizeMatchStatus(broadcastCode, started) {
if (broadcastCode && STATUS_BY_BROADCAST[broadcastCode]) {
return STATUS_BY_BROADCAST[broadcastCode];
}
return started ? 'live' : 'scheduled';
}
function normalizeFormResult(result) {
switch (String(result || '').toLowerCase()) {
case 'v':
return 'W';
case 'e':
return 'D';
case 'd':
return 'L';
default:
return String(result || '').toUpperCase() || null;
}
}
function extractScriptReact(html) {
const match = html.match(/<script type="text\/javascript" id="scriptReact">([\s\S]*?)<\/script>/);
if (!match) {
throw new BrasileiroApiError(
'INVALID_RESPONSE',
'The source page no longer contains the scriptReact payload.'
);
}
return match[1];
}
function extractResourceId(html) {
const match = html.match(/data-bs-resource-id="([^"]+)"/);
return match ? match[1] : null;
}
function extractTuuid(script) {
const match = script.match(/tUUID:\s*"([^"]+)"/);
return match ? match[1] : null;
}
function extractConstLiteral(script, name) {
const marker = `const ${name} = `;
const start = script.indexOf(marker);
if (start < 0) {
throw new BrasileiroApiError(
'INVALID_RESPONSE',
`The source page does not contain the "${name}" payload.`
);
}
let index = start + marker.length;
while (index < script.length && /\s/.test(script[index])) {
index += 1;
}
const firstCharacter = script[index];
if (!['{', '[', '"', '\''].includes(firstCharacter)) {
const end = script.indexOf(';', index);
return script.slice(index, end).trim();
}
let depth = firstCharacter === '{' || firstCharacter === '[' ? 1 : 0;
let inString = firstCharacter === '"' || firstCharacter === '\'';
let quote = inString ? firstCharacter : null;
let escaped = false;
for (let cursor = index + 1; cursor < script.length; cursor += 1) {
const character = script[cursor];
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (character === '\\') {
escaped = true;
continue;
}
if (character === quote) {
inString = false;
quote = null;
if (depth === 0) {
return script.slice(index, cursor + 1).trim();
}
}
continue;
}
if (character === '"' || character === '\'') {
inString = true;
quote = character;
continue;
}
if (character === '{' || character === '[') {
depth += 1;
continue;
}
if (character === '}' || character === ']') {
depth -= 1;
if (depth === 0) {
return script.slice(index, cursor + 1).trim();
}
}
}
throw new BrasileiroApiError(
'INVALID_RESPONSE',
`Could not safely extract the "${name}" payload from the source page.`
);
}
function parseJsonLiteral(literal, name) {
try {
return JSON.parse(literal);
} catch (error) {
throw new BrasileiroApiError(
'INVALID_RESPONSE',
`The "${name}" payload could not be parsed as JSON.`,
{
cause: error instanceof Error ? error.message : String(error)
}
);
}
}
function resolveCompetition(serie) {
const key = normalizeKey(serie);
switch (key) {
case 'a':
case 'seriea':
case 'brasileiraoseriea':
return COMPETITIONS.a;
case 'b':
case 'serieb':
case 'brasileiraoserieb':
return COMPETITIONS.b;
case 'c':
case 'seriec':
case 'brasileiraoseriec':
return COMPETITIONS.c;
case 'd':
case 'seried':
case 'brasileiraoseried':
return COMPETITIONS.d;
default:
throw new BrasileiroApiError(
'INVALID_SERIE',
'Unsupported serie. Use one of: a, b, c or d.',
{
received: serie,
supported: SUPPORTED_SERIES.map((competition) => competition.code)
}
);
}
}
function selectGroup(collection, group) {
if (group == null) {
return null;
}
const wanted = normalizeGroupKey(group);
const selected = collection.find((entry) => {
if (String(entry.id) === String(group)) {
return true;
}
return normalizeGroupKey(entry.name || entry.groupName) === wanted;
});
if (!selected) {
throw new BrasileiroApiError(
'GROUP_NOT_FOUND',
`Group "${group}" was not found in the current competition payload.`,
{
received: group,
availableGroups: collection.map((entry) => ({
id: entry.id,
name: entry.name || entry.groupName
}))
}
);
}
return selected;
}
function ensureRoundIsAvailable(rounds, requestedRound) {
if (requestedRound == null || Number.isNaN(requestedRound)) {
return;
}
const availableRounds = [...new Set(rounds.map((round) => round.number).filter((round) => round != null))];
if (availableRounds.includes(requestedRound)) {
return;
}
throw new BrasileiroApiError(
'ROUND_NOT_AVAILABLE',
'The current GE payload exposes only the active rodada for each competition page.',
{
requested: requestedRound,
available: availableRounds
}
);
}
function toLegacyTableEntry(entry) {
return {
nome: entry.team.name,
sigla: entry.team.shortName,
escudo: entry.team.badge,
posicao: entry.position,
pontos: String(entry.points),
jogos: String(entry.matches),
vitorias: String(entry.wins),
empates: String(entry.draws),
derrotas: String(entry.losses),
golsPro: String(entry.goalsFor),
golsContra: String(entry.goalsAgainst),
saldoGols: String(entry.goalDifference),
percentual: String(entry.efficiency)
};
}
function toLegacyRoundMatch(match) {
return {
mandante: match.homeTeam.name,
placarMandante: match.score.home,
visitante: match.awayTeam.name,
placarVisitante: match.score.away
};
}
function extractSeason(value) {
const match = String(value || '').match(/\b(20\d{2})\b/);
return match ? Number(match[1]) : null;
}
function normalizeGroupKey(value) {
return normalizeKey(String(value || '').replace(/^grupo/i, ''));
}
function normalizeKey(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
function numberOrNull(value) {
if (value == null) {
return null;
}
const number = typeof value === 'number' ? value : Number(value);
return Number.isNaN(number) ? null : number;
}
function isPlainObject(value) {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
const api = {
BrasileiroApiError,
SUPPORTED_SERIES,
listSeries,
getCompetition,
getStandings,
getTable,
getGroups,
getRounds,
getCurrentRound,
tabela,
rodadaAtual
};
module.exports = api;
module.exports.default = api;