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);