diff --git a/app/static/index.html b/app/static/index.html
index 88589d5..442fe14 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -138,6 +138,53 @@
.cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; }
.cam-pop td.k { color: var(--muted); text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.04em; white-space: nowrap; width: 34%; }
.cam-pop a { color: var(--accent); word-break: break-all; }
+
+ /* ── News panel (scraper feed + exec summaries) ─────────────── */
+ .news-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-bottom: 0.9rem; flex-wrap: wrap; }
+ .news-refresh { display: flex; align-items: center; gap: 0.6rem; }
+ .news-updated { font-size: 0.72rem; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+ .news-summary {
+ position: relative; background: linear-gradient(160deg, rgba(56,189,248,0.10), rgba(30,41,59,0.6) 45%);
+ border: 1px solid rgba(56,189,248,0.35); border-radius: 12px; padding: 1rem 1.25rem 1.1rem;
+ box-shadow: 0 0 24px rgba(56,189,248,0.14), inset 0 0 32px rgba(56,189,248,0.05);
+ margin-bottom: 1.2rem; overflow: hidden;
+ }
+ .news-summary::after {
+ content: ''; position: absolute; inset: 0; pointer-events: none;
+ background: radial-gradient(120px 60px at 85% -10%, rgba(56,189,248,0.22), transparent 70%);
+ }
+ .ns-kicker { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); text-shadow: 0 0 12px rgba(56,189,248,0.6); display: flex; align-items: center; gap: 0.5rem; }
+ .ns-badge { font-size: 0.6rem; font-weight: 700; background: rgba(56,189,248,0.16); border: 1px solid rgba(56,189,248,0.4); border-radius: 9999px; padding: 0.12rem 0.5rem; color: var(--accent); letter-spacing: 0.08em; }
+ .ns-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; margin: 0.35rem 0 0.6rem; }
+ .ns-body { font-size: 0.92rem; line-height: 1.62; color: var(--text); white-space: pre-wrap; position: relative; z-index: 1; }
+ .ns-body h1, .ns-body h2, .ns-body h3, .ns-body h4 { font-size: 1rem; margin: 0.5rem 0 0.25rem; color: var(--accent); }
+ .ns-body ul, .ns-body ol { margin: 0.35rem 0 0.35rem 1.2rem; }
+ .ns-body li { margin-bottom: 0.2rem; }
+ .ns-body code { background: rgba(15,23,42,0.6); border: 1px solid var(--border); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; }
+ .ns-empty { color: var(--muted); font-size: 0.85rem; font-style: italic; }
+ .ns-loading { color: var(--muted); font-size: 0.85rem; }
+ .ns-error { color: var(--red); font-size: 0.85rem; }
+ .news-list-head { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.4rem; }
+ .news-count { font-size: 0.7rem; color: var(--accent); font-family: ui-monospace, monospace; }
+ .news-list { max-height: 46vh; min-height: 120px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(15,23,42,0.35); }
+ .news-list::-webkit-scrollbar { width: 8px; }
+ .news-list::-webkit-scrollbar-track { background: rgba(30,41,59,0.4); border-radius: 8px; }
+ .news-list::-webkit-scrollbar-thumb { background: rgba(56,189,248,0.35); border-radius: 8px; }
+ .news-list::-webkit-scrollbar-thumb:hover { background: rgba(56,189,248,0.55); }
+ .news-item {
+ display: flex; align-items: center; gap: 0.75rem; padding: 0.55rem 0.9rem;
+ border-bottom: 1px solid rgba(51,65,85,0.5); text-decoration: none; color: var(--text); transition: background .15s, box-shadow .15s;
+ }
+ .news-item:last-child { border-bottom: none; }
+ .news-item:hover { background: rgba(56,189,248,0.07); box-shadow: inset 2px 0 0 var(--accent); }
+ .news-dom { font-size: 0.66rem; font-family: ui-monospace, monospace; color: var(--accent); background: rgba(56,189,248,0.1); border: 1px solid rgba(56,189,248,0.25); border-radius: 9999px; padding: 0.12rem 0.5rem; white-space: nowrap; flex-shrink: 0; max-width: 30%; overflow: hidden; text-overflow: ellipsis; }
+ .news-title { flex: 1; font-size: 0.87rem; line-height: 1.35; color: var(--text); word-break: break-word; }
+ .news-item:hover .news-title { color: var(--accent); }
+ .news-time { font-size: 0.7rem; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; flex-shrink: 0; }
+ .news-geo { color: var(--green); font-size: 0.8rem; flex-shrink: 0; text-shadow: 0 0 8px rgba(74,222,128,0.6); }
+ .news-loading, .news-error { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; }
+ .news-error { color: var(--red); }
+ .news-empty { padding: 1rem 0.9rem; color: var(--muted); font-size: 0.85rem; font-style: italic; }
@@ -185,6 +232,7 @@
+
@@ -323,6 +371,38 @@
+
+
+
+
+
Global News Feed
+
Scraped hourly from ~257 RSS sources · latest executive summary pinned above
+
+
+ —
+
+
+
+
+
+
+
Executive Summary LATEST
+
—
+
+
No executive summary yet — the Gemini summarizer is idle until GEMINI_API_KEY is set on the Pi.
+
+
+
+
+
+ Recent Headlines
+ 0 articles
+
+
+
+
Search Results (0)
@@ -496,16 +576,17 @@ async function clearKey(name) {
}
function showTab(name) {
- ['recent','alerts','entities','ingest','keys','map'].forEach(t => {
+ ['recent','alerts','entities','ingest','keys','map','news'].forEach(t => {
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
});
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
- b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map'][i]===name);
+ b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map','news'][i]===name);
});
if (name==='alerts') loadAlerts();
if (name==='entities') loadEntities();
if (name==='keys') loadKeys();
if (name==='map') initMap();
+ if (name==='news') loadNews();
}
async function ingestRSS() {
@@ -549,6 +630,122 @@ async function checkHealth() {
}
}
+// ── News feed (scraper pipe + exec summaries) ─────────────────────────────
+let newsInterval = null;
+const NEWS_REFRESH_MS = 900000; // 15 min
+let newsGeo = [];
+let newsPinsLayer = null;
+
+function newsEsc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g,
+ c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+}
+function newsTimeAgo(ts) {
+ if (!ts) return '—';
+ const diff = Date.now() - new Date(ts).getTime();
+ if (diff < 60000) return `${Math.floor(diff/1000)}s ago`;
+ if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
+ if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
+ return `${Math.floor(diff/86400000)}d ago`;
+}
+function newsSimpleMD(text) {
+ // Lightweight markdown→HTML for summary bodies (bold, headers, lists, paragraphs)
+ if (!text) return '';
+ let h = newsEsc(String(text));
+ // code blocks (already escaped so match escaped backticks)
+ h = h.replace(/`([^`]+)`/g, '
$1');
+ // bold
+ h = h.replace(/\*\*(.+?)\*\*/g, '
$1');
+ // headers (start of line # through ##)
+ h = h.replace(/^### (.+)$/gm, '
$1
');
+ h = h.replace(/^## (.+)$/gm, '
$1
');
+ h = h.replace(/^# (.+)$/gm, '
$1
');
+ // unordered lists
+ h = h.replace(/^[\*\-]\s(.+)$/gm, '
$1');
+ h = h.replace(/(
.*<\/li>\n?)+/g, '');
+ // double-newline = paragraph
+ h = h.replace(/\n{2,}/g, '');
+ // single newline = br
+ h = h.replace(/\n/g, '
');
+ return '
'+h+'
';
+}
+async function loadNews(force) {
+ document.getElementById('news-updated').textContent = 'updating…';
+ let articles = [];
+ let summaries = [];
+ try {
+ const [rArticles, rSumm] = await Promise.all([
+ fetch(`${API}/api/news?limit=100`),
+ fetch(`${API}/api/news/summaries?limit=1`)
+ ]);
+ if (rArticles.ok) articles = await rArticles.json();
+ if (rSumm.ok) summaries = await rSumm.json();
+ } catch(e) {
+ document.getElementById('news-list').innerHTML = 'Failed to load news feed: ' + newsEsc(e.message || e) + '
';
+ document.getElementById('ns-body').innerHTML = 'Failed to load summary: ' + newsEsc(e.message || e) + '
';
+ document.getElementById('news-updated').textContent = 'error';
+ console.error('News load failed', e);
+ return;
+ }
+ renderNewsSummary(summaries);
+ renderNewsList(articles);
+ // Geo-pin stretch: if articles carry lat/lon (future backend), collect + plot on GIBS map
+ newsGeo = articles.filter(a => typeof a.lat === 'number' && typeof a.lon === 'number');
+ if (newsGeo.length && map) {
+ renderNewsPins();
+ }
+ document.getElementById('news-updated').textContent = 'updated ' + new Date().toLocaleTimeString();
+ if (!newsInterval) {
+ newsInterval = setInterval(() => loadNews(false), NEWS_REFRESH_MS);
+ }
+}
+function renderNewsSummary(summaries) {
+ const bodyEl = document.getElementById('ns-body');
+ const timeEl = document.getElementById('ns-time');
+ const badgeEl = document.getElementById('ns-badge');
+ if (!summaries || !summaries.length) {
+ bodyEl.innerHTML = 'No executive summary yet — the Gemini summarizer is idle until GEMINI_API_KEY is set on the Pi.
';
+ timeEl.textContent = '—';
+ badgeEl.textContent = 'WAITING';
+ return;
+ }
+ const s = summaries[0];
+ badgeEl.textContent = 'LATEST';
+ timeEl.textContent = s.batch_timestamp ? 'Batch: ' + new Date(s.batch_timestamp).toLocaleString() : '—';
+ bodyEl.innerHTML = newsSimpleMD(s.summary_text || '');
+}
+function renderNewsList(articles) {
+ const listEl = document.getElementById('news-list');
+ document.getElementById('news-count').textContent = articles.length + ' articles';
+ if (!articles || !articles.length) {
+ listEl.innerHTML = 'No articles scraped yet — the scraper runs hourly at :00. Check news-scraper logs.
';
+ return;
+ }
+ listEl.innerHTML = articles.map(a => {
+ const geoTag = a.lat != null && a.lon != null ? '📍' : '';
+ return '' +
+ '' + newsEsc(a.domain || '?') + '' +
+ '' + newsEsc((a.title||'').trim()) + '' +
+ geoTag +
+ '' + newsTimeAgo(a.timestamp) + '' +
+ '';
+ }).join('');
+}
+function renderNewsPins() {
+ if (!map || !newsGeo.length) return;
+ if (newsPinsLayer) { map.removeLayer(newsPinsLayer); newsPinsLayer = null; }
+ newsPinsLayer = L.layerGroup(newsGeo.map(a => {
+ const icon = L.divIcon({
+ className: '',
+ html:'',
+ iconSize: [12, 12], iconAnchor: [6, 6],
+ });
+ return L.marker([a.lat, a.lon], { icon })
+ .bindPopup(``);
+ }));
+ newsPinsLayer.addTo(map);
+}
+
// ── Map (NASA GIBS basemap + FIRMS heatmap + open cameras) ───────────────
let map = null, mapLayer = null, mapInitStarted = false;
let firesHeat = null, camsGroup = null, firesOn = false, camsOn = false;