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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); 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®ion=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®ion=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.