From 1039b41a9cc59700825c0f2a7bafba63094f1fe7 Mon Sep 17 00:00:00 2001 From: Sean Moss-Pultz Date: Tue, 28 Jul 2026 15:41:15 +0800 Subject: [PATCH] fix(opensea): title collections by their collection name, not the page title An OpenSea collection page's embeds the floor price and page furniture -- "PXL NET 0.1904 ETH - Collection | OpenSea" -- and the generic title chain persisted it (minus the "| OpenSea" suffix) as the playlist title, freezing a price that is stale the moment it is stored. No adapter implemented extractTitleFromHtml, so the raw <title> always outranked the humanized-slug fallback that already produced a usable name. Give the OpenSea adapter an extractTitleFromHtml for collection pages. The collection's real name rides the same embedded JSON payloads the keyless item extraction already scans; the collection object carries "name" adjacent to a "slug" that is known from the parsed URL, so the extractor takes the name nearest that slug marker (window-bounded, so a payment-token or item name elsewhere in the payload is never read as the collection's). When no embedded name is present it falls back to the <title> scrubbed of the price/"- Collection" furniture, and returns null when there is no furniture -- pages whose <title> is already just the collection name keep their existing behavior unchanged. Verified against the live pxl-net page: extracts "PXL NET". Fixes #15. --- src/sites/opensea/index.ts | 4 ++ src/sites/opensea/pages/title.ts | 102 +++++++++++++++++++++++++++++++ tests/source-resolver.test.ts | 35 +++++++++++ 3 files changed, 141 insertions(+) create mode 100644 src/sites/opensea/pages/title.ts diff --git a/src/sites/opensea/index.ts b/src/sites/opensea/index.ts index 2a5c76f..13e8587 100644 --- a/src/sites/opensea/index.ts +++ b/src/sites/opensea/index.ts @@ -3,6 +3,7 @@ import { resolveOpenSeaCollectionFromApi } from './pages/api'; import { parseOpenSeaCollection } from './pages/collection'; import { extractOpenSeaEmbeddedItems, extractOpenSeaEmbeddedItemTokens } from './pages/embedded-items'; import { parseOpenSeaItem } from './pages/item'; +import { extractOpenSeaCollectionTitle } from './pages/title'; import { resolveOpenSeaArtworkSources } from './pages/source'; /** @@ -26,6 +27,9 @@ export const openSeaAdapter: SourceSiteAdapter = { } return extractOpenSeaEmbeddedItems(html); }, + extractTitleFromHtml(url: URL, html: string, parsed: ParsedFindInput | null): string | null { + return extractOpenSeaCollectionTitle(url, html, parsed); + }, extractTokensFromHtml(url: URL, html: string): readonly ParsedFindInput[] { if (parseOpenSeaCollection(url)?.kind !== 'os-collection') { return []; diff --git a/src/sites/opensea/pages/title.ts b/src/sites/opensea/pages/title.ts new file mode 100644 index 0000000..34d0605 --- /dev/null +++ b/src/sites/opensea/pages/title.ts @@ -0,0 +1,102 @@ +import type { ParsedFindInput } from '../../../types'; + +const NAME_WINDOW_CHARS = 500; +const NAME_PATTERN = /"name"\s*:\s*"((?:[^"\\]|\\.)*)"/g; + +/** + * extractOpenSeaCollectionTitle returns the collection's own name for a + * collection page, never the page `<title>` verbatim. + * + * OpenSea's collection `<title>` embeds the floor price and page furniture + * ("PXL NET 0.1904 ETH - Collection | OpenSea"), so a title taken from it + * persists a price that is stale the moment it is stored. The collection's + * real name rides the same embedded JSON payloads the keyless item extraction + * already scans: the collection object carries adjacent `"name"` and + * `"slug"` fields, and the slug is known from the parsed URL. + * + * Fallback order matters: embedded name first (exact), then a `<title>` + * scrubbed of the price/"- Collection" furniture (close), then null so the + * caller's generic title chain keeps its existing behavior. + */ +export function extractOpenSeaCollectionTitle( + _url: URL, + html: string, + parsed: ParsedFindInput | null +): string | null { + if (parsed?.kind !== 'os-collection') { + return null; + } + return embeddedCollectionName(html, parsed.slug) ?? scrubbedCollectionHtmlTitle(html); +} + +/** + * embeddedCollectionName finds the `"name"` nearest to `"slug":"<slug>"` in + * the page's embedded JSON. The window bound keeps a payment-token or item + * name elsewhere in the payload from being read as the collection's. + */ +function embeddedCollectionName(html: string, slug: string): string | null { + const slugMarker = `"slug":"${slug}"`; + let searchFrom = 0; + for (;;) { + const slugIndex = html.indexOf(slugMarker, searchFrom); + if (slugIndex < 0) { + return null; + } + const windowStart = Math.max(0, slugIndex - NAME_WINDOW_CHARS); + const window = html.slice(windowStart, slugIndex + slugMarker.length + NAME_WINDOW_CHARS); + const name = nearestName(window, slugIndex - windowStart); + if (name) { + return name; + } + searchFrom = slugIndex + slugMarker.length; + } +} + +function nearestName(window: string, slugOffset: number): string | null { + let best: { distance: number; raw: string } | null = null; + NAME_PATTERN.lastIndex = 0; + for (;;) { + const match = NAME_PATTERN.exec(window); + if (!match) { + break; + } + const distance = Math.abs(match.index - slugOffset); + if (!best || distance < best.distance) { + best = { distance, raw: match[1] }; + } + } + if (!best) { + return null; + } + try { + const decoded = JSON.parse(`"${best.raw}"`) as string; + const trimmed = decoded.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +/** + * scrubbedCollectionHtmlTitle strips the collection-page furniture from a + * `<title>` when no embedded name was found: the "| OpenSea" suffix, then a + * trailing floor price + "- Collection" segment. + */ +function scrubbedCollectionHtmlTitle(html: string): string | null { + const titleMatch = /<title[^>]*>([^<]*)<\/title>/i.exec(html); + if (!titleMatch) { + return null; + } + let title = titleMatch[1].trim(); + title = title.replace(/\s*\|\s*OpenSea\s*$/i, '').trim(); + const withoutFurniture = title + .replace(/\s+[\d.,]+\s*ETH\s*[-–—]\s*Collection\s*$/i, '') + .replace(/\s*[-–—]\s*Collection\s*$/i, '') + .trim(); + if (withoutFurniture.length === 0 || withoutFurniture === title) { + // No collection furniture found: leave the generic title chain to apply + // its own marketplace suffix handling unchanged. + return null; + } + return withoutFurniture; +} diff --git a/tests/source-resolver.test.ts b/tests/source-resolver.test.ts index 74401d3..4957dd8 100644 --- a/tests/source-resolver.test.ts +++ b/tests/source-resolver.test.ts @@ -1083,6 +1083,41 @@ describe('resolveTokenInfos collection support', () => { assert.equal(result.title, 'Chromie Squiggle by Snowfro'); }); + test('OpenSea collection prefers the embedded collection name over the page title', async () => { + const html = [ + '<html><head><title>PXL NET 0.1904 ETH - Collection | OpenSea', + '', + openSeaCollectionItems(openSeaItem('ethereum', OPENSEA_COLLECTION_CONTRACT, '97')), + '', + ].join(''); + const result = await resolveTokenInfos('https://opensea.io/collection/pxl-net', { + fetch: htmlFetch(html) as typeof fetch, + }); + + assert.equal(result.kind, 'tokens'); + if (result.kind !== 'tokens') { + throw new Error('narrowing'); + } + assert.equal(result.title, 'PXL NET'); + }); + + test('OpenSea collection scrubs floor price and furniture from a title fallback', async () => { + const html = [ + 'PXL NET 0.1904 ETH - Collection | OpenSea', + openSeaCollectionItems(openSeaItem('ethereum', OPENSEA_COLLECTION_CONTRACT, '97')), + '', + ].join(''); + const result = await resolveTokenInfos('https://opensea.io/collection/pxl-net', { + fetch: htmlFetch(html) as typeof fetch, + }); + + assert.equal(result.kind, 'tokens'); + if (result.kind !== 'tokens') { + throw new Error('narrowing'); + } + assert.equal(result.title, 'PXL NET'); + }); + test('OpenSea collection does not call the authenticated collection NFTs API', async () => { const html = openSeaCollectionItems(openSeaItem('ethereum', OPENSEA_COLLECTION_CONTRACT, '97')); const calledUrls: string[] = [];