diff --git a/src/platforms/instagram.ts b/src/platforms/instagram.ts index 8a4d91d..4d26f53 100644 --- a/src/platforms/instagram.ts +++ b/src/platforms/instagram.ts @@ -1,6 +1,12 @@ import { ResolveError, oneSegment, type Media, type Post } from '../types.ts'; import { readOpenGraph } from '../browser/capture.ts'; -import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts'; +import { + findCaption, + findMetaMedia, + mediaFromMetaNode, + mediaFromOpenGraph, + metaHeaders, +} from './meta-media.ts'; import { extractJsonObjectAfterKey } from './scan.ts'; import { hostMatcher } from './url.ts'; import type { PlatformSpec, ResolveContext } from './types.ts'; @@ -202,22 +208,8 @@ function fromPayloads(texts: string[]): Scraped | undefined { /** Absolute floor: whatever the page offers a link preview. */ async function fromOpenGraph(ctx: ResolveContext): Promise { const og = await readOpenGraph(ctx.page); - const fetchHeaders = metaHeaders(REFERER); - const media: Media[] = []; - - if (og['og:video']) { - media.push({ - kind: 'video', - url: og['og:video'], - fetchHeaders, - ...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}), - }); - } else if (og['og:image']) { - media.push({ kind: 'image', url: og['og:image'], fetchHeaders }); - } - return { - media, + media: mediaFromOpenGraph(og, REFERER), ...(og['og:description'] ? { text: og['og:description'] } : {}), }; } diff --git a/src/platforms/meta-media.ts b/src/platforms/meta-media.ts index 2fd2e52..8c0cc66 100644 --- a/src/platforms/meta-media.ts +++ b/src/platforms/meta-media.ts @@ -1,8 +1,10 @@ import type { Media } from '../types.ts'; /** - * Instagram and Threads are the same product underneath and serve the same - * media schema, so both adapters normalize through here. + * Instagram, Threads and Facebook are the same product underneath. The first + * two serve the same media schema, so both normalize through here; Facebook's + * is its own, but the headers its CDN wants and the link preview it falls + * back to are shared with them. */ type Candidate = { url?: string; width?: number; height?: number }; @@ -66,6 +68,27 @@ export function mediaFromMetaNode(node: MetaMediaNode, referer: string): Media[] return []; } +/** + * The link preview as media: the floor on all three, and all that is left + * when a post is refused to a logged-out reader. + */ +export function mediaFromOpenGraph(og: Record, referer: string): Media[] { + const fetchHeaders = metaHeaders(referer); + + if (og['og:video']) { + return [{ + kind: 'video', + url: og['og:video'], + fetchHeaders, + ...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}), + }]; + } + if (og['og:image']) { + return [{ kind: 'image', url: og['og:image'], fetchHeaders }]; + } + return []; +} + function nonEmptyArray(value: unknown): boolean { return Array.isArray(value) && value.length > 0; } diff --git a/src/platforms/threads.ts b/src/platforms/threads.ts index 73eab15..165fc3c 100644 --- a/src/platforms/threads.ts +++ b/src/platforms/threads.ts @@ -1,6 +1,12 @@ -import { ResolveError, type Media, type Post, type Segment } from '../types.ts'; +import { ResolveError, type Post, type Segment } from '../types.ts'; import { readOpenGraph } from '../browser/capture.ts'; -import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders, type MetaMediaNode } from './meta-media.ts'; +import { + findCaption, + findMetaMedia, + mediaFromMetaNode, + mediaFromOpenGraph, + type MetaMediaNode, +} from './meta-media.ts'; import { hostMatcher } from './url.ts'; import type { PlatformSpec, ResolveContext } from './types.ts'; @@ -129,19 +135,7 @@ function segmentOf(post: ThreadsPost, isAnchor: boolean): Segment { /** The floor, when the structured payload is not there to be read. */ async function fromOpenGraph(ctx: ResolveContext): Promise { const og = await readOpenGraph(ctx.page); - const fetchHeaders = metaHeaders(REFERER); - const media: Media[] = []; - - if (og['og:video']) { - media.push({ - kind: 'video', - url: og['og:video'], - fetchHeaders, - ...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}), - }); - } else if (og['og:image']) { - media.push({ kind: 'image', url: og['og:image'], fetchHeaders }); - } + const media = mediaFromOpenGraph(og, REFERER); const text = og['og:description']; if (media.length === 0 && !text) return undefined; diff --git a/test/meta-media.test.ts b/test/meta-media.test.ts index de5fe74..83657ea 100644 --- a/test/meta-media.test.ts +++ b/test/meta-media.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { findCaption, findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts'; +import { + findCaption, + findMetaMedia, + mediaFromMetaNode, + mediaFromOpenGraph, +} from '../src/platforms/meta-media.ts'; import { fixture } from './helpers.ts'; const REFERER = 'https://www.threads.com/'; @@ -68,3 +73,22 @@ test('the caption is found the same way, and an empty one is not a caption', () assert.equal(findCaption({ x: { caption: { text: '' } } }), undefined); assert.equal(findCaption({ x: { caption: null } }), undefined); }); + +test('the link preview stands in for the post when there is nothing better', () => { + const image = mediaFromOpenGraph({ 'og:image': 'https://image.example/a.jpg' }, REFERER); + assert.deepEqual(image.map((item) => [item.kind, item.url]), [['image', 'https://image.example/a.jpg']]); + assert.equal(image[0]?.fetchHeaders?.['Referer'], REFERER); +}); + +test('a preview naming a video is a video, and its image is the poster', () => { + const media = mediaFromOpenGraph( + { 'og:video': 'https://video.example/a.mp4', 'og:image': 'https://image.example/a.jpg' }, + REFERER, + ); + assert.equal(media[0]?.kind, 'video'); + assert.equal(media[0]?.kind === 'video' ? media[0].poster?.url : undefined, 'https://image.example/a.jpg'); +}); + +test('a page with no preview at all yields no media rather than an empty asset', () => { + assert.deepEqual(mediaFromOpenGraph({ 'og:title': 'just a title' }, REFERER), []); +});