1(function () { 2 'use strict'; 3 4 // Weather config 5 const WEATHER_CONFIG = { 6 API_BASE: 'https://api.weather.gov', 7 STATION: 'KATL', 8 LAT: 33.748547, 9 LON: -84.391502, 10 GRID_OFFICE: 'FFC', 11 GRID_X: 52, 12 GRID_Y: 88, 13 REFRESH_INTERVAL: 600000, // 10 min 14 FORECAST_URL: 'https://forecast.weather.gov/MapClick.php?lat=33.748547&lon=-84.391502' 15 }; 16 17 const NEWS_CONFIG = { 18 CORS_PROXIES: [ 19 'https://api.codetabs.com/v1/proxy?quest=', 20 'https://corsproxy.io/?' 21 ], 22 // allorigins /get endpoint (JSON wrapper with proper CORS headers) 23 ALLORIGINS_GET: 'https://api.allorigins.win/get?url=', 24 BBC_MUNDO_RSS: 'https://feeds.bbci.co.uk/mundo/rss.xml', 25 BBC_IMG_SIZE: 640, 26 REFRESH_INTERVAL: 300000 // 5 min 27 }; 28 29 // Translation dictionary for weather conditions 30 const WEATHER_ES = { 31 'Sunny': 'Soleado', 'Clear': 'Despejado', 'Mostly Clear': 'Mayormente Despejado', 32 'Partly Cloudy': 'Parcialmente Nublado', 'Mostly Cloudy': 'Mayormente Nublado', 33 'Cloudy': 'Nublado', 'Overcast': 'Cielo Cubierto', 34 'Fog': 'Niebla', 'Haze': 'Neblina', 'Mist': 'Bruma', 35 'Rain': 'Lluvia', 'Light Rain': 'Lluvia Ligera', 'Heavy Rain': 'Lluvia Fuerte', 36 'Showers': 'Chubascos', 'Drizzle': 'Llovizna', 37 'Thunderstorms': 'Tormentas', 'Severe Thunderstorms': 'Tormentas Severas', 38 'Snow': 'Nieve', 'Light Snow': 'Nieve Ligera', 'Heavy Snow': 'Nevada Fuerte', 39 'Sleet': 'Aguanieve', 'Freezing Rain': 'Lluvia Helada', 40 'Windy': 'Ventoso', 'Breezy': 'Ventoso', 41 'Hot': 'Caluroso', 'Cold': 'FrÃo', 42 'Chance Showers': 'Prob. Chubascos', 'Slight Chance Showers': 'Baja Prob. Chubascos', 43 'Showers Likely': 'Prob. Alta de Chubascos', 44 'Chance Rain': 'Prob. Lluvia', 'Rain Likely': 'Prob. Alta de Lluvia', 45 'Chance Thunderstorms': 'Prob. Tormentas', 46 'Partly Sunny': 'Parcialmente Soleado', 'Mostly Sunny': 'Mayormente Soleado', 47 'Cold Weather Advisory': 'Aviso de Clima FrÃo' 48 }; 49 50 const WEATHER_ICONS = { 51 'sunny': 'fa-sun', 'clear': 'fa-moon', 'mostly clear': 'fa-moon', 52 'partly cloudy': 'fa-cloud-sun', 'mostly cloudy': 'fa-cloud', 53 'cloudy': 'fa-cloud', 'overcast': 'fa-cloud', 54 'fog': 'fa-smog', 'haze': 'fa-smog', 'mist': 'fa-smog', 55 'rain': 'fa-cloud-rain', 'light rain': 'fa-cloud-rain', 56 'heavy rain': 'fa-cloud-showers-heavy', 'showers': 'fa-cloud-showers-heavy', 57 'drizzle': 'fa-cloud-rain', 58 'thunderstorms': 'fa-bolt', 'severe thunderstorms': 'fa-bolt', 59 'snow': 'fa-snowflake', 'light snow': 'fa-snowflake', 60 'sleet': 'fa-cloud-meatball', 'freezing rain': 'fa-icicles', 61 'windy': 'fa-wind', 'breezy': 'fa-wind', 62 'hot': 'fa-temperature-high', 'cold': 'fa-temperature-low' 63 }; 64 65 const DAY_NAMES_ES = { 66 'Monday': 'Lun', 'Tuesday': 'Mar', 'Wednesday': 'Mié', 67 'Thursday': 'Jue', 'Friday': 'Vie', 'Saturday': 'Sáb', 'Sunday': 'Dom', 68 'Today': 'Hoy', 'Tonight': 'Noche', 'Overnight': 'Noche', 69 'This Afternoon': 'Tarde', 'This Evening': 'Noche' 70 }; 71 72 document.addEventListener('DOMContentLoaded', function () { 73 loadWeather(); 74 setInterval(loadWeather, WEATHER_CONFIG.REFRESH_INTERVAL); 75 loadNews(); 76 setInterval(loadNews, NEWS_CONFIG.REFRESH_INTERVAL); 77 78 const newsRefreshBtn = document.getElementById('newsRefreshBtn'); 79 if (newsRefreshBtn) { 80 newsRefreshBtn.addEventListener('click', function () { 81 this.classList.add('spinning'); 82 loadNews().finally(() => { 83 setTimeout(() => this.classList.remove('spinning'), 600); 84 }); 85 }); 86 } 87 }); 88 89 // Load weather 90 async function loadWeather() { 91 try { 92 // Fetch current conditions 93 const currentRes = await fetch( 94 `${WEATHER_CONFIG.API_BASE}/stations/${WEATHER_CONFIG.STATION}/observations/latest`, 95 { headers: { 'Accept': 'application/geo+json', 'User-Agent': '
95LaQueBuena101.9FM' } } 96 ); 97 98 if (currentRes.ok) { 99 const currentData = await currentRes.json(); 100 updateCurrentWeather(currentData); 101 } 102 } catch (err) { 103 console.warn('NWS current weather failed:', err); 104 setFallbackWeather(); 105 } 106 107 try { 108 // Fetch forecast 109 const forecastRes = await fetch( 110 `${WEATHER_CONFIG.API_BASE}/gridpoints/${WEATHER_CONFIG.GRID_OFFICE}/${WEATHER_CONFIG.GRID_X},${WEATHER_CONFIG.GRID_Y}/forecast`, 111 { headers: { 'Accept': 'application/geo+json', 'User-Agent': 'LaQueBuena101.9FM' } } 112 ); 113 114 if (forecastRes.ok) { 115 const forecastData = await forecastRes.json(); 116 updateForecast(forecastData); 117 checkAlerts(forecastData); 118 } 119 } catch (err) { 120 console.warn('NWS forecast failed:', err); 121 } 122 123 // Check for active alerts 124 try { 125 const alertsRes = await fetch( 126 `${WEATHER_CONFIG.API_BASE}/alerts/active?point=${WEATHER_CONFIG.LAT},${WEATHER_CONFIG.LON}`, 127 { headers: { 'Accept': 'application/geo+json', 'User-Agent': 'LaQueBuena101.9FM' } } 128 ); 129 if (alertsRes.ok) { 130 const alertsData = await alertsRes.json(); 131 updateAlerts(alertsData); 132 } 133 } catch (err) { 134 console.warn('NWS alerts failed:', err); 135 } 136 } 137 138 function updateCurrentWeather(data) { 139 const props = data.properties; 140 if (!props) return; 141 142 const tempC = props.temperature?.value; 143 const tempF = tempC != null ? Math.round((tempC * 9 / 5) + 32) : '--'; 144 const desc = props.textDescription || 'N/A'; 145 const humidity = props.relativeHumidity?.value != null ? Math.round(props.relativeHumidity.value) : '--'; 146 const windSpeedKmh = props.windSpeed?.value; 147 const windMph = windSpeedKmh != null ? Math.round(windSpeedKmh * 0.621371) : '--'; 148 const windChillC = props.windChill?.value; 149 const feelsF = windChillC != null ? Math.round((windChillC * 9 / 5) + 32) : tempF; 150 151 const el = (id) => document.getElementById(id); 152 153 if (el('weatherTemp')) el('weatherTemp').textContent = tempF; 154 if (el('weatherDesc')) el('weatherDesc').textContent = translateWeather(desc); 155 if (el('weatherHumidity')) el('weatherHumidity').textContent = `${humidity}%`; 156 if (el('weatherWind')) el('weatherWind').textContent = `${windMph} mph`; 157 if (el('weatherFeels')) el('weatherFeels').textContent = `${feelsF}°F`; 158 159 // Update icon 160 const iconEl = el('weatherIconMain'); 161 if (iconEl) { 162 const iconClass = getWeatherIcon(desc); 163 iconEl.className = `fas ${iconClass} weather-icon-main`; 164 165 // Color based on temp 166 if (tempF !== '--') { 167 if (tempF >= 85) iconEl.style.color = '#ff6b35'; 168 else if (tempF >= 70) iconEl.style.color = '#FFD700'; 169 else if (tempF >= 50) iconEl.style.color = '#4facfe'; 170 else if (tempF >= 32) iconEl.style.color = '#82c8ff'; 171 else iconEl.style.color = '#a0d2ff'; 172 } 173 } 174 } 175 176 function updateForecast(data) { 177 const periods = data.properties?.periods; 178 if (!periods || periods.length === 0) return; 179 180 const forecastEl = document.getElementById('weatherForecast'); 181 if (!forecastEl) return; 182 183 let html = ''; 184 const count = Math.min(5, periods.length); 185 186 for (let i = 0; i < count; i++) { 187 const p = periods[i]; 188 const dayName = translateDay(p.name); 189 const icon = getWeatherIcon(p.shortForecast); 190 const temp = p.temperature; 191 const isNight = p.isDaytime === false; 192 193 html += ` 194 <div class="weather-fc-item"> 195 <span class="weather-fc-day">${dayName}</span> 196 <i class="fas ${icon}
196 weather-fc-icon" style="color: ${isNight ? '#82c8ff' : '#FFD700'}"></i> 197 <span class="weather-fc-temp">${temp}°</span> 198 </div>`; 199 } 200 201 forecastEl.innerHTML = html; 202 } 203 204 function updateAlerts(data) { 205 const features = data.features; 206 const alertEl = document.getElementById('weatherAlert'); 207 const alertText = document.getElementById('weatherAlertText'); 208 if (!alertEl || !alertText) return; 209 210 if (features && features.length > 0) { 211 const alert = features[0].properties; 212 const headline = alert.headline || alert.event || 'Alerta Meteorológica'; 213 const translated = translateWeather(headline); 214 alertText.textContent = translated; 215 alertEl.style.display = 'flex'; 216 } 217 } 218 219 function checkAlerts(data) { 220 // Check if first period has hazard info 221 const periods = data.properties?.periods; 222 if (!periods || periods.length === 0) return; 223 224 const first = periods[0]; 225 if (first.detailedForecast && first.detailedForecast.toLowerCase().includes('advisory')) { 226 const alertEl = document.getElementById('weatherAlert'); 227 const alertText = document.getElementById('weatherAlertText'); 228 if (alertEl && alertText && alertEl.style.display === 'none') { 229 alertText.textContent = 'Aviso meteorológico activo para el área de Atlanta'; 230 alertEl.style.display = 'flex'; 231 } 232 } 233 } 234 235 function setFallbackWeather() { 236 // Use scraped data from NWS as static fallback 237 const el = (id) => document.getElementById(id); 238 if (el('weatherTemp')) el('weatherTemp').textContent = '30'; 239 if (el('weatherDesc')) el('weatherDesc').textContent = 'Parcialmente Nublado'; 240 if (el('weatherHumidity')) el('weatherHumidity').textContent = '51%'; 241 if (el('weatherWind')) el('weatherWind').textContent = '17 mph NW'; 242 if (el('weatherFeels')) el('weatherFeels').textContent = '18°F'; 243 244 const alertEl = document.getElementById('weatherAlert'); 245 const alertText = document.getElementById('weatherAlertText'); 246 if (alertEl && alertText) { 247 alertText.textContent = 'Aviso de Clima FrÃo hasta las 10:00 AM'; 248 alertEl.style.display = 'flex'; 249 } 250 251 // Static forecast 252 const forecastEl = document.getElementById('weatherForecast'); 253 if (forecastEl) { 254 forecastEl.innerHTML = ` 255 <div class="weather-fc-item"><span class="weather-fc-day">Noche</span><i class="fas fa-cloud-moon weather-fc-icon" style="color:#82c8ff"></i><span class="weather-fc-temp">25°</span></div> 256 <div class="weather-fc-item"><span class="weather-fc-day">Lun</span><i class="fas fa-sun weather-fc-icon" style="color:#FFD700"></i><span class="weather-fc-temp">41°</span></div> 257 <div class="weather-fc-item"><span class="weather-fc-day">Mar</span><i class="fas fa-sun weather-fc-icon" style="color:#FFD700"></i><span class="weather-fc-temp">53°</span></div> 258 <div class="weather-fc-item"><span class="weather-fc-day">Mié</span><i class="fas fa-cloud weather-fc-icon" style="color:#a0a0a0"></i><span class="weather-fc-temp">61°</span></div> 259 <div class="weather-fc-item"><span class="weather-fc-day">Jue</span><i class="fas fa-cloud-showers-heavy weather-fc-icon" style="color:#4facfe"></i><span class="weather-fc-temp">66°</span></div>`; 260 } 261 } 262 263 function translateWeather(text) { 264 for (const [en, es] of Object.entries(WEATHER_ES)) { 265 if (text.toLowerCase().includes(en.toLowerCase())) { 266 return es; 267 } 268 } 269 return text; 270 } 271 272 function translateDay(name) { 273 // Remove "Night" suffix for translation 274 for (const [en, es] of Object.entries(DAY_NAMES_ES)) { 275 if (name.includes(en)) return es; 276 } 277 if (name.includes('Night')) { 278 const dayPart = name.replace(' Night', ''); 279 for (const [en, es] of Object.entries(DAY_NAMES_ES)) { 280 if (dayPart.includes(en)) return es + ' N'; 281 } 282 return 'Noche'; 283 } 284 return name.substring(0, 3); 285 } 286 287 function getWeatherIcon(desc) { 288 const d = desc.toLowerCase(); 289 for (const [key, icon] of Object.entries(WEATHER_ICONS)) { 290 if (d.includes(key)) return icon; 291 } 292 return 'fa-cloud-sun'; 293 } 294 295 // ======================================== 296 // NEWS â BBC Mundo RSS 297 // ======================================== 298 299 async function loadNews() { 300 const newsGrid = document.getElementById('newsGrid'); 301 if (!newsGrid) return Promise.resolve(); 302 303 try { 304 const articles = await fetchBBCMundoRSS(); 305 if (articles && articles.length >= 4) { 306 renderNews(articles); 307 return; 308 } 309 } catch (err) { 310 console.warn('BBC Mundo RSS failed:', err); 311 } 312 313 // Show error state 314 newsGrid.innerHTML = ` 315 <div class="news-loading" style="grid-column:1/-1"> 316 <i class="fas fa-exclamation-triangle"></i> 317 <p>No se pudieron cargar las noticias. Intenta de nuevo.</p> 318 </div>`; 319 } 320 321 async function fetchBBCMundoRSS() { 322 // Try direct CORS proxies first 323 for (const proxy of NEWS_CONFIG.CORS_PROXIES) { 324 try { 325 const url = proxy + encodeURIComponent(NEWS_CONFIG.BBC_MUNDO_RSS); 326 const response = await fetch(url, { signal: AbortSignal.timeout(12000) }); 327 if (!response.ok) continue; 328 329 const xml = await response.text(); 330 if (!xml.includes('<rss') && !xml.includes('<?xml')) continue; 331 return parseBBCMundoRSS(xml); 332 } catch (e) { 333 continue; 334 } 335 } 336 337 // Fallback: allorigins /get endpoint (returns JSON wrapper with CORS) 338 try { 339 const url = NEWS_CONFIG.ALLORIGINS_GET + encodeURIComponent(NEWS_CONFIG.BBC_MUNDO_RSS); 340 const response = await fetch(url, { signal: AbortSignal.timeout(12000) }); 341 if (response.ok) { 342 const json = await response.json(); 343 const xml = json.contents || ''; 344 if (xml.includes('<rss') || xml.includes('<?xml')) { 345 return parseBBCMundoRSS(xml); 346 } 347 } 348 } catch (e) { /* silent */ } 349 350 return null; 351 } 352 353 function parseBBCMundoRSS(xml) { 354 const parser = new DOMParser(); 355 const doc = parser.parseFromString(xml, 'text/xml'); 356 const items = doc.querySelectorAll('item'); 357 const articles = []; 358
359 items.forEach((item, i) => { 360 if (i >= 10) return; 361 362 const title = item.querySelector('title') ? item.querySelector('title').textContent.trim() : ''; 363 const link = item.querySelector('link') ? item.querySelector('link').textContent.trim() : ''; 364 const desc = item.querySelector('description') ? item.querySelector('description').textContent.trim() : ''; 365 const pubDate = item.querySelector('pubDate') ? item.querySelector('pubDate').textContent.trim() : ''; 366 367 if (!title || !link) return; 368 369 // Get thumbnail â BBC uses media:thumbnail namespace 370 let image = ''; 371 const thumbnail = item.getElementsByTagNameNS('http://search.yahoo.com/mrss/', 'thumbnail')[0]; 372 if (thumbnail) { 373 image = thumbnail.getAttribute('url') || ''; 374 // Upgrade from 240px to higher res 375 if (image) { 376 image = image.replace(/\/\d+\/cpsprodpb/, '/' + NEWS_CONFIG.BBC_IMG_SIZE + '/cpsprodpb'); 377 } 378 } 379 380 const category = categorizeNewsES(title); 381 const timeAgo = pubDate ? formatPubDate(pubDate) : ''; 382 383 articles.push({ 384 title: title, 385 desc: cleanDescription(desc), 386 url: link, 387 image: image, 388 category: category, 389 icon: getCategoryIcon(category), 390 timeAgo: timeAgo 391 }); 392 }); 393 394 return articles; 395 } 396 397 function cleanDescription(desc) { 398 if (!desc) return ''; 399 // Strip HTML tags from description 400 const tmp = document.createElement('div'); 401 tmp.innerHTML = desc; 402 const text = tmp.textContent || tmp.innerText || ''; 403 return text.length > 150 ? text.substring(0, 147) + '...' : text; 404 } 405 406 function formatPubDate(pubDateStr) { 407 try { 408 const pubDate = new Date(pubDateStr); 409 const now = new Date(); 410 const diffMs = now - pubDate; 411 const diffMins = Math.floor(diffMs / 60000); 412 const diffHours = Math.floor(diffMs / 3600000); 413 414 if (diffMins < 5) return 'Hace un momento'; 415 if (diffMins < 60) return `Hace ${diffMins} min`; 416 if (diffHours < 2) return 'Hace 1 hora'; 417 if (diffHours < 24) return `Hace ${diffHours} horas`; 418 if (diffHours < 48) return 'Ayer'; 419 return pubDate.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }); 420 } catch (e) { 421 return ''; 422 } 423 } 424 425 function categorizeNewsES(title) { 426 const t = title.toLowerCase(); 427 if (t.includes('guerra') || t.includes('soldado') || t.includes('militar') || t.includes('ejército') || t.includes('bomba')) return 'MUNDO'; 428 if (t.includes('incendio') || t.includes('rescate') || t.includes('terremoto') || t.includes('emergencia') || t.includes('erupción')) return 'EMERGENCIA'; 429 if (t.includes('accidente') || t.includes('tráfico') || t.includes('carretera') || t.includes('choque')) return 'TRÃFICO'; 430 if (t.includes('clima') || t.includes('tormenta') || t.includes('lluvia') || t.includes('huracán') || t.includes('frÃo') || t.includes('calor')) return 'CLIMA'; 431 if (t.includes('policÃa') || t.includes('tiroteo') || t.includes('asesinato') || t.includes('crimen') || t.includes('arresto') || t.includes('narco')) return 'POLICÃA'; 432 if (t.includes('vuelo') || t.includes('aeropuerto') || t.includes('avión') || t.includes('aviación')) return 'AVIACIÃN'; 433 if (t.includes('salud') || t.includes('hospital') || t.includes('médico') || t.includes('enfermedad') || t.includes('virus') || t.includes('vacuna')) return 'SALUD'; 434 if (t.includes('fútbol') || t.includes('deporte') || t.includes('juego') || t.includes('gol') || t.includes('olimpi')) return 'DEPORTES'; 435 if (t.includes('trump') || t.includes('biden') || t.includes('congreso') || t.includes('elecciones') || t.includes('presidente') || t.includes('gobierno')) return 'POLÃTICA'; 436 if (t.includes('negocio') || t.includes('economÃa') || t.includes('mercado') || t.includes('dólar') || t.includes('inflación')) return 'ECONOMÃA'; 437 if (t.includes('tecnologÃa') || t.includes('inteligencia artificial') || t.includes('app') || t.includes('internet')) return 'TECNOLOGÃA'; 438 if (t.includes('cuba') || t.includes('venezuela') || t.includes('méxico') || t.includes('colombia') || t.includes('latino') || t.includes('migra')) return 'LATINOAMÃRICA'; 439 return 'MUNDO'; 440 } 441 442 function getCategoryIcon(cat) { 443 const icons = { 444 'EMERGENCIA': 'fa-fire', 'TRÃFICO': 'fa-road', 'CLIMA': 'fa-cloud-sun', 445 'POLICÃA': 'fa-shield-alt', 'AVIACIÃN': 'fa-plane', 'SALUD': 'fa-heartbeat', 446 'MUNDO': 'fa-globe-americas', 'DEPORTES': 'fa-football-ball', 447 'POLÃTICA': 'fa-landmark', 'ECONOMÃA': 'fa-chart-line', 448 'TECNOLOGÃA': 'fa-microchip', 'LATINOAMÃRICA': 'fa-globe-americas' 449 }; 450 return icons[cat] || 'fa-newspaper'; 451 } 452 453 function renderNews(articles) { 454 const newsGrid = document.getElementById('newsGrid'); 455 if (!newsGrid) return; 456 457 if (!Array.isArray(articles) || articles.length === 0) { 458 newsGrid.innerHTML = ` 459 <div class="news-loading" style="grid-column:1/-1"> 460 <i class="fas fa-exclamation-triangle"></i> 461 <p>No hay noticias disponibles en este momento.</p> 462 </div>`; 463 return; 464 } 465 466 let html = ''; 467
468 articles.forEach((article, i) => { 469 if (!article || typeof article !== 'object') return; 470 471 const isLarge = i === 0; 472 const delay = i * 0.08; 473 const safeTitle = escapeHtml(article.title || 'Sin titular'); 474 const safeDesc = escapeHtml(article.desc || ''); 475 const safeCategory = escapeHtml(article.category || 'MUNDO'); 476 const safeTimeAgo = escapeHtml(article.timeAgo || 'Reciente'); 477 const safeIcon = escapeHtml(article.icon || 'fa-newspaper'); 478 const safeImage = escapeHtml(article.image || ''); 479 const hasImage = safeImage.startsWith('http'); 480 481 html += ` 482 <div class="news-card ${isLarge ? 'large' : ''}" style="animation-delay: ${delay}s"> 483 <div class="news-image"> 484 ${hasImage ? `<img src="${safeImage}" alt="" loading="lazy" onerror="this.style.display='none';var icon=(this.parentElement&&this.parentElement.querySelector('.news-icon'))?this.parentElement.querySelector('.news-icon'):null;if(icon){icon.style.display='flex';}">` : ''} 485 <i class="fas ${safeIcon} news-icon" ${hasImage ? 'style="display:none"' : ''}></i> 486 <span class="news-category">${safeCategory}</span> 487 </div> 488 <div class="news-content"> 489 <span class="news-date"><i class="fas fa-clock"></i> ${safeTimeAgo}</span> 490 <h4>${safeTitle}</h4> 491 ${safeDesc ? `<p>${safeDesc}</p>` : ''} 492 <span class="news-source-badge"><i class="fas fa-newspaper"></i> Fuente: BBC Mundo</span> 493 </div> 494 </div>`; 495 }); 496 497 newsGrid.innerHTML = html; 498 } 499 500 function escapeHtml(str) { 501 if (!str) return ''; 502 const div = document.createElement('div'); 503 div.textContent = str; 504 return div.innerHTML; 505 } 506 507})();
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.