diff --git a/index.html b/index.html index 981c10d..fe9d8f9 100644 --- a/index.html +++ b/index.html @@ -370,6 +370,9 @@ async function updateWeather() { if (d.alerts && d.alerts.length) { html += ' ⚠️ ' + d.alerts.join(', ') + ''; } + if (d.tropical) { + html += ' 🌀 ' + d.tropical + ''; + } document.getElementById('weather-line').innerHTML = html; } catch(e) {} } diff --git a/server.js b/server.js index f728564..706ec59 100644 --- a/server.js +++ b/server.js @@ -123,24 +123,68 @@ app.get('/api/weather', async (req, res) => { result.high = parseInt(today.maxtempF); result.low = parseInt(today.mintempF); - // NWS severe alerts - const nws = await new Promise((resolve, reject) => { - const r = require('https').get('https://api.weather.gov/alerts/active?point=27.7676,-82.6403', { - headers: { 'User-Agent': 'james-dashboard' } - }, (resp) => { - let body = ''; - resp.on('data', c => body += c); - resp.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } }); + // NWS severe alerts (imminent) + try { + const nws = await new Promise((resolve, reject) => { + const r = require('https').get('https://api.weather.gov/alerts/active?point=27.7676,-82.6403', { + headers: { 'User-Agent': 'james-dashboard' } + }, (resp) => { + let body = ''; + resp.on('data', c => body += c); + resp.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } }); + }); + r.on('error', reject); + r.setTimeout(10000, () => { r.destroy(); reject(new Error('timeout')); }); }); - r.on('error', reject); - r.setTimeout(10000, () => { r.destroy(); reject(new Error('timeout')); }); - }); - for (const f of (nws.features || [])) { - const event = (f.properties.event || '').toLowerCase(); - if (SEVERE_EVENTS.some(s => event.includes(s))) { - result.alerts.push(f.properties.event); + for (const f of (nws.features || [])) { + const event = (f.properties.event || '').toLowerCase(); + if (SEVERE_EVENTS.some(s => event.includes(s))) { + result.alerts.push(f.properties.event); + } } - } + } catch(e) {} + + // NHC Tropical Weather Outlook (7-day formation, hurricane season) + try { + const twoHtml = await new Promise((resolve, reject) => { + const r = require('https').get('https://www.nhc.noaa.gov/text/MIATWOAT.shtml', { + headers: { 'User-Agent': 'james-dashboard' } + }, (resp) => { + let body = ''; + resp.on('data', c => body += c); + resp.on('end', () => resolve(body)); + }); + r.on('error', reject); + r.setTimeout(10000, () => { r.destroy(); reject(new Error('timeout')); }); + }); + const preMatch = twoHtml.match(/
([\s\S]*?)<\/pre>/);
+ if (preMatch) {
+ const text = preMatch[1].replace(/<[^>]+>/g, '');
+ // Skip if off-season / "not expected"
+ if (!text.includes('formation is not expected')) {
+ // Extract formation probabilities
+ const lines = text.split('\n');
+ const formations = [];
+ for (let i = 0; i < lines.length; i++) {
+ const pctMatch = lines[i].match(/(\d+)\s*percent/i);
+ if (pctMatch && parseInt(pctMatch[1]) >= 20) {
+ // Find nearby context about Gulf/Caribbean
+ const context = lines.slice(Math.max(0, i-3), i+2).join(' ');
+ const loc = context.match(/Gulf|Caribbean|Atlantic|Florida|Bahamas/i);
+ formations.push({
+ pct: parseInt(pctMatch[1]),
+ location: loc ? loc[0] : 'Atlantic',
+ text: lines[i].trim()
+ });
+ }
+ }
+ if (formations.length) {
+ const worst = formations.sort((a,b) => b.pct - a.pct)[0];
+ result.tropical = `${worst.pct}% tropical development (${worst.location}, 7-day)`;
+ }
+ }
+ }
+ } catch(e) {}
weatherCache = { data: result, ts: Date.now() };
res.json(result);