Add Facebook #17

Merged
thatguygriff merged 2 commits from facebook-posts into main 2026-09-12 14:43:21 +00:00
4 changed files with 67 additions and 34 deletions
Showing only changes of commit fa07b43c1c - Show all commits
+8 -16
View File
@@ -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<Scraped> {
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'] } : {}),
};
}
+25 -2
View File
@@ -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<string, string>, 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;
}
+9 -15
View File
@@ -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<Segment | undefined> {
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;
+25 -1
View File
@@ -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), []);
});