Benzinga Market Data: Fetches Benzinga news, analyst ratings, earnings, guidance, dividends, splits, IPO, and economic calendars via API, with a news list view.
| 1 | import type { BackendContext } from '@jaspers-ai/sdk' |
| 2 | |
| 3 | export const HOST = 'api.benzinga.com' |
| 4 | |
| 5 | /** Calls give up after this long, so a source always answers within a few seconds. */ |
| 6 | const TIMEOUT_MS = 8000 |
| 7 | |
| 8 | /** YYYY-MM-DD for today plus `days` (negative for the past). */ |
| 9 | export function isoDay(days: number): string { |
| 10 | return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) |
| 11 | } |
| 12 | |
| 13 | /** GET a Benzinga path with the token and JSON accept header; throws a clear error on 401, 403, and others. */ |
| 14 | export async function benzingaGet( |
| 15 | ctx: BackendContext, |
| 16 | label: string, |
| 17 | path: string, |
| 18 | params: Record<string, string | number | undefined>, |
| 19 | ): Promise<unknown> { |
| 20 | const query = new URLSearchParams() |
| 21 | for (const [key, value] of Object.entries(params)) { |
| 22 | if (value !== undefined && value !== '') query.set(key, String(value)) |
| 23 | } |
| 24 | // The token goes in as written, never through a template literal; the app fills it in. |
| 25 | const url = `https://${HOST}${path}?${query}&token=` + '${secret:apikey}' |
| 26 | const controller = new AbortController() |
| 27 | const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) |
| 28 | let response: Response |
| 29 | try { |
| 30 | response = await ctx.fetch(url, { headers: { accept: 'application/json' }, signal: controller.signal }) |
| 31 | } catch (error) { |
| 32 | if (controller.signal.aborted) throw new Error(`Benzinga ${label}: no answer within ${TIMEOUT_MS / 1000} s. Narrow the date range or tickers.`) |
| 33 | throw error |
| 34 | } finally { |
| 35 | clearTimeout(timer) |
| 36 | } |
| 37 | const text = await response.text() |
| 38 | if (!response.ok) { |
| 39 | const first = (text.split('\n')[0] ?? '').slice(0, 200) |
| 40 | if (response.status === 401) |
| 41 | throw new Error(`Benzinga ${label}: 401 unauthorized: the API token is missing or invalid. ${first}`) |
| 42 | if (response.status === 403) |
| 43 | throw new Error(`Benzinga ${label}: 403 forbidden: this token's plan does not include the ${label} endpoint. ${first}`) |
| 44 | if (response.status === 429) throw new Error(`Benzinga ${label}: 429 rate limited; try again shortly. ${first}`) |
| 45 | throw new Error(`Benzinga ${label} answered ${response.status}: ${first}`) |
| 46 | } |
| 47 | // An empty result can come back as an empty body. |
| 48 | if (!text.trim()) return null |
| 49 | try { |
| 50 | return JSON.parse(text) |
| 51 | } catch { |
| 52 | throw new Error(`Benzinga ${label}: expected JSON, got: ${text.slice(0, 200)}`) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /** A number from Benzinga's string fields, or null when empty. */ |
| 57 | export function num(value: unknown): number | null { |
| 58 | if (value === null || value === undefined || value === '') return null |
| 59 | const n = Number(value) |
| 60 | return Number.isFinite(n) ? n : null |
| 61 | } |
| 62 | |
| 63 | /** A string field, or null when empty. */ |
| 64 | export function str(value: unknown): string | null { |
| 65 | if (value === null || value === undefined) return null |
| 66 | const s = String(value).trim() |
| 67 | return s === '' ? null : s |
| 68 | } |
| 69 | |
| 70 | /** The rows under `key` of a calendar answer like { ratings: [...] }; an empty answer is no rows. */ |
| 71 | export function calendarRows(body: unknown, key: string): Record<string, unknown>[] { |
| 72 | if (!body) return [] |
| 73 | if (Array.isArray(body)) return body as Record<string, unknown>[] |
| 74 | const list = (body as Record<string, unknown>)[key] |
| 75 | return Array.isArray(list) ? (list as Record<string, unknown>[]) : [] |
| 76 | } |
| 77 | |
| 78 | /** The parameters every calendar takes. */ |
| 79 | export function calendarParams( |
| 80 | args: { tickers?: string; date_from?: string; date_to?: string; page: number; pagesize: number }, |
| 81 | back: number, |
| 82 | ahead: number, |
| 83 | ): Record<string, string | number | undefined> { |
| 84 | return { |
| 85 | 'parameters[tickers]': args.tickers?.replace(/\s+/g, '').toUpperCase(), |
| 86 | 'parameters[date_from]': args.date_from ?? isoDay(-back), |
| 87 | 'parameters[date_to]': args.date_to ?? isoDay(ahead), |
| 88 | page: args.page, |
| 89 | pagesize: args.pagesize, |
| 90 | } |
| 91 | } |