diff --git a/.changeset/met-v1-1-search.md b/.changeset/met-v1-1-search.md new file mode 100644 index 0000000..c4a4537 --- /dev/null +++ b/.changeset/met-v1-1-search.md @@ -0,0 +1,5 @@ +--- +'@refkit/provider-met': patch +--- + +Met: search through `/public/collection/v1.1/search`. The Met retired `/v1/search` on 2026-10-01, and it now answers 410 Gone, so every Met search failed. The v1.1 endpoint takes the same filters and returns one page of object IDs per request, so the provider now asks for exactly the page it needs with `offset` and `limit` instead of downloading every matching ID and slicing it. Object records still come from `/v1/objects/{id}`. Every `providerOptions` filter is forwarded unchanged. diff --git a/packages/provider-met/src/__tests__/met.test.ts b/packages/provider-met/src/__tests__/met.test.ts index 09690a5..3f25acf 100644 --- a/packages/provider-met/src/__tests__/met.test.ts +++ b/packages/provider-met/src/__tests__/met.test.ts @@ -68,6 +68,58 @@ describe('met provider', () => { expect(refs).toEqual([]) }) + // /v1/search was retired on 2026-10-01 (410 Gone). /v1.1/search takes the same filters but + // returns one page of ids per request (offset/limit); object records stay on /v1/objects. + it('asks the paginated v1.1 search for one page and reads objects from v1', async () => { + const urls: string[] = [] + const inner = ctxRouting(SEARCH, { '436535': OBJ_PD, '999999': OBJ_COPYRIGHT }) + const ctx: ProviderContext = { + fetch: (async (input: Parameters[0], init?: RequestInit) => { + urls.push(String(input)) + return inner.fetch(input, init) + }) as typeof fetch, + } + await met().search({ text: 'wheat', modalities: ['image'], limit: 5 }, ctx) + const search = new URL(urls[0]) + expect(search.pathname).toBe('/public/collection/v1.1/search') + expect(search.searchParams.get('offset')).toBe('0') + expect(search.searchParams.get('limit')).toBe('5') + expect(urls.slice(1).map(u => new URL(u).pathname).sort()).toEqual([ + '/public/collection/v1/objects/436535', + '/public/collection/v1/objects/999999', + ]) + }) + + it('pages upstream with offset and maps every id the page returns', async () => { + let searchUrl = '' + const inner = ctxRouting({ total: 40, objectIDs: [436535, 1] }, { '436535': OBJ_PD, '1': { ...OBJ_PD, objectID: 1, title: 'Second' } }) + const ctx: ProviderContext = { + fetch: (async (input: Parameters[0], init?: RequestInit) => { + if (String(input).includes('/search')) searchUrl = String(input) + return inner.fetch(input, init) + }) as typeof fetch, + } + const refs = await met().search({ text: 'wheat', modalities: ['image'], limit: 2, controls: { page: 3 } }, ctx) + expect(new URL(searchUrl).searchParams.get('offset')).toBe('4') + expect(new URL(searchUrl).searchParams.get('limit')).toBe('2') + expect(refs.map(r => r.title)).toEqual(['Wheat Field with Cypresses', 'Second']) + }) + + it('never fetches more objects than one page even if upstream returns extra ids', async () => { + let objectFetches = 0 + const ids = [436535, 2, 3, 4, 5, 6] + const inner = ctxRouting({ total: 6, objectIDs: ids }, Object.fromEntries(ids.map(id => [String(id), { ...OBJ_PD, objectID: id }]))) + const ctx: ProviderContext = { + fetch: (async (input: Parameters[0], init?: RequestInit) => { + if (String(input).includes('/objects/')) objectFetches++ + return inner.fetch(input, init) + }) as typeof fetch, + } + const refs = await met().search({ text: 'wheat', modalities: ['image'], limit: 3 }, ctx) + expect(objectFetches).toBe(3) + expect(refs).toHaveLength(3) + }) + it('forwards documented Met search options', async () => { let searchUrl = '' const ctx: ProviderContext = { diff --git a/packages/provider-met/src/index.ts b/packages/provider-met/src/index.ts index 5d089ae..75536d9 100644 --- a/packages/provider-met/src/index.ts +++ b/packages/provider-met/src/index.ts @@ -24,6 +24,10 @@ export interface MetSearchOptions { } const BASE = 'https://collectionapi.metmuseum.org/public/collection/v1' +// /v1/search (every matching id in one response) was retired on 2026-10-01 and now answers +// 410. /v1.1/search takes the same filters plus offset/limit and returns one page of ids; +// upstream caps limit at 500 and serves only the first 10,000 results of a query. +const SEARCH = 'https://collectionapi.metmuseum.org/public/collection/v1.1/search' interface MetSearchResponse { total: number; objectIDs: number[] | null } interface MetObject { @@ -81,9 +85,12 @@ export function met(config: MetConfig = {}) { description: 'Open Access artworks from the Metropolitan Museum of Art', capabilities: { controls: ['page'] }, async search(q: NormalizedQuery, ctx: ProviderContext): Promise { - const searchUrl = new URL(`${BASE}/search`) + const n = Math.min(config.maxObjects ?? q.limit ?? 12, 30) + const searchUrl = new URL(SEARCH) searchUrl.searchParams.set('q', q.text) searchUrl.searchParams.set('hasImages', 'true') + searchUrl.searchParams.set('offset', String(offsetForPage(q.controls?.page, n) ?? 0)) + searchUrl.searchParams.set('limit', String(n)) const opts = q.providerOptions as MetSearchOptions | undefined setIfBoolean(searchUrl, 'isHighlight', opts?.isHighlight) setIfBoolean(searchUrl, 'title', opts?.title) @@ -98,10 +105,8 @@ export function met(config: MetConfig = {}) { const res = await ctx.fetch(searchUrl.toString(), { signal: ctx.signal }) const { objectIDs } = await okJson(res, 'met search') if (!objectIDs || objectIDs.length === 0) return [] - const n = Math.min(config.maxObjects ?? q.limit ?? 12, 30) - // the search endpoint returns every matching id — page = a window over that list - const from = offsetForPage(q.controls?.page, n) ?? 0 - const objects = await Promise.all(objectIDs.slice(from, from + n).map(async (id) => { + // objectIDs is already the requested page; the slice only bounds the N+1 object fan-out + const objects = await Promise.all(objectIDs.slice(0, n).map(async (id) => { try { const r = await ctx.fetch(`${BASE}/objects/${id}`, { signal: ctx.signal }) if (!r.ok) return null