From fa07b43c1cf60ba777553b445e945811d3dbffbb Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Sat, 12 Sep 2026 11:35:16 -0300 Subject: [PATCH 1/2] Share the link-preview fallback between the Meta adapters Instagram and Threads both ended with the same twelve lines: read the Open Graph tags, make og:video a video with og:image as its poster, otherwise make og:image an image, and attach the referrer headers the CDN wants. Facebook needs the same floor for the same reason, and three copies of a thing is where it stops being a coincidence. meta-media.ts already exists because these platforms are one product underneath. The media schema it normalizes is Instagram's and Threads'; Facebook's is its own, but the headers and the link preview are shared with both, so that is where this goes. Co-Authored-By: Claude Opus 5 --- src/platforms/instagram.ts | 24 ++++++++---------------- src/platforms/meta-media.ts | 27 +++++++++++++++++++++++++-- src/platforms/threads.ts | 24 +++++++++--------------- test/meta-media.test.ts | 26 +++++++++++++++++++++++++- 4 files changed, 67 insertions(+), 34 deletions(-) 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), []); +}); -- 2.54.0 From ab23c2b9febbc91446418112daf6f6b119f72286 Mon Sep 17 00:00:00 2001 From: Kydoimos Date: Sat, 12 Sep 2026 11:35:32 -0300 Subject: [PATCH 2/2] Add Facebook A shared Facebook link lands on a page that carries the whole post logged out -- the caption, the author, the files, the dimensions -- in ScheduledServerJS payloads that are plain JSON in ordinary script tags. What it does not carry is only that post. A reel arrives with the next five reels of the feed attached under viewer.lasso_blue_feed, a video with its related videos, and every one of them has the same fields in the same shape as the real one. Reading the first node with media on it gets a stranger's reel under someone else's name, which is the same false attribution a lifted quote-post picture used to be. So nothing is read until the post has been picked out. partsOfPost matches the id in the address against every node that names one; an address with no id in it -- a pfbid permalink -- is matched on permalink_url instead. Only with nothing to match on at all does it fall back to the route's query results, which is still narrower than the whole payload: the page ships its entire client configuration alongside the post, thousands of nodes carrying a name or an id, and a plain search finds a video player setting long before it finds the author. An id that matches nothing is a failure rather than a best guess. Facebook answers a link to something it no longer has by quietly serving something else -- /watch/ for a video that is gone comes back as the Watch home page, feed and all -- so the error card, which still carries the link and the copy button, is the honest answer. One post's pieces are spread across several payload blocks: a video post keeps its files in one, its caption in another, its author's avatar in a third and its timestamp in a fourth. Every claiming node is collected, not just the first, and the author's gaps are filled only from nodes carrying the same id. The author is whatever the payload calls the owner -- actors, owner, video_owner, owner_as_page. `author` on a Facebook page means the author of a comment, which sits right there in the same shape with a name and a picture of its own. Media is videoDeliveryLegacyFields.browser_native_hd_url with preferred_thumbnail as its poster, photo_image or image for a picture, and all_subattachments.nodes for a post of several -- which Facebook ships empty on every single-picture post, so only a populated one is a carousel. The CDN is signed and serves Range without asking for a referrer, but the assets are proxied like everything else. /share/{r,v,p,g} links are stubs, so the adapter follows one and hands back where it landed: the share code says nothing about what it opens, and rdid, share_url and fs are what the redirect leaves behind. m.facebook.com is a login wall logged out, so the rewrite rule rebuilds on www. Fixtures are real captures of a reel, a photo post and a video post, trimmed of the DASH manifests and tracking blobs. The reel keeps its recommendations and the video post keeps its comments, because those are the two things that have to survive being read past. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 35 +- README.md | 25 +- src/platforms/facebook.ts | 451 +++ src/platforms/index.ts | 5 +- src/platforms/url.ts | 1 + src/render/layout.ts | 1 + src/render/text.ts | 1 + src/types.ts | 3 +- test/facebook.test.ts | 191 + test/fixtures/facebook/photo-post.json | 4540 ++++++++++++++++++++++++ test/fixtures/facebook/reel.json | 775 ++++ test/fixtures/facebook/video-post.json | 2662 ++++++++++++++ 12 files changed, 8681 insertions(+), 9 deletions(-) create mode 100644 src/platforms/facebook.ts create mode 100644 test/facebook.test.ts create mode 100644 test/fixtures/facebook/photo-post.json create mode 100644 test/fixtures/facebook/reel.json create mode 100644 test/fixtures/facebook/video-post.json diff --git a/CLAUDE.md b/CLAUDE.md index 05c7919..ee7ca03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # antisocial — working notes A self-hosted page that shows a social post without the app. StopTheMadness rewrites -links to X, Threads, Instagram, TikTok, Bluesky and Reddit into +links to X, Threads, Instagram, Facebook, TikTok, Bluesky and Reddit into `//`; this resolves the post by driving a real headless Chromium and renders the media, the text, a platform badge and a copy-the-original button. @@ -35,6 +35,7 @@ different fixes: | Right media, wrong count | The structured payload was missed and it fell through to the DOM, which only shows the first carousel item. | | Poster image where a video belongs | Same fall-through, plus the `