Jaspers Hub

biseroff / benzinga

pluginReviewed★ 0↓ 2

Benzinga Market Data: Fetches Benzinga news, analyst ratings, earnings, guidance, dividends, splits, IPO, and economic calendars via API, with a news list view.

3.6 KB
1import type { BackendContext } from '@jaspers-ai/sdk'
2
3export const HOST = 'api.benzinga.com'
4
5/** Calls give up after this long, so a source always answers within a few seconds. */
6const TIMEOUT_MS = 8000
7
8/** YYYY-MM-DD for today plus `days` (negative for the past). */
9export 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. */
14export 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. */
57export 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. */
64export 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. */
71export 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. */
79export 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}