Jaspers Hub

jaspers / oecd

pluginOfficialReviewed★ 0↓ 2

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.

3.5 KB
1import { defineSource } from '@jaspers-ai/sdk'
2import { 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
9const HOST = 'sdmx.oecd.org'
10
11/** dataflow and key, for the series worth asking for by name. `%COUNTRY%` is filled in. */
12const 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
25interface Sdmx {
26 dataSets?: { series: Record<string, { observations: Record<string, (number | null)[]> }> }[]
27 structure?: { dimensions: { observation: { values: { id: string }[] }[] } }
28}
29
30export 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})