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 `
` verbatim.
+ *
+ * OpenSea's collection `` 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 ``
+ * 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":""` 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
+ * `` 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>/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 = [
+ '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[] = [];