mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
93
api/oref-alerts.js
Normal file
93
api/oref-alerts.js
Normal 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 },
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
95
src/components/OrefSirensPanel.ts
Normal file
95
src/components/OrefSirensPanel.ts
Normal 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">✅</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>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,5 @@ export * from './UnifiedSettings';
|
||||
export * from './TradePolicyPanel';
|
||||
export * from './SupplyChainPanel';
|
||||
export * from './SecurityAdvisoriesPanel';
|
||||
export * from './OrefSirensPanel';
|
||||
export * from './BreakingNewsBanner';
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "지역",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
208
src/services/oref-alerts.ts
Normal 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 = [];
|
||||
}
|
||||
@@ -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
107
tests/oref-proxy.test.mjs
Normal 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();
|
||||
Reference in New Issue
Block a user