| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { extractCountryCode } from './shared/geo-extract.mjs'; |
| import { decodeHtmlEntities } from './_html-entities.mjs'; |
|
|
| |
| |
| const WHO_NAME_OVERRIDES = { |
| 'democratic republic of the congo': 'CD', |
| 'dr congo': 'CD', |
| 'timor-leste': 'TL', |
| 'east timor': 'TL', |
| 'papua new guinea': 'PG', |
| 'kingdom of saudi arabia': 'SA', |
| 'united kingdom': 'GB', |
| }; |
|
|
| export function extractCountryCodeFull(text) { |
| const lower = text.toLowerCase(); |
| for (const [name, iso2] of Object.entries(WHO_NAME_OVERRIDES)) { |
| if (lower.includes(name)) return iso2; |
| } |
| return extractCountryCode(text) ?? ''; |
| } |
|
|
| export function stableHash(str) { |
| let h = 0; |
| for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0; |
| return Math.abs(h).toString(36); |
| } |
|
|
| |
| |
| |
| |
| export function extractLocationFromTitle(title) { |
| |
| |
| const segments = title.split(/\s*[ββ]\s*|\s+-\s+/); |
| if (segments.length >= 2) { |
| const last = segments[segments.length - 1].trim(); |
| if (/^[A-Z]/.test(last)) return last; |
| } |
| |
| const inMatch = title.match(/\bin\s+([A-Z][^,.(]+)/); |
| if (inMatch) return inMatch[1].trim(); |
| return ''; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const DISEASE_ALERT_KEYWORDS = Object.freeze(['outbreak', 'emergency', 'epidemic', 'pandemic']); |
| |
| export const DISEASE_WARNING_KEYWORDS = Object.freeze(['warning', 'spread', 'cases increasing']); |
| export const ALERT_LEVEL_METHODOLOGY_VERSION = 'v1'; |
|
|
| |
| |
| export const DISEASE_ALERT_RE = new RegExp(`\\b(?:${DISEASE_ALERT_KEYWORDS.join('|')})\\b`, 'i'); |
| export const DISEASE_WARNING_RE = new RegExp(`\\b(?:${DISEASE_WARNING_KEYWORDS.join('|')})\\b`, 'i'); |
|
|
| export function detectAlertLevel(title, desc) { |
| const text = `${title ?? ''} ${desc ?? ''}`; |
| if (DISEASE_ALERT_RE.test(text)) return 'alert'; |
| if (DISEASE_WARNING_RE.test(text)) return 'warning'; |
| return 'watch'; |
| } |
|
|
| export function detectDisease(title) { |
| const lower = title.toLowerCase(); |
| const known = ['mpox', 'monkeypox', 'ebola', 'cholera', 'covid', 'dengue', 'measles', |
| 'polio', 'marburg', 'lassa', 'plague', 'yellow fever', 'typhoid', 'influenza', |
| 'avian flu', 'h5n1', 'h5n2', 'anthrax', 'rabies', 'meningitis', 'hepatitis', |
| 'nipah', 'rift valley', 'crimean-congo', 'leishmaniasis', 'malaria', 'diphtheria', |
| 'chikungunya', 'botulism', 'brucellosis', 'salmonella', 'listeria', 'e. coli', |
| 'norovirus', 'legionella', 'campylobacter']; |
| for (const d of known) { |
| if (lower.includes(d)) return d.charAt(0).toUpperCase() + d.slice(1); |
| } |
| return 'Unknown Disease'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| export function whoNormalizeItem(item, nowMs = Date.now()) { |
| const origMs = item.PublicationDateAndTime ? new Date(item.PublicationDateAndTime).getTime() : null; |
| const hasOrig = origMs != null && Number.isFinite(origMs); |
| return { |
| title: (item.Title || '').trim(), |
| link: item.ItemDefaultUrl ? `https://www.who.int${item.ItemDefaultUrl}` : '', |
| desc: '', |
| publishedMs: hasOrig ? origMs : nowMs, |
| _originalPublishedMs: hasOrig ? origMs : null, |
| _publishedAtIsSynthetic: !hasOrig, |
| sourceName: 'WHO', |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function cleanRssDescription(rawDesc) { |
| return decodeHtmlEntities(rawDesc || '') |
| .replace(/<[^>]+>/g, '').trim().slice(0, 300); |
| } |
|
|
| |
| |
| |
| |
| |
| export function rssNormalizeItem({ title, link, desc, pubDate, sourceName }, nowMs = Date.now()) { |
| const origMs = pubDate ? new Date(pubDate).getTime() : null; |
| const hasOrig = origMs != null && Number.isFinite(origMs); |
| return { |
| title, link, desc, |
| publishedMs: hasOrig ? origMs : nowMs, |
| sourceName, |
| _originalPublishedMs: hasOrig ? origMs : null, |
| _publishedAtIsSynthetic: !hasOrig, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function tghNormalizeItem(rec) { |
| const publishedMs = new Date(rec.date).getTime(); |
| |
| const cityName = (rec.placeName || '').split(',')[0].trim() || rec.country || ''; |
| return { |
| title: `${rec.disease}${rec.country ? ` - ${rec.country}` : ''}`, |
| link: rec.sourceUrl || '', |
| desc: rec.summary ? rec.summary.slice(0, 300) : '', |
| publishedMs, |
| sourceName: 'ThinkGlobalHealth', |
| _country: rec.country || '', |
| _disease: rec.disease || '', |
| _location: cityName, |
| _lat: Number.isFinite(rec.lat) ? rec.lat : null, |
| _lng: Number.isFinite(rec.lng) ? rec.lng : null, |
| _cases: rec.cases ?? 0, |
| _originalPublishedMs: publishedMs, |
| _publishedAtIsSynthetic: false, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function mapItem(item) { |
| const location = item._location || extractLocationFromTitle(item.title) |
| || (item.sourceName === 'CDC' ? 'United States' : ''); |
| const disease = item._disease || detectDisease(item.title); |
| const countryCode = item._country |
| ? (extractCountryCodeFull(item._country) || extractCountryCodeFull(location || item.title)) |
| : extractCountryCodeFull(location || `${item.title} ${item.desc}`); |
| return { |
| id: `${item.sourceName.toLowerCase()}-${stableHash(item.link || item.title)}-${item.publishedMs}`, |
| disease, |
| location, |
| countryCode, |
| alertLevel: detectAlertLevel(item.title, item.desc), |
| summary: item.desc, |
| sourceUrl: item.link, |
| publishedAt: item.publishedMs, |
| sourceName: item.sourceName, |
| lat: item._lat ?? 0, |
| lng: item._lng ?? 0, |
| cases: item._cases || 0, |
| |
| _publishedAtIsSynthetic: item._publishedAtIsSynthetic === true, |
| _originalPublishedMs: item._originalPublishedMs ?? null, |
| }; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function diseaseContentMeta(data, nowMs = Date.now()) { |
| const items = Array.isArray(data?.outbreaks) ? data.outbreaks : []; |
| let newest = -Infinity, oldest = Infinity, validCount = 0; |
| const skewLimit = nowMs + 60 * 60 * 1000; |
| for (const item of items) { |
| if (item._publishedAtIsSynthetic === true) continue; |
| const ts = item._originalPublishedMs; |
| if (typeof ts !== 'number' || !Number.isFinite(ts) || ts <= 0) continue; |
| if (ts > skewLimit) continue; |
| validCount++; |
| if (ts > newest) newest = ts; |
| if (ts < oldest) oldest = ts; |
| } |
| if (validCount === 0) return null; |
| return { newestItemAt: newest, oldestItemAt: oldest }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function diseasePublishTransform(data) { |
| const outbreaks = Array.isArray(data?.outbreaks) ? data.outbreaks : []; |
| return { |
| ...data, |
| outbreaks: outbreaks.map((item) => { |
| const { _publishedAtIsSynthetic: _a, _originalPublishedMs: _b, ...rest } = item; |
| return rest; |
| }), |
| }; |
| } |
|
|
| |
| |
| export const DISEASE_MAX_CONTENT_AGE_MIN = 9 * 24 * 60; |
|
|