feat(oref): add OREF sirens panel with Hebrew-to-English translation (#545)

Add real-time Israel Home Front Command (OREF) siren alerts panel:
- Edge Function proxy at api/oref-alerts.js
- OrefSirensPanel component with live/history views
- oref-alerts service with 10s polling and update callbacks
- Hebrew→English translation via existing translateText() LLM chain
  with 3-layer caching (in-memory Map → server Redis → circuit breaker)
- i18n strings for all 23 locales
- Panel registration, data-loader integration, CSS styles
This commit is contained in:
Elie Habib
2026-02-28 18:02:23 +04:00
committed by GitHub
parent 3547c46a3a
commit 0226b89b22
28 changed files with 904 additions and 18 deletions

93
api/oref-alerts.js Normal file
View File

@@ -0,0 +1,93 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
function getRelayBaseUrl() {
const relayUrl = process.env.WS_RELAY_URL;
if (!relayUrl) return null;
return relayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, '');
}
function getRelayHeaders(baseHeaders = {}) {
const headers = { ...baseHeaders };
const relaySecret = process.env.RELAY_SHARED_SECRET || '';
if (relaySecret) {
const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase();
headers[relayHeader] = relaySecret;
headers.Authorization = `Bearer ${relaySecret}`;
}
return headers;
}
async function fetchWithTimeout(url, options, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const requestUrl = new URL(req.url);
const endpoint = requestUrl.searchParams.get('endpoint');
const isHistory = endpoint === 'history';
const relayBaseUrl = getRelayBaseUrl();
if (relayBaseUrl) {
try {
const relayPath = isHistory ? '/oref/history' : '/oref/alerts';
const response = await fetchWithTimeout(`${relayBaseUrl}${relayPath}`, {
headers: getRelayHeaders({ Accept: 'application/json' }),
}, 12000);
if (response.ok) {
const cacheControl = isHistory
? 'public, max-age=30, s-maxage=30, stale-while-revalidate=10'
: 'public, max-age=5, s-maxage=5, stale-while-revalidate=3';
return new Response(await response.text(), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': cacheControl,
...corsHeaders,
},
});
}
} catch {
// Relay failed
}
}
return new Response(JSON.stringify({
configured: false,
alerts: [],
historyCount24h: 0,
timestamp: new Date().toISOString(),
error: 'No data source available',
}), {
status: 503,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}

View File

@@ -73,6 +73,14 @@ const RELAY_RSS_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_RSS_RA
const RELAY_LOG_THROTTLE_MS = Math.max(1000, Number(process.env.RELAY_LOG_THROTTLE_MS || 10000));
const ALLOW_VERCEL_PREVIEW_ORIGINS = process.env.ALLOW_VERCEL_PREVIEW_ORIGINS === 'true';
// OREF (Israel Home Front Command) siren alerts — fetched via HTTP proxy (Israel exit)
const OREF_PROXY_AUTH = process.env.OREF_PROXY_AUTH || ''; // format: user:pass@host:port
const OREF_ALERTS_URL = 'https://www.oref.org.il/WarningMessages/alert/alerts.json';
const OREF_POLL_INTERVAL_MS = Math.max(30_000, Number(process.env.OREF_POLL_INTERVAL_MS || 300_000));
const OREF_ENABLED = !!OREF_PROXY_AUTH;
const RELAY_OREF_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_OREF_RATE_LIMIT_MAX))
? Number(process.env.RELAY_OREF_RATE_LIMIT_MAX) : 600;
if (IS_PRODUCTION_RELAY && !RELAY_SHARED_SECRET && !ALLOW_UNAUTHENTICATED_RELAY) {
console.error('[Relay] Error: RELAY_SHARED_SECRET is required in production');
console.error('[Relay] Set RELAY_SHARED_SECRET on Railway and Vercel to secure relay endpoints');
@@ -162,6 +170,15 @@ const telegramState = {
startedAt: Date.now(),
};
const orefState = {
lastAlerts: [],
lastAlertsJson: '[]',
lastPollAt: 0,
lastError: null,
historyCount24h: 0,
history: [],
};
function loadTelegramChannels() {
// Product-managed curated list lives in repo root under data/ (shared by web + desktop).
// Relay is executed from scripts/, so resolve ../data.
@@ -332,6 +349,79 @@ function startTelegramPollLoop() {
console.log('[Relay] Telegram poll loop started');
}
// ─────────────────────────────────────────────────────────────
// OREF Siren Alerts (Israel Home Front Command)
// Polls oref.org.il via HTTP CONNECT tunnel through residential proxy (Israel exit)
// ─────────────────────────────────────────────────────────────
function stripBom(text) {
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
}
function orefCurlFetch(proxyAuth, url) {
// Use curl via child_process — Node.js TLS fingerprint (JA3) gets blocked by Akamai,
// but curl's fingerprint passes. curl is available on Railway (Linux) and macOS.
const { execSync } = require('child_process');
const proxyUrl = `http://${proxyAuth}`;
const result = execSync(
`curl -s -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${url}"`,
{ encoding: 'utf8', timeout: 20000 }
);
return result;
}
async function orefFetchAlerts() {
if (!OREF_ENABLED) return;
try {
const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL);
const cleaned = stripBom(raw).trim();
let alerts = [];
if (cleaned && cleaned !== '[]' && cleaned !== 'null') {
try {
const parsed = JSON.parse(cleaned);
alerts = Array.isArray(parsed) ? parsed : [parsed];
} catch { alerts = []; }
}
const newJson = JSON.stringify(alerts);
const changed = newJson !== orefState.lastAlertsJson;
orefState.lastAlerts = alerts;
orefState.lastAlertsJson = newJson;
orefState.lastPollAt = Date.now();
orefState.lastError = null;
if (changed && alerts.length > 0) {
orefState.history.push({
alerts,
timestamp: new Date().toISOString(),
});
}
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
orefState.history = orefState.history.filter(
h => new Date(h.timestamp).getTime() > cutoff
);
orefState.historyCount24h = orefState.history.length;
} catch (err) {
orefState.lastError = err.message || String(err);
console.warn('[Relay] OREF poll error:', orefState.lastError);
}
}
function startOrefPollLoop() {
if (!OREF_ENABLED) {
console.log('[Relay] OREF disabled (no OREF_PROXY_AUTH)');
return;
}
orefFetchAlerts().catch(e => console.warn('[Relay] OREF initial poll error:', e?.message || e));
setInterval(() => {
orefFetchAlerts().catch(e => console.warn('[Relay] OREF poll error:', e?.message || e));
}, OREF_POLL_INTERVAL_MS).unref?.();
console.log(`[Relay] OREF poll loop started (interval ${OREF_POLL_INTERVAL_MS}ms)`);
}
function gzipSyncBuffer(body) {
try {
return zlib.gzipSync(typeof body === 'string' ? Buffer.from(body) : body);
@@ -386,12 +476,14 @@ function getRouteGroup(pathname) {
if (pathname.startsWith('/worldbank')) return 'worldbank';
if (pathname.startsWith('/polymarket')) return 'polymarket';
if (pathname.startsWith('/ucdp-events')) return 'ucdp-events';
if (pathname.startsWith('/oref')) return 'oref';
return 'other';
}
function getRateLimitForPath(pathname) {
if (pathname.startsWith('/opensky')) return RELAY_OPENSKY_RATE_LIMIT_MAX;
if (pathname.startsWith('/rss')) return RELAY_RSS_RATE_LIMIT_MAX;
if (pathname.startsWith('/oref')) return RELAY_OREF_RATE_LIMIT_MAX;
return RELAY_RATE_LIMIT_MAX;
}
@@ -2140,6 +2232,13 @@ const server = http.createServer(async (req, res) => {
lastPollAt: telegramState.lastPollAt ? new Date(telegramState.lastPollAt).toISOString() : null,
hasError: !!telegramState.lastError,
},
oref: {
enabled: OREF_ENABLED,
alertCount: orefState.lastAlerts?.length || 0,
historyCount24h: orefState.historyCount24h,
lastPollAt: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : null,
hasError: !!orefState.lastError,
},
memory: {
rss: `${(mem.rss / 1024 / 1024).toFixed(0)}MB`,
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB`,
@@ -2506,6 +2605,27 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify({ error: err.message }));
}
}
} else if (pathname === '/oref/alerts') {
sendCompressed(req, res, 200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=5, s-maxage=5, stale-while-revalidate=3',
}, JSON.stringify({
configured: OREF_ENABLED,
alerts: orefState.lastAlerts || [],
historyCount24h: orefState.historyCount24h,
timestamp: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : new Date().toISOString(),
...(orefState.lastError ? { error: orefState.lastError } : {}),
}));
} else if (pathname === '/oref/history') {
sendCompressed(req, res, 200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=10',
}, JSON.stringify({
configured: OREF_ENABLED,
history: orefState.history || [],
historyCount24h: orefState.historyCount24h,
timestamp: orefState.lastPollAt ? new Date(orefState.lastPollAt).toISOString() : new Date().toISOString(),
}));
} else if (pathname.startsWith('/ucdp-events')) {
handleUcdpEventsRequest(req, res);
} else if (pathname.startsWith('/opensky')) {
@@ -2625,6 +2745,7 @@ const wss = new WebSocketServer({ server });
server.listen(PORT, () => {
console.log(`[Relay] WebSocket relay on port ${PORT}`);
startTelegramPollLoop();
startOrefPollLoop();
});
wss.on('connection', (ws, req) => {

View File

@@ -72,6 +72,7 @@ import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchU
import { fetchUnhcrPopulation } from '@/services/displacement';
import { fetchClimateAnomalies } from '@/services/climate';
import { fetchSecurityAdvisories } from '@/services/security-advisories';
import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate } from '@/services/oref-alerts';
import { enrichEventsWithExposure } from '@/services/population-exposure';
import { debounce, getCircuitBreakerCooldownInfo } from '@/utils';
import { isFeatureAvailable } from '@/services/runtime-config';
@@ -101,6 +102,7 @@ import {
TradePolicyPanel,
SupplyChainPanel,
SecurityAdvisoriesPanel,
OrefSirensPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { classifyNewsItem } from '@/services/positive-classifier';
@@ -143,7 +145,9 @@ export class DataLoaderManager implements AppModule {
init(): void {}
destroy(): void {}
destroy(): void {
stopOrefPolling();
}
private shouldShowIntelligenceNotifications(): boolean {
return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled();
@@ -1072,6 +1076,20 @@ export class DataLoaderManager implements AppModule {
// Security advisories
tasks.push(this.loadSecurityAdvisories());
// OREF sirens
tasks.push((async () => {
try {
const data = await fetchOrefAlerts();
(this.ctx.panels['oref-sirens'] as OrefSirensPanel)?.setData(data);
onOrefAlertsUpdate((update) => {
(this.ctx.panels['oref-sirens'] as OrefSirensPanel)?.setData(update);
});
startOrefPolling();
} catch (error) {
console.error('[Intelligence] OREF alerts fetch failed:', error);
}
})());
await Promise.allSettled(tasks);
try {

View File

@@ -34,6 +34,7 @@ import {
TradePolicyPanel,
SupplyChainPanel,
SecurityAdvisoriesPanel,
OrefSirensPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel';
@@ -548,6 +549,9 @@ export class PanelLayoutManager implements AppModule {
void this.callbacks.loadSecurityAdvisories?.();
});
this.ctx.panels['security-advisories'] = securityAdvisoriesPanel;
const orefSirensPanel = new OrefSirensPanel();
this.ctx.panels['oref-sirens'] = orefSirensPanel;
}
if (SITE_VARIANT === 'finance') {

View File

@@ -0,0 +1,95 @@
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
import { t } from '@/services/i18n';
import type { OrefAlertsResponse, OrefAlert } from '@/services/oref-alerts';
export class OrefSirensPanel extends Panel {
private alerts: OrefAlert[] = [];
private historyCount24h = 0;
constructor() {
super({
id: 'oref-sirens',
title: t('panels.orefSirens'),
showCount: true,
trackActivity: true,
infoTooltip: t('components.orefSirens.infoTooltip'),
});
this.showLoading(t('components.orefSirens.checking'));
}
public setData(data: OrefAlertsResponse): void {
if (!data.configured) {
this.setContent(`<div class="panel-empty">${t('components.orefSirens.notConfigured')}</div>`);
this.setCount(0);
return;
}
const prevCount = this.alerts.length;
this.alerts = data.alerts || [];
this.historyCount24h = data.historyCount24h || 0;
this.setCount(this.alerts.length);
if (prevCount === 0 && this.alerts.length > 0) {
this.setNewBadge(this.alerts.length);
}
this.render();
}
private formatAlertTime(dateStr: string): string {
try {
const diff = Date.now() - new Date(dateStr).getTime();
if (diff < 60_000) return t('components.orefSirens.justNow');
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
} catch {
return '';
}
}
private render(): void {
if (this.alerts.length === 0) {
this.setContent(`
<div class="oref-panel-content">
<div class="oref-status oref-ok">
<span class="oref-status-icon">&#x2705;</span>
<span>${t('components.orefSirens.noAlerts')}</span>
</div>
<div class="oref-footer">
<span class="oref-history">${t('components.orefSirens.historyCount', { count: String(this.historyCount24h) })}</span>
</div>
</div>
`);
return;
}
const alertRows = this.alerts.slice(0, 20).map(alert => {
const areas = (alert.data || []).map(a => escapeHtml(a)).join(', ');
const time = this.formatAlertTime(alert.alertDate);
return `<div class="oref-alert-row">
<div class="oref-alert-header">
<span class="oref-alert-title">${escapeHtml(alert.title || alert.cat)}</span>
<span class="oref-alert-time">${time}</span>
</div>
<div class="oref-alert-areas">${areas}</div>
</div>`;
}).join('');
this.setContent(`
<div class="oref-panel-content">
<div class="oref-status oref-danger">
<span class="oref-pulse"></span>
<span>${t('components.orefSirens.activeSirens', { count: String(this.alerts.length) })}</span>
</div>
<div class="oref-list">${alertRows}</div>
<div class="oref-footer">
<span class="oref-history">${t('components.orefSirens.historyCount', { count: String(this.historyCount24h) })}</span>
</div>
</div>
`);
}
}

View File

@@ -42,4 +42,5 @@ export * from './UnifiedSettings';
export * from './TradePolicyPanel';
export * from './SupplyChainPanel';
export * from './SecurityAdvisoriesPanel';
export * from './OrefSirensPanel';
export * from './BreakingNewsBanner';

View File

@@ -51,6 +51,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
climate: { name: 'Climate Anomalies', enabled: true, priority: 2 },
'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 },
'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 },
'oref-sirens': { name: 'OREF Sirens', enabled: true, priority: 2 },
};
const FULL_MAP_LAYERS: MapLayers = {
@@ -604,7 +605,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
},
dataTracking: {
labelKey: 'header.panelCatDataTracking',
panelKeys: ['monitors', 'satellite-fires', 'ucdp-events', 'displacement', 'climate', 'population-exposure', 'security-advisories'],
panelKeys: ['monitors', 'satellite-fires', 'ucdp-events', 'displacement', 'climate', 'population-exposure', 'security-advisories', 'oref-sirens'],
variants: ['full'],
},

View File

@@ -204,7 +204,8 @@
"gccInvestments": "استثمارات دول الخليج",
"geoHubs": "مراكز جيوسياسية",
"liveWebcams": "كاميرات مباشرة",
"securityAdvisories": "تنبيهات أمنية"
"securityAdvisories": "تنبيهات أمنية",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "منذ {{count}} يوم"
},
"infoTooltip": "<strong>تنبيهات أمنية</strong><br>تحذيرات السفر والتنبيهات الأمنية من الوكالات الحكومية."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "GCC-Investitionen",
"geoHubs": "Geopolitical Hotspots",
"liveWebcams": "Live-Webcams",
"securityAdvisories": "Sicherheitshinweise"
"securityAdvisories": "Sicherheitshinweise",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "vor {{count}} Tagen"
},
"infoTooltip": "<strong>Sicherheitshinweise</strong><br>Reisewarnungen und Sicherheitshinweise von Regierungsbehörden."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "Επενδύσεις GCC",
"geoHubs": "Γεωπολιτικά Κέντρα",
"liveWebcams": "Ζωντανές Κάμερες",
"securityAdvisories": "Προειδοποιήσεις Ασφαλείας"
"securityAdvisories": "Προειδοποιήσεις Ασφαλείας",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1339,6 +1340,17 @@
"daysAgo": "{{count}} ημέρες πριν"
},
"infoTooltip": "<strong>Προειδοποιήσεις Ασφαλείας</strong><br>Ταξιδιωτικές οδηγίες και προειδοποιήσεις ασφαλείας."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -186,6 +186,7 @@
"climate": "Climate Anomalies",
"populationExposure": "Population Exposure",
"securityAdvisories": "Security Advisories",
"orefSirens": "OREF Sirens",
"startups": "Startups & VC",
"vcblogs": "VC Insights & Essays",
"regionalStartups": "Global Startup News",
@@ -1110,6 +1111,17 @@
},
"infoTooltip": "<strong>Security Advisories</strong><br>Travel advisories and security alerts from government foreign affairs agencies:<br><br><strong>Sources:</strong><br>\uD83C\uDDFA\uD83C\uDDF8 US State Dept Travel Advisories<br>\uD83C\uDDE6\uD83C\uDDFA AU DFAT Smartraveller<br>\uD83C\uDDEC\uD83C\uDDE7 UK FCDO Travel Advice<br>\uD83C\uDDF3\uD83C\uDDFF NZ MFAT SafeTravel<br><br><strong>Levels:</strong><br>\uD83D\uDFE5 Do Not Travel<br>\uD83D\uDFE7 Reconsider Travel<br>\uD83D\uDFE8 Exercise Caution<br>\uD83D\uDFE9 Normal Precautions"
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
},
"satelliteFires": {
"noData": "No fire data available",
"region": "Region",

View File

@@ -204,7 +204,8 @@
"gccInvestments": "Inversiones del CCG",
"geoHubs": "Geopolitical Hotspots",
"liveWebcams": "Cámaras en Vivo",
"securityAdvisories": "Alertas de Seguridad"
"securityAdvisories": "Alertas de Seguridad",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "hace {{count}} d"
},
"infoTooltip": "<strong>Alertas de Seguridad</strong><br>Avisos de viaje y alertas de seguridad de agencias gubernamentales."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "Investissements du CCG",
"geoHubs": "Centres géopolitiques",
"liveWebcams": "Webcams en Direct",
"securityAdvisories": "Avis de Sécurité"
"securityAdvisories": "Avis de Sécurité",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "il y a {{count}} j"
},
"infoTooltip": "<strong>Avis de Sécurité</strong><br>Avis aux voyageurs et alertes de sécurité des agences gouvernementales."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "Investimenti GCC",
"geoHubs": "Hotspot geopolitici",
"liveWebcams": "Webcam in Diretta",
"securityAdvisories": "Avvisi di Sicurezza"
"securityAdvisories": "Avvisi di Sicurezza",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} giorni fa"
},
"infoTooltip": "<strong>Avvisi di Sicurezza</strong><br>Avvisi di viaggio e allerte di sicurezza dalle agenzie governative."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "GCC投資",
"geoHubs": "地政学ハブ",
"liveWebcams": "ライブカメラ",
"securityAdvisories": "セキュリティアドバイザリー"
"securityAdvisories": "セキュリティアドバイザリー",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}}日前"
},
"infoTooltip": "<strong>セキュリティアドバイザリー</strong><br>各国政府の渡航情報と安全警告。"
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -186,6 +186,7 @@
"climate": "기후 이상",
"populationExposure": "인구 노출",
"securityAdvisories": "보안 권고",
"orefSirens": "OREF Sirens",
"startups": "스타트업 & VC",
"vcblogs": "VC 인사이트 & 에세이",
"regionalStartups": "글로벌 스타트업 뉴스",
@@ -1106,6 +1107,17 @@
},
"infoTooltip": "<strong>보안 주의보</strong><br>각국 외교부의 여행 주의보 및 보안 경보:<br><br><strong>출처:</strong><br>🇺🇸 미 국무부 여행 주의보<br>🇦🇺 호주 DFAT Smartraveller<br>🇬🇧 영국 FCDO 여행 안내<br>🇳🇿 뉴질랜드 MFAT SafeTravel<br><br><strong>수준:</strong><br>🟥 여행 금지<br>🟧 여행 재고<br>🟨 주의 필요<br>🟩 일반 주의"
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
},
"satelliteFires": {
"noData": "화재 데이터 없음",
"region": "지역",

View File

@@ -63,7 +63,8 @@
"polymarket": "Voorspellingen",
"climate": "Klimaatafwijkingen",
"liveWebcams": "Live Webcams",
"securityAdvisories": "Veiligheidsadviezen"
"securityAdvisories": "Veiligheidsadviezen",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1171,6 +1172,17 @@
"daysAgo": "{{count}} dagen geleden"
},
"infoTooltip": "<strong>Veiligheidsadviezen</strong><br>Reisadviezen en veiligheidswaarschuwingen van overheidsinstanties."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "Inwestycje GCC",
"geoHubs": "Geopolitical Hotspots",
"liveWebcams": "Kamery na Żywo",
"securityAdvisories": "Ostrzeżenia Bezpieczeństwa"
"securityAdvisories": "Ostrzeżenia Bezpieczeństwa",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} dni temu"
},
"infoTooltip": "<strong>Ostrzeżenia Bezpieczeństwa</strong><br>Ostrzeżenia podróżne i alerty bezpieczeństwa z agencji rządowych."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -63,7 +63,8 @@
"polymarket": "Previsões",
"climate": "Anomalias Climáticas",
"liveWebcams": "Câmeras ao Vivo",
"securityAdvisories": "Alertas de Segurança"
"securityAdvisories": "Alertas de Segurança",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1171,6 +1172,17 @@
"daysAgo": "há {{count}} d"
},
"infoTooltip": "<strong>Alertas de Segurança</strong><br>Avisos de viagem e alertas de segurança de agências governamentais."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "Инвестиции стран Залива",
"geoHubs": "Геополитические хабы",
"liveWebcams": "Веб-камеры",
"securityAdvisories": "Предупреждения безопасности"
"securityAdvisories": "Предупреждения безопасности",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} дн. назад"
},
"infoTooltip": "<strong>Предупреждения безопасности</strong><br>Рекомендации по поездкам и предупреждения от государственных ведомств."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -63,7 +63,8 @@
"polymarket": "Förutsägelser",
"climate": "Klimatanomalier",
"liveWebcams": "Webbkameror",
"securityAdvisories": "Säkerhetsvarningar"
"securityAdvisories": "Säkerhetsvarningar",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1171,6 +1172,17 @@
"daysAgo": "{{count}} dagar sedan"
},
"infoTooltip": "<strong>Säkerhetsvarningar</strong><br>Resevarningar och säkerhetsmeddelanden från myndigheters utrikesdepartement."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "การลงทุน GCC",
"geoHubs": "ศูนย์กลางภูมิรัฐศาสตร์",
"liveWebcams": "เว็บแคมสด",
"securityAdvisories": "คำเตือนด้านความปลอดภัย"
"securityAdvisories": "คำเตือนด้านความปลอดภัย",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} วันที่แล้ว"
},
"infoTooltip": "<strong>คำเตือนด้านความปลอดภัย</strong><br>คำเตือนการเดินทางและความปลอดภัยจากหน่วยงานรัฐบาล."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "GCC Yatirimlari",
"geoHubs": "Jeopolitik Merkezler",
"liveWebcams": "Canli Web Kameralari",
"securityAdvisories": "Güvenlik Uyarıları"
"securityAdvisories": "Güvenlik Uyarıları",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} gün önce"
},
"infoTooltip": "<strong>Güvenlik Uyarıları</strong><br>Hükümet kurumlarından seyahat uyarıları ve güvenlik bildirimleri."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "Đầu tư GCC",
"geoHubs": "Trung tâm Địa chính trị",
"liveWebcams": "Webcam Trực tiếp",
"securityAdvisories": "Cảnh báo An ninh"
"securityAdvisories": "Cảnh báo An ninh",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}} ngày trước"
},
"infoTooltip": "<strong>Cảnh báo An ninh</strong><br>Cảnh báo du lịch và an ninh từ các cơ quan chính phủ."
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

View File

@@ -204,7 +204,8 @@
"gccInvestments": "GCC投资",
"geoHubs": "地缘政治枢纽",
"liveWebcams": "实时摄像头",
"securityAdvisories": "安全警告"
"securityAdvisories": "安全警告",
"orefSirens": "OREF Sirens"
},
"modals": {
"search": {
@@ -1312,6 +1313,17 @@
"daysAgo": "{{count}}天前"
},
"infoTooltip": "<strong>安全警告</strong><br>来自各国政府的旅行警告和安全提示。"
},
"orefSirens": {
"checking": "Checking OREF alerts...",
"noAlerts": "No active sirens — all clear",
"notConfigured": "OREF proxy not configured",
"activeSirens": "{{count}} active siren(s)",
"area": "Area",
"time": "Time",
"justNow": "just now",
"historyCount": "{{count}} alerts in last 24h",
"infoTooltip": "<strong>OREF Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command (oref.org.il).<br><br>Data is polled every 10 seconds via a proxy relay. A pulsing red indicator means active sirens are sounding."
}
},
"popups": {

208
src/services/oref-alerts.ts Normal file
View File

@@ -0,0 +1,208 @@
import { getApiBaseUrl } from '@/services/runtime';
import { translateText } from '@/services/summarization';
export interface OrefAlert {
id: string;
cat: string;
title: string;
data: string[];
desc: string;
alertDate: string;
}
export interface OrefAlertsResponse {
configured: boolean;
alerts: OrefAlert[];
historyCount24h: number;
timestamp: string;
error?: string;
}
export interface OrefHistoryEntry {
alerts: OrefAlert[];
timestamp: string;
}
export interface OrefHistoryResponse {
configured: boolean;
history: OrefHistoryEntry[];
historyCount24h: number;
timestamp: string;
error?: string;
}
let cachedResponse: OrefAlertsResponse | null = null;
let lastFetchAt = 0;
const CACHE_TTL = 8_000;
let pollingInterval: ReturnType<typeof setInterval> | null = null;
let updateCallbacks: Array<(data: OrefAlertsResponse) => void> = [];
const MAX_TRANSLATION_CACHE = 200;
const translationCache = new Map<string, { title: string; data: string[]; desc: string }>();
let translationPromise: Promise<boolean> | null = null;
const HEBREW_RE = /[\u0590-\u05FF]/;
function hasHebrew(text: string): boolean {
return HEBREW_RE.test(text);
}
function alertNeedsTranslation(alert: OrefAlert): boolean {
return hasHebrew(alert.title) || alert.data.some(hasHebrew) || hasHebrew(alert.desc);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildTranslationPrompt(alerts: OrefAlert[]): string {
const lines: string[] = [];
for (const a of alerts) {
lines.push(`ALERT[${a.id}]: ${a.title || '(none)'}`);
lines.push(`AREAS[${a.id}]: ${a.data.join(', ') || '(none)'}`);
lines.push(`DESC[${a.id}]: ${a.desc || '(none)'}`);
}
return 'Translate each line from Hebrew to English. Keep the ALERT/AREAS/DESC labels and IDs exactly as-is. Only translate the text after the colon.\n' + lines.join('\n');
}
function parseTranslationResponse(raw: string, alerts: OrefAlert[]): void {
const lines = raw.split('\n');
for (const alert of alerts) {
const eid = escapeRegExp(alert.id);
const reAlert = new RegExp(`ALERT\\[${eid}\\]:\\s*(.+)`);
const reAreas = new RegExp(`AREAS\\[${eid}\\]:\\s*(.+)`);
const reDesc = new RegExp(`DESC\\[${eid}\\]:\\s*(.+)`);
let title = alert.title;
let areas = alert.data;
let desc = alert.desc;
for (const line of lines) {
const alertMatch = line.match(reAlert);
if (alertMatch?.[1]) title = alertMatch[1].trim();
const areasMatch = line.match(reAreas);
if (areasMatch?.[1]) areas = areasMatch[1].split(',').map(s => s.trim());
const descMatch = line.match(reDesc);
if (descMatch?.[1]) desc = descMatch[1].trim();
}
translationCache.set(alert.id, { title, data: areas, desc });
}
if (translationCache.size > MAX_TRANSLATION_CACHE) {
const excess = translationCache.size - MAX_TRANSLATION_CACHE;
const iter = translationCache.keys();
for (let i = 0; i < excess; i++) {
const k = iter.next().value;
if (k !== undefined) translationCache.delete(k);
}
}
}
function applyTranslations(alerts: OrefAlert[]): OrefAlert[] {
return alerts.map(a => {
const cached = translationCache.get(a.id);
if (cached) return { ...a, ...cached };
return a;
});
}
async function translateAlerts(alerts: OrefAlert[]): Promise<boolean> {
const untranslated = alerts.filter(a => !translationCache.has(a.id) && alertNeedsTranslation(a));
if (!untranslated.length) {
if (translationPromise) await translationPromise;
return false;
}
if (translationPromise) {
await translationPromise;
return translateAlerts(alerts);
}
let translated = false;
translationPromise = (async () => {
try {
const prompt = buildTranslationPrompt(untranslated);
const result = await translateText(prompt, 'en');
if (result) {
parseTranslationResponse(result, untranslated);
translated = true;
}
} catch (e) {
console.warn('OREF alert translation failed', e);
} finally {
translationPromise = null;
}
return translated;
})();
await translationPromise;
return translated;
}
function getOrefApiUrl(endpoint?: string): string {
const base = getApiBaseUrl();
const suffix = endpoint ? `?endpoint=${endpoint}` : '';
return `${base}/api/oref-alerts${suffix}`;
}
export async function fetchOrefAlerts(): Promise<OrefAlertsResponse> {
const now = Date.now();
if (cachedResponse && now - lastFetchAt < CACHE_TTL) {
return { ...cachedResponse, alerts: applyTranslations(cachedResponse.alerts) };
}
try {
const res = await fetch(getOrefApiUrl(), {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
return { configured: false, alerts: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: `HTTP ${res.status}` };
}
const data: OrefAlertsResponse = await res.json();
cachedResponse = data;
lastFetchAt = now;
if (data.alerts.length) {
translateAlerts(data.alerts).then((didTranslate) => {
if (didTranslate) {
for (const cb of updateCallbacks) cb({ ...data, alerts: applyTranslations(data.alerts) });
}
}).catch(() => {});
}
return { ...data, alerts: applyTranslations(data.alerts) };
} catch (err) {
return { configured: false, alerts: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: String(err) };
}
}
export async function fetchOrefHistory(): Promise<OrefHistoryResponse> {
try {
const res = await fetch(getOrefApiUrl('history'), {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: `HTTP ${res.status}` };
}
return await res.json();
} catch (err) {
return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: String(err) };
}
}
export function onOrefAlertsUpdate(cb: (data: OrefAlertsResponse) => void): void {
updateCallbacks.push(cb);
}
export function startOrefPolling(): void {
if (pollingInterval) return;
pollingInterval = setInterval(async () => {
const data = await fetchOrefAlerts();
for (const cb of updateCallbacks) cb(data);
}, 10_000);
}
export function stopOrefPolling(): void {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
updateCallbacks = [];
}

View File

@@ -259,3 +259,25 @@
.sa-footer-source { font-size: 9px; color: var(--text-muted); }
.sa-refresh-btn { background: transparent; border: 1px solid var(--border); color: var(--text-dim); padding: 3px 10px; font-size: 10px; cursor: pointer; border-radius: 3px; }
.sa-refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ----------------------------------------------------------
OREF Sirens Panel
---------------------------------------------------------- */
.oref-panel-content { font-size: 12px; }
.oref-status { display: flex; align-items: center; gap: 8px; padding: 10px; border-radius: 4px; margin-bottom: 8px; font-weight: 600; }
.oref-ok { background: color-mix(in srgb, var(--semantic-normal) 10%, transparent); color: var(--semantic-normal); }
.oref-danger { background: color-mix(in srgb, var(--semantic-critical) 12%, transparent); color: var(--semantic-critical); }
.oref-status-icon { font-size: 16px; }
.oref-pulse { width: 10px; height: 10px; border-radius: 50%; background: var(--semantic-critical); animation: oref-pulse-anim 1.2s ease-in-out infinite; flex-shrink: 0; }
@keyframes oref-pulse-anim {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--semantic-critical) 60%, transparent); }
50% { opacity: 0.6; box-shadow: 0 0 0 6px transparent; }
}
.oref-list { display: flex; flex-direction: column; gap: 4px; }
.oref-alert-row { padding: 8px; border-radius: 4px; border-left: 3px solid var(--semantic-critical); background: var(--overlay-subtle); }
.oref-alert-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px; }
.oref-alert-title { font-size: 11px; font-weight: 600; color: var(--text-primary); }
.oref-alert-time { font-size: 9px; color: var(--text-muted); }
.oref-alert-areas { font-size: 10px; color: var(--text-secondary); line-height: 1.35; }
.oref-footer { display: flex; align-items: center; justify-content: center; margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--border-subtle); }
.oref-history { font-size: 9px; color: var(--text-muted); }

107
tests/oref-proxy.test.mjs Normal file
View File

@@ -0,0 +1,107 @@
/**
* OREF Proxy Connectivity Test
*
* Tests the curl-based proxy approach used by ais-relay.cjs
* to reach oref.org.il through a residential proxy with Israel exit node.
*
* Requires OREF_PROXY_AUTH env var (format: user:pass@host:port)
*
* Usage:
* OREF_PROXY_AUTH='user:pass;il;;;@proxy.froxy.com:9000' node tests/oref-proxy.test.mjs
*/
import { execSync } from 'node:child_process';
import { strict as assert } from 'node:assert';
const OREF_PROXY_AUTH = process.env.OREF_PROXY_AUTH || '';
const OREF_ALERTS_URL = 'https://www.oref.org.il/WarningMessages/alert/alerts.json';
function stripBom(text) {
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
}
function orefCurlFetch(proxyAuth, url) {
const proxyUrl = `http://${proxyAuth}`;
return execSync(
`curl -s -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${url}"`,
{ encoding: 'utf8', timeout: 20000 }
);
}
async function runTests() {
if (!OREF_PROXY_AUTH) {
console.log('SKIP: OREF_PROXY_AUTH not set — set it to run proxy connectivity tests');
console.log(' Example: OREF_PROXY_AUTH="user:pass;il;;;@proxy.froxy.com:9000" node tests/oref-proxy.test.mjs');
process.exit(0);
}
console.log('--- OREF Proxy Connectivity Tests ---\n');
let passed = 0;
let failed = 0;
// Test 1: curl is available
try {
execSync('curl --version', { encoding: 'utf8', timeout: 5000 });
console.log(' PASS: curl is available');
passed++;
} catch {
console.log(' FAIL: curl not found — required for OREF proxy');
failed++;
process.exit(1);
}
// Test 2: Fetch OREF alerts through proxy via curl
try {
const raw = orefCurlFetch(OREF_PROXY_AUTH, OREF_ALERTS_URL);
assert.ok(typeof raw === 'string', 'response should be a string');
const cleaned = stripBom(raw).trim();
if (cleaned === '' || cleaned === '[]' || cleaned === 'null') {
console.log(' PASS: OREF alerts fetch → no active alerts (empty response)');
} else {
const parsed = JSON.parse(cleaned);
// OREF returns a single object when 1 alert, or an array for multiple
const alerts = Array.isArray(parsed) ? parsed : [parsed];
assert.ok(alerts.length > 0, 'should have at least one alert');
assert.ok(alerts[0].id || alerts[0].cat, 'alert should have id or cat field');
console.log(` PASS: OREF alerts fetch → ${alerts.length} active alert(s)`);
}
passed++;
} catch (err) {
console.log(` FAIL: OREF alerts fetch — ${err.message}`);
failed++;
}
// Test 3: Fetch with HTTP status code check
try {
const proxyUrl = `http://${OREF_PROXY_AUTH}`;
const output = execSync(
`curl -s -o /dev/null -w "%{http_code}" -x "${proxyUrl}" --max-time 15 -H "Accept: application/json" -H "Referer: https://www.oref.org.il/" "${OREF_ALERTS_URL}"`,
{ encoding: 'utf8', timeout: 20000 }
).trim();
assert.equal(output, '200', `Expected HTTP 200, got ${output}`);
console.log(' PASS: OREF HTTP status is 200');
passed++;
} catch (err) {
console.log(` FAIL: OREF HTTP status check — ${err.message}`);
failed++;
}
// Test 4: Invalid proxy should fail gracefully
try {
assert.throws(
() => orefCurlFetch('baduser:badpass@127.0.0.1:1', OREF_ALERTS_URL),
/./
);
console.log(' PASS: Invalid proxy fails gracefully');
passed++;
} catch (err) {
console.log(` FAIL: Invalid proxy error handling — ${err.message}`);
failed++;
}
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();