PageSourceSearch

https://chiledeportes.cl/js/partidos.js

js chiledeportes.cl collected 2026-10-02 10:10:51 UTC 12,871 bytes, 204 lines download raw bytes

1/**
2 * Partidos del día y tabla de posiciones — datos de ESPN (en español, región Chile).
3 *
4 * Por qué: «partidos de hoy» tiene 43.000 búsquedas al mes en Chile (Ahrefs,
5 * 29-09-2026) y en Search Console el sitio ya recibía «a qué hora juega…».
6 * Antes la portada mostraba una tabla de fútbol ESCRITA A MANO: decía
7 * Colo-Colo 46 pts con 22 PJ cuando eran 54 con 23, e incluía a un equipo
8 * que no está en Primera.
9 *
10 * Reglas: la hora siempre en hora de Chile; sin canal de TV (la fuente no lo
11 * trae para Chile y no se inventa); si ESPN no responde, el módulo se oculta.
12 * Módulo sin DOM ni fs: lo usan scripts/build.mjs y el navegador.
13 */
14function esc(valor) {
15  return String(valor ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
16}
17
18export const API = 'https://site.api.espn.com/apis/site/v2/sports/soccer';
19export const API_TABLA = 'https://site.api.espn.com/apis/v2/sports/soccer/chi.1/standings?lang=es&region=cl';
20
21/** Ligas que sigue un hincha chileno, en el orden en que se muestran. */
22export const LIGAS = [
23  { id: 'chi.1', nombre: 'Primera División de Chile', chilena: true },
24  { id: 'chi.2', nombre: 'Primera B de Chile', chilena: true },
25  { id: 'conmebol.libertadores', nombre: 'Copa Libertadores' },
26  { id: 'conmebol.sudamericana', nombre: 'Copa Sudamericana' },
27  { id: 'fifa.worldq.conmebol', nombre: 'Clasificatorias sudamericanas' },
28  { id: 'fifa.friendly', nombre: 'Amistosos internacionales', filtrar: true },
29  { id: 'uefa.champions', nombre: 'Champions League' },
30  { id: 'uefa.europa', nombre: 'Europa League' },
31  { id: 'eng.1', nombre: 'Premier League' },
32  { id: 'esp.1', nombre: 'LaLiga' },
33  { id: 'ita.1', nombre: 'Serie A de Italia' },
34  { id: 'ger.1', nombre: 'Bundesliga' },
35  { id: 'fra.1', nombre: 'Ligue 1' },
36  { id: 'arg.1', nombre: 'Liga Profesional de Argentina' },
37  { id: 'bra.1', nombre: 'Brasileirão' },
38  { id: 'mex.1', nombre: 'Liga MX' },
39  { id: 'usa.1', nombre: 'MLS' },
40  { id: 'uefa.nations', nombre: 'Liga de Naciones', filtrar: true }
41];
42
43/** En amistosos y Liga de Naciones hay decenas de partidos lejanos: solo estas selecciones. */
44const SELECCIONES = new Set(['CHI', 'ARG', 'BRA', 'URU', 'COL', 'PER', 'ECU', 'PAR', 'BOL', 'VEN', 'MEX', 'EUA', 'USA', 'CAN', 'CRC',
45  'ESP', 'FRA', 'GER', 'ALE', 'ITA', 'ENG', 'ING', 'POR', 'NED', 'HOL', 'BEL', 'CRO', 'JPN', 'JAP', 'KOR', 'MAR', 'AUS']);
46
47const HORA = new Intl.DateTimeFormat('es-CL', { timeZone: 'America/Santiago', hour: '2-digit', minute: '2-digit', hour12: false });
48const DIA = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Santiago', year: 'numeric', month: '2-digit', day: '2-digit' });
49const DIA_LARGO = new Intl.DateTimeFormat('es-CL', { timeZone: 'America/Santiago', weekday: 'long', day: 'numeric', month: 'long' });
50
51/** «2026-09-29» en hora de Chile. */
52export function diaChile(fecha) { return DIA.format(fecha); }
53export function horaChile(fecha) { return HORA.format(fecha); }
54export function diaLargo(fecha) { const t = DIA_LARGO.format(fecha); return t.charAt(0).toUpperCase() + t.slice(1); }
55
56/** Fechas que hay que pedirle a ESPN (usa el día de EE.UU.) para cubrir hoy y mañana en Chile. */
57export function fechasConsulta(ahora = new Date()) {
58  const r = [];
59  for (const d of [-1, 0, 1, 2]) r.push(new Date(ahora.getTime() + d * 864e5).toISOString().slice(0, 10).replace(/-/g, ''));
60  return r;
61}
62
63function equipo(c) {
64  const t = c?.team || {};
65  return { nombre: t.displayName || t.name || '', corto: t.shortDisplayName || t.displayName || '', abrev: t.abbreviation || '', goles: c?.score ?? '', ganador: c?.winner === true };
66}
67
68/**
69 * Estado legible, calculado por nosotros: el texto de ESPN llega en inglés.
70 * `texto` es la versión larga (franja y lectores de pantalla); `etiqueta` y
71 * `valor` van en la columna de la hora, que es angosta: dos líneas cortas en
72 * vez de una larga. El 29-09-2026 «Entretiempo» en una sola línea se montaba
73 * sobre el nombre del equipo.
74 */
75export function estadoPartido(status) {
76  const tipo = status?.type || {};
77  if (tipo.state === 'in') {
78    // Solo el descanso: STATUS_FIRST_HALF y STATUS_SECOND_HALF también dicen
79    // «HALF» y el 29-09-2026 mostraban «Entretiempo» con el partido en juego.
80    if (/HALFTIME|HALF_TIME/i.test(tipo.name || '') || tipo.shortDetail === 'HT') return { clase: 'pausa', texto: 'Entretiempo', etiqueta: 'Entretiempo', valor: '' };
81    const minuto = String(status.displayClock || '').trim();
82    return { clase: 'en-vivo', texto: `En vivo ${minuto}`.trim(), etiqueta: 'En vivo', valor: minuto };
83  }
84  if (tipo.state === 'post') {
85    if (/postpon|cancel|suspend|abandon/i.test(tipo.name || '')) return { clase: 'final', texto: 'Suspendido', etiqueta: 'Suspendido', valor: '' };
86    const penales = /pen/i.test(tipo.shortDetail || '');
87    return { clase: 'final', texto: penales ? 'Final (penales)' : 'Final', etiqueta: 'Final', valor: penales ? 'penales' : '' };
88  }
89  if (/postpon|cancel|suspend/i.test(tipo.name || '')) return { clase: 'final', texto: 'Suspendido', etiqueta: 'Suspendido', valor: '' };
90  if (/tbd/i.test(tipo.name || '') || /TBD/.test(tipo.shortDetail || '')) return { clase: '', texto: 'Hora por confirmar', etiqueta: '', valor: '', porConfirmar: true };
91  return { clase: '', texto: '', etiqueta: '', valor: '' };
92}
93
94export function normalizarEventos(json, liga) {
95  const lista = [];
96  for (const e of json?.events || []) {
97    const c = e.competitions?.[0];
98    if (!c) continue;
99    const local = equipo(c.competitors?.find((x) => x.homeAway === 'home'));
100    const visita = equipo(c.competitors?.find((x) => x.homeAway === 'away'));
101    if (liga.filtrar && !SELECCIONES.has(local.abrev) && !SELECCIONES.has(visita.abrev)) continue;
102    const esChile = liga.chilena || local.abrev === 'CHI' || visita.abrev === 'CHI';
103    lista.push({
104      id: e.id, liga: liga.id, ligaNombre: liga.nombre, inicio: e.date, estado: c.status?.type?.state || 'pre',
105      detalle: estadoPartido(c.status), local, visita, estadio: c.venue?.fullName || '', chile: esChile
106    });
107  }
108  return lista;
109}
110
111/** Pide a ESPN todas las ligas y fechas. `pedir(url)` devuelve el JSON o lanza. */
112export async function obtenerPartidos(pedir, ahora = new Date(), { ligas = null, dias = [-1, 0, 1, 2] } = {}) {
113  const fechas = dias.map((d) => new Date(ahora.getTime() + d * 864e5).toISOString().slice(0, 10).replace(/-/g, ''));
114  const tareas = [];
115  for (const liga of LIGAS.filter((l) => !ligas || ligas.includes(l.id))) for (const f of fechas) {
116    tareas.push(pedir(`${API}/${liga.id}/scoreboard?dates=${f}&lang=es&region=cl`).then((j) => normalizarEventos(j, liga)).catch(() => null));
117  }
118  const res = await Promise.all(tareas);
119  const ok = res.filter(Boolean);
120  if (!ok.length) return null;
121  const vistos = new Set();
122  const hoy = diaChile(ahora);
123  const manana = diaChile(new Date(ahora.getTime() + 864e5));
124  return ok.flat().filter((p) => { if (vistos.has(p.id)) return false; vistos.add(p.id); const d = diaChile(new Date(p.inicio)); return d === hoy || d === manana; })
125    .sort((a, b) => (b.chile - a.chile) || (LIGAS.findIndex((l) => l.id === a.liga) - LIGAS.findIndex((l) => l.id === b.liga)) || a.inicio.localeCompare(b.inicio));
126}
127
128export function normalizarTabla(json) {
129  const entradas = json?.children?.[0]?.standings?.entries;
130  if (!Array.isArray(entradas) || !entradas.length) return null;
131  const filas = entradas.map((e) => {
132    const s = Object.fromEntries((e.stats || []).map((x) => [x.name, x.value]));
133    return { pos: s.rank, club: e.team?.displayName || '', abrev: e.team?.abbreviation || '', pj: s.gamesPlayed, g: s.wins, e: s.ties, p: s.losses, gf: s.pointsFor, gc: s.pointsAgainst, dif: s.pointDifferential, pts: s.points };
134  }).filter((f) => f.club && Number.isFinite(f.pts)).sort((a, b) => a.pos - b.pos);
135  if (!filas.length) return null;
136  const st = json.children[0].standings || {};
137  const anio = (String(st.seasonDisplayName || json.children[0].name || '').match(/20\d\d/) || [])[0];
138  const temporada = anio ? `Primera División ${anio}` : 'Primera División';
139  return { temporada, filas };
140}
141
142/* --- Marcado ---------------------------------------------------------------------- */
143
144function goles(p, lado) {
145  return p.estado === 'pre' ? '' : esc(String(p[lado].goles));
146}
147
148export function chipPartido(p) {
149  const inicio = new Date(p.inicio);
150  const estado = p.estado === 'pre' ? `${horaChile(inicio)}${p.detalle.texto ? ` · ${p.detalle.texto}` : ''}` : p.detalle.texto;
151  return `<a class="chip-partido${p.chile ? ' chile' : ''}" href="/partidos-de-hoy/#p-${esc(p.id)}" data-partido="${esc(p.id)}">
152    <span class="liga">${esc(p.ligaNombre)}</span>
153    <span class="equipo">${esc(p.local.corto)}</span><span class="goles">${goles(p, 'local')}</span>
154    <span class="equipo">${esc(p.visita.corto)}</span><span class="goles">${goles(p, 'visita')}</span>
155    <span class="estado ${p.detalle.clase}">${esc(estado)}</span>
156  </a>`;
157}
158
159function columnaHora(p) {
160  const d = p.detalle || {};
161  if (p.estado === 'pre' && !d.etiqueta) return `<b class="hora-num">${d.porConfirmar ? '—' : esc(horaChile(new Date(p.inicio)))}</b>`;
162  return `<span class="estado-partido ${esc(d.clase || '')}">${esc(d.etiqueta || d.texto || '')}</span>${d.valor ? (d.clase === 'en-vivo' ? `<b class="hora-num vivo-num">${esc(d.valor)}</b>` : `<small class="nota-estado">${esc(d.valor)}</small>`) : ''}`;
163}
164
165export function filaPartido(p) {
166  return `<li class="partido${p.chile ? ' chile' : ''}" id="p-${esc(p.id)}">
167    <span class="hora" title="${esc(p.detalle?.texto || '')}">${columnaHora(p)}</span>
168    <span class="equipo">${esc(p.local.nombre)}</span><span class="goles">${goles(p, 'local')}</span>
169    <span class="equipo">${esc(p.visita.nombre)}</span><span class="goles">${goles(p, 'visita')}</span>
170    ${p.estadio || (p.estado === 'pre' && p.detalle.texto) ? `<span class="lugar-partido">${esc([p.estado === 'pre' ? p.detalle.texto : '', p.estadio].filter(Boolean).join(' · '))}</span>` : ''}
171  </li>`;
172}
173
174/** Hoy y mañana, agrupados por liga. */
175export function listadoPartidos(partidos, ahora = new Date()) {
176  if (!partidos) return '<p class="vacio">ESPN no respondió al publicar esta página. Estamos intentando de nuevo desde tu navegador.</p>';
177  const hoy = diaChile(ahora);
178  const dias = [
179    { clave: hoy, titulo: `Hoy, ${diaLargo(ahora).toLowerCase()}` },
180    { clave: diaChile(new Date(ahora.getTime() + 864e5)), titulo: `Mañana, ${diaLargo(new Date(ahora.getTime() + 864e5)).toLowerCase()}` }
181  ];
182  return dias.map((d) => {
183    const delDia = partidos.filter((p) => diaChile(new Date(p.inicio)) === d.clave);
184    const ligas = [...new Set(delDia.map((p) => p.ligaNombre))];
185    return `<section class="dia-partidos" aria-labelledby="dia-${d.clave}">
186      <h2 class="titulo-seccion" id="dia-${d.clave}">${esc(d.titulo)}</h2>
187      ${delDia.length ? ligas.map((l) => `<div class="liga-partidos"><h3>${esc(l)}</h3><ul class="partidos">${delDia.filter((p) => p.ligaNombre === l).map(filaPartido).join('')}</ul></div>`).join('')
188        : '<p class="vacio" style="margin-top:16px">No hay partidos de estas ligas ese día.</p>'}
189    </section>`;
190  }).join('');
191}
192
193export function tablaHTML(tabla, { completa = false, filas = 16 } = {}) {
194  if (!tabla) return '';
195  // En un celular se ven #, club, PJ, DIF y PTS: los puntos no pueden quedar
196  // fuera de la pantalla. Ganados, empatados, perdidos y goles, con el botón.
197  const x = ' class="extra"';
198  return `${completa ? '<p class="solo-angosto"><button class="boton-quieto" type="button" data-columnas aria-pressed="false">Ver ganados, empatados y goles</button></p>' : ''}<div class="tabla-marco"><table class="tabla">
199    <caption class="sr-only">${esc(tabla.temporada || 'Primera División')}: tabla de posiciones</caption>
200    <thead><tr><th scope="col" title="Posición">#</th><th scope="col" class="club">Club</th><th scope="col" title="Partidos jugados">PJ</th><th scope="col"${x} title="Ganados">G</th><th scope="col"${x} title="Empatados">E</th><th scope="col"${x} title="Perdidos">P</th>${completa ? `<th scope="col"${x} title="Goles a favor">GF</th><th scope="col"${x} title="Goles en contra">GC</th>` : ''}<th scope="col" title="Diferencia de goles">DIF</th><th scope="col" title="Puntos">PTS</th></tr></thead>
201    <tbody>${tabla.filas.slice(0, filas).map((f) => `<tr><td>${f.pos}</td><td class="club">${esc(f.club)}</td><td>${f.pj}</td><td${x}>${f.g}</td><td${x}>${f.e}</td><td${x}>${f.p}</td>${completa ? `<td${x}>${f.gf}</td><td${x}>${f.gc}</td>` : ''}<td>${f.dif > 0 ? '+' : ''}${f.dif}</td><td class="pts">${f.pts}</td></tr>`).join('')}</tbody>
202  </table></div>
203  <p class="nota-dato leyenda-tabla">${esc(tabla.temporada || 'Primera División')}. Datos: ESPN. En amarillo, los tres primeros.</p>`;
204}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.