// Rule-based report categories, relevance and severity: the News layer's // base reading, shared by every news server. Multilingual stems (en, uk, ru, // ar, he and more) matched at a word start on normalised text; a trailing `$` // makes a stem whole-word. A title hit counts double. Ties go to the earlier // category in CATEGORY_ORDER, which runs from most to least specific. // // A news server may replace these readings with its own; the rules stay // as the fallback. /** Text as the rules match it: folded case, one apostrophe, no vowel marks. */ function normaliseForMatch(text) { return String(text || '') .normalize('NFKC') .toLowerCase() .replace(/ё/g, 'е') .replace(/[’ʼ`´]/g, "'") .replace(/[ً-ٰٟ֑-ׇ]/g, '') .replace(/[أإآ]/g, 'ا'); } export const CATEGORY_ORDER = Object.freeze([ 'strike', 'alert', 'air-defence', 'ground', 'casualties', 'humanitarian', 'infrastructure', 'disaster', 'protest', 'political', // Business and trade: the rules' reading, or World News API's business. 'economy', ]); // [stem, weight]; weight defaults to 1. const RULES = { strike: [ 'strike', 'airstrike', 'air strike', 'missile', 'rocket', 'drone', 'shelling', 'shelled', 'bomb', 'explosion', 'blast', 'artillery', 'shahed', ['attack', 0.5], 'удар', 'ракет', 'дрон', 'бпла', 'обстріл', 'обстрел', 'вибух', 'взрыв', 'шахед', 'бомб', ['атак', 0.5], 'غارة', 'غارات', 'قصف', 'صاروخ', 'صواريخ', 'مسيرة', 'انفجار', ['استهداف', 0.7], ['هجوم', 0.5], 'תקיפה', 'טיל', 'רקט', 'כטב"מ', 'פיצוץ', 'הפצצ', ], 'air-defence': [ 'air defen', 'intercept', 'shot down', 'downed', 'air raid', 'ппо', 'збит', 'сбит', 'перехоп', 'перехват', 'повітряна тривога', 'воздушная тревога', 'الدفاع الجوي', 'اعتراض', 'יירוט', 'יורט', 'כיפת ברזל', 'אזעק', ], ground: [ 'advance$', 'advances$', 'advancing', 'offensive', 'captur', 'liberat', 'assault', 'clash', 'fighting', 'counteroffensive', 'frontline', 'front line', 'battle', 'encircl', ['troops', 0.5], 'наступ', 'штурм', 'звільн', 'освобо', 'бої$', 'бои$', 'контрнаступ', 'захоп', 'захват', 'оточ', 'окруж', 'продвиж', 'просуван', 'اشتباك', 'معارك', 'تقدم', 'سيطر', 'اقتحام', 'توغل', 'לחימה', 'קרב', 'פשיט', 'תמרון', ], casualties: [ 'killed', 'dead$', 'deaths', 'wounded', 'injured', 'casualt', 'death toll', 'загин', 'поранен', 'жертв', 'вбит', 'погиб', 'ранен', 'убит', 'قتلى', 'شهداء', 'شهيد', 'جرحى', 'مقتل', 'اصاب', 'הרוג', 'נהרג', 'פצוע', 'נפצע', ], humanitarian: [ 'evacuat', 'aid$', 'refugee', 'displac', 'humanitarian', 'famine', 'hunger', 'convoy', 'shelter', 'евакуац', 'эвакуац', 'гуманітар', 'гуманитар', 'біженц', 'беженц', 'انسانية', 'نازح', 'نزوح', 'مساعدات', 'اجلاء', 'مجاعة', 'הומניטרי', 'פינוי', 'סיוע', ], infrastructure: [ 'power outage', 'blackout', 'electricity', 'energy', 'grid$', 'substation', 'water supply', 'bridge', 'railway', 'refinery', 'port$', 'dam$', 'power plant', 'енерг', 'энерг', 'знеструм', 'обесточ', 'підстанц', 'подстанц', 'залізниц', 'железн', 'нпз', 'الكهرباء', 'محطة', 'مياه', 'جسر', 'חשמל', 'תשתית', ], disaster: [ 'earthquake', 'flood', 'wildfire', 'landslide', 'cyclone', 'hurricane', 'typhoon', 'tsunami', 'eruption', 'glof$', 'glacial lake', 'storm$', 'lightning', 'землетрус', 'землетрясен', 'повін', 'паводк', 'наводнен', 'زلزال', 'فيضان', 'سيول', 'רעידת אדמה', 'שיטפון', ], protest: [ 'protest', 'demonstrat', 'rally$', 'rallies', 'riot', // Not a bare "протест": "протестировал" is "tested". 'протест$', 'протеста', 'протесту', 'протестом', 'протесте$', 'протесты', 'протестов', 'протестн', 'протестую', 'протестова', 'мітинг', 'митинг', 'احتجاج', 'مظاهر', 'تظاهر', 'הפגנ', 'מחאה', ], political: [ 'talks$', 'negotiat', 'sanction', 'summit', 'ceasefire', 'truce', 'peace', 'election', 'parliament', ['president', 0.5], ['minister', 0.5], ['statement', 0.5], // A state visit ("Emir of Qatar arrives in Amman"). ['visit', 0.5], ['delegation', 0.5], ['agreement', 0.5], 'memorandum', 'diplomat', 'bilateral', 'ambassador', ['визит', 0.5], ['делегац', 0.5], 'дипломат', 'переговор', 'санкц', 'перемир', 'вибори', 'выборы', ['президент', 0.5], ['міністр', 0.5], ['министр', 0.5], 'مفاوضات', 'عقوبات', 'هدنة', 'وقف اطلاق النار', ['وزير', 0.5], ['رئيس', 0.5], 'משא ומתן', 'הפסקת אש', 'סנקצי', ['ראש הממשלה', 0.5], ], // Elections and party politics ("How Gina Hinojosa Captured Texas" is a // governor's race, not a battle). They count toward political only when // nothing physical happened (categorizeText): a strike in an election week // is still a strike. election: [ 'midterm', 'electoral', 'elect$', 'ballot', 'voter', 'vote$', 'votes$', 'voting', 'polls$', 'candidate', 'senate race', 'referendum', 'inaugurat', // Plurals only: "Democratic Republic of Congo", "Republican Guard". 'democrats$', 'republicans$', 'gop$', 'избира', 'голосован', 'виборц', 'голосуван', 'انتخاب', 'בחירות', ], }; // Business, trade, industry and energy, counted only when nothing physical // happened (categorizeText): an oil depot struck is a strike. English, // Russian, Ukrainian, Turkish and the European stems. RULES.economy = [ 'econom', 'gdp$', 'inflation', 'investment', 'investor', 'export', 'imports$', 'import$', 'tariff', 'trade deal', 'trade turnover', 'central bank', 'stock market', 'budget', 'oil and gas', 'oilfield', 'oil field', 'gas field', 'exploration', 'drilling', 'mining', 'industr', 'manufactur', 'business', 'company', 'companies', 'revenue', 'profit', 'merger', 'tourism', 'agricultur', 'harvest', 'экономи', 'инвест', 'экспорт', 'импорт', 'инфляц', 'ввп$', 'бюджет', 'промышлен', 'бизнес', 'компани', 'товарооборот', 'економі', 'інвест', 'експорт', 'промисло', 'ekonomi', 'yatırım', 'ihracat', 'ithalat', 'enflasyon', 'sanayi', 'şirket', 'wirtschaft', 'konjunktur', 'unternehm', 'inversi', 'investiss', 'investiment', 'empresa', 'entreprise', 'impresa', ]; // Attack words beyond PHYSICAL's harm, which keep a report off economy. const ATTACK = new RegExp( String.raw`(? ({ category, rules: stems.map((entry) => { const [stem, weight = 1] = Array.isArray(entry) ? entry : [entry]; const whole = stem.endsWith('$'); const body = escapeRegex( normaliseForMatch(whole ? stem.slice(0, -1) : stem), ).replace(/\s+/g, '\\s+'); const arabic = /\p{Script=Arabic}/u.test(stem) ? '(?:[وفبلك]?(?:ال)?)' : ''; const hebrew = /\p{Script=Hebrew}/u.test(stem) ? '(?:[ובלמהשכ]{0,2})' : ''; return { weight, regex: new RegExp( `(? `(? termPattern(term).test(text)); // Any event reading counts, even when another category wins the title // ("the accident toll rises to 74": infrastructure first, casualties too). if ( EVENT_CATEGORIES.has(category) || tags.some((tag) => EVENT_CATEGORIES.has(tag)) ) return 'core'; if (conflict && ['humanitarian', 'infrastructure'].includes(category)) return 'core'; if (conflict || dedicated || category === 'humanitarian') return 'related'; return floor; } // Alerts: air-raid alerts and reports of drones or missiles on their way // ("Реактивний БпЛА курсом на Полтаву", "КАБи на Донеччину", "drone heading // for Kyiv", "➡️"). Something is in the air; nothing has hit yet. Most of an // air force's channel is these, and as "strikes" they would merge into real // strike events and inflate them. // A direction counts only after a weapon ("БпЛА … з півдня"), so a convoy // leaving "in the direction of Oleshky" is not an alert. const WEAPON = '(?:бпла|дрон|шахед|герань|ракет|каб|балістик|крилат|боєприпас|боеприпас|uav|drone|missile|shahed|ballistic|cruise|munition)'; const DIRECTION = '(?:курсом на|у напрямку|в напрямку|в направлении|у бік|в сторону|повз |з (?:північ|півден|схід|сход|захід|заход)|с (?:север|юг|восток|запад)|на |над |heading |on course|toward)'; const ALERT = new RegExp( `${WEAPON}[^.!?]{0,40}${DIRECTION}|^[^\\p{L}]*курсом на|тривог|тревог|загроза застосування|угроза применения|air[- ]raid|threat of (?:missile|drone|ballistic|uav)|[➡⬅⬆⬇↗↘↙↖]`, 'u', ); // Wording that something did hit: then it is a strike (or air defence), not an alert. const IMPACT = /влуч|вибух|уражен|пошкодж|загин|поранен|зруйн|пожеж|атак|удар по|збит|знищ|попадан|взрыв|поражен|поврежд|погиб|ранен|разруш|сбит|уничтож|\bhit\b|\bhits\b|struck|explosion|blast|killed|injur|wounded|damage|destroy|fire\b|shot down|intercept|attack/u; /** Whether a report's title is an alert: a threat on its way, nothing hit yet. */ function isAlertText(title = '', summary = '') { const t = normaliseForMatch(`${title} ${summary}`.slice(0, 400)); return ALERT.test(t) && !IMPACT.test(t); } // "Hit" or "struck" says nothing on its own: what hit decides (a missile is a // strike, a flood or lightning a disaster), and those words score by // themselves. A few phrases use a category's word for another thing. const PHRASES = [ [/storm shadow/gu, 'missile'], [/lightning strikes?/gu, 'lightning'], [ /(?:general|hunger|labou?r|workers?|nationwide|national|transport|rail|teachers?|doctors?|nurses?) strikes?|on strike|strike action/gu, 'protest', ], ]; const rephrase = (text) => PHRASES.reduce((out, [re, word]) => out.replace(re, word), text); // An election story borrows the words of war and disaster ("‘An Earthquake’: // A Democratic Rout", "captured Texas", "a bloodbath for Republicans"). With // election wording and nothing physical (no weapon, no one hurt, no quake's // magnitude), the event categories give way to political. const ELECTION = /(? 0) scores.set(category, score); } const all = `${t} ${s}`; const election = scores.get('election') || 0; scores.delete('election'); const physical = PHYSICAL.test(all); // Business words and no harm or attack: "advance exploration" is no // front-line advance; "attack an industrial facility" stays a strike. if (physical || ATTACK.test(all)) scores.delete('economy'); else if (scores.has('economy')) for (const kind of EVENT_KINDS) scores.delete(kind); if (!physical) { if (election) scores.set('political', (scores.get('political') || 0) + election); if (ELECTION.test(all)) { for (const kind of EVENT_KINDS) scores.delete(kind); scores.set('political', Math.max(scores.get('political') || 0, 1)); } } if (!scores.size) return { category: 'other', tags: [] }; const ranked = CATEGORY_ORDER.filter((c) => scores.has(c)).sort( (a, b) => scores.get(b) - scores.get(a), ); return { category: ranked[0], tags: ranked }; } // Severity by rule, for reports without a severity of their own (0 none, // 1 minor incident, 2 significant: deaths, injuries or real damage, 3 major: // mass casualties). Wording in en, uk, ru, ar, he, es, fr, de. const SEVERITY_EVENTS = new Set([...EVENT_CATEGORIES, 'infrastructure']); const MAJOR_HARM = /mass casualt|massacre|dozens (?:of \p{L}+ )?(?:killed|dead)|hundreds (?:of \p{L}+ )?(?:killed|dead)|масов\p{L}* (?:жертв|загибел|гибел)|десятки (?:загибл|погибш|убит|вбит|людей загин|человек погиб)|مجزرة|عشرات (?:القتلى|الشهداء)|טבח/u; const HARM_WORDS = 'kill|dead|died|death|injur|wound|casualt|загин|загибл|погиб|убит|вбит|поранен|ранен|постражд|пострадал|травм|قتل|قتيل|شهيد|شهداء|جرح|جريح|إصاب|اصاب|נהרג|הרוג|פצוע|muert|herid|tué|mort|bless|getötet|tote|verletz'; const HARM = new RegExp(String.raw`(? 2100) most = Math.max(most, n); } return most; } /** A report's severity by rule, 0–3. */ export function severityByRules({ category, tags = [], title = '', summary = '', }) { const text = normaliseForMatch(`${title} ${summary}`); const harmed = HARM.test(text) && !NO_HARM.test(text); if (MAJOR_HARM.test(text) || (harmed && largestCount(text) >= 10)) return 3; const event = SEVERITY_EVENTS.has(category) || tags.some((tag) => SEVERITY_EVENTS.has(tag)); if (harmed && (event || category === 'humanitarian')) return 2; if (event && DAMAGE.test(text)) return 2; return event ? 1 : 0; }