Spaces:
Sleeping
Sleeping
Download src/server/validation.ts from vooom/NewsAnalyze: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/vooom/NewsAnalyze/resolve/main/src/server/validation.ts
- Command line
-
hf download hf://spaces/vooom/NewsAnalyze/src/server/validation.ts
-
curl -L -o validation.ts https://huggingface.co/spaces/vooom/NewsAnalyze/resolve/main/src/server/validation.ts
10.6 kB
| /** | |
| * Request Input Validation | |
| * Pure parsing helpers shared by the HTTP API and the Telegram bot. | |
| * Each parser returns a normalized value or throws InputValidationError. | |
| */ | |
| export class InputValidationError extends Error { | |
| constructor(message: string) { | |
| super(message); | |
| this.name = "InputValidationError"; | |
| } | |
| } | |
| export const HORIZON_MIN_YEARS = 1; | |
| export const HORIZON_MAX_YEARS = 30; | |
| export const VAR_MAX_PERIODS = 40; | |
| export const QUERY_MAX_LENGTH = 1000; | |
| export const LIVE_WIRE_CATEGORIES = [ | |
| "all", | |
| "sovereign_decisions", | |
| "economic", | |
| "political", | |
| "local", | |
| "international", | |
| "saudi_gulf", | |
| "global_macro", | |
| "energy_markets", | |
| "trade_policy", | |
| ] as const; | |
| export type LiveWireCategory = (typeof LIVE_WIRE_CATEGORIES)[number]; | |
| const ISO3_REGEX = /^[A-Z]{3}$/; | |
| const INDICATOR_REGEX = /^[A-Za-z0-9._]{1,40}$/; | |
| const TELEGRAM_TOKEN_REGEX = /^\d{5,15}:[A-Za-z0-9_-]{30,64}$/; | |
| function firstValue(raw: unknown): unknown { | |
| return Array.isArray(raw) ? raw[0] : raw; | |
| } | |
| const COUNTRY_MAP: Record<string, string> = { | |
| // Saudi Arabia | |
| SAU: "SAU", SA: "SAU", KSA: "SAU", SAUDI: "SAU", "SAUDI ARABIA": "SAU", | |
| "السعودية": "SAU", "المملكة العربية السعودية": "SAU", "المملكة": "SAU", "الرياض": "SAU", | |
| // Egypt | |
| EGY: "EGY", EG: "EGY", EGYPT: "EGY", "مصر": "EGY", "جمهورية مصر العربية": "EGY", "القاهرة": "EGY", | |
| // United States | |
| USA: "USA", US: "USA", AMERICA: "USA", "UNITED STATES": "USA", "UNITED STATES OF AMERICA": "USA", | |
| "أمريكا": "USA", "امريكا": "USA", "الولايات المتحدة": "USA", "الولايات المتحدة الأمريكية": "USA", "واشنطن": "USA", | |
| // United Arab Emirates | |
| ARE: "ARE", UAE: "ARE", EMIRATES: "ARE", "الإمارات": "ARE", "الامارات": "ARE", "الإمارات العربية المتحدة": "ARE", "أبوظبي": "ARE", "دبي": "ARE", | |
| // Qatar | |
| QAT: "QAT", QA: "QAT", QATAR: "QAT", "قطر": "QAT", "دولة قطر": "QAT", "الدوحة": "QAT", | |
| // Kuwait | |
| KWT: "KWT", KW: "KWT", KUWAIT: "KWT", "الكويت": "KWT", "دولة الكويت": "KWT", | |
| // Oman | |
| OMN: "OMN", OM: "OMN", OMAN: "OMN", "عمان": "OMN", "سلطنة عمان": "OMN", "مسقط": "OMN", | |
| // Bahrain | |
| BHR: "BHR", BH: "BHR", BAHRAIN: "BHR", "البحرين": "BHR", "مملكة البحرين": "BHR", | |
| // Jordan | |
| JOR: "JOR", JO: "JOR", JORDAN: "JOR", "الأردن": "JOR", "الاردن": "JOR", "المملكة الأردنية الهاشمية": "JOR", | |
| // Turkey | |
| TUR: "TUR", TR: "TUR", TURKEY: "TUR", TURKIYE: "TUR", "TÜRKIYE": "TUR", "تركيا": "TUR", "أنقرة": "TUR", | |
| // Germany | |
| DEU: "DEU", DE: "DEU", GERMANY: "DEU", DEUTSCHLAND: "DEU", "ألمانيا": "DEU", "المانيا": "DEU", "برلين": "DEU", | |
| // Japan | |
| JPN: "JPN", JP: "JPN", JAPAN: "JPN", "اليابان": "JPN", "طوكيو": "JPN", | |
| // United Kingdom | |
| GBR: "GBR", GB: "GBR", UK: "GBR", "GREAT BRITAIN": "GBR", "UNITED KINGDOM": "GBR", "المملكة المتحدة": "GBR", "بريطانيا": "GBR", "لندن": "GBR", | |
| // China | |
| CHN: "CHN", CN: "CHN", CHINA: "CHN", "الصين": "CHN", "بكين": "CHN", | |
| // Russia | |
| RUS: "RUS", RU: "RUS", RUSSIA: "RUS", "روسيا": "RUS", "موسكو": "RUS", | |
| // Iran | |
| IRN: "IRN", IR: "IRN", IRAN: "IRN", "إيران": "IRN", "ايران": "IRN", "طهران": "IRN", | |
| // Iraq | |
| IRQ: "IRQ", IQ: "IRQ", IRAQ: "IRQ", "العراق": "IRQ", "بغداد": "IRQ", | |
| // Syria | |
| SYR: "SYR", SY: "SYR", SYRIA: "SYR", "سوريا": "SYR", "دمشق": "SYR", | |
| // Lebanon | |
| LBN: "LBN", LB: "LBN", LEBANON: "LBN", "لبنان": "LBN", "بيروت": "LBN", | |
| // Palestine | |
| PSE: "PSE", PS: "PSE", PALESTINE: "PSE", "فلسطين": "PSE", "القدس": "PSE", "غزة": "PSE", | |
| // Israel | |
| ISR: "ISR", IL: "ISR", ISRAEL: "ISR", "إسرائيل": "ISR", "اسرائيل": "ISR", "تل أبيب": "ISR", "نتنياهو": "ISR", "NETANYAHU": "ISR", | |
| // Ukraine | |
| UKR: "UKR", UA: "UKR", UKRAINE: "UKR", "أوكرانيا": "UKR", "اوكرانيا": "UKR", "كييف": "UKR", | |
| // Norway & Finland | |
| NOR: "NOR", NO: "NOR", NORWAY: "NOR", "النرويج": "NOR", "أوسلو": "NOR", | |
| FIN: "FIN", FI: "FIN", FINLAND: "FIN", "فنلندا": "FIN", "هلسنكي": "FIN", | |
| // Sudan, Morocco, Algeria, Tunisia | |
| SDN: "SDN", SD: "SDN", SUDAN: "SDN", "السودان": "SDN", "الخرطوم": "SDN", | |
| MAR: "MAR", MA: "MAR", MOROCCO: "MAR", "المغرب": "MAR", "الرباط": "MAR", | |
| DZA: "DZA", DZ: "DZA", ALGERIA: "DZA", "الجزائر": "DZA", | |
| TUN: "TUN", TN: "TUN", TUNISIA: "TUN", "تونس": "TUN", | |
| // Yemen | |
| YEM: "YEM", YE: "YEM", YEMEN: "YEM", "اليمن": "YEM", "صنعاء": "YEM", | |
| // France | |
| FRA: "FRA", FR: "FRA", FRANCE: "FRA", "فرنسا": "FRA", "باريس": "FRA", | |
| // India | |
| IND: "IND", IN: "IND", INDIA: "IND", "الهند": "IND", | |
| // Brazil | |
| BRA: "BRA", BR: "BRA", BRAZIL: "BRA", "البرازيل": "BRA", | |
| }; | |
| export function detectCountryFromHeadline(text: string, defaultIso: string = "SAU"): string { | |
| if (!text) return defaultIso; | |
| const lower = text.toLowerCase(); | |
| for (const [key, isoCode] of Object.entries(COUNTRY_MAP)) { | |
| if (key.length > 2 && lower.includes(key.toLowerCase())) { | |
| return isoCode; | |
| } | |
| } | |
| return defaultIso; | |
| } | |
| export function parseIso3(raw: unknown, fallback?: string): string { | |
| const value = firstValue(raw); | |
| if (value === undefined || value === null || value === "") { | |
| if (fallback !== undefined) return fallback; | |
| throw new InputValidationError("country is required"); | |
| } | |
| if (typeof value !== "string") { | |
| throw new InputValidationError("country must be a string"); | |
| } | |
| const str = value.trim(); | |
| if (!str) { | |
| if (fallback !== undefined) return fallback; | |
| throw new InputValidationError("country cannot be empty"); | |
| } | |
| const upper = str.toUpperCase(); | |
| if (ISO3_REGEX.test(upper)) { | |
| return upper; | |
| } | |
| // Check full country name if key is longer than 2 characters (e.g. SAUDI ARABIA, GERMANY, مصر) | |
| if (COUNTRY_MAP[upper] && upper.length > 2) { | |
| return COUNTRY_MAP[upper]; | |
| } | |
| if (COUNTRY_MAP[str] && str.length > 2) { | |
| return COUNTRY_MAP[str]; | |
| } | |
| throw new InputValidationError(`Invalid country ISO3 code: ${str}`); | |
| } | |
| export function parseHorizonYears(raw: unknown, fallback: number = 15): number { | |
| const value = firstValue(raw); | |
| if (value === undefined || value === null || value === "") return fallback; | |
| const num = typeof value === "number" ? value : Number(value); | |
| if (!Number.isInteger(num) || num < HORIZON_MIN_YEARS || num > HORIZON_MAX_YEARS) { | |
| throw new InputValidationError( | |
| `horizon_years must be an integer between ${HORIZON_MIN_YEARS} and ${HORIZON_MAX_YEARS}` | |
| ); | |
| } | |
| return num; | |
| } | |
| export function parseQueryText(raw: unknown, fallback: string, field: string = "query"): string { | |
| const value = firstValue(raw); | |
| if (value === undefined || value === null) return fallback; | |
| if (typeof value !== "string") throw new InputValidationError(`${field} must be a string`); | |
| const text = value.trim(); | |
| if (!text) return fallback; | |
| if (text.length > QUERY_MAX_LENGTH) { | |
| throw new InputValidationError(`${field} must be at most ${QUERY_MAX_LENGTH} characters`); | |
| } | |
| return text; | |
| } | |
| export function parseAlpha(raw: unknown, fallback: number = 0.6): number { | |
| const value = firstValue(raw); | |
| if (value === undefined || value === null || value === "") return fallback; | |
| const num = Number(value); | |
| if (!Number.isFinite(num) || num < 0 || num > 1) { | |
| throw new InputValidationError("alpha must be a number between 0 and 1"); | |
| } | |
| return num; | |
| } | |
| export function parseIndicatorCode(raw: unknown, fallback: string): string { | |
| const value = firstValue(raw); | |
| if (value === undefined || value === null || value === "") return fallback; | |
| if (typeof value !== "string" || !INDICATOR_REGEX.test(value.trim())) { | |
| throw new InputValidationError("indicator must be an alphanumeric series code (e.g. NY.GDP.MKTP.KD.ZG)"); | |
| } | |
| return value.trim(); | |
| } | |
| export function parseMacroSource(raw: unknown): "WorldBank" | "IMF" | "FRED" { | |
| const value = firstValue(raw); | |
| if (value === undefined || value === null || value === "") return "WorldBank"; | |
| if (value === "WorldBank" || value === "IMF" || value === "FRED") return value; | |
| throw new InputValidationError("source must be one of WorldBank, IMF, FRED"); | |
| } | |
| export function parseLiveWireCategory(raw: unknown): LiveWireCategory { | |
| const value = firstValue(raw); | |
| if (value === undefined || value === null || value === "") return "all"; | |
| if (typeof value === "string" && (LIVE_WIRE_CATEGORIES as readonly string[]).includes(value)) { | |
| return value as LiveWireCategory; | |
| } | |
| throw new InputValidationError(`category must be one of: ${LIVE_WIRE_CATEGORIES.join(", ")}`); | |
| } | |
| export function isValidTelegramBotToken(token: unknown): token is string { | |
| return typeof token === "string" && TELEGRAM_TOKEN_REGEX.test(token.trim()); | |
| } | |
| /** | |
| * Clamps a numeric value into [min, max], falling back when it is not a finite number. | |
| * Used inside simulation templates as a last line of defense against oversized workloads. | |
| */ | |
| export function clampInteger(raw: unknown, min: number, max: number, fallback: number): number { | |
| const num = Number(raw); | |
| if (!Number.isFinite(num)) return fallback; | |
| return Math.min(max, Math.max(min, Math.round(num))); | |
| } | |
| export function parseBoundedNumber(raw: unknown, min: number, max: number, field: string): number | undefined { | |
| if (raw === undefined || raw === null || raw === "") return undefined; | |
| const num = typeof raw === "number" ? raw : Number(raw); | |
| if (!Number.isFinite(num) || num < min || num > max) { | |
| throw new InputValidationError(`${field} must be a number between ${min} and ${max}`); | |
| } | |
| return num; | |
| } | |
| export function parseEnum<T extends string>(raw: unknown, allowed: readonly T[], fallback: T, field: string): T { | |
| if (raw === undefined || raw === null || raw === "") return fallback; | |
| if (typeof raw === "string" && (allowed as readonly string[]).includes(raw)) return raw as T; | |
| throw new InputValidationError(`${field} must be one of: ${allowed.join(", ")}`); | |
| } | |
| /** Like parseIso3, but also accepts "GLOBAL", which the news wire uses for non-country feeds. */ | |
| export function parseIso3OrGlobal(raw: unknown, fallback: string): string { | |
| const value = Array.isArray(raw) ? raw[0] : raw; | |
| if (typeof value === "string" && value.trim().toUpperCase() === "GLOBAL") return "GLOBAL"; | |
| return parseIso3(raw, fallback); | |
| } | |