OECD Economic Series: Fetches monthly OECD series by country from the OECD's public SDMX API: CPI year on year, industrial production, or any dataflow and key you give it. No key needed.
| 1 | import { defineSource } from '@jaspers-ai/sdk' |
| 2 | import { z } from 'zod' |
| 3 | |
| 4 | |
| 5 | // The OECD's SDMX API. No key. Cross-country indicators, addressed by a dataflow and a key, which |
| 6 | // is how SDMX works and is not friendly; the shortcuts below are the ones worth having by name and |
| 7 | // each was checked against the live service. |
| 8 | |
| 9 | const HOST = 'sdmx.oecd.org' |
| 10 | |
| 11 | /** dataflow and key, for the series worth asking for by name. `%COUNTRY%` is filled in. */ |
| 12 | const KNOWN: Record<string, { dataflow: string; key: string; what: string }> = { |
| 13 | cpi: { |
| 14 | dataflow: 'OECD.SDD.TPS,DSD_PRICES@DF_PRICES_ALL,1.0', |
| 15 | key: '%COUNTRY%.M.N.CPI.PA._T.N.GY', |
| 16 | what: 'Consumer prices, year on year, monthly', |
| 17 | }, |
| 18 | production: { |
| 19 | dataflow: 'OECD.SDD.STES,DSD_KEI@DF_KEI,4.0', |
| 20 | key: '%COUNTRY%.M.PRVM.IX.....', |
| 21 | what: 'Industrial production, index, monthly', |
| 22 | }, |
| 23 | } |
| 24 | |
| 25 | interface Sdmx { |
| 26 | dataSets?: { series: Record<string, { observations: Record<string, (number | null)[]> }> }[] |
| 27 | structure?: { dimensions: { observation: { values: { id: string }[] }[] } } |
| 28 | } |
| 29 | |
| 30 | export const series = defineSource({ |
| 31 | description: `A cross-country OECD series. Ask by name for one of: ${Object.entries(KNOWN) |
| 32 | .map(([name, one]) => `${name} (${one.what})`) |
| 33 | .join(', ')}. Anything else needs the dataflow and key SDMX addresses it by, which the OECD data explorer shows for a series.`, |
| 34 | hosts: [HOST], |
| 35 | input: z.object({ |
| 36 | series: z.string().default('cpi').describe(`One of ${Object.keys(KNOWN).join(', ')}, or "custom" with dataflow and key.`), |
| 37 | country: z.string().default('USA').describe('An ISO three letter code, like USA, DEU, JPN.'), |
| 38 | dataflow: z.string().optional().describe('For a series this does not know by name.'), |
| 39 | key: z.string().optional(), |
| 40 | points: z.number().int().min(1).max(2000).default(60), |
| 41 | }), |
| 42 | async run({ series: wanted, country, dataflow, key, points }, ctx) { |
| 43 | const known = KNOWN[wanted.trim().toLowerCase()] |
| 44 | const flow = known?.dataflow ?? dataflow |
| 45 | const path = (known?.key ?? key)?.replace('%COUNTRY%', country.trim().toUpperCase()) |
| 46 | if (!flow || !path) { |
| 47 | throw new Error(`No OECD series called "${wanted}". Ask for ${Object.keys(KNOWN).join(' or ')}, or give dataflow and key.`) |
| 48 | } |
| 49 | const url = `https://${HOST}/public/rest/data/${flow}/${path}?lastNObservations=${points}&format=jsondata` |
| 50 | // The service returns the odd 500 on a request it answers a moment later, so one retry. |
| 51 | let response = await ctx.fetch(url) |
| 52 | if (response.status >= 500) { |
| 53 | await new Promise((resolve) => setTimeout(resolve, 700)) |
| 54 | response = await ctx.fetch(url) |
| 55 | } |
| 56 | if (!response.ok) { |
| 57 | const said = await response.text().catch(() => '') |
| 58 | throw new Error(`The OECD answered ${response.status}. ${said.slice(0, 120)}`) |
| 59 | } |
| 60 | // The OECD wraps its payload in `data`, where the ECB puts the same fields at the top level. |
| 61 | const body = (await response.json()) as { data?: Sdmx } & Partial<Sdmx> |
| 62 | const payload = body.data ?? (body as Sdmx) |
| 63 | const dates = payload.structure?.dimensions.observation[0]?.values.map((one) => one.id) ?? [] |
| 64 | const found = Object.values(payload.dataSets?.[0]?.series ?? {})[0] |
| 65 | if (!found) throw new Error('The OECD has nothing for that country on that series.') |
| 66 | const rows = Object.entries(found.observations) |
| 67 | .map(([at, values]) => ({ country: country.toUpperCase(), date: dates[Number(at)] ?? at, value: values[0] })) |
| 68 | .filter((one) => typeof one.value === 'number') |
| 69 | .sort((a, b) => a.date.localeCompare(b.date)) |
| 70 | return { series: wanted, what: known?.what ?? 'custom', rows } |
| 71 | }, |
| 72 | }) |