mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate regional briefing and weather sources
This commit is contained in:
1 parent
213edd80ce
commit
318d37ad8d
11 files changed
+475
-348
No files matched your search
+31
-348
@@ -18,6 +18,10 @@ export { writeMilitaryInstallationDisk } from './military-installations/cache.js
|
||||
export { validMilitaryInstallationBox } from './military-installations/query.js';
|
||||
export { militaryInstallationFailureReason } from './military-installations/query.js';
|
||||
import { militaryInstallationsProxy } from './military-installations.js';
|
||||
export { validRegionalPoint } from './regional/query.js';
|
||||
export { regionalBriefHasAnySource } from './regional/briefing.js';
|
||||
import { regionalBriefProxy } from './regional/briefing.js';
|
||||
import { weatherEffectsProxy } from './regional/weather-effects.js';
|
||||
import { cctvProxy } from './cctv.js';
|
||||
import { radioBrowserProxy } from './radio.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream } from './cctv.js';
|
||||
@@ -1472,372 +1476,51 @@ const GEV_REALTIME_TOOLS = [
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regional cockpit briefing proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
const REGIONAL_BRIEF_CACHE_MS = 5 * 60_000;
|
||||
const REGIONAL_BRIEF_STALE_MS = 60 * 60_000;
|
||||
const REGIONAL_BRIEF_MAX_CACHE = 120;
|
||||
const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const _regionalBriefCache = new Map();
|
||||
const _regionalBriefInFlight = new Map();
|
||||
const _regionalBriefRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 90 });
|
||||
const WEATHER_EFFECTS_CACHE_MS = 5 * 60_000;
|
||||
const WEATHER_EFFECTS_STALE_MS = 30 * 60_000;
|
||||
const WEATHER_EFFECTS_MAX_CACHE = 180;
|
||||
const WEATHER_EFFECTS_MAX_RESPONSE_BYTES = 512 * 1024;
|
||||
const _weatherEffectsCache = new Map();
|
||||
const _weatherEffectsInFlight = new Map();
|
||||
const _weatherEffectsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 45, globalMax: 120 });
|
||||
let _nominatimQueue = Promise.resolve();
|
||||
let _nominatimLastRequestAt = 0;
|
||||
|
||||
export function validRegionalPoint(params) {
|
||||
const latitude = requiredFiniteQueryNumber(params, 'latitude');
|
||||
const longitude = requiredFiniteQueryNumber(params, 'longitude');
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
function trimRegionalBriefCache() {
|
||||
while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) {
|
||||
const oldest = _regionalBriefCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
_regionalBriefCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function trimWeatherEffectsCache() {
|
||||
while (_weatherEffectsCache.size > WEATHER_EFFECTS_MAX_CACHE) {
|
||||
const oldest = _weatherEffectsCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
_weatherEffectsCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegionalJson(url, {
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal, headers });
|
||||
if (!response.ok) throw new Error(`Upstream returned ${response.status}`);
|
||||
return readResponseJsonCapped(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegionalText(url, {
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal, headers });
|
||||
if (!response.ok) throw new Error(`Upstream returned ${response.status}`);
|
||||
return readResponseTextCapped(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRssText(value) {
|
||||
return String(value || '')
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function rssTag(block, tag) {
|
||||
return decodeRssText(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(block)?.[1] || '');
|
||||
}
|
||||
|
||||
function normalizeRssArticles(xml, limit = 5) {
|
||||
const seen = new Set();
|
||||
const articles = [];
|
||||
for (const match of String(xml || '').matchAll(/<item>([\s\S]*?)<\/item>/gi)) {
|
||||
const item = match[1];
|
||||
const title = rssTag(item, 'title').slice(0, 180);
|
||||
const url = rssTag(item, 'link');
|
||||
let parsedUrl;
|
||||
try { parsedUrl = new URL(url); } catch { continue; }
|
||||
if (!title || !['http:', 'https:'].includes(parsedUrl.protocol)) continue;
|
||||
const source = rssTag(item, 'source');
|
||||
const signature = `${title.toLowerCase()}|${source.toLowerCase() || parsedUrl.hostname}`;
|
||||
if (seen.has(signature)) continue;
|
||||
seen.add(signature);
|
||||
const rawDate = rssTag(item, 'pubDate');
|
||||
articles.push({
|
||||
title,
|
||||
url: parsedUrl.href,
|
||||
domain: source || parsedUrl.hostname.replace(/^www\./, ''),
|
||||
publishedAt: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(),
|
||||
sourceCountry: null,
|
||||
});
|
||||
if (articles.length >= limit) break;
|
||||
}
|
||||
return articles;
|
||||
}
|
||||
|
||||
function fetchRegionalPlace(point) {
|
||||
const task = _nominatimQueue.then(async () => {
|
||||
const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt));
|
||||
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
_nominatimLastRequestAt = Date.now();
|
||||
const params = new URLSearchParams({
|
||||
format: 'jsonv2',
|
||||
lat: point.latitude.toFixed(5),
|
||||
lon: point.longitude.toFixed(5),
|
||||
zoom: '10',
|
||||
addressdetails: '1',
|
||||
'accept-language': 'en',
|
||||
});
|
||||
const payload = await fetchRegionalJson(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: {
|
||||
'User-Agent': 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
},
|
||||
});
|
||||
return normalizeRegionalPlace(payload);
|
||||
});
|
||||
_nominatimQueue = task.catch(() => null);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function fetchRegionalNews(place) {
|
||||
const query = place?.locality || place?.region || place?.country;
|
||||
if (!query) return { status: 'unavailable', query: null, articles: [], source: null };
|
||||
const rssParams = new URLSearchParams({
|
||||
q: String(query).replace(/["\\]/g, ' ').trim(),
|
||||
hl: 'en-US',
|
||||
gl: 'US',
|
||||
ceid: 'US:en',
|
||||
});
|
||||
try {
|
||||
const xml = await fetchRegionalText(`https://news.google.com/rss/search?${rssParams}`, {
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
const articles = normalizeRssArticles(xml, 5);
|
||||
if (articles.length) return { status: 'ready', query, articles, source: 'Google News RSS' };
|
||||
} catch { /* fall through to the existing free index */ }
|
||||
const params = new URLSearchParams({
|
||||
query: `"${String(query).replace(/["\\]/g, ' ').trim()}"`,
|
||||
mode: 'artlist',
|
||||
format: 'json',
|
||||
maxrecords: '5',
|
||||
sort: 'datedesc',
|
||||
timespan: '48h',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.gdeltproject.org/api/v2/doc/doc?${params}`, {
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
const articles = normalizeRegionalArticles(payload, 5);
|
||||
return { status: articles.length ? 'ready' : 'empty', query, articles, source: 'GDELT fallback' };
|
||||
} catch {
|
||||
return { status: 'unavailable', query, articles: [], source: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegionalWeather(point) {
|
||||
const params = new URLSearchParams({
|
||||
latitude: point.latitude.toFixed(5),
|
||||
longitude: point.longitude.toFixed(5),
|
||||
current: 'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.open-meteo.com/v1/forecast?${params}`, {
|
||||
maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES,
|
||||
});
|
||||
return normalizeRegionalWeather(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/** True when at least one regional source produced usable data. */
|
||||
export function regionalBriefHasAnySource({ place, weather, news } = {}) {
|
||||
return Boolean(place || weather || (news && news.status !== 'unavailable'));
|
||||
}
|
||||
|
||||
function regionalBriefProxy() {
|
||||
async function refresh(point, key) {
|
||||
const [placeResult, weatherResult] = await Promise.allSettled([
|
||||
fetchRegionalPlace(point),
|
||||
fetchRegionalWeather(point),
|
||||
]);
|
||||
const place = placeResult.status === 'fulfilled' ? placeResult.value : null;
|
||||
const weather = weatherResult.status === 'fulfilled' ? weatherResult.value : null;
|
||||
const news = await fetchRegionalNews(place);
|
||||
if (!regionalBriefHasAnySource({ place, weather, news })) {
|
||||
throw new Error('All regional briefing sources unavailable');
|
||||
}
|
||||
const payload = {
|
||||
status: place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
|
||||
retrievedAt: new Date().toISOString(),
|
||||
coordinates: point,
|
||||
place,
|
||||
placeStatus: place ? 'ready' : 'unavailable',
|
||||
weather,
|
||||
weatherStatus: weather ? 'ready' : 'unavailable',
|
||||
newsStatus: news.status,
|
||||
newsQuery: news.query,
|
||||
newsSource: news.source,
|
||||
articles: news.articles,
|
||||
};
|
||||
_regionalBriefCache.set(key, { payload, cachedAt: Date.now() });
|
||||
trimRegionalBriefCache();
|
||||
return payload;
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/regional-brief', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!_regionalBriefRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
const now = Date.now();
|
||||
const cached = _regionalBriefCache.get(key);
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_CACHE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Regional-Brief': 'HIT' });
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_regionalBriefInFlight, key, () => refresh(point, key));
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Regional-Brief': request.shared ? 'INFLIGHT' : 'MISS',
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch {
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_STALE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Regional-Brief': 'STALE' });
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Regional briefing is temporarily unavailable' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'regional-brief-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function weatherEffectsProxy() {
|
||||
async function refresh(point, key) {
|
||||
const weather = await fetchRegionalWeather(point);
|
||||
if (!weather) throw new Error('Weather observation unavailable');
|
||||
const payload = {
|
||||
status: 'ready',
|
||||
retrievedAt: new Date().toISOString(),
|
||||
coordinates: point,
|
||||
weather,
|
||||
};
|
||||
_weatherEffectsCache.set(key, { payload, cachedAt: Date.now() });
|
||||
trimWeatherEffectsCache();
|
||||
return payload;
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/weather-effects', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!_weatherEffectsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
const now = Date.now();
|
||||
const cached = _weatherEffectsCache.get(key);
|
||||
if (cached && now - cached.cachedAt <= WEATHER_EFFECTS_CACHE_MS) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Weather-Effects': 'HIT',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () => refresh(point, key));
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Weather-Effects': request.shared ? 'INFLIGHT' : 'MISS',
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch {
|
||||
if (cached && now - cached.cachedAt <= WEATHER_EFFECTS_STALE_MS) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Weather-Effects': 'STALE',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Weather effects are temporarily unavailable' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'weather-effects-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app key setup ("POWER UP" panel) — dev-server only.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { regionalBriefProxy, regionalBriefHasAnySource } from './regional/briefing.js';
|
||||
export { weatherEffectsProxy } from './regional/weather-effects.js';
|
||||
export { validRegionalPoint } from './regional/query.js';
|
||||
@@ -0,0 +1,125 @@
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { fetchRegionalPlace } from './place.js';
|
||||
import { fetchRegionalWeather } from './weather.js';
|
||||
import { fetchRegionalNews } from './news.js';
|
||||
import { validRegionalPoint } from './query.js';
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regional cockpit briefing proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
const REGIONAL_BRIEF_CACHE_MS = 5 * 60_000;
|
||||
|
||||
const REGIONAL_BRIEF_STALE_MS = 60 * 60_000;
|
||||
|
||||
const REGIONAL_BRIEF_MAX_CACHE = 120;
|
||||
|
||||
const _regionalBriefCache = new Map();
|
||||
|
||||
const _regionalBriefInFlight = new Map();
|
||||
|
||||
const _regionalBriefRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 90 });
|
||||
|
||||
function trimRegionalBriefCache() {
|
||||
while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) {
|
||||
const oldest = _regionalBriefCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
_regionalBriefCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when at least one regional source produced usable data. */
|
||||
function regionalBriefHasAnySource({ place, weather, news } = {}) {
|
||||
return Boolean(place || weather || (news && news.status !== 'unavailable'));
|
||||
}
|
||||
|
||||
function regionalBriefProxy() {
|
||||
async function refresh(point, key) {
|
||||
const [placeResult, weatherResult] = await Promise.allSettled([
|
||||
fetchRegionalPlace(point),
|
||||
fetchRegionalWeather(point),
|
||||
]);
|
||||
const place = placeResult.status === 'fulfilled' ? placeResult.value : null;
|
||||
const weather = weatherResult.status === 'fulfilled' ? weatherResult.value : null;
|
||||
const news = await fetchRegionalNews(place);
|
||||
if (!regionalBriefHasAnySource({ place, weather, news })) {
|
||||
throw new Error('All regional briefing sources unavailable');
|
||||
}
|
||||
const payload = {
|
||||
status: place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
|
||||
retrievedAt: new Date().toISOString(),
|
||||
coordinates: point,
|
||||
place,
|
||||
placeStatus: place ? 'ready' : 'unavailable',
|
||||
weather,
|
||||
weatherStatus: weather ? 'ready' : 'unavailable',
|
||||
newsStatus: news.status,
|
||||
newsQuery: news.query,
|
||||
newsSource: news.source,
|
||||
articles: news.articles,
|
||||
};
|
||||
_regionalBriefCache.set(key, { payload, cachedAt: Date.now() });
|
||||
trimRegionalBriefCache();
|
||||
return payload;
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/regional-brief', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!_regionalBriefRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
const now = Date.now();
|
||||
const cached = _regionalBriefCache.get(key);
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_CACHE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Regional-Brief': 'HIT' });
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_regionalBriefInFlight, key, () => refresh(point, key));
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Regional-Brief': request.shared ? 'INFLIGHT' : 'MISS',
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch {
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_STALE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Regional-Brief': 'STALE' });
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Regional briefing is temporarily unavailable' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'regional-brief-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { regionalBriefHasAnySource, regionalBriefProxy };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readResponseJsonCapped, readResponseTextCapped } from '../common/http.js';
|
||||
|
||||
const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
async function fetchRegionalJson(url, {
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal, headers });
|
||||
if (!response.ok) throw new Error(`Upstream returned ${response.status}`);
|
||||
return readResponseJsonCapped(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegionalText(url, {
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal, headers });
|
||||
if (!response.ok) throw new Error(`Upstream returned ${response.status}`);
|
||||
return readResponseTextCapped(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchRegionalJson, fetchRegionalText };
|
||||
@@ -0,0 +1,80 @@
|
||||
import { fetchRegionalText, fetchRegionalJson } from './http.js';
|
||||
import { normalizeRegionalArticles } from '../../../src/data/regionalBrief.js';
|
||||
|
||||
function decodeRssText(value) {
|
||||
return String(value || '')
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function rssTag(block, tag) {
|
||||
return decodeRssText(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(block)?.[1] || '');
|
||||
}
|
||||
|
||||
function normalizeRssArticles(xml, limit = 5) {
|
||||
const seen = new Set();
|
||||
const articles = [];
|
||||
for (const match of String(xml || '').matchAll(/<item>([\s\S]*?)<\/item>/gi)) {
|
||||
const item = match[1];
|
||||
const title = rssTag(item, 'title').slice(0, 180);
|
||||
const url = rssTag(item, 'link');
|
||||
let parsedUrl;
|
||||
try { parsedUrl = new URL(url); } catch { continue; }
|
||||
if (!title || !['http:', 'https:'].includes(parsedUrl.protocol)) continue;
|
||||
const source = rssTag(item, 'source');
|
||||
const signature = `${title.toLowerCase()}|${source.toLowerCase() || parsedUrl.hostname}`;
|
||||
if (seen.has(signature)) continue;
|
||||
seen.add(signature);
|
||||
const rawDate = rssTag(item, 'pubDate');
|
||||
articles.push({
|
||||
title,
|
||||
url: parsedUrl.href,
|
||||
domain: source || parsedUrl.hostname.replace(/^www\./, ''),
|
||||
publishedAt: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(),
|
||||
sourceCountry: null,
|
||||
});
|
||||
if (articles.length >= limit) break;
|
||||
}
|
||||
return articles;
|
||||
}
|
||||
|
||||
async function fetchRegionalNews(place) {
|
||||
const query = place?.locality || place?.region || place?.country;
|
||||
if (!query) return { status: 'unavailable', query: null, articles: [], source: null };
|
||||
const rssParams = new URLSearchParams({
|
||||
q: String(query).replace(/["\\]/g, ' ').trim(),
|
||||
hl: 'en-US',
|
||||
gl: 'US',
|
||||
ceid: 'US:en',
|
||||
});
|
||||
try {
|
||||
const xml = await fetchRegionalText(`https://news.google.com/rss/search?${rssParams}`, {
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
const articles = normalizeRssArticles(xml, 5);
|
||||
if (articles.length) return { status: 'ready', query, articles, source: 'Google News RSS' };
|
||||
} catch { /* fall through to the existing free index */ }
|
||||
const params = new URLSearchParams({
|
||||
query: `"${String(query).replace(/["\\]/g, ' ').trim()}"`,
|
||||
mode: 'artlist',
|
||||
format: 'json',
|
||||
maxrecords: '5',
|
||||
sort: 'datedesc',
|
||||
timespan: '48h',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.gdeltproject.org/api/v2/doc/doc?${params}`, {
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
const articles = normalizeRegionalArticles(payload, 5);
|
||||
return { status: articles.length ? 'ready' : 'empty', query, articles, source: 'GDELT fallback' };
|
||||
} catch {
|
||||
return { status: 'unavailable', query, articles: [], source: null };
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchRegionalNews };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fetchRegionalJson } from './http.js';
|
||||
import { normalizeRegionalPlace } from '../../../src/data/regionalBrief.js';
|
||||
|
||||
let _nominatimQueue = Promise.resolve();
|
||||
|
||||
let _nominatimLastRequestAt = 0;
|
||||
|
||||
function fetchRegionalPlace(point) {
|
||||
const task = _nominatimQueue.then(async () => {
|
||||
const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt));
|
||||
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
_nominatimLastRequestAt = Date.now();
|
||||
const params = new URLSearchParams({
|
||||
format: 'jsonv2',
|
||||
lat: point.latitude.toFixed(5),
|
||||
lon: point.longitude.toFixed(5),
|
||||
zoom: '10',
|
||||
addressdetails: '1',
|
||||
'accept-language': 'en',
|
||||
});
|
||||
const payload = await fetchRegionalJson(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: {
|
||||
'User-Agent': 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
},
|
||||
});
|
||||
return normalizeRegionalPlace(payload);
|
||||
});
|
||||
_nominatimQueue = task.catch(() => null);
|
||||
return task;
|
||||
}
|
||||
|
||||
export { fetchRegionalPlace };
|
||||
@@ -0,0 +1,11 @@
|
||||
import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||
|
||||
function validRegionalPoint(params) {
|
||||
const latitude = requiredFiniteQueryNumber(params, 'latitude');
|
||||
const longitude = requiredFiniteQueryNumber(params, 'longitude');
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
export { validRegionalPoint };
|
||||
@@ -0,0 +1,108 @@
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { fetchRegionalWeather } from './weather.js';
|
||||
import { validRegionalPoint } from './query.js';
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
|
||||
const WEATHER_EFFECTS_CACHE_MS = 5 * 60_000;
|
||||
|
||||
const WEATHER_EFFECTS_STALE_MS = 30 * 60_000;
|
||||
|
||||
const WEATHER_EFFECTS_MAX_CACHE = 180;
|
||||
|
||||
const _weatherEffectsCache = new Map();
|
||||
|
||||
const _weatherEffectsInFlight = new Map();
|
||||
|
||||
const _weatherEffectsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 45, globalMax: 120 });
|
||||
|
||||
function trimWeatherEffectsCache() {
|
||||
while (_weatherEffectsCache.size > WEATHER_EFFECTS_MAX_CACHE) {
|
||||
const oldest = _weatherEffectsCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
_weatherEffectsCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function weatherEffectsProxy() {
|
||||
async function refresh(point, key) {
|
||||
const weather = await fetchRegionalWeather(point);
|
||||
if (!weather) throw new Error('Weather observation unavailable');
|
||||
const payload = {
|
||||
status: 'ready',
|
||||
retrievedAt: new Date().toISOString(),
|
||||
coordinates: point,
|
||||
weather,
|
||||
};
|
||||
_weatherEffectsCache.set(key, { payload, cachedAt: Date.now() });
|
||||
trimWeatherEffectsCache();
|
||||
return payload;
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/weather-effects', async (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!_weatherEffectsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
const now = Date.now();
|
||||
const cached = _weatherEffectsCache.get(key);
|
||||
if (cached && now - cached.cachedAt <= WEATHER_EFFECTS_CACHE_MS) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Weather-Effects': 'HIT',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () => refresh(point, key));
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Weather-Effects': request.shared ? 'INFLIGHT' : 'MISS',
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch {
|
||||
if (cached && now - cached.cachedAt <= WEATHER_EFFECTS_STALE_MS) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Weather-Effects': 'STALE',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Weather effects are temporarily unavailable' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'weather-effects-proxy',
|
||||
configureServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
install(server.middlewares);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { weatherEffectsProxy };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fetchRegionalJson } from './http.js';
|
||||
import { normalizeRegionalWeather } from '../../../src/data/regionalBrief.js';
|
||||
|
||||
const WEATHER_EFFECTS_MAX_RESPONSE_BYTES = 512 * 1024;
|
||||
|
||||
async function fetchRegionalWeather(point) {
|
||||
const params = new URLSearchParams({
|
||||
latitude: point.latitude.toFixed(5),
|
||||
longitude: point.longitude.toFixed(5),
|
||||
current: 'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.open-meteo.com/v1/forecast?${params}`, {
|
||||
maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES,
|
||||
});
|
||||
return normalizeRegionalWeather(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { fetchRegionalWeather };
|
||||
Reference in new issue
Block a user