diff --git a/api/rss-proxy.js b/api/rss-proxy.js index 3a6ef35bb..2fd77ae07 100644 --- a/api/rss-proxy.js +++ b/api/rss-proxy.js @@ -279,6 +279,29 @@ const ALLOWED_DOMAINS = [ 'seekingalpha.com', 'www.coindesk.com', 'cointelegraph.com', + // Security advisories — government travel advisory feeds + 'travel.state.gov', + 'www.smartraveller.gov.au', + 'www.safetravel.govt.nz', + // US Embassy security alerts + 'th.usembassy.gov', + 'ae.usembassy.gov', + 'de.usembassy.gov', + 'ua.usembassy.gov', + 'mx.usembassy.gov', + 'in.usembassy.gov', + 'pk.usembassy.gov', + 'co.usembassy.gov', + 'pl.usembassy.gov', + 'bd.usembassy.gov', + 'it.usembassy.gov', + 'do.usembassy.gov', + 'mm.usembassy.gov', + // Health advisories + 'wwwnc.cdc.gov', + 'www.ecdc.europa.eu', + 'www.who.int', + 'www.afro.who.int', // Happy variant — positive news sources 'www.goodnewsnetwork.org', 'www.positive.news', diff --git a/src/App.ts b/src/App.ts index e5541e1fc..71aeb0ffe 100644 --- a/src/App.ts +++ b/src/App.ts @@ -262,6 +262,7 @@ export class App { openCountryStory: (code, name) => this.countryIntel.openCountryStory(code, name), loadAllData: () => this.dataLoader.loadAllData(), updateMonitorResults: () => this.dataLoader.updateMonitorResults(), + loadSecurityAdvisories: () => this.dataLoader.loadSecurityAdvisories(), }); this.eventHandlers = new EventHandlerManager(this.state, { diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index b30eb65c2..8e27d2683 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -69,6 +69,7 @@ import { dataFreshness, type DataSourceId } from '@/services/data-freshness'; import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchUcdpEvents, deduplicateAgainstAcled } from '@/services/conflict'; import { fetchUnhcrPopulation } from '@/services/displacement'; import { fetchClimateAnomalies } from '@/services/climate'; +import { fetchSecurityAdvisories } from '@/services/security-advisories'; import { enrichEventsWithExposure } from '@/services/population-exposure'; import { debounce, getCircuitBreakerCooldownInfo } from '@/utils'; import { isFeatureAvailable } from '@/services/runtime-config'; @@ -95,6 +96,7 @@ import { PopulationExposurePanel, TradePolicyPanel, SupplyChainPanel, + SecurityAdvisoriesPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { classifyNewsItem } from '@/services/positive-classifier'; @@ -1051,6 +1053,9 @@ export class DataLoaderManager implements AppModule { } })()); + // Security advisories + tasks.push(this.loadSecurityAdvisories()); + await Promise.allSettled(tasks); try { @@ -1865,4 +1870,15 @@ export class DataLoaderManager implements AppModule { // EIA failure does not break the existing World Bank gauge } } + + async loadSecurityAdvisories(): Promise { + try { + const result = await fetchSecurityAdvisories(); + if (result.ok) { + (this.ctx.panels['security-advisories'] as SecurityAdvisoriesPanel)?.setData(result.advisories); + } + } catch (error) { + console.error('[App] Security advisories fetch failed:', error); + } + } } diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 474cecc2d..c137e446f 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -33,6 +33,7 @@ import { InvestmentsPanel, TradePolicyPanel, SupplyChainPanel, + SecurityAdvisoriesPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel'; @@ -63,6 +64,7 @@ export interface PanelLayoutCallbacks { openCountryStory: (code: string, name: string) => void; loadAllData: () => Promise; updateMonitorResults: () => void; + loadSecurityAdvisories?: () => Promise; } export class PanelLayoutManager implements AppModule { @@ -539,6 +541,12 @@ export class PanelLayoutManager implements AppModule { const populationExposurePanel = new PopulationExposurePanel(); this.ctx.panels['population-exposure'] = populationExposurePanel; + + const securityAdvisoriesPanel = new SecurityAdvisoriesPanel(); + securityAdvisoriesPanel.setRefreshHandler(() => { + void this.callbacks.loadSecurityAdvisories?.(); + }); + this.ctx.panels['security-advisories'] = securityAdvisoriesPanel; } if (SITE_VARIANT === 'finance') { diff --git a/src/components/SecurityAdvisoriesPanel.ts b/src/components/SecurityAdvisoriesPanel.ts new file mode 100644 index 000000000..a1021d210 --- /dev/null +++ b/src/components/SecurityAdvisoriesPanel.ts @@ -0,0 +1,203 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import type { SecurityAdvisory } from '@/services/security-advisories'; + +type AdvisoryFilter = 'all' | 'critical' | 'US' | 'AU' | 'UK' | 'NZ' | 'health'; + +export class SecurityAdvisoriesPanel extends Panel { + private advisories: SecurityAdvisory[] = []; + private activeFilter: AdvisoryFilter = 'all'; + private refreshInterval: ReturnType | null = null; + private onRefreshRequest?: () => void; + + constructor() { + super({ + id: 'security-advisories', + title: t('panels.securityAdvisories'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.securityAdvisories.infoTooltip'), + }); + this.showLoading(t('components.securityAdvisories.loading')); + + this.content.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const filterBtn = target.closest('.sa-filter'); + if (filterBtn) { + this.activeFilter = (filterBtn.dataset.filter || 'all') as AdvisoryFilter; + this.render(); + return; + } + if (target.closest('.sa-refresh-btn')) { + this.showLoading(t('components.securityAdvisories.loading')); + this.onRefreshRequest?.(); + } + }); + } + + public setData(advisories: SecurityAdvisory[]): void { + const prevCount = this.advisories.length; + this.advisories = advisories; + this.setCount(advisories.length); + + if (prevCount > 0 && advisories.length > prevCount) { + this.setNewBadge(advisories.length - prevCount); + } + + this.render(); + } + + private getFiltered(): SecurityAdvisory[] { + switch (this.activeFilter) { + case 'critical': + return this.advisories.filter(a => a.level === 'do-not-travel' || a.level === 'reconsider'); + case 'health': + return this.advisories.filter(a => a.sourceCountry === 'EU' || a.sourceCountry === 'INT'); + case 'US': + case 'AU': + case 'UK': + case 'NZ': + return this.advisories.filter(a => a.sourceCountry === this.activeFilter); + default: + return this.advisories; + } + } + + private getLevelClass(level?: SecurityAdvisory['level']): string { + switch (level) { + case 'do-not-travel': return 'sa-level-dnt'; + case 'reconsider': return 'sa-level-reconsider'; + case 'caution': return 'sa-level-caution'; + case 'normal': return 'sa-level-normal'; + default: return 'sa-level-info'; + } + } + + private getLevelLabel(level?: SecurityAdvisory['level']): string { + switch (level) { + case 'do-not-travel': return t('components.securityAdvisories.levels.doNotTravel'); + case 'reconsider': return t('components.securityAdvisories.levels.reconsider'); + case 'caution': return t('components.securityAdvisories.levels.caution'); + case 'normal': return t('components.securityAdvisories.levels.normal'); + default: return t('components.securityAdvisories.levels.info'); + } + } + + private getSourceFlag(sourceCountry: string): string { + switch (sourceCountry) { + case 'US': return '\u{1F1FA}\u{1F1F8}'; + case 'AU': return '\u{1F1E6}\u{1F1FA}'; + case 'UK': return '\u{1F1EC}\u{1F1E7}'; + case 'NZ': return '\u{1F1F3}\u{1F1FF}'; + case 'EU': return '\u{1F1EA}\u{1F1FA}'; + case 'INT': return '\u{1F3E5}'; + default: return '\u{1F310}'; + } + } + + private formatTime(date: Date): string { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 1) return t('components.securityAdvisories.time.justNow'); + if (minutes < 60) return t('components.securityAdvisories.time.minutesAgo', { count: String(minutes) }); + if (hours < 24) return t('components.securityAdvisories.time.hoursAgo', { count: String(hours) }); + if (days < 7) return t('components.securityAdvisories.time.daysAgo', { count: String(days) }); + return date.toLocaleDateString(); + } + + private render(): void { + if (this.advisories.length === 0) { + this.setContent(`
${t('common.noDataAvailable')}
`); + return; + } + + const filtered = this.getFiltered(); + + const dntCount = this.advisories.filter(a => a.level === 'do-not-travel').length; + const reconsiderCount = this.advisories.filter(a => a.level === 'reconsider').length; + const cautionCount = this.advisories.filter(a => a.level === 'caution').length; + + const summaryHtml = ` +
+
+ ${dntCount} + ${t('components.securityAdvisories.levels.doNotTravel')} +
+
+ ${reconsiderCount} + ${t('components.securityAdvisories.levels.reconsider')} +
+
+ ${cautionCount} + ${t('components.securityAdvisories.levels.caution')} +
+
+ `; + + const filtersHtml = ` +
+ + + + + + + +
+ `; + + const displayed = filtered.slice(0, 30); + let itemsHtml: string; + + if (displayed.length === 0) { + itemsHtml = `
${t('components.securityAdvisories.noMatching')}
`; + } else { + itemsHtml = displayed.map(a => { + const levelCls = this.getLevelClass(a.level); + const levelLabel = this.getLevelLabel(a.level); + const flag = this.getSourceFlag(a.sourceCountry); + + return `
+
+ ${levelLabel} + ${flag} ${escapeHtml(a.source)} +
+ ${escapeHtml(a.title)} +
${this.formatTime(a.pubDate)}
+
`; + }).join(''); + } + + const footerHtml = ` + + `; + + this.setContent(` +
+ ${summaryHtml} + ${filtersHtml} +
${itemsHtml}
+ ${footerHtml} +
+ `); + } + + public setRefreshHandler(handler: () => void): void { + this.onRefreshRequest = handler; + } + + public destroy(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + super.destroy(); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 14919ecdd..bd1db6b65 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -41,3 +41,4 @@ export * from './InvestmentsPanel'; export * from './UnifiedSettings'; export * from './TradePolicyPanel'; export * from './SupplyChainPanel'; +export * from './SecurityAdvisoriesPanel'; diff --git a/src/config/panels.ts b/src/config/panels.ts index 5710378fd..84d60c9ce 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -50,6 +50,7 @@ const FULL_PANELS: Record = { displacement: { name: 'UNHCR Displacement', enabled: true, priority: 2 }, 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 }, }; const FULL_MAP_LAYERS: MapLayers = { @@ -587,7 +588,7 @@ export const PANEL_CATEGORY_MAP: Recordتنبيهات أمنية
تحذيرات السفر والتنبيهات الأمنية من الوكالات الحكومية." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "إغلاق", "currentVariant": "(الحالي)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "الكل" } } diff --git a/src/locales/de.json b/src/locales/de.json index 7c7eaee78..fb8d2f1cb 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -203,7 +203,8 @@ "techHubs": "Heiße Tech-Hubs", "gccInvestments": "GCC-Investitionen", "geoHubs": "Geopolitical Hotspots", - "liveWebcams": "Live-Webcams" + "liveWebcams": "Live-Webcams", + "securityAdvisories": "Sicherheitshinweise" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Asien", "regionMiddleEast": "Naher Osten", "regionAfrica": "Afrika" + }, + "securityAdvisories": { + "loading": "Reisehinweise werden geladen...", + "noMatching": "Keine Hinweise für diesen Filter", + "critical": "Kritisch", + "health": "Gesundheit", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Aktualisieren", + "levels": { + "doNotTravel": "Nicht reisen", + "reconsider": "Reise überdenken", + "caution": "Vorsicht", + "normal": "Normal", + "info": "Info" + }, + "time": { + "justNow": "gerade eben", + "minutesAgo": "vor {{count}} Min.", + "hoursAgo": "vor {{count}} Std.", + "daysAgo": "vor {{count}} Tagen" + }, + "infoTooltip": "Sicherheitshinweise
Reisewarnungen und Sicherheitshinweise von Regierungsbehörden." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Schließen", "currentVariant": "(aktuell)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Alle" } } diff --git a/src/locales/el.json b/src/locales/el.json index c1ed5a7db..c7f088bff 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -203,7 +203,8 @@ "techReadiness": "Δείκτης Τεχνολογικής Ετοιμότητας", "gccInvestments": "Επενδύσεις GCC", "geoHubs": "Γεωπολιτικά Κέντρα", - "liveWebcams": "Ζωντανές Κάμερες" + "liveWebcams": "Ζωντανές Κάμερες", + "securityAdvisories": "Προειδοποιήσεις Ασφαλείας" }, "modals": { "search": { @@ -1312,6 +1313,28 @@ "regionAsia": "Ασία", "regionMiddleEast": "Μέση Ανατολή", "regionAfrica": "Αφρική" + }, + "securityAdvisories": { + "loading": "Φόρτωση ταξιδιωτικών οδηγιών...", + "noMatching": "Δεν βρέθηκαν οδηγίες για αυτό το φίλτρο", + "critical": "Κρίσιμο", + "health": "Υγεία", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Ανανέωση", + "levels": { + "doNotTravel": "Μην ταξιδεύετε", + "reconsider": "Επανεξετάστε", + "caution": "Προσοχή", + "normal": "Κανονικό", + "info": "Πληροφορία" + }, + "time": { + "justNow": "μόλις τώρα", + "minutesAgo": "{{count}} λεπτά πριν", + "hoursAgo": "{{count}} ώρες πριν", + "daysAgo": "{{count}} ημέρες πριν" + }, + "infoTooltip": "Προειδοποιήσεις Ασφαλείας
Ταξιδιωτικές οδηγίες και προειδοποιήσεις ασφαλείας." } }, "popups": { @@ -1949,6 +1972,7 @@ "close": "Κλείσιμο", "currentVariant": "(τρέχον)", "retry": "Επανάληψη", - "refresh": "Ανανέωση" + "refresh": "Ανανέωση", + "all": "Όλα" } } diff --git a/src/locales/en.json b/src/locales/en.json index 67e6a22c4..393095115 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -185,6 +185,7 @@ "displacement": "UNHCR Displacement", "climate": "Climate Anomalies", "populationExposure": "Population Exposure", + "securityAdvisories": "Security Advisories", "startups": "Startups & VC", "vcblogs": "VC Insights & Essays", "regionalStartups": "Global Startup News", @@ -1082,6 +1083,28 @@ "radiusKm": "{{km}}km radius", "infoTooltip": "Population Exposure Estimates Estimated population within event impact radius. Based on WorldPop country density data.
  • Conflict: 50km radius
  • Earthquake: 100km radius
  • Flood: 100km radius
  • Wildfire: 30km radius
" }, + "securityAdvisories": { + "loading": "Fetching travel advisories...", + "noMatching": "No advisories match this filter", + "critical": "Critical", + "health": "Health", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Refresh", + "levels": { + "doNotTravel": "Do Not Travel", + "reconsider": "Reconsider Travel", + "caution": "Exercise Caution", + "normal": "Normal", + "info": "Info" + }, + "time": { + "justNow": "just now", + "minutesAgo": "{{count}}m ago", + "hoursAgo": "{{count}}h ago", + "daysAgo": "{{count}}d ago" + }, + "infoTooltip": "Security Advisories
Travel advisories and security alerts from government foreign affairs agencies:

Sources:
\uD83C\uDDFA\uD83C\uDDF8 US State Dept Travel Advisories
\uD83C\uDDE6\uD83C\uDDFA AU DFAT Smartraveller
\uD83C\uDDEC\uD83C\uDDE7 UK FCDO Travel Advice
\uD83C\uDDF3\uD83C\uDDFF NZ MFAT SafeTravel

Levels:
\uD83D\uDFE5 Do Not Travel
\uD83D\uDFE7 Reconsider Travel
\uD83D\uDFE8 Exercise Caution
\uD83D\uDFE9 Normal Precautions" + }, "satelliteFires": { "noData": "No fire data available", "region": "Region", @@ -2020,6 +2043,7 @@ "currentVariant": "(current)", "retry": "Retry", "retrying": "Retrying...", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "All" } } diff --git a/src/locales/es.json b/src/locales/es.json index 11ca7ddd4..e018a360b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -203,7 +203,8 @@ "techHubs": "Centros tecnológicos de moda", "gccInvestments": "Inversiones del CCG", "geoHubs": "Geopolitical Hotspots", - "liveWebcams": "Cámaras en Vivo" + "liveWebcams": "Cámaras en Vivo", + "securityAdvisories": "Alertas de Seguridad" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Asia", "regionMiddleEast": "Oriente Medio", "regionAfrica": "África" + }, + "securityAdvisories": { + "loading": "Cargando alertas de viaje...", + "noMatching": "No hay alertas para este filtro", + "critical": "Crítico", + "health": "Salud", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Actualizar", + "levels": { + "doNotTravel": "No viajar", + "reconsider": "Reconsiderar viaje", + "caution": "Precaución", + "normal": "Normal", + "info": "Info" + }, + "time": { + "justNow": "ahora", + "minutesAgo": "hace {{count}} min", + "hoursAgo": "hace {{count}} h", + "daysAgo": "hace {{count}} d" + }, + "infoTooltip": "Alertas de Seguridad
Avisos de viaje y alertas de seguridad de agencias gubernamentales." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Cerrar", "currentVariant": "(actual)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Todos" } } diff --git a/src/locales/fr.json b/src/locales/fr.json index cd43b917f..969f6faa3 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -203,7 +203,8 @@ "techHubs": "Pôles Technologiques", "gccInvestments": "Investissements du CCG", "geoHubs": "Centres géopolitiques", - "liveWebcams": "Webcams en Direct" + "liveWebcams": "Webcams en Direct", + "securityAdvisories": "Avis de Sécurité" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Asie", "regionMiddleEast": "Moyen-Orient", "regionAfrica": "Afrique" + }, + "securityAdvisories": { + "loading": "Chargement des avis de voyage...", + "noMatching": "Aucun avis pour ce filtre", + "critical": "Critique", + "health": "Santé", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Actualiser", + "levels": { + "doNotTravel": "Ne pas voyager", + "reconsider": "Reconsidérer le voyage", + "caution": "Prudence", + "normal": "Normal", + "info": "Info" + }, + "time": { + "justNow": "à l'instant", + "minutesAgo": "il y a {{count}} min", + "hoursAgo": "il y a {{count}} h", + "daysAgo": "il y a {{count}} j" + }, + "infoTooltip": "Avis de Sécurité
Avis aux voyageurs et alertes de sécurité des agences gouvernementales." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Fermer", "currentVariant": "(actuel)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Tous" } } diff --git a/src/locales/it.json b/src/locales/it.json index 44ff9451a..cb1dde5d9 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -203,7 +203,8 @@ "techHubs": "Hub tecnologici caldi", "gccInvestments": "Investimenti GCC", "geoHubs": "Hotspot geopolitici", - "liveWebcams": "Webcam in Diretta" + "liveWebcams": "Webcam in Diretta", + "securityAdvisories": "Avvisi di Sicurezza" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Asia", "regionMiddleEast": "Medio Oriente", "regionAfrica": "Africa" + }, + "securityAdvisories": { + "loading": "Caricamento avvisi di viaggio...", + "noMatching": "Nessun avviso per questo filtro", + "critical": "Critico", + "health": "Salute", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Aggiorna", + "levels": { + "doNotTravel": "Non viaggiare", + "reconsider": "Riconsiderare il viaggio", + "caution": "Cautela", + "normal": "Normale", + "info": "Info" + }, + "time": { + "justNow": "adesso", + "minutesAgo": "{{count}} min fa", + "hoursAgo": "{{count}} ore fa", + "daysAgo": "{{count}} giorni fa" + }, + "infoTooltip": "Avvisi di Sicurezza
Avvisi di viaggio e allerte di sicurezza dalle agenzie governative." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Chiudi", "currentVariant": "(corrente)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Tutti" } } diff --git a/src/locales/ja.json b/src/locales/ja.json index 006c10ff2..d2fa82cc9 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -203,7 +203,8 @@ "techReadiness": "テック準備度指数", "gccInvestments": "GCC投資", "geoHubs": "地政学ハブ", - "liveWebcams": "ライブカメラ" + "liveWebcams": "ライブカメラ", + "securityAdvisories": "セキュリティアドバイザリー" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "アジア", "regionMiddleEast": "中東", "regionAfrica": "アフリカ" + }, + "securityAdvisories": { + "loading": "渡航情報を取得中...", + "noMatching": "該当する情報なし", + "critical": "重大", + "health": "健康", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "更新", + "levels": { + "doNotTravel": "渡航中止", + "reconsider": "渡航再検討", + "caution": "注意", + "normal": "通常", + "info": "情報" + }, + "time": { + "justNow": "たった今", + "minutesAgo": "{{count}}分前", + "hoursAgo": "{{count}}時間前", + "daysAgo": "{{count}}日前" + }, + "infoTooltip": "セキュリティアドバイザリー
各国政府の渡航情報と安全警告。" } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "閉じる", "currentVariant": "(現在)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "すべて" } } diff --git a/src/locales/nl.json b/src/locales/nl.json index ebb127368..2b5faf4ad 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -62,7 +62,8 @@ "geoHubs": "Geopolitical Hotspots", "polymarket": "Voorspellingen", "climate": "Klimaatafwijkingen", - "liveWebcams": "Live Webcams" + "liveWebcams": "Live Webcams", + "securityAdvisories": "Veiligheidsadviezen" }, "modals": { "search": { @@ -1144,6 +1145,28 @@ "regionAsia": "Azië", "regionMiddleEast": "Midden-Oosten", "regionAfrica": "Afrika" + }, + "securityAdvisories": { + "loading": "Reisadviezen laden...", + "noMatching": "Geen adviezen voor dit filter", + "critical": "Kritiek", + "health": "Gezondheid", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Vernieuwen", + "levels": { + "doNotTravel": "Niet reizen", + "reconsider": "Reis heroverwegen", + "caution": "Voorzichtigheid", + "normal": "Normaal", + "info": "Info" + }, + "time": { + "justNow": "zojuist", + "minutesAgo": "{{count}} min geleden", + "hoursAgo": "{{count}} uur geleden", + "daysAgo": "{{count}} dagen geleden" + }, + "infoTooltip": "Veiligheidsadviezen
Reisadviezen en veiligheidswaarschuwingen van overheidsinstanties." } }, "popups": { @@ -1781,7 +1804,8 @@ "close": "Sluiten", "currentVariant": "(huidig)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Alle" }, "header": { "world": "WERELD", diff --git a/src/locales/pl.json b/src/locales/pl.json index b53f43681..f78c4abce 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -203,7 +203,8 @@ "techHubs": "Gorące centra technologiczne", "gccInvestments": "Inwestycje GCC", "geoHubs": "Geopolitical Hotspots", - "liveWebcams": "Kamery na Żywo" + "liveWebcams": "Kamery na Żywo", + "securityAdvisories": "Ostrzeżenia Bezpieczeństwa" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Azja", "regionMiddleEast": "Bliski Wschód", "regionAfrica": "Afryka" + }, + "securityAdvisories": { + "loading": "Ładowanie ostrzeżeń podróżnych...", + "noMatching": "Brak ostrzeżeń dla tego filtra", + "critical": "Krytyczny", + "health": "Zdrowie", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Odśwież", + "levels": { + "doNotTravel": "Nie podróżuj", + "reconsider": "Rozważ podróż", + "caution": "Ostrożność", + "normal": "Normalny", + "info": "Info" + }, + "time": { + "justNow": "właśnie", + "minutesAgo": "{{count}} min temu", + "hoursAgo": "{{count}} godz. temu", + "daysAgo": "{{count}} dni temu" + }, + "infoTooltip": "Ostrzeżenia Bezpieczeństwa
Ostrzeżenia podróżne i alerty bezpieczeństwa z agencji rządowych." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Zamknij", "currentVariant": "(bieżący)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Wszystkie" } } diff --git a/src/locales/pt.json b/src/locales/pt.json index 85ffd3eb8..19eb876ed 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -62,7 +62,8 @@ "geoHubs": "Geopolitical Hotspots", "polymarket": "Previsões", "climate": "Anomalias Climáticas", - "liveWebcams": "Câmeras ao Vivo" + "liveWebcams": "Câmeras ao Vivo", + "securityAdvisories": "Alertas de Segurança" }, "modals": { "search": { @@ -1144,6 +1145,28 @@ "regionAsia": "Ásia", "regionMiddleEast": "Oriente Médio", "regionAfrica": "África" + }, + "securityAdvisories": { + "loading": "Carregando alertas de viagem...", + "noMatching": "Nenhum alerta para este filtro", + "critical": "Crítico", + "health": "Saúde", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Atualizar", + "levels": { + "doNotTravel": "Não viajar", + "reconsider": "Reconsiderar viagem", + "caution": "Cautela", + "normal": "Normal", + "info": "Info" + }, + "time": { + "justNow": "agora", + "minutesAgo": "há {{count}} min", + "hoursAgo": "há {{count}} h", + "daysAgo": "há {{count}} d" + }, + "infoTooltip": "Alertas de Segurança
Avisos de viagem e alertas de segurança de agências governamentais." } }, "popups": { @@ -1781,7 +1804,8 @@ "close": "Fechar", "currentVariant": "(atual)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Todos" }, "header": { "world": "MUNDO", diff --git a/src/locales/ru.json b/src/locales/ru.json index 955bf5aa4..58ba906ae 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -203,7 +203,8 @@ "techReadiness": "Индекс технологической готовности", "gccInvestments": "Инвестиции стран Залива", "geoHubs": "Геополитические хабы", - "liveWebcams": "Веб-камеры" + "liveWebcams": "Веб-камеры", + "securityAdvisories": "Предупреждения безопасности" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Азия", "regionMiddleEast": "Ближний Восток", "regionAfrica": "Африка" + }, + "securityAdvisories": { + "loading": "Загрузка предупреждений...", + "noMatching": "Нет предупреждений для этого фильтра", + "critical": "Критический", + "health": "Здоровье", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Обновить", + "levels": { + "doNotTravel": "Не путешествовать", + "reconsider": "Пересмотреть поездку", + "caution": "Осторожность", + "normal": "Нормально", + "info": "Инфо" + }, + "time": { + "justNow": "только что", + "minutesAgo": "{{count}} мин. назад", + "hoursAgo": "{{count}} ч. назад", + "daysAgo": "{{count}} дн. назад" + }, + "infoTooltip": "Предупреждения безопасности
Рекомендации по поездкам и предупреждения от государственных ведомств." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Закрыть", "currentVariant": "(текущий)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Все" } } diff --git a/src/locales/sv.json b/src/locales/sv.json index 9a7df4661..90e6d49b6 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -62,7 +62,8 @@ "geoHubs": "Geopolitical Hotspots", "polymarket": "Förutsägelser", "climate": "Klimatanomalier", - "liveWebcams": "Webbkameror" + "liveWebcams": "Webbkameror", + "securityAdvisories": "Säkerhetsvarningar" }, "modals": { "search": { @@ -1144,6 +1145,28 @@ "regionAsia": "Asien", "regionMiddleEast": "Mellanöstern", "regionAfrica": "Afrika" + }, + "securityAdvisories": { + "loading": "Hämtar resevarningar...", + "noMatching": "Inga varningar för detta filter", + "critical": "Kritisk", + "health": "Hälsa", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Uppdatera", + "levels": { + "doNotTravel": "Res inte", + "reconsider": "Överväg resa", + "caution": "Försiktighet", + "normal": "Normal", + "info": "Info" + }, + "time": { + "justNow": "just nu", + "minutesAgo": "{{count}} min sedan", + "hoursAgo": "{{count}} tim sedan", + "daysAgo": "{{count}} dagar sedan" + }, + "infoTooltip": "Säkerhetsvarningar
Resevarningar och säkerhetsmeddelanden från myndigheters utrikesdepartement." } }, "popups": { @@ -1781,7 +1804,8 @@ "close": "Stäng", "currentVariant": "(aktuell)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Alla" }, "header": { "world": "VÄRLD", diff --git a/src/locales/th.json b/src/locales/th.json index b07ceba1f..763c7992e 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -203,7 +203,8 @@ "techReadiness": "ดัชนีความพร้อมด้านเทคโนโลยี", "gccInvestments": "การลงทุน GCC", "geoHubs": "ศูนย์กลางภูมิรัฐศาสตร์", - "liveWebcams": "เว็บแคมสด" + "liveWebcams": "เว็บแคมสด", + "securityAdvisories": "คำเตือนด้านความปลอดภัย" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "เอเชีย", "regionMiddleEast": "ตะวันออกกลาง", "regionAfrica": "แอฟริกา" + }, + "securityAdvisories": { + "loading": "กำลังโหลดคำเตือนการเดินทาง...", + "noMatching": "ไม่พบคำเตือนสำหรับตัวกรองนี้", + "critical": "วิกฤต", + "health": "สุขภาพ", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "รีเฟรช", + "levels": { + "doNotTravel": "ห้ามเดินทาง", + "reconsider": "ทบทวนการเดินทาง", + "caution": "ระวัง", + "normal": "ปกติ", + "info": "ข้อมูล" + }, + "time": { + "justNow": "เมื่อสักครู่", + "minutesAgo": "{{count}} นาทีที่แล้ว", + "hoursAgo": "{{count}} ชั่วโมงที่แล้ว", + "daysAgo": "{{count}} วันที่แล้ว" + }, + "infoTooltip": "คำเตือนด้านความปลอดภัย
คำเตือนการเดินทางและความปลอดภัยจากหน่วยงานรัฐบาล." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "ปิด", "currentVariant": "(ปัจจุบัน)", "retry": "ลองใหม่", - "refresh": "รีเฟรช" + "refresh": "รีเฟรช", + "all": "ทั้งหมด" } } diff --git a/src/locales/tr.json b/src/locales/tr.json index d79be2396..e0e0b742a 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -203,7 +203,8 @@ "techReadiness": "Teknoloji Hazirlik Endeksi", "gccInvestments": "GCC Yatirimlari", "geoHubs": "Jeopolitik Merkezler", - "liveWebcams": "Canli Web Kameralari" + "liveWebcams": "Canli Web Kameralari", + "securityAdvisories": "Güvenlik Uyarıları" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Asya", "regionMiddleEast": "Orta Doğu", "regionAfrica": "Afrika" + }, + "securityAdvisories": { + "loading": "Seyahat uyarıları yükleniyor...", + "noMatching": "Bu filtre için uyarı yok", + "critical": "Kritik", + "health": "Sağlık", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Yenile", + "levels": { + "doNotTravel": "Seyahat etmeyin", + "reconsider": "Seyahati yeniden düşünün", + "caution": "Dikkat", + "normal": "Normal", + "info": "Bilgi" + }, + "time": { + "justNow": "şimdi", + "minutesAgo": "{{count}} dk önce", + "hoursAgo": "{{count}} sa önce", + "daysAgo": "{{count}} gün önce" + }, + "infoTooltip": "Güvenlik Uyarıları
Hükümet kurumlarından seyahat uyarıları ve güvenlik bildirimleri." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Kapat", "currentVariant": "(mevcut)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "Tümü" } } diff --git a/src/locales/vi.json b/src/locales/vi.json index 8b9ddc995..4d3e35acc 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -203,7 +203,8 @@ "techReadiness": "Chỉ số Sẵn sàng Công nghệ", "gccInvestments": "Đầu tư GCC", "geoHubs": "Trung tâm Địa chính trị", - "liveWebcams": "Webcam Trực tiếp" + "liveWebcams": "Webcam Trực tiếp", + "securityAdvisories": "Cảnh báo An ninh" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "Châu Á", "regionMiddleEast": "Trung Đông", "regionAfrica": "Châu Phi" + }, + "securityAdvisories": { + "loading": "Đang tải cảnh báo du lịch...", + "noMatching": "Không có cảnh báo cho bộ lọc này", + "critical": "Nghiêm trọng", + "health": "Sức khỏe", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "Làm mới", + "levels": { + "doNotTravel": "Không du lịch", + "reconsider": "Cân nhắc lại", + "caution": "Thận trọng", + "normal": "Bình thường", + "info": "Thông tin" + }, + "time": { + "justNow": "vừa xong", + "minutesAgo": "{{count}} phút trước", + "hoursAgo": "{{count}} giờ trước", + "daysAgo": "{{count}} ngày trước" + }, + "infoTooltip": "Cảnh báo An ninh
Cảnh báo du lịch và an ninh từ các cơ quan chính phủ." } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "Đóng", "currentVariant": "(hiện tại)", "retry": "Thử lại", - "refresh": "Làm mới" + "refresh": "Làm mới", + "all": "Tất cả" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 1a79e3781..a9f405cda 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -203,7 +203,8 @@ "techReadiness": "科技就绪指数", "gccInvestments": "GCC投资", "geoHubs": "地缘政治枢纽", - "liveWebcams": "实时摄像头" + "liveWebcams": "实时摄像头", + "securityAdvisories": "安全警告" }, "modals": { "search": { @@ -1285,6 +1286,28 @@ "regionAsia": "亚洲", "regionMiddleEast": "中东", "regionAfrica": "非洲" + }, + "securityAdvisories": { + "loading": "正在加载旅行警告...", + "noMatching": "没有匹配的警告", + "critical": "严重", + "health": "健康", + "sources": "US State Dept, AU DFAT, UK FCDO, NZ MFAT, CDC, ECDC, WHO, US Embassies", + "refresh": "刷新", + "levels": { + "doNotTravel": "禁止出行", + "reconsider": "重新考虑出行", + "caution": "谨慎", + "normal": "正常", + "info": "信息" + }, + "time": { + "justNow": "刚刚", + "minutesAgo": "{{count}}分钟前", + "hoursAgo": "{{count}}小时前", + "daysAgo": "{{count}}天前" + }, + "infoTooltip": "安全警告
来自各国政府的旅行警告和安全提示。" } }, "popups": { @@ -1922,6 +1945,7 @@ "close": "关闭", "currentVariant": "(当前)", "retry": "Retry", - "refresh": "Refresh" + "refresh": "Refresh", + "all": "全部" } } diff --git a/src/services/data-freshness.ts b/src/services/data-freshness.ts index 316b25666..bc6265e78 100644 --- a/src/services/data-freshness.ts +++ b/src/services/data-freshness.ts @@ -35,7 +35,8 @@ export type DataSourceId = | 'giving' // Global giving activity data | 'bis' // BIS central bank data | 'wto_trade' // WTO trade policy data - | 'supply_chain'; // Supply chain disruption intelligence + | 'supply_chain' // Supply chain disruption intelligence + | 'security_advisories'; // Government travel/security advisories export type FreshnessStatus = 'fresh' | 'stale' | 'very_stale' | 'no_data' | 'disabled' | 'error'; @@ -101,6 +102,7 @@ const SOURCE_METADATA: Record = { bis: 'Central bank policy data may be stale—BIS feed unavailable', wto_trade: 'Trade policy intelligence unavailable—WTO data not updating', supply_chain: 'Supply chain disruption status unavailable—chokepoint monitoring offline', + security_advisories: 'Government travel advisory data unavailable—security alerts may be missed', }; /** diff --git a/src/services/security-advisories.ts b/src/services/security-advisories.ts new file mode 100644 index 000000000..6fb90bbdd --- /dev/null +++ b/src/services/security-advisories.ts @@ -0,0 +1,240 @@ +import { proxyUrl } from '@/utils'; +import { getPersistentCache, setPersistentCache } from './persistent-cache'; +import { dataFreshness } from './data-freshness'; + +export interface SecurityAdvisory { + title: string; + link: string; + pubDate: Date; + source: string; + sourceCountry: string; + level?: 'do-not-travel' | 'reconsider' | 'caution' | 'normal' | 'info'; + country?: string; +} + +export interface SecurityAdvisoriesFetchResult { + ok: boolean; + advisories: SecurityAdvisory[]; + cachedAt?: string; +} + +interface AdvisoryFeed { + name: string; + sourceCountry: string; + url: string; + parseLevel?: (title: string) => SecurityAdvisory['level']; +} + +const US_LEVEL_RE = /Level (\d)/i; + +function parseUsLevel(title: string): SecurityAdvisory['level'] { + const m = title.match(US_LEVEL_RE); + if (!m) return 'info'; + switch (m[1]) { + case '4': return 'do-not-travel'; + case '3': return 'reconsider'; + case '2': return 'caution'; + case '1': return 'normal'; + default: return 'info'; + } +} + +function parseAuLevel(title: string): SecurityAdvisory['level'] { + const lower = title.toLowerCase(); + if (lower.includes('do not travel')) return 'do-not-travel'; + if (lower.includes('reconsider')) return 'reconsider'; + if (lower.includes('exercise a high degree of caution') || lower.includes('high degree')) return 'caution'; + return 'info'; +} + +const ADVISORY_FEEDS: AdvisoryFeed[] = [ + // United States (State Dept) + { + name: 'US State Dept', + sourceCountry: 'US', + url: 'https://travel.state.gov/_res/rss/TAsTWs.xml', + parseLevel: parseUsLevel, + }, + // Australia (DFAT Smartraveller) — all destinations + { + name: 'AU Smartraveller', + sourceCountry: 'AU', + url: 'https://www.smartraveller.gov.au/countries/documents/index.rss', + parseLevel: parseAuLevel, + }, + // Australia — Do Not Travel specifically + { + name: 'AU DNT', + sourceCountry: 'AU', + url: 'https://www.smartraveller.gov.au/countries/documents/do-not-travel.rss', + parseLevel: () => 'do-not-travel', + }, + // Australia — Reconsider + { + name: 'AU Reconsider', + sourceCountry: 'AU', + url: 'https://www.smartraveller.gov.au/countries/documents/reconsider-your-need-to-travel.rss', + parseLevel: () => 'reconsider', + }, + // New Zealand MFAT + { + name: 'NZ MFAT', + sourceCountry: 'NZ', + url: 'https://www.safetravel.govt.nz/news/feed', + parseLevel: parseAuLevel, + }, + // UK FCDO — GOV.UK travel advice atom feed + { + name: 'UK FCDO', + sourceCountry: 'UK', + url: 'https://www.gov.uk/foreign-travel-advice.atom', + }, + // US Embassy security alerts (per-country) + { name: 'US Embassy Thailand', sourceCountry: 'US', url: 'https://th.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy UAE', sourceCountry: 'US', url: 'https://ae.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Germany', sourceCountry: 'US', url: 'https://de.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Ukraine', sourceCountry: 'US', url: 'https://ua.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Mexico', sourceCountry: 'US', url: 'https://mx.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy India', sourceCountry: 'US', url: 'https://in.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Pakistan', sourceCountry: 'US', url: 'https://pk.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Colombia', sourceCountry: 'US', url: 'https://co.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Poland', sourceCountry: 'US', url: 'https://pl.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Bangladesh', sourceCountry: 'US', url: 'https://bd.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Italy', sourceCountry: 'US', url: 'https://it.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Dominican Republic', sourceCountry: 'US', url: 'https://do.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Myanmar', sourceCountry: 'US', url: 'https://mm.usembassy.gov/category/alert/feed/' }, + // Health advisories + { name: 'CDC Travel Notices', sourceCountry: 'US', url: 'https://wwwnc.cdc.gov/travel/rss/notices.xml' }, + { name: 'ECDC Epidemiological Updates', sourceCountry: 'EU', url: 'https://www.ecdc.europa.eu/en/taxonomy/term/1310/feed' }, + { name: 'ECDC Threats Report', sourceCountry: 'EU', url: 'https://www.ecdc.europa.eu/en/taxonomy/term/1505/feed' }, + { name: 'ECDC Risk Assessments', sourceCountry: 'EU', url: 'https://www.ecdc.europa.eu/en/taxonomy/term/1295/feed' }, + { name: 'ECDC Avian Influenza', sourceCountry: 'EU', url: 'https://www.ecdc.europa.eu/en/taxonomy/term/323//feed' }, + { name: 'ECDC Publications', sourceCountry: 'EU', url: 'https://www.ecdc.europa.eu/en/taxonomy/term/1244/feed' }, + { name: 'WHO News', sourceCountry: 'INT', url: 'https://www.who.int/rss-feeds/news-english.xml' }, + { name: 'WHO Africa Emergencies', sourceCountry: 'INT', url: 'https://www.afro.who.int/rss/emergencies.xml' }, +]; + +const CACHE_KEY = 'security-advisories'; +const CACHE_TTL = 15 * 60 * 1000; // 15 minutes +let lastFetch = 0; +let cachedResult: SecurityAdvisory[] | null = null; + +function parseFeedXml( + text: string, + feed: AdvisoryFeed, +): SecurityAdvisory[] { + const parser = new DOMParser(); + const doc = parser.parseFromString(text, 'text/xml'); + + const parseError = doc.querySelector('parsererror'); + if (parseError) return []; + + // Try RSS items first, then Atom entries + let items = doc.querySelectorAll('item'); + const isAtom = items.length === 0; + if (isAtom) items = doc.querySelectorAll('entry'); + + return Array.from(items).slice(0, 15).map(item => { + const title = item.querySelector('title')?.textContent?.trim() || ''; + let link = ''; + if (isAtom) { + const linkEl = item.querySelector('link[href]'); + link = linkEl?.getAttribute('href') || ''; + } else { + link = item.querySelector('link')?.textContent?.trim() || ''; + } + + const pubDateStr = isAtom + ? (item.querySelector('updated')?.textContent || item.querySelector('published')?.textContent || '') + : (item.querySelector('pubDate')?.textContent || ''); + const parsed = pubDateStr ? new Date(pubDateStr) : new Date(); + const pubDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed; + + const level = feed.parseLevel ? feed.parseLevel(title) : 'info'; + + return { + title, + link, + pubDate, + source: feed.name, + sourceCountry: feed.sourceCountry, + level, + }; + }); +} + +function toSerializable(items: SecurityAdvisory[]): Array & { pubDate: string }> { + return items.map(item => ({ ...item, pubDate: item.pubDate.toISOString() })); +} + +function fromSerializable(items: Array & { pubDate: string }>): SecurityAdvisory[] { + return items.map(item => ({ ...item, pubDate: new Date(item.pubDate) })); +} + +export async function fetchSecurityAdvisories( + signal?: AbortSignal, +): Promise { + const now = Date.now(); + + // Return in-memory cache if fresh + if (cachedResult && now - lastFetch < CACHE_TTL) { + return { ok: true, advisories: cachedResult }; + } + + const allAdvisories: SecurityAdvisory[] = []; + const feedResults = await Promise.allSettled( + ADVISORY_FEEDS.map(async (feed) => { + try { + const response = await fetch(proxyUrl(feed.url), { + headers: { Accept: 'application/rss+xml, application/xml, text/xml, */*' }, + ...(signal ? { signal } : {}), + }); + if (!response.ok) { + console.warn(`[SecurityAdvisories] ${feed.name} HTTP ${response.status}`); + return []; + } + const text = await response.text(); + return parseFeedXml(text, feed); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') throw e; + console.warn(`[SecurityAdvisories] ${feed.name} failed:`, e); + return []; + } + }), + ); + + for (const result of feedResults) { + if (result.status === 'fulfilled') { + allAdvisories.push(...result.value); + } + } + + // Deduplicate by title (AU feeds can overlap) + const seen = new Set(); + const deduped = allAdvisories.filter(a => { + const key = a.title.toLowerCase().trim(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + // Sort by date descending + deduped.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()); + + // Cache + cachedResult = deduped; + lastFetch = now; + void setPersistentCache(CACHE_KEY, toSerializable(deduped)); + + if (deduped.length > 0) { + dataFreshness.recordUpdate('security_advisories', deduped.length); + } + + return { ok: true, advisories: deduped }; +} + +export async function loadCachedAdvisories(): Promise { + const entry = await getPersistentCache & { pubDate: string }>>(CACHE_KEY); + if (!entry?.data?.length) return null; + return fromSerializable(entry.data); +} diff --git a/src/styles/panels.css b/src/styles/panels.css index dc482619f..571fbae60 100644 --- a/src/styles/panels.css +++ b/src/styles/panels.css @@ -221,3 +221,41 @@ .giving-receiver-list li:last-child { border-bottom: none; } .giving-inst-content { } .giving-inst-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; } + +/* ---------------------------------------------------------- + Security Advisories Panel + ---------------------------------------------------------- */ +.sa-panel-content { font-size: 12px; } +.sa-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 8px; } +.sa-summary-item { background: var(--overlay-subtle); border: 1px solid var(--border); border-radius: 4px; padding: 8px 6px; text-align: center; } +.sa-summary-count { display: block; font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; } +.sa-summary-label { display: block; font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; } +.sa-summary-item.sa-level-dnt .sa-summary-count { color: var(--semantic-critical); } +.sa-summary-item.sa-level-reconsider .sa-summary-count { color: var(--semantic-high); } +.sa-summary-item.sa-level-caution .sa-summary-count { color: var(--semantic-elevated); } +.sa-filters { display: flex; gap: 2px; margin-bottom: 6px; flex-wrap: wrap; } +.sa-filter { background: transparent; border: 1px solid var(--border-strong); color: var(--text-dim); padding: 3px 10px; font-size: 10px; cursor: pointer; border-radius: 3px; transition: all 0.15s; } +.sa-filter:hover { border-color: var(--text-faint); color: var(--text-secondary); } +.sa-filter-active { background: color-mix(in srgb, var(--accent) 10%, transparent); border-color: var(--accent); color: var(--accent); } +.sa-list { display: flex; flex-direction: column; gap: 4px; } +.sa-item { padding: 8px; border-radius: 4px; border-left: 3px solid var(--border); background: var(--overlay-subtle); } +.sa-item.sa-level-dnt { border-left-color: var(--semantic-critical); } +.sa-item.sa-level-reconsider { border-left-color: var(--semantic-high); } +.sa-item.sa-level-caution { border-left-color: var(--semantic-elevated); } +.sa-item.sa-level-normal { border-left-color: var(--semantic-normal); } +.sa-item.sa-level-info { border-left-color: var(--text-muted); } +.sa-item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } +.sa-badge { font-size: 8px; font-weight: 700; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.5px; text-transform: uppercase; } +.sa-badge.sa-level-dnt { background: color-mix(in srgb, var(--semantic-critical) 20%, transparent); color: var(--semantic-critical); } +.sa-badge.sa-level-reconsider { background: color-mix(in srgb, var(--semantic-high) 15%, transparent); color: var(--semantic-high); } +.sa-badge.sa-level-caution { background: color-mix(in srgb, var(--semantic-elevated) 12%, transparent); color: var(--semantic-elevated); } +.sa-badge.sa-level-normal { background: color-mix(in srgb, var(--semantic-normal) 10%, transparent); color: var(--semantic-normal); } +.sa-badge.sa-level-info { background: color-mix(in srgb, var(--text-muted) 10%, transparent); color: var(--text-muted); } +.sa-source { font-size: 10px; color: var(--text-muted); margin-left: auto; } +.sa-title { display: block; color: var(--text-secondary); text-decoration: none; font-size: 11px; line-height: 1.35; } +.sa-title:hover { color: var(--accent); text-decoration: underline; } +.sa-time { font-size: 9px; color: var(--text-muted); margin-top: 3px; } +.sa-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--border-subtle); } +.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); }