From e2e5d1c0430aa138c41e071eccb4fa5aaaab75d0 Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Sat, 25 Jul 2026 09:49:58 +0800 Subject: [PATCH 1/2] raster: fall back to GraphQL slug lookup when the artwork page is bot-challenged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit raster.art now serves a Vercel bot-protection checkpoint (HTTP 429 challenge page) to non-browser fetchers, so the serialized page payload stopped being a reliable source for the numeric artwork id and the kit API path died before it started — the nightly live fixture has failed since 2026-07-20. The public keyless GraphQL API (api.raster.art) is not behind the checkpoint and models not-found as a null query field. When page HTML is unavailable or carries no artworkId, resolve the id (and title) via artworkBySlug and continue through the kit tokens API as before. The page-payload path still wins when HTML is available (e.g. from the headless renderer), and the page fetch is now non-fatal. --- src/sites/raster/graphql.ts | 57 ++++++++++++++++++++++++++ src/sites/raster/index.ts | 21 +++++++--- tests/source-resolver.test.ts | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 src/sites/raster/graphql.ts diff --git a/src/sites/raster/graphql.ts b/src/sites/raster/graphql.ts new file mode 100644 index 0000000..f53f26e --- /dev/null +++ b/src/sites/raster/graphql.ts @@ -0,0 +1,57 @@ +const RASTER_GRAPHQL_ENDPOINT = 'https://api.raster.art/graphql'; + +export interface RasterArtworkRef { + id: string; + title?: string; +} + +interface RasterArtworkBySlugResponse { + data?: { + artworkBySlug?: { + id?: string | number; + title?: string; + } | null; + }; +} + +/** + * resolveRasterArtworkBySlug maps an artwork slug to Raster's numeric artwork + * id (and title) via the public keyless GraphQL API. + * + * Raster's web pages sit behind a Vercel bot-protection checkpoint (observed + * 2026-07: HTTP 429 challenge page for non-browser fetchers), so the + * serialized page payload is not a reliable source for the artwork id. The + * GraphQL API answers without credentials and models not-found as a null + * query field rather than an HTTP error. + */ +export async function resolveRasterArtworkBySlug( + slug: string, + fetchImpl: typeof fetch +): Promise { + let response: Response; + try { + response = await fetchImpl(RASTER_GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + query: 'query ArtworkBySlug($slug: String!) { artworkBySlug(slug: $slug) { id title } }', + variables: { slug }, + }), + }); + } catch { + return null; + } + if (!response.ok) { + return null; + } + + const body = (await response.json().catch(() => null)) as RasterArtworkBySlugResponse | null; + const artwork = body?.data?.artworkBySlug; + if (artwork?.id == null) { + return null; + } + return { + id: String(artwork.id), + ...(artwork.title ? { title: artwork.title } : {}), + }; +} diff --git a/src/sites/raster/index.ts b/src/sites/raster/index.ts index 4f3ea73..1b8fdde 100644 --- a/src/sites/raster/index.ts +++ b/src/sites/raster/index.ts @@ -12,6 +12,7 @@ import { extractRasterArtworkTokensFromHtml, parseRasterArtwork, } from './pages/artwork'; +import { resolveRasterArtworkBySlug } from './graphql'; import { resolveRasterArtworkSources } from './pages/source'; import { parseRasterToken } from './pages/token'; @@ -70,17 +71,24 @@ async function resolveRasterArtworkTokensFromApi( } let html = context?.html ?? null; if (!html) { + // Non-fatal: raster.art serves a Vercel bot-protection challenge (429) to + // non-browser fetchers, so the page payload is a best-effort id source. const page = await fetchImpl(url.toString(), { headers: RASTER_PAGE_HEADERS, - }); - if (!page.ok) { - return { findings: [] }; + }).catch(() => null); + if (page?.ok) { + html = await page.text(); } - html = await page.text(); } - const artworkId = extractRasterArtworkId(html); + let artworkId = html ? extractRasterArtworkId(html) : null; + let apiTitle: string | undefined; if (!artworkId) { - return { findings: [] }; + const artwork = await resolveRasterArtworkBySlug(parsed.slug, fetchImpl); + if (!artwork) { + return { findings: [] }; + } + artworkId = artwork.id; + apiTitle = artwork.title; } const results: ParsedFindInput[] = []; @@ -126,6 +134,7 @@ async function resolveRasterArtworkTokensFromApi( } return { findings: limitTokenFindings(results, context?.limit), + ...(apiTitle ? { title: apiTitle } : {}), ...(hasMore ? { hasMore } : {}), }; } diff --git a/tests/source-resolver.test.ts b/tests/source-resolver.test.ts index fe9cbca..74401d3 100644 --- a/tests/source-resolver.test.ts +++ b/tests/source-resolver.test.ts @@ -1308,6 +1308,31 @@ describe('resolveTokenInfos collection support', () => { ]); }); + test('Raster artwork falls back to GraphQL slug lookup when the page is bot-challenged', async () => { + const result = await resolveTokenInfos('https://raster.art/artwork/split-logic-by-ricky-retouch', { + fetch: rasterChallengedApiFetch() as typeof fetch, + }); + + assert.equal(result.kind, 'tokens'); + if (result.kind !== 'tokens') { + throw new Error('narrowing'); + } + assert.equal(result.method, 'api'); + assert.equal(result.title, 'Split Logic'); + assert.deepEqual(result.coords, [ + { chain: 'ethereum', contract: '0xf5705202462f066ac55c293f5798ae027b2f27b5', tokenId: '95' }, + { chain: 'ethereum', contract: '0xf5705202462f066ac55c293f5798ae027b2f27b5', tokenId: '100' }, + ]); + }); + + test('Raster artwork reports not-found when the page is challenged and GraphQL misses', async () => { + const result = await resolveTokenInfos('https://raster.art/artwork/no-such-artwork', { + fetch: rasterChallengedApiFetch() as typeof fetch, + }); + + assert.equal(result.kind, 'not-found'); + }); + test('fxhash project resolves full collection through public GraphQL', async () => { const result = await resolveTokenInfos('https://www.fxhash.xyz/generative/slug/the-fable', { fetch: fxhashProjectFetch() as typeof fetch, @@ -2600,3 +2625,54 @@ function rasterApiFetch(): (input: string | URL | Request, init?: RequestInit) = return new Response('not found', { status: 404 }); }; } + +/** + * rasterChallengedApiFetch simulates raster.art behind the Vercel bot-protection + * checkpoint: artwork pages answer 429 with a challenge document, while the + * keyless GraphQL and kit APIs stay reachable. + */ +function rasterChallengedApiFetch(): ( + input: string | URL | Request, + init?: RequestInit +) => Promise { + return async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.startsWith('https://raster.art/artwork/')) { + return new Response( + 'Vercel Security Checkpoint', + { status: 429, headers: { 'Content-Type': 'text/html' } } + ); + } + if (url === 'https://api.raster.art/graphql') { + const body = JSON.parse(String(init?.body ?? '{}')) as { + variables?: { slug?: string }; + }; + const artwork = + body.variables?.slug === 'split-logic-by-ricky-retouch' + ? { id: 2886465, title: 'Split Logic' } + : null; + return Response.json({ data: { artworkBySlug: artwork } }); + } + if (url.includes('https://kit.raster.art/artwork/2886465/tokens?cursor=0')) { + return Response.json({ + tokens: [ + { + chain_id: 'eip155:1', + contract_address: '0xf5705202462f066ac55c293f5798ae027b2f27b5', + token_id: '95', + }, + { + chain_id: 'eip155:1', + contract_address: '0xf5705202462f066ac55c293f5798ae027b2f27b5', + token_id: '100', + }, + ], + cursor: 2, + }); + } + if (url.includes('https://kit.raster.art/artwork/2886465/tokens?cursor=2')) { + return Response.json({ tokens: [], cursor: 2 }); + } + return new Response('not found', { status: 404 }); + }; +} From 3a219264e19d46d4abd378d6d177ede87f857582 Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Sat, 25 Jul 2026 09:58:17 +0800 Subject: [PATCH 2/2] tests: make headless fixtures tolerate live-marketplace drift and bot challenges Two failure modes were masked by the live-fixture step failing first (fail-fast) since 2026-07-17: - OpenSea and Verse fixtures pinned the exact first-listed token id, which tracks the marketplace's live listing order, not resolver behavior (OpenSea drifted #63 -> #190, Verse #178 -> #236 with resolution working correctly). Fixtures now assert collection identity (chain + contract) and a well-formed numeric token id. - The raster headless fixture cannot render from datacenter IPs while raster.art's Vercel checkpoint challenges them (it renders fine from residential networks). When rendering finds nothing and the page is provably bot-challenged, the fixture now skips with a diagnostic instead of failing. --- tests/playwright-headless.test.ts | 76 ++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/tests/playwright-headless.test.ts b/tests/playwright-headless.test.ts index e7e147d..53a162b 100644 --- a/tests/playwright-headless.test.ts +++ b/tests/playwright-headless.test.ts @@ -19,7 +19,12 @@ interface HeadlessFixture { url: string; expectedMethod: 'dom' | 'headless'; expectedSource: string; - expectedCoords: TokenCoords; + /** + * Collection identity the resolved token must belong to. The exact token id + * is deliberately not pinned: it reflects the marketplace's live listing + * order (OpenSea and Verse both drifted in 2026-07), not resolver behavior. + */ + expectedCollection: Pick; expectedArtworkSource: RegExp; } @@ -29,10 +34,9 @@ const HEADLESS_FIXTURES: HeadlessFixture[] = [ url: 'https://www.artblocks.io/collection/ringers-by-dmitri-cherniak', expectedMethod: 'dom', expectedSource: 'artblocks', - expectedCoords: { + expectedCollection: { chain: 'ethereum', contract: '0xa7d8d9ef8d8ce8992df33d8b8cf4aebabd5bd270', - tokenId: '13000116', }, expectedArtworkSource: /^https:\/\/generator\.artblocks\.io\//, }, @@ -41,10 +45,9 @@ const HEADLESS_FIXTURES: HeadlessFixture[] = [ url: 'https://opensea.io/collection/azuki', expectedMethod: 'dom', expectedSource: 'opensea', - expectedCoords: { + expectedCollection: { chain: 'ethereum', contract: '0xed5af388653567af2f388e6224dc7c4b3241c544', - tokenId: '63', }, expectedArtworkSource: /^https:\/\//, }, @@ -53,10 +56,9 @@ const HEADLESS_FIXTURES: HeadlessFixture[] = [ url: 'https://superrare.com/collection/0x3e930455dcbf4bc69de9926bdaf8ef782398786f', expectedMethod: 'headless', expectedSource: 'superrare', - expectedCoords: { + expectedCollection: { chain: 'ethereum', contract: '0x3e930455dcbf4bc69de9926bdaf8ef782398786f', - tokenId: '7', }, expectedArtworkSource: /^https:\/\//, }, @@ -65,10 +67,9 @@ const HEADLESS_FIXTURES: HeadlessFixture[] = [ url: 'https://verse.works/series/quantizer-by-harm-van-den-dorpel', expectedMethod: 'headless', expectedSource: 'verse', - expectedCoords: { + expectedCollection: { chain: 'ethereum', contract: '0x23b72f7458a204446983f544d655df10f70533e9', - tokenId: '178', }, expectedArtworkSource: /^https:\/\//, }, @@ -77,10 +78,9 @@ const HEADLESS_FIXTURES: HeadlessFixture[] = [ url: 'https://raster.art/artwork/split-logic-by-ricky-retouch', expectedMethod: 'headless', expectedSource: 'raster', - expectedCoords: { + expectedCollection: { chain: 'ethereum', contract: '0xf5705202462f066ac55c293f5798ae027b2f27b5', - tokenId: '95', }, expectedArtworkSource: /^https:\/\//, }, @@ -100,13 +100,23 @@ describe('Playwright headless resolver fixtures', { skip: !RUN_HEADLESS }, () => }); for (const fixture of HEADLESS_FIXTURES) { - test(fixture.name, async () => { + test(fixture.name, async (t) => { const staticResult = await resolveTokenInfo(fixture.url); const renderedResult = await resolveTokenInfo(fixture.url, { renderer, includeArtworkSource: true, }); + if (renderedResult.kind === 'not-found') { + const challenge = await detectBotChallenge(fixture.url); + if (challenge) { + // Bot protection is a network-reputation outcome, not a resolver + // regression: the same fixture renders fine from residential IPs. + t.skip(`page is bot-challenged from this network (${challenge})`); + return; + } + } + if (fixture.expectedMethod === 'headless') { assert.equal(staticResult.kind, 'not-found'); } @@ -116,12 +126,36 @@ describe('Playwright headless resolver fixtures', { skip: !RUN_HEADLESS }, () => } assert.equal(renderedResult.method, fixture.expectedMethod); assert.equal(renderedResult.source, fixture.expectedSource); - assertTokenCoords(renderedResult.coords, fixture.expectedCoords); + assertTokenCoords(renderedResult.coords, fixture.expectedCollection); assert.match(renderedResult.artworkSource ?? '', fixture.expectedArtworkSource); }); } }); +/** + * detectBotChallenge reports whether a fixture URL is currently answering with + * a bot-protection challenge (e.g. raster.art's Vercel Security Checkpoint, + * observed 2026-07 for datacenter IPs). Returns a short description when + * challenged, or null when the page answers normally. + */ +async function detectBotChallenge(url: string): Promise { + try { + const response = await fetch(url, { + headers: { Accept: 'text/html,application/xhtml+xml' }, + }); + if (response.status === 429 || response.status === 403) { + return `HTTP ${response.status}`; + } + const body = await response.text(); + if (/vercel security checkpoint|just a moment|attention required/i.test(body)) { + return 'challenge page'; + } + return null; + } catch { + return null; + } +} + class PlaywrightRenderer implements HeadlessPageRenderer { constructor(private readonly browser: Browser) {} @@ -172,9 +206,17 @@ function renderedTokenReadySelector(value: string): string | null { } /** - * assertTokenCoords compares the complete coordinates currently produced by - * each browsed fixture. A token-id drift is a resolver signal, not a pass. + * assertTokenCoords checks that the resolved token belongs to the fixture's + * collection and carries a well-formed token id. Chain or contract drift is a + * resolver signal; the exact token id is the marketplace's live listing order + * (OpenSea moved #63→#190 and Verse #178→#236 in 2026-07 with the resolver + * behaving correctly), so it is deliberately not pinned. */ -function assertTokenCoords(actual: TokenCoords, expected: TokenCoords): void { - assert.deepEqual(actual, expected); +function assertTokenCoords( + actual: TokenCoords, + expected: Pick +): void { + assert.equal(actual.chain, expected.chain); + assert.equal(actual.contract, expected.contract); + assert.match(actual.tokenId, /^\d+$/, `tokenId should be numeric, got "${actual.tokenId}"`); }